From d6b2257177a3c60ef8880edfeb6a7e5030eb8b43 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Sun, 22 Feb 2026 20:19:12 +0400 Subject: [PATCH 01/37] feat: Refined Popper animation flow and side-based unfolding Separated floating-ui positioning and visual animation by introducing a positioner wrapper. Reworked menu popper transitions to use side-aware uncollapse behavior with optional animation settings. Updated React/Vue popper tests for the new DOM structure and animation variables. Added draft notes with hypotheses, checks, and source references for M3 motion guidance. --- .../stylesheets/components/menu/index.scss | 2 +- .../stylesheets/components/popper/index.scss | 71 ++++++- m3-foundation/eslint.config.js | 14 ++ m3-foundation/lib/popper/floating.ts | 38 +++- m3-foundation/types/components/popper.d.ts | 3 +- m3-react/eslint.config.js | 14 ++ m3-react/src/components/menu/M3Menu.tsx | 1 + m3-react/src/components/popper/M3Popper.tsx | 100 ++++++++-- m3-react/src/components/popper/types.d.ts | 3 +- m3-react/tests/M3Popper.e2e.tsx | 176 +++++++++++++++--- m3-react/tests/M3Popper.test.tsx | 17 +- m3-vue/eslint.config.js | 14 ++ m3-vue/src/components/menu/M3Menu.vue | 3 +- m3-vue/src/components/popper/M3Popper.vue | 107 +++++++++-- m3-vue/tests/M3Popper.e2e.ts | 172 ++++++++++++++--- 15 files changed, 629 insertions(+), 106 deletions(-) diff --git a/m3-foundation/assets/stylesheets/components/menu/index.scss b/m3-foundation/assets/stylesheets/components/menu/index.scss index 997f10f..1ac4f58 100644 --- a/m3-foundation/assets/stylesheets/components/menu/index.scss +++ b/m3-foundation/assets/stylesheets/components/menu/index.scss @@ -87,4 +87,4 @@ flex: 1 0 0; color: var(--m3-sys-on-surface); } -} \ No newline at end of file +} diff --git a/m3-foundation/assets/stylesheets/components/popper/index.scss b/m3-foundation/assets/stylesheets/components/popper/index.scss index 5725b6a..200aba4 100644 --- a/m3-foundation/assets/stylesheets/components/popper/index.scss +++ b/m3-foundation/assets/stylesheets/components/popper/index.scss @@ -1,22 +1,77 @@ @use "../../basics/motion" as m3-motion; +.m3-popper-positioner { + position: absolute; + top: 0; + left: 0; +} + .m3-popper { + --m3-popper-enter-duration: #{m3-motion.duration('short4')}; + --m3-popper-exit-duration: #{m3-motion.duration('short2')}; + --m3-popper-opacity-enter-duration: #{m3-motion.duration('short2')}; + --m3-popper-opacity-exit-duration: #{m3-motion.duration('short2')}; + --m3-popper-enter-easing: #{m3-motion.easing('standard-decelerate')}; + --m3-popper-exit-easing: #{m3-motion.easing('standard-accelerate')}; + --m3-popper-enter-x: 0px; + --m3-popper-enter-y: 0px; + --m3-popper-origin-x: center; + --m3-popper-origin-y: top; + --m3-popper-scale-x-hidden: 0.96; + --m3-popper-scale-y-hidden: 0.96; + visibility: hidden; opacity: 0; transition: - m3-motion.timing-standard-decelerate(opacity), - m3-motion.timing-standard-decelerate(visibility) + opacity var(--m3-popper-opacity-exit-duration) var(--m3-popper-exit-easing), + visibility 0s linear var(--m3-popper-exit-duration) ; - position: absolute; - top: 0; - left: 0; + + &_animated { + transform-origin: var(--m3-popper-origin-x) var(--m3-popper-origin-y); + transform: translate(var(--m3-popper-enter-x), var(--m3-popper-enter-y)) scale(var(--m3-popper-scale-x-hidden), var(--m3-popper-scale-y-hidden)); + will-change: opacity, transform; + transition: + opacity var(--m3-popper-opacity-exit-duration) var(--m3-popper-exit-easing), + transform var(--m3-popper-exit-duration) var(--m3-popper-exit-easing), + visibility 0s linear var(--m3-popper-exit-duration) + ; + } + + &_animated#{&}_shown { + transform: translate(0, 0) scale(1); + transition: + opacity var(--m3-popper-opacity-enter-duration) var(--m3-popper-enter-easing), + transform var(--m3-popper-enter-duration) var(--m3-popper-enter-easing), + visibility 0s linear 0ms + ; + } &_shown { visibility: visible; opacity: 1; transition: - m3-motion.timing-standard-accelerate(opacity), - m3-motion.timing-standard-accelerate(visibility) + opacity var(--m3-popper-opacity-enter-duration) var(--m3-popper-enter-easing), + visibility 0s linear 0ms ; } -} \ No newline at end of file +} + +@media (prefers-reduced-motion: reduce) { + .m3-popper { + &_animated { + transform: translate(0, 0) scale(1); + transition: + opacity 0ms linear, + visibility 0s linear 0ms + ; + } + + &_animated#{&}_shown { + transition: + opacity 0ms linear, + visibility 0s linear 0ms + ; + } + } +} diff --git a/m3-foundation/eslint.config.js b/m3-foundation/eslint.config.js index 930988d..8f49a1c 100644 --- a/m3-foundation/eslint.config.js +++ b/m3-foundation/eslint.config.js @@ -92,4 +92,18 @@ export default [ 'max-lines-per-function': 'off', }, }, + { + files: [ + '**/*.e2e.ts', + '**/*.e2e.tsx', + '**/*.e2e.test.ts', + '**/*.e2e.test.tsx', + '**/*.smote.ts', + '**/*.smoke.ts', + ], + rules: { + 'max-lines': 'off', + 'max-lines-per-function': 'off', + }, + }, ] diff --git a/m3-foundation/lib/popper/floating.ts b/m3-foundation/lib/popper/floating.ts index 62be158..2201180 100644 --- a/m3-foundation/lib/popper/floating.ts +++ b/m3-foundation/lib/popper/floating.ts @@ -9,6 +9,13 @@ import { shift, } from '@floating-ui/dom' +type PopperSide = 'top' | 'bottom' | 'left' | 'right' + +export type PopperPositionResult = { + placement: string; + side: PopperSide; +} + const computeMiddleware = (options: Required) => { const middleware: Middleware[] = [] @@ -34,10 +41,27 @@ const computeMiddleware = (options: Required) => { return middleware } +const toSide = (placement: string): PopperSide => placement.split('-')[0] as PopperSide + +const notifyWhenReferenceHidden = ( + referenceHidden: boolean | undefined, + onReferenceHidden: () => void +) => { + if (referenceHidden) { + onReferenceHidden() + } +} + export const computePosition = async (el: HTMLElement, target: Element, options: Required & { onReferenceHidden: () => void -}) => { - const { strategy, x, y, middlewareData } = await _compute(target, el, { +}): Promise => { + const { + strategy, + x, + y, + middlewareData, + placement, + } = await _compute(target, el, { middleware: computeMiddleware(options), placement: options.placement, strategy: options.strategy, @@ -45,11 +69,7 @@ export const computePosition = async (el: HTMLElement, target: Element, options: el.style.position = strategy el.style.transform = `translate3d(${Math.round(x)}px,${Math.round(y)}px,0)` + notifyWhenReferenceHidden(middlewareData.hide?.referenceHidden, options.onReferenceHidden) - if (middlewareData.hide) { - const { referenceHidden } = middlewareData.hide - if (referenceHidden) { - options.onReferenceHidden() - } - } -} \ No newline at end of file + return { placement, side: toSide(placement) } +} diff --git a/m3-foundation/types/components/popper.d.ts b/m3-foundation/types/components/popper.d.ts index 233f286..66a51b2 100644 --- a/m3-foundation/types/components/popper.d.ts +++ b/m3-foundation/types/components/popper.d.ts @@ -47,6 +47,7 @@ export type ShowingOptions = { shown?: boolean; container?: Element | string; disabled?: boolean; + animated?: boolean; } export type PopperOptions = FloatingOptions @@ -62,4 +63,4 @@ export type CloserEvent = E & { export type CloserTarget = E & { m3PopperCloseAll?: boolean; m3PopperCloserTouch?: Touch; -} \ No newline at end of file +} diff --git a/m3-react/eslint.config.js b/m3-react/eslint.config.js index 3d7d997..e2bac2b 100644 --- a/m3-react/eslint.config.js +++ b/m3-react/eslint.config.js @@ -111,5 +111,19 @@ export default [ 'max-lines-per-function': 'off', }, }, + { + files: [ + '**/*.e2e.ts', + '**/*.e2e.tsx', + '**/*.e2e.test.ts', + '**/*.e2e.test.tsx', + '**/*.smote.ts', + '**/*.smoke.ts', + ], + rules: { + 'max-lines': 'off', + 'max-lines-per-function': 'off', + }, + }, { ignores: ['dist/*'] }, ] diff --git a/m3-react/src/components/menu/M3Menu.tsx b/m3-react/src/components/menu/M3Menu.tsx index 3aaa202..2fbbfdf 100644 --- a/m3-react/src/components/menu/M3Menu.tsx +++ b/m3-react/src/components/menu/M3Menu.tsx @@ -43,6 +43,7 @@ const M3Menu: FC = ({ offsetCrossAxis={offsetCrossAxis} delay={delay} disabled={disabled} + animated={true} detachTimeout={detachTimeout} className={toClassName(['m3-menu', className])} hideOnMissClick={true} diff --git a/m3-react/src/components/popper/M3Popper.tsx b/m3-react/src/components/popper/M3Popper.tsx index 9ada3f0..be7c137 100644 --- a/m3-react/src/components/popper/M3Popper.tsx +++ b/m3-react/src/components/popper/M3Popper.tsx @@ -59,6 +59,7 @@ const M3Popper: ForwardRefRenderFunction = ({ overflow = [], delay = 0, disabled = false, + animated = false, detachTimeout = 5000, onShow = () => {}, onHide = (_: HideReason) => {}, @@ -103,8 +104,54 @@ const M3Popper: ForwardRefRenderFunction = ({ useWatch(onDispose, onDispose => handlers.onDispose = onDispose) const targetRef = useRef(target) + const positionerRef = useRef(null) const popperRef = useRef(null) + const applyAnimationSide = useCallback((side: 'top' | 'bottom' | 'left' | 'right') => { + if (!popperRef.current) { + return + } + + const style = popperRef.current.style + + if (side === 'top') { + style.setProperty('--m3-popper-origin-x', 'center') + style.setProperty('--m3-popper-origin-y', 'bottom') + style.setProperty('--m3-popper-enter-x', '0px') + style.setProperty('--m3-popper-enter-y', '-2px') + style.setProperty('--m3-popper-scale-x-hidden', '0.995') + style.setProperty('--m3-popper-scale-y-hidden', '0.72') + return + } + + if (side === 'left') { + style.setProperty('--m3-popper-origin-x', 'right') + style.setProperty('--m3-popper-origin-y', 'center') + style.setProperty('--m3-popper-enter-x', '-2px') + style.setProperty('--m3-popper-enter-y', '0px') + style.setProperty('--m3-popper-scale-x-hidden', '0.72') + style.setProperty('--m3-popper-scale-y-hidden', '0.995') + return + } + + if (side === 'right') { + style.setProperty('--m3-popper-origin-x', 'left') + style.setProperty('--m3-popper-origin-y', 'center') + style.setProperty('--m3-popper-enter-x', '2px') + style.setProperty('--m3-popper-enter-y', '0px') + style.setProperty('--m3-popper-scale-x-hidden', '0.72') + style.setProperty('--m3-popper-scale-y-hidden', '0.995') + return + } + + style.setProperty('--m3-popper-origin-x', 'center') + style.setProperty('--m3-popper-origin-y', 'top') + style.setProperty('--m3-popper-enter-x', '0px') + style.setProperty('--m3-popper-enter-y', '2px') + style.setProperty('--m3-popper-scale-x-hidden', '0.995') + style.setProperty('--m3-popper-scale-y-hidden', '0.72') + }, []) + const positioning = useMemo(() => ({ placement, strategy, @@ -122,18 +169,26 @@ const M3Popper: ForwardRefRenderFunction = ({ ]) const adjustDo = useCallback(async () => { - if (targetRef.current && popperRef.current && !state.disposed) { - await computePosition(popperRef.current, targetRef.current, { + if (targetRef.current && positionerRef.current && !state.disposed) { + const result = await computePosition(positionerRef.current, targetRef.current, { ...positioning, onReferenceHidden: hide, }) + + if (animated) { + applyAnimationSide(result.side) + } } - }, [positioning]) + }, [ + animated, + applyAnimationSide, + positioning, + ]) const [ adjustOn, adjustOff, - ] = useAutoAdjust(targetRef, popperRef, adjustDo) + ] = useAutoAdjust(targetRef, positionerRef, adjustDo) const adjust = useRecord({ do: adjustDo, @@ -185,7 +240,7 @@ const M3Popper: ForwardRefRenderFunction = ({ } }, []) - const contains = useCallback((el: Element | null): boolean => popperRef.current?.contains(el) ?? false, []) + const contains = useCallback((el: Element | null): boolean => positionerRef.current?.contains(el) ?? false, []) const show = useCallback((immediately = false) => { if (state.disposed) { @@ -253,8 +308,8 @@ const M3Popper: ForwardRefRenderFunction = ({ listening.target.start(targetRef.current, targetTriggers) } - if (popperRef.current) { - listening.popper.start(popperRef.current, popperTriggers) + if (positionerRef.current) { + listening.popper.start(positionerRef.current, popperTriggers) } } else { state.disposed = true @@ -328,6 +383,17 @@ const M3Popper: ForwardRefRenderFunction = ({ } }) + useWatch(animated, animated => { + if (!animated && popperRef.current) { + popperRef.current.style.removeProperty('--m3-popper-origin-x') + popperRef.current.style.removeProperty('--m3-popper-origin-y') + popperRef.current.style.removeProperty('--m3-popper-enter-x') + popperRef.current.style.removeProperty('--m3-popper-enter-y') + popperRef.current.style.removeProperty('--m3-popper-scale-x-hidden') + popperRef.current.style.removeProperty('--m3-popper-scale-y-hidden') + } + }) + useEffect(() => { const onGlobalClick = (event: CloserEvent) => onGlobalTap(event) const onGlobalTouch = (event: CloserEvent) => onGlobalTap(event, true) @@ -358,14 +424,20 @@ const M3Popper: ForwardRefRenderFunction = ({ return state.attached ? createPortal(
- {children} +
+ {children} +
, (typeof container === 'string' ? document.querySelector(container) : container) ?? document.body ) : null diff --git a/m3-react/src/components/popper/types.d.ts b/m3-react/src/components/popper/types.d.ts index 7bb14e2..2d681d4 100644 --- a/m3-react/src/components/popper/types.d.ts +++ b/m3-react/src/components/popper/types.d.ts @@ -30,6 +30,7 @@ export interface M3PopperProps extends HTMLAttributes { overflow?: OverflowBehavior[] delay?: number | string | Delay; disabled?: boolean; + animated?: boolean; detachTimeout?: null | number | string; onShow?: () => void; onHide?: (reason: HideReason) => void; @@ -42,4 +43,4 @@ export interface M3PopperMethods { contains (el: Element | null): boolean; show (immediately?: boolean): void; hide (immediately?: boolean, reason?: HideReason): void; -} \ No newline at end of file +} diff --git a/m3-react/tests/M3Popper.e2e.tsx b/m3-react/tests/M3Popper.e2e.tsx index 9516c23..5f789fc 100644 --- a/m3-react/tests/M3Popper.e2e.tsx +++ b/m3-react/tests/M3Popper.e2e.tsx @@ -12,6 +12,8 @@ import { createRef } from 'react' import { M3Popper } from '@/components/popper' +type PopperSide = 'top' | 'bottom' | 'left' | 'right' + const rect = (x: number, y: number, width: number, height: number): DOMRect => ( DOMRect.fromRect({ x, @@ -26,14 +28,59 @@ const expectedX = (popper: HTMLElement, offsetCrossAxis = 0) => { return Math.round(100 + 20 - width / 2 + offsetCrossAxis) } -const expectTransform = (popper: HTMLElement, x: number, y: number) => { - expect(popper.style.transform).toMatch(new RegExp(`^translate3d\\(${x}px,\\s*${y}px,\\s*0px\\)$`)) +const expectTransform = (positioner: HTMLElement, x: number, y: number) => { + expect(positioner.style.transform).toMatch(new RegExp(`^translate3d\\(${x}px,\\s*${y}px,\\s*0px\\)$`)) +} + +const expectAnimationSide = (popper: HTMLElement, side: PopperSide) => { + const expected = { + top: { + originX: 'center', + originY: 'bottom', + enterX: '0px', + enterY: '-2px', + scaleX: '0.995', + scaleY: '0.72', + }, + bottom: { + originX: 'center', + originY: 'top', + enterX: '0px', + enterY: '2px', + scaleX: '0.995', + scaleY: '0.72', + }, + left: { + originX: 'right', + originY: 'center', + enterX: '-2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + right: { + originX: 'left', + originY: 'center', + enterX: '2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + }[side] + + expect(popper.classList.contains('m3-popper_animated')).toBe(true) + expect(popper.style.getPropertyValue('--m3-popper-origin-x')).toBe(expected.originX) + expect(popper.style.getPropertyValue('--m3-popper-origin-y')).toBe(expected.originY) + expect(popper.style.getPropertyValue('--m3-popper-enter-x')).toBe(expected.enterX) + expect(popper.style.getPropertyValue('--m3-popper-enter-y')).toBe(expected.enterY) + expect(popper.style.getPropertyValue('--m3-popper-scale-x-hidden')).toBe(expected.scaleX) + expect(popper.style.getPropertyValue('--m3-popper-scale-y-hidden')).toBe(expected.scaleY) } -const parseTransform = (popper: HTMLElement) => { - const match = popper.style.transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px,\s*0px\)/) +const parseTransform = (positioner: HTMLElement) => { + const match = positioner.style.transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px,\s*0px\)/) if (!match) { - throw new Error(`Unexpected transform: ${popper.style.transform}`) + throw new Error(`Unexpected transform: ${positioner.style.transform}`) } return { @@ -44,10 +91,14 @@ const parseTransform = (popper: HTMLElement) => { const waitForPopper = async () => { await waitFor(() => { + expect(document.body.querySelector('.m3-popper-positioner')).not.toBeNull() expect(document.body.querySelector('.m3-popper')).not.toBeNull() }) - return document.body.querySelector('.m3-popper') as HTMLElement + return { + positioner: document.body.querySelector('.m3-popper-positioner') as HTMLElement, + popper: document.body.querySelector('.m3-popper') as HTMLElement, + } } describe('m3-react/popper e2e', () => { @@ -86,7 +137,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) const x = expectedX(popper) @@ -96,8 +147,13 @@ describe('m3-react/popper e2e', () => { }) await waitFor(() => { - expectTransform(popper, x, 80) - expect(popper.style.position).toBe('absolute') + expectTransform(positioner, x, 80) + expect(positioner.style.position).toBe('absolute') + expect(popper.classList.contains('m3-popper_animated')).toBe(false) + expect(popper.style.getPropertyValue('--m3-popper-enter-x')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-enter-y')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-scale-x-hidden')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-scale-y-hidden')).toBe('') }) }) @@ -125,7 +181,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) const x = expectedX(popper, 7) @@ -135,8 +191,8 @@ describe('m3-react/popper e2e', () => { }) await waitFor(() => { - expectTransform(popper, x, 70) - expect(popper.style.position).toBe('absolute') + expectTransform(positioner, x, 70) + expect(positioner.style.position).toBe('absolute') }) }) @@ -155,6 +211,7 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={0} offsetCrossAxis={0} + animated={true} detachTimeout={null} >
@@ -164,7 +221,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) @@ -175,9 +232,10 @@ describe('m3-react/popper e2e', () => { let bottomX = 0 let bottomY = 0 await waitFor(() => { - const point = parseTransform(popper) + const point = parseTransform(positioner) bottomX = point.x bottomY = point.y + expectAnimationSide(popper, 'bottom') }) mounted.rerender( @@ -189,6 +247,7 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={0} offsetCrossAxis={0} + animated={true} detachTimeout={null} >
@@ -197,14 +256,11 @@ describe('m3-react/popper e2e', () => { ) - await act(async () => { - await popperRef.current?.adjust() - }) - await waitFor(() => { - const point = parseTransform(popper) + const point = parseTransform(positioner) expect(point.x).toBeGreaterThan(bottomX) expect(point.y).not.toBe(bottomY) + expectAnimationSide(popper, 'right') }) }) @@ -223,6 +279,7 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={0} offsetCrossAxis={0} + animated={true} detachTimeout={null} >
@@ -232,7 +289,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) @@ -242,7 +299,7 @@ describe('m3-react/popper e2e', () => { let y0 = 0 await waitFor(() => { - y0 = parseTransform(popper).y + y0 = parseTransform(positioner).y }) mounted.rerender( @@ -254,6 +311,38 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={10} offsetCrossAxis={0} + animated={true} + detachTimeout={null} + > +
+ Popper content +
+ + ) + + await waitFor(() => { + const y1 = parseTransform(positioner).y + expect(Math.round(Math.abs(y1 - y0))).toBe(10) + expectAnimationSide(popper, 'top') + }) + }) + + test('updates animation direction after flip when bottom placement has no space', async () => { + target = document.createElement('button') + document.body.append(target) + + const popperRef = createRef() + + const mounted = render( +
@@ -261,14 +350,55 @@ describe('m3-react/popper e2e', () => {
) + unmount = mounted.unmount + + const { popper } = await waitForPopper() + const y = window.innerHeight - 12 + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, y, 40, 20)) await act(async () => { await popperRef.current?.adjust() }) await waitFor(() => { - const y1 = parseTransform(popper).y - expect(Math.round(Math.abs(y1 - y0))).toBe(10) + expectAnimationSide(popper, 'top') + }) + }) + + test('applies animation vectors for left placement', async () => { + target = document.createElement('button') + document.body.append(target) + + const popperRef = createRef() + + const mounted = render( + +
+ Popper content +
+
+ ) + unmount = mounted.unmount + + const { popper } = await waitForPopper() + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) + + await act(async () => { + await popperRef.current?.adjust() + }) + + await waitFor(() => { + expectAnimationSide(popper, 'left') }) }) }) diff --git a/m3-react/tests/M3Popper.test.tsx b/m3-react/tests/M3Popper.test.tsx index 1eba043..2fc1ca7 100644 --- a/m3-react/tests/M3Popper.test.tsx +++ b/m3-react/tests/M3Popper.test.tsx @@ -22,10 +22,14 @@ const rect = (x: number, y: number, width: number, height: number): DOMRect => ( const waitForPopper = async () => { await waitFor(() => { + expect(document.body.querySelector('.m3-popper-positioner')).not.toBeNull() expect(document.body.querySelector('.m3-popper')).not.toBeNull() }) - return document.body.querySelector('.m3-popper') as HTMLElement + return { + positioner: document.body.querySelector('.m3-popper-positioner') as HTMLElement, + popper: document.body.querySelector('.m3-popper') as HTMLElement, + } } describe('m3-react/popper', () => { @@ -79,7 +83,7 @@ describe('m3-react/popper', () => { fireEvent.focus(target) - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitFor(() => { expect(popper.classList.contains('m3-popper_shown')).toBe(true) }) @@ -107,7 +111,7 @@ describe('m3-react/popper', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitFor(() => { expect(popper.classList.contains('m3-popper_shown')).toBe(true) }) @@ -142,7 +146,10 @@ describe('m3-react/popper', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { + popper, + positioner, + } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(-10000, -10000, 40, 20)) @@ -151,7 +158,7 @@ describe('m3-react/popper', () => { }) await waitFor(() => { - expect(popper.style.position).toBe('absolute') + expect(positioner.style.position).toBe('absolute') expect(popper.classList.contains('m3-popper_shown')).toBe(false) }) }) diff --git a/m3-vue/eslint.config.js b/m3-vue/eslint.config.js index de9716d..d1083e4 100644 --- a/m3-vue/eslint.config.js +++ b/m3-vue/eslint.config.js @@ -161,5 +161,19 @@ export default [ 'max-lines-per-function': 'off', }, }, + { + files: [ + '**/*.e2e.ts', + '**/*.e2e.tsx', + '**/*.e2e.test.ts', + '**/*.e2e.test.tsx', + '**/*.smote.ts', + '**/*.smoke.ts', + ], + rules: { + 'max-lines': 'off', + 'max-lines-per-function': 'off', + }, + }, { ignores: ['dist/*'] }, ] diff --git a/m3-vue/src/components/menu/M3Menu.vue b/m3-vue/src/components/menu/M3Menu.vue index 4e4d1db..62b0df5 100644 --- a/m3-vue/src/components/menu/M3Menu.vue +++ b/m3-vue/src/components/menu/M3Menu.vue @@ -12,6 +12,7 @@ :offset-cross-axis="offsetCrossAxis" :delay="delay" :disabled="disabled" + animated :detach-timeout="detachTimeout" v-bind="$attrs" class="m3-menu" @@ -133,4 +134,4 @@ defineEmits([ 'hidden', 'update:shown', ]) - \ No newline at end of file + diff --git a/m3-vue/src/components/popper/M3Popper.vue b/m3-vue/src/components/popper/M3Popper.vue index edd7e70..77189a5 100644 --- a/m3-vue/src/components/popper/M3Popper.vue +++ b/m3-vue/src/components/popper/M3Popper.vue @@ -4,15 +4,21 @@ :to="container" >
- +
+ +
@@ -155,6 +161,11 @@ const props = defineProps({ default: false, }, + animated: { + type: Boolean, + default: false, + }, + detachTimeout: { type: null as unknown as PropType, validator: Or(isNull, isNumeric), @@ -174,6 +185,7 @@ const emit = defineEmits([ ]) const target = computed(() => typeof props.target === 'function' ? props.target() : props.target?.value) +const positioner = ref(null) const popper = ref(null) const positioning = computed(() => ({ @@ -197,21 +209,75 @@ const state = reactive({ const delay = computed(() => normalizeDelay(props.delay)) +const animationBySide = { + top: { + originX: 'center', + originY: 'bottom', + enterX: '0px', + enterY: '-2px', + scaleX: '0.995', + scaleY: '0.72', + }, + bottom: { + originX: 'center', + originY: 'top', + enterX: '0px', + enterY: '2px', + scaleX: '0.995', + scaleY: '0.72', + }, + left: { + originX: 'right', + originY: 'center', + enterX: '-2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + right: { + originX: 'left', + originY: 'center', + enterX: '2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, +} as const + +const applyAnimationSide = (side: 'top' | 'bottom' | 'left' | 'right') => { + const style = popper.value?.style + if (!style) { + return + } + + const preset = animationBySide[side] + style.setProperty('--m3-popper-origin-x', preset.originX) + style.setProperty('--m3-popper-origin-y', preset.originY) + style.setProperty('--m3-popper-enter-x', preset.enterX) + style.setProperty('--m3-popper-enter-y', preset.enterY) + style.setProperty('--m3-popper-scale-x-hidden', preset.scaleX) + style.setProperty('--m3-popper-scale-y-hidden', preset.scaleY) +} + const adjust = async () => { - if (target.value && popper.value && !state.disposed) { - await computePosition(popper.value, target.value, { + if (target.value && positioner.value && !state.disposed) { + const result = await computePosition(positioner.value, target.value, { ...positioning.value, onReferenceHidden: hide, }) + + if (props.animated) { + applyAnimationSide(result.side) + } } } -const contains = (el: Element | null): boolean => popper.value?.contains(el) ?? false +const contains = (el: Element | null): boolean => positioner.value?.contains(el) ?? false const { autoAdjustOn, autoAdjustOff, -} = useAutoUpdate(target, popper, adjust) +} = useAutoUpdate(target, positioner, adjust) const showingScheduler = new Scheduler() const detachScheduler = new Scheduler() @@ -332,8 +398,8 @@ const initialize = (disposed = false): void => { targetListener.start(target.value, props.targetTriggers) } - if (popper.value) { - popperListener.start(popper.value, props.popperTriggers) + if (positioner.value) { + popperListener.start(positioner.value, props.popperTriggers) } } else { state.disposed = true @@ -400,6 +466,17 @@ watch(() => props.disabled, disabled => { } }) +watch(() => props.animated, animated => { + if (!animated && popper.value) { + popper.value.style.removeProperty('--m3-popper-origin-x') + popper.value.style.removeProperty('--m3-popper-origin-y') + popper.value.style.removeProperty('--m3-popper-enter-x') + popper.value.style.removeProperty('--m3-popper-enter-y') + popper.value.style.removeProperty('--m3-popper-scale-x-hidden') + popper.value.style.removeProperty('--m3-popper-scale-y-hidden') + } +}) + onMounted(() => { globalEvents.on('click', onGlobalClick) globalEvents.on('mousedown', onGlobalMousedown) @@ -425,4 +502,4 @@ onBeforeUnmount(() => { dispose() }) - \ No newline at end of file + diff --git a/m3-vue/tests/M3Popper.e2e.ts b/m3-vue/tests/M3Popper.e2e.ts index f36008c..dc82726 100644 --- a/m3-vue/tests/M3Popper.e2e.ts +++ b/m3-vue/tests/M3Popper.e2e.ts @@ -16,6 +16,8 @@ import { vM3PopperCloser, } from '@/components/popper' +type PopperSide = 'top' | 'bottom' | 'left' | 'right' + const rect = (x: number, y: number, width: number, height: number): DOMRect => ( DOMRect.fromRect({ x, @@ -25,10 +27,10 @@ const rect = (x: number, y: number, width: number, height: number): DOMRect => ( }) ) -const parseTransform = (popper: HTMLElement) => { - const match = popper.style.transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px,\s*0px\)/) +const parseTransform = (positioner: HTMLElement) => { + const match = positioner.style.transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px,\s*0px\)/) if (!match) { - throw new Error(`Unexpected transform: ${popper.style.transform}`) + throw new Error(`Unexpected transform: ${positioner.style.transform}`) } return { @@ -37,11 +39,56 @@ const parseTransform = (popper: HTMLElement) => { } } -const expectY = (popper: HTMLElement, expectedY: number) => { - const { y } = parseTransform(popper) +const expectY = (positioner: HTMLElement, expectedY: number) => { + const { y } = parseTransform(positioner) expect(y).toBe(expectedY) } +const expectAnimationSide = (popper: HTMLElement, side: PopperSide) => { + const expected = { + top: { + originX: 'center', + originY: 'bottom', + enterX: '0px', + enterY: '-2px', + scaleX: '0.995', + scaleY: '0.72', + }, + bottom: { + originX: 'center', + originY: 'top', + enterX: '0px', + enterY: '2px', + scaleX: '0.995', + scaleY: '0.72', + }, + left: { + originX: 'right', + originY: 'center', + enterX: '-2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + right: { + originX: 'left', + originY: 'center', + enterX: '2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + }[side] + + expect(popper.classList.contains('m3-popper_animated')).toBe(true) + expect(popper.style.getPropertyValue('--m3-popper-origin-x')).toBe(expected.originX) + expect(popper.style.getPropertyValue('--m3-popper-origin-y')).toBe(expected.originY) + expect(popper.style.getPropertyValue('--m3-popper-enter-x')).toBe(expected.enterX) + expect(popper.style.getPropertyValue('--m3-popper-enter-y')).toBe(expected.enterY) + expect(popper.style.getPropertyValue('--m3-popper-scale-x-hidden')).toBe(expected.scaleX) + expect(popper.style.getPropertyValue('--m3-popper-scale-y-hidden')).toBe(expected.scaleY) +} + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const waitFor = async (assertion: () => void, timeoutMs = 1200) => { @@ -62,14 +109,20 @@ const waitFor = async (assertion: () => void, timeoutMs = 1200) => { } const waitForPopper = async () => { + let positioner: HTMLElement | null = null let popper: HTMLElement | null = null await waitFor(() => { + positioner = document.body.querySelector('.m3-popper-positioner') as HTMLElement | null popper = document.body.querySelector('.m3-popper') as HTMLElement | null + expect(positioner).not.toBeNull() expect(popper).not.toBeNull() }) - return popper as HTMLElement + return { + positioner: positioner as HTMLElement, + popper: popper as HTMLElement, + } } type MountResult = { @@ -139,10 +192,10 @@ const createTarget = () => { } const setupGeometryCase = async (target: HTMLButtonElement) => { - const popper = await waitForPopper() + const elements = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) - return popper + return elements } const waitForShown = async (popper: HTMLElement, shown: boolean) => { @@ -151,7 +204,6 @@ const waitForShown = async (popper: HTMLElement, shown: boolean) => { }) } -// eslint-disable-next-line max-lines-per-function describe('m3-vue/popper e2e', () => { let target: HTMLButtonElement | null = null let mounted: MountResult | null = null @@ -178,19 +230,30 @@ describe('m3-vue/popper e2e', () => { vi.restoreAllMocks() }) test('applies bottom placement geometry with main axis offset', async () => { - const popper = await setupGeometryCase(target as HTMLButtonElement) + const { + popper, + positioner, + } = await setupGeometryCase(target as HTMLButtonElement) await (mounted as MountResult).setProps({ offsetMainAxis: 10, }) await waitFor(() => { - expectY(popper, 80) - expect(popper.style.position).toBe('absolute') + expectY(positioner, 80) + expect(positioner.style.position).toBe('absolute') + expect(popper.classList.contains('m3-popper_animated')).toBe(false) + expect(popper.style.getPropertyValue('--m3-popper-enter-x')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-enter-y')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-scale-x-hidden')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-scale-y-hidden')).toBe('') }) }) test('applies cross axis offset for bottom placement', async () => { - const popper = await setupGeometryCase(target as HTMLButtonElement) + const { + popper, + positioner, + } = await setupGeometryCase(target as HTMLButtonElement) await (mounted as MountResult).setProps({ offsetCrossAxis: 0, @@ -198,8 +261,8 @@ describe('m3-vue/popper e2e', () => { let x0 = 0 await waitFor(() => { - x0 = parseTransform(popper).x - expectY(popper, 70) + x0 = parseTransform(positioner).x + expectY(positioner, 70) }) await (mounted as MountResult).setProps({ @@ -207,56 +270,66 @@ describe('m3-vue/popper e2e', () => { }) await waitFor(() => { - const { x, y } = parseTransform(popper) + const { x, y } = parseTransform(positioner) expect(y).toBe(70) expect(Math.round(x - x0)).toBe(7) - expect(popper.style.position).toBe('absolute') + expect(positioner.style.position).toBe('absolute') }) }) test('changes geometry when placement switches from bottom to right', async () => { - const popper = await setupGeometryCase(target as HTMLButtonElement) + const { + popper, + positioner, + } = await setupGeometryCase(target as HTMLButtonElement) await (mounted as MountResult).setProps({ placement: 'bottom', offsetMainAxis: 0, offsetCrossAxis: 0, + animated: true, }) let bottomX = 0 let bottomY = 0 await waitFor(() => { - const point = parseTransform(popper) + const point = parseTransform(positioner) bottomX = point.x bottomY = point.y + expectAnimationSide(popper, 'bottom') }) await (mounted as MountResult).setProps({ placement: 'right', offsetMainAxis: 0, offsetCrossAxis: 0, + animated: true, }) await waitFor(() => { - const point = parseTransform(popper) + const point = parseTransform(positioner) expect(point.x).toBeGreaterThan(bottomX) expect(point.y).not.toBe(bottomY) - expect(popper.style.position).toBe('absolute') + expectAnimationSide(popper, 'right') }) }) test('applies main axis offset delta for top placement', async () => { - const popper = await setupGeometryCase(target as HTMLButtonElement) + const { + popper, + positioner, + } = await setupGeometryCase(target as HTMLButtonElement) await (mounted as MountResult).setProps({ placement: 'top', offsetMainAxis: 0, offsetCrossAxis: 0, + animated: true, }) let y0 = 0 await waitFor(() => { - y0 = parseTransform(popper).y + y0 = parseTransform(positioner).y }) await (mounted as MountResult).setProps({ @@ -266,9 +339,52 @@ describe('m3-vue/popper e2e', () => { }) await waitFor(() => { - const { y } = parseTransform(popper) + const { y } = parseTransform(positioner) expect(Math.round(Math.abs(y - y0))).toBe(10) - expect(popper.style.position).toBe('absolute') + expect(positioner.style.position).toBe('absolute') + expectAnimationSide(popper, 'top') + }) + }) + + test('updates animation direction after flip when bottom placement has no space', async () => { + const { popper } = await setupGeometryCase(target as HTMLButtonElement) + + await (mounted as MountResult).setProps({ + placement: 'bottom', + overflow: ['flip'], + offsetMainAxis: 0, + offsetCrossAxis: 0, + animated: true, + }) + + vi.spyOn(target as HTMLButtonElement, 'getBoundingClientRect').mockReturnValue(rect(100, window.innerHeight - 12, 40, 20)) + + await (mounted as MountResult).setProps({ + placement: 'bottom', + overflow: ['flip'], + offsetMainAxis: 0, + offsetCrossAxis: 0, + animated: true, + }) + + await waitFor(() => { + expectAnimationSide(popper, 'top') + }) + }) + + test('applies animation vectors for left placement', async () => { + const { popper } = await setupGeometryCase(target as HTMLButtonElement) + + await (mounted as MountResult).setProps({ + placement: 'left', + overflow: [], + offsetMainAxis: 0, + offsetCrossAxis: 0, + animated: true, + }) + + await waitFor(() => { + expectAnimationSide(popper, 'left') }) }) @@ -280,7 +396,7 @@ describe('m3-vue/popper e2e', () => { }, 'Close'), [[vM3PopperCloser]]), }) - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitForShown(popper, true) const button = document.body.querySelector('[data-testid="closer-button"]') as HTMLButtonElement @@ -300,7 +416,7 @@ describe('m3-vue/popper e2e', () => { }, 'Menu item'), [[vM3PopperCloser, true, undefined, { all: true }]]), }) - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitForShown(popper, true) const menuItem = document.body.querySelector('[data-testid="menu-item-closer"]') as HTMLDivElement @@ -319,7 +435,7 @@ describe('m3-vue/popper e2e', () => { }, 'No close'), [[vM3PopperCloser, false]]), }) - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitForShown(popper, true) const button = document.body.querySelector('[data-testid="disabled-closer-button"]') as HTMLButtonElement From 85cbddf273a1b5e026d461040b6d638f1d997dc7 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Sun, 22 Feb 2026 20:47:01 +0400 Subject: [PATCH 02/37] feat(m3-react): Added Select and Slider parity with Storybook updates Added M3Select and M3Slider components with public exports for React. Added Storybook stories/docs for both components and country flag assets for M3Select. Updated story sorting and reworked M3Link stories to show custom controls based on M3Link, including target=_blank for example.com links. --- m3-react/src/components/select/M3Select.tsx | 258 +++++++ m3-react/src/components/select/index.ts | 6 + m3-react/src/components/slider/M3Slider.tsx | 725 ++++++++++++++++++ m3-react/src/components/slider/index.ts | 7 + m3-react/src/index.ts | 19 + .../storybook/components/M3Link.stories.tsx | 192 ++++- m3-react/storybook/components/M3Select.mdx | 8 + .../storybook/components/M3Select.stories.tsx | 126 +++ m3-react/storybook/components/M3Slider.mdx | 8 + .../storybook/components/M3Slider.stories.tsx | 81 ++ m3-react/storybook/countries/CountryFlag.tsx | 32 + .../countries/CountryFlagProvider.ts | 68 ++ m3-react/storybook/countries/codes.ts | 49 ++ m3-react/storybook/countries/names.json | 17 + m3-react/storybook/preview.ts | 26 +- 15 files changed, 1600 insertions(+), 22 deletions(-) create mode 100644 m3-react/src/components/select/M3Select.tsx create mode 100644 m3-react/src/components/select/index.ts create mode 100644 m3-react/src/components/slider/M3Slider.tsx create mode 100644 m3-react/src/components/slider/index.ts create mode 100644 m3-react/storybook/components/M3Select.mdx create mode 100644 m3-react/storybook/components/M3Select.stories.tsx create mode 100644 m3-react/storybook/components/M3Slider.mdx create mode 100644 m3-react/storybook/components/M3Slider.stories.tsx create mode 100644 m3-react/storybook/countries/CountryFlag.tsx create mode 100644 m3-react/storybook/countries/CountryFlagProvider.ts create mode 100644 m3-react/storybook/countries/codes.ts create mode 100644 m3-react/storybook/countries/names.json diff --git a/m3-react/src/components/select/M3Select.tsx b/m3-react/src/components/select/M3Select.tsx new file mode 100644 index 0000000..fa56363 --- /dev/null +++ b/m3-react/src/components/select/M3Select.tsx @@ -0,0 +1,258 @@ +import type { + FC, + HTMLAttributes, + ReactElement, + ReactNode, + SVGAttributes, +} from 'react' + +import type { Placement } from '@floating-ui/dom' + +import { + M3Menu, + M3MenuItem, +} from '@/components/menu' +import { M3ScrollRail } from '@/components/scroll-rail' +import { M3TextField } from '@/components/text-field' + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { + useId, +} from '@/hooks' + +import { distinct } from '@/utils/content' +import { toClassName } from '@/utils/styling' + +export type M3SelectOption = { + value: Value; + label: string; +} + +type SelectValue = Value | null +type SlotContext = { + active: boolean; + option: M3SelectOption; +} + +export interface M3SelectProps extends HTMLAttributes { + id?: string; + value?: SelectValue; + label?: string; + options?: Array>; + equalPredicate?: (a: SelectValue, b: SelectValue) => boolean; + invalid?: boolean; + placeholder?: string; + placement?: Placement; + disabled?: boolean; + readonly?: boolean; + outlined?: boolean; + onUpdate?: (value: Value) => void; +} + +const CaretIcon: FC> = (attrs) => ( + + + +) + +const Leading: FC<{ children: ReactNode }> = props => <>{props.children} +const OptionLeading: FC<{ children: ReactNode }> = props => <>{props.children} +const OptionContent: FC<{ children: ReactNode }> = props => <>{props.children} + +const asRenderProp = (value: unknown): null | ((context: Context) => ReactNode) => { + return typeof value === 'function' ? value as (context: Context) => ReactNode : null +} + +const renderSlot = (slot: ReactElement | null, context: Context): ReactNode => { + if (!slot) { + return null + } + + const child = (slot.props as { children?: unknown }).children + const renderProp = asRenderProp(child) + + return renderProp ? renderProp(context) : child as ReactNode +} + +const M3Select = ({ + id, + value = null, + label = '', + options = [], + equalPredicate = (a, b) => a === b, + invalid = false, + placeholder = '', + placement = 'bottom-start', + disabled = false, + readonly = false, + outlined = false, + className = '', + children = [], + onUpdate = (_: Value) => {}, + ...attrs +}: M3SelectProps) => { + const _id = useId(id, 'm3-select') + + const [expanded, setExpanded] = useState(false) + const [shouldBeExpanded, setShouldBeExpanded] = useState(false) + const [rootWidth, setRootWidth] = useState(0) + + const root = useRef(null) + + const [slots] = useMemo(() => distinct(children, { + leading: Leading, + optionLeading: OptionLeading, + optionContent: OptionContent, + }), [children]) + + const text = useMemo(() => { + return options.find(option => equalPredicate(option.value, value))?.label ?? '' + }, [ + options, + value, + equalPredicate, + ]) + + const pick = useCallback((option: M3SelectOption) => { + onUpdate(option.value) + setShouldBeExpanded(false) + }, [ + onUpdate, + ]) + + useEffect(() => { + const _root = root.current + if (!_root) { + return + } + + setRootWidth(_root.offsetWidth) + + let frameId: number | null = null + const observer = new ResizeObserver(([entry]) => { + if (!entry) { + return + } + + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + + frameId = requestAnimationFrame(() => setRootWidth(entry.contentRect.width)) + }) + + observer.observe(_root) + + return () => { + observer.disconnect() + + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, []) + + return ( +
+ + {slots.leading ? ( + + {renderSlot(slots.leading, { active: shouldBeExpanded })} + + ) : null} + + + + + + { + setExpanded(shown) + setShouldBeExpanded(shown) + }} + > +
+ + + {options.map((option, index) => ( + pick(option)} + > + {slots.optionLeading ? ( + + {renderSlot>(slots.optionLeading, { + option, + active: shouldBeExpanded, + })} + + ) : null} + + {slots.optionContent ? ( + renderSlot>(slots.optionContent, { + option, + active: shouldBeExpanded, + }) + ) : option.label} + + ))} +
+
+
+ ) +} + +export default Object.assign(M3Select, { + Leading, + OptionLeading, + OptionContent, +}) diff --git a/m3-react/src/components/select/index.ts b/m3-react/src/components/select/index.ts new file mode 100644 index 0000000..0ecbae8 --- /dev/null +++ b/m3-react/src/components/select/index.ts @@ -0,0 +1,6 @@ +export type { + M3SelectProps, + M3SelectOption, +} from './M3Select' + +export { default as M3Select } from './M3Select' diff --git a/m3-react/src/components/slider/M3Slider.tsx b/m3-react/src/components/slider/M3Slider.tsx new file mode 100644 index 0000000..d90ddb6 --- /dev/null +++ b/m3-react/src/components/slider/M3Slider.tsx @@ -0,0 +1,725 @@ +import type { + CSSProperties, + FC, + HTMLAttributes, + KeyboardEvent as ReactKeyboardEvent, +} from 'react' + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { compose } from '@/utils/events' +import { toClassName } from '@/utils/styling' + +type AriaOptions = { + label?: string; + labelledBy?: string; +} + +type DraggingHandle = 'max' | 'min' + +export type M3SliderType = 'single' | 'range' +export type M3SliderValue = number | [number, number] | null + +export interface M3SliderProps extends HTMLAttributes { + type?: M3SliderType; + value?: M3SliderValue; + max?: number; + min?: number; + step?: number; + disabled?: boolean; + ariaHandle?: AriaOptions; + ariaHandleMax?: AriaOptions; + ariaHandleMin?: AriaOptions; + onUpdate?: (value: number | [number, number]) => void; +} + +const ariaOptionsToAttrs = (options: AriaOptions): { + 'aria-label'?: string; + 'aria-labelledby'?: string; +} => { + return { + ...(options.label ? { 'aria-label': options.label } : {}), + ...(options.labelledBy ? { 'aria-labelledby': options.labelledBy } : {}), + } +} + +const restrict = (value: number, [min, max]: [number, number]): number => { + return Math.max(Math.min(max, value), min) +} + +const distance = (a: number, b: number): number => Math.abs(a - b) +const inRange = (value: number, [min, max]: [number, number]): boolean => min <= value && value <= max +const toGap = ({ left, right }: DOMRect): [number, number] => [left, right] + +const withPercentage = (value: number): CSSProperties => { + return { '--percentage': `${value}%` } as CSSProperties +} + +const getEventX = (event: globalThis.MouseEvent | globalThis.TouchEvent): number => { + return 'clientX' in event ? event.clientX : event.touches[0].clientX +} + +const M3Slider: FC = ({ + type = 'single', + value = null, + max = 100, + min = 0, + step = 0, + disabled = false, + ariaHandle = {}, + ariaHandleMax = {}, + ariaHandleMin = {}, + className = '', + onKeyDown = () => {}, + onKeyUp = () => {}, + onUpdate = (_value) => {}, + ...attrs +}) => { + const [dragging, setDragging] = useState<{ + max: number | null; + min: number | null; + }>({ + max: null, + min: null, + }) + const [draggingHandle, setDraggingHandle] = useState(null) + + const keys = useRef({ + space: false, + }) + + const track = useRef(null) + const fillerActive = useRef(null) + const handleMax = useRef(null) + const handleMin = useRef(null) + const notches = useRef>([]) + const draggingResetId = useRef(null) + + const safeStep = Math.max(step, 0) + + const current = useMemo<[number, number]>(() => { + if (Array.isArray(value)) { + return value + } + + return value === null ? [min, max] : [value, value] + }, [ + max, + min, + value, + ]) + + const percentageOf = useCallback((value: number): number => { + const denominator = max - min + + if (denominator === 0) { + return 0 + } + + return 100 * Math.abs(restrict(value, [min, max]) / denominator) + }, [ + max, + min, + ]) + + const percentage = useMemo(() => { + const [valueMin, valueMax] = current + + return { + max: dragging.max ?? percentageOf(valueMax), + min: dragging.min ?? percentageOf(valueMin), + } + }, [ + current, + dragging.max, + dragging.min, + percentageOf, + ]) + + const steps = useMemo(() => { + const steps: number[] = [] + + if (safeStep > 0) { + let next = min + safeStep + + while (next < max) { + steps.push(next) + next += safeStep + } + } + + return steps + }, [ + max, + min, + safeStep, + ]) + + const nearest = useCallback((value: number) => { + if (safeStep > 0) { + let prev = min + + while (prev + safeStep < value) { + prev += safeStep + } + + const next = prev + safeStep + + return distance(value, prev) < distance(value, next) ? prev : next + } + + return value + }, [ + min, + safeStep, + ]) + + const getEventShare = useCallback((event: globalThis.MouseEvent | globalThis.TouchEvent): number | null => { + const _track = track.current + + if (!_track) { + return null + } + + const width = _track.offsetWidth + const { left, right } = _track.getBoundingClientRect() + + return width > 0 + ? (restrict(getEventX(event), [left, right]) - left) / width + : null + }, []) + + const getEventValue = useCallback((event: globalThis.MouseEvent | globalThis.TouchEvent): number | null => { + const share = getEventShare(event) + + if (share === null) { + return null + } + + return nearest(min + (max - min) * share) + }, [ + getEventShare, + max, + min, + nearest, + ]) + + const stepFor = useCallback((leap: boolean): number => { + const step = distance(min, max) / 100 + + return safeStep > 0 ? safeStep : leap ? 10 * step : step + }, [ + max, + min, + safeStep, + ]) + + const nextFor = useCallback((value: number, stepOrLeap: number | boolean = false): number => { + const step = typeof stepOrLeap === 'boolean' ? stepFor(stepOrLeap) : stepOrLeap + const next = value + step + + return distance(next, max) < step ? max : next + }, [ + max, + stepFor, + ]) + + const rangeBy = useCallback((value: number, step: number): [number, number] => { + const restricted = restrict(value, [min, max]) + + if (step > 0) { + let prev = min + + while (prev + step < restricted) { + prev += step + } + + return [prev, nextFor(prev, step)] + } + + return [restricted, restricted] + }, [ + max, + min, + nextFor, + ]) + + const resetDragging = useCallback((handle: DraggingHandle) => { + if (draggingResetId.current !== null) { + cancelAnimationFrame(draggingResetId.current) + } + + draggingResetId.current = requestAnimationFrame(() => { + setDragging(current => ({ + ...current, + [handle]: null, + })) + draggingResetId.current = null + }) + }, []) + + const setValueMax = useCallback((value: number) => { + if (type === 'range') { + const [valueMin] = current + + onUpdate([valueMin, Math.max(valueMin, value)]) + + return + } + + onUpdate(value) + }, [ + current, + onUpdate, + type, + ]) + + const setValueMin = useCallback((value: number) => { + if (type === 'range') { + const [, valueMax] = current + + onUpdate([Math.min(value, valueMax), valueMax]) + } + }, [ + current, + onUpdate, + type, + ]) + + const onNotchMaxClick = useCallback(() => { + if (disabled) { + return + } + + if (type === 'single') { + onUpdate(max) + return + } + + onUpdate([current[0], max]) + }, [ + current, + disabled, + max, + onUpdate, + type, + ]) + + const onNotchMinClick = useCallback(() => { + if (disabled) { + return + } + + if (type === 'single') { + onUpdate(min) + return + } + + onUpdate([min, current[1]]) + }, [ + current, + disabled, + min, + onUpdate, + type, + ]) + + const onNotchClick = useCallback((value: number, index: number) => { + if (disabled || notches.current[index]?.classList.contains('m3-slider__notch_hidden')) { + return + } + + if (type === 'single') { + onUpdate(value) + return + } + + const [valueMin, valueMax] = current + + onUpdate(distance(value, valueMin) < distance(value, valueMax) + ? [value, valueMax] + : [valueMin, value]) + }, [ + current, + disabled, + onUpdate, + type, + ]) + + const onKeyDownForMax = useCallback((event: ReactKeyboardEvent) => { + if (disabled) { + return + } + + const [, valueMax] = current + const [rangeMin, rangeMax] = rangeBy(valueMax, stepFor(keys.current.space)) + + switch (event.code) { + case 'ArrowLeft': + setValueMax(rangeMin) + break + case 'ArrowRight': + setValueMax(rangeMax === valueMax ? nextFor(rangeMax, keys.current.space) : rangeMax) + break + case 'End': + setValueMax(max) + break + case 'Home': + setValueMax(min) + break + default: + break + } + }, [ + current, + disabled, + max, + min, + nextFor, + rangeBy, + setValueMax, + stepFor, + ]) + + const onKeyDownForMin = useCallback((event: ReactKeyboardEvent) => { + if (disabled) { + return + } + + const [valueMin] = current + const [rangeMin, rangeMax] = rangeBy(valueMin, stepFor(keys.current.space)) + + switch (event.code) { + case 'ArrowLeft': + setValueMin(rangeMin) + break + case 'ArrowRight': + setValueMin(rangeMax === valueMin ? nextFor(rangeMax, keys.current.space) : rangeMax) + break + case 'End': + setValueMin(max) + break + case 'Home': + setValueMin(min) + break + default: + break + } + }, [ + current, + disabled, + max, + min, + nextFor, + rangeBy, + setValueMin, + stepFor, + ]) + + const onMoveMax = useCallback((event: globalThis.MouseEvent | globalThis.TouchEvent) => { + const value = getEventValue(event) + const [valueMin] = current + + if (value === null) { + return + } + + if (type === 'single') { + setDragging(current => ({ + ...current, + max: percentageOf(value), + })) + setValueMax(value) + } else { + setDragging(current => ({ + ...current, + max: percentageOf(Math.max(valueMin, value)), + })) + setValueMax(value) + } + + resetDragging('max') + }, [ + current, + getEventValue, + percentageOf, + resetDragging, + setValueMax, + type, + ]) + + const onMoveMin = useCallback((event: globalThis.MouseEvent | globalThis.TouchEvent) => { + if (type === 'single') { + return + } + + const value = getEventValue(event) + const [, valueMax] = current + + if (value === null) { + return + } + + setDragging(current => ({ + ...current, + min: percentageOf(Math.min(value, valueMax)), + })) + setValueMin(value) + resetDragging('min') + }, [ + current, + getEventValue, + percentageOf, + resetDragging, + setValueMin, + type, + ]) + + const updateNotches = useCallback(() => { + const _active = fillerActive.current?.getBoundingClientRect() + const _max = handleMax.current?.getBoundingClientRect() + const _min = handleMin.current?.getBoundingClientRect() + + notches.current.forEach((notch) => { + if (!notch) { + return + } + + const { left: x } = notch.getBoundingClientRect() + + const hidden = _max && (inRange(x - 2, toGap(_max)) || inRange(x + 2, toGap(_max))) || + _min && (inRange(x - 2, toGap(_min)) || inRange(x + 2, toGap(_min))) + + notch.classList.toggle('m3-slider__notch_active', !!_active && inRange(x, toGap(_active))) + notch.classList.toggle('m3-slider__notch_hidden', !!hidden) + + notch.setAttribute('aria-hidden', hidden ? 'true' : 'false') + }) + }, []) + + const setNotchAt = useCallback((index: number, notch: HTMLDivElement | null) => { + notches.current[index] = notch + }, []) + + useEffect(() => { + if (!draggingHandle || disabled) { + return + } + + const onMove = (event: globalThis.MouseEvent | globalThis.TouchEvent) => { + draggingHandle === 'max' ? onMoveMax(event) : onMoveMin(event) + } + + const stop = () => setDraggingHandle(null) + + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', stop) + window.addEventListener('touchmove', onMove) + window.addEventListener('touchcancel', stop) + window.addEventListener('touchend', stop) + + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', stop) + window.removeEventListener('touchmove', onMove) + window.removeEventListener('touchcancel', stop) + window.removeEventListener('touchend', stop) + } + }, [ + disabled, + draggingHandle, + onMoveMax, + onMoveMin, + ]) + + useEffect(() => { + if (disabled) { + setDraggingHandle(null) + } + }, [disabled]) + + useEffect(() => { + const updateId = requestAnimationFrame(updateNotches) + + return () => cancelAnimationFrame(updateId) + }, [ + current, + dragging.max, + dragging.min, + steps, + updateNotches, + ]) + + useEffect(() => { + const observer = new ResizeObserver(() => requestAnimationFrame(updateNotches)) + + if (fillerActive.current) { + observer.observe(fillerActive.current) + } + + if (handleMax.current) { + observer.observe(handleMax.current) + } + + if (handleMin.current) { + observer.observe(handleMin.current) + } + + return () => observer.disconnect() + }, [ + type, + updateNotches, + ]) + + useEffect(() => { + return () => { + if (draggingResetId.current !== null) { + cancelAnimationFrame(draggingResetId.current) + } + } + }, []) + + return ( +
0, + 'm3-slider_disabled': disabled, + }])} + role="group" + onKeyDown={compose((event) => { + if (event.code === 'Space') { + keys.current.space = true + } + }, onKeyDown)} + onKeyUp={compose((event) => { + if (event.code === 'Space') { + keys.current.space = false + } + }, onKeyUp)} + {...attrs} + > +
+
+
setNotchAt(0, el)} + aria-label={String(min)} + className="m3-slider__notch" + style={withPercentage(0)} + role="button" + onClick={onNotchMinClick} + > +
+
+ + {steps.map((p, i) => ( +
setNotchAt(i + 1, el)} + aria-label={String(p)} + className="m3-slider__notch" + style={withPercentage(percentageOf(p))} + role="button" + onClick={() => onNotchClick(p, i + 1)} + > +
+
+ ))} + +
setNotchAt(steps.length + 1, el)} + aria-label={String(max)} + className="m3-slider__notch" + style={withPercentage(100)} + role="button" + onClick={onNotchMaxClick} + > +
+
+
+ + {type === 'range' ? ( +
+
{ + if (!disabled && event.button === 0) { + setDraggingHandle('min') + } + }} + /> +
+ ) : null} + +
+
{ + if (!disabled && event.button === 0) { + setDraggingHandle('max') + } + }} + /> +
+ + {type === 'range' ? ( +
+ ) : null} + +
+ +
+
+
+ ) +} + +export default M3Slider diff --git a/m3-react/src/components/slider/index.ts b/m3-react/src/components/slider/index.ts new file mode 100644 index 0000000..22c0eb8 --- /dev/null +++ b/m3-react/src/components/slider/index.ts @@ -0,0 +1,7 @@ +export type { + M3SliderProps, + M3SliderType, + M3SliderValue, +} from './M3Slider' + +export { default as M3Slider } from './M3Slider' diff --git a/m3-react/src/index.ts b/m3-react/src/index.ts index b8dcdac..05606e1 100644 --- a/m3-react/src/index.ts +++ b/m3-react/src/index.ts @@ -61,6 +61,17 @@ export type { M3ScrollRailProps, } from '@/components/scroll-rail' +export type { + M3SelectOption, + M3SelectProps, +} from '@/components/select' + +export type { + M3SliderProps, + M3SliderType, + M3SliderValue, +} from '@/components/slider' + export type { M3SwitchMethods, M3SwitchProps, @@ -139,6 +150,14 @@ export { M3SideSheet, } from '@/components/side-sheet' +export { + M3Select, +} from '@/components/select' + +export { + M3Slider, +} from '@/components/slider' + export { M3Switch, M3SwitchScope, diff --git a/m3-react/storybook/components/M3Link.stories.tsx b/m3-react/storybook/components/M3Link.stories.tsx index 30ab11f..962cfb4 100644 --- a/m3-react/storybook/components/M3Link.stories.tsx +++ b/m3-react/storybook/components/M3Link.stories.tsx @@ -1,7 +1,181 @@ +import type { + CSSProperties, + FC, +} from 'react' import type { Meta, StoryObj } from '@storybook/react' +import type { M3LinkProps } from '@/components/link' import { M3Link } from '@/components/link' +const styles = { + stack: { + display: 'grid', + gap: '16px', + minWidth: '360px', + } as CSSProperties, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: '12px', + } as CSSProperties, + section: { + display: 'grid', + gap: '8px', + } as CSSProperties, + title: { + margin: 0, + fontWeight: 600, + fontSize: '14px', + } as CSSProperties, + description: { + margin: 0, + color: '#5f6368', + fontSize: '13px', + lineHeight: 1.4, + } as CSSProperties, + solidButton: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '38px', + border: 0, + borderRadius: '10px', + padding: '0 14px', + fontWeight: 600, + fontSize: '14px', + color: '#ffffff', + background: '#0f6adf', + textDecoration: 'none', + cursor: 'pointer', + } as CSSProperties, + ghostButton: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '38px', + borderRadius: '10px', + border: '1px solid #c4d1e0', + padding: '0 14px', + fontWeight: 600, + fontSize: '14px', + color: '#243447', + background: '#ffffff', + textDecoration: 'none', + cursor: 'pointer', + } as CSSProperties, + textLink: { + color: '#0f6adf', + textDecoration: 'underline', + textUnderlineOffset: '2px', + fontWeight: 500, + } as CSSProperties, + tileLink: { + display: 'grid', + gap: '4px', + borderRadius: '12px', + border: '1px solid #dbe5ef', + padding: '12px', + textDecoration: 'none', + color: '#1f2d3a', + background: '#f8fbff', + minWidth: '220px', + } as CSSProperties, + tileTitle: { + fontWeight: 600, + fontSize: '14px', + } as CSSProperties, + tileMeta: { + fontSize: '12px', + color: '#5f6368', + } as CSSProperties, +} as const + +const PrimaryAction: FC> = (props) => { + return ( + + Save changes + + ) +} + +const SecondaryAction: FC> = (props) => { + return ( + + Cancel + + ) +} + +const DocumentationLink: FC> = (props) => { + return ( + + Read API reference + + ) +} + +const ResourceCardLink: FC> = (props) => { + return ( + + Deploy checklist + 8 items • 5 minutes + + ) +} + +const M3LinkAsBaseStory = () => { + return ( +
+
+

Custom button controls on top of `M3Link`

+

+ Same primitive, different presentation and semantics: + one remains a button, another becomes an anchor. +

+
+ + + +
+
+ +
+

Custom link controls on top of `M3Link`

+

+ Inline text-link and card-link are also built from the same base element. +

+
+ + +
+
+
+ ) +} + +const PrimitiveShapeStory = (args: M3LinkProps) => { + const sharedStyle = args.href.length > 0 ? styles.ghostButton : styles.solidButton + + return ( + + {args.href.length > 0 ? 'I am rendered as ' : 'I am rendered as
-### Resources +## Resources * [Guidelines](https://m3.material.io/components/switch/guidelines) + * [M3 Switch overview](https://m3.material.io/components/switch/overview) * [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) + * [WAI-ARIA APG: Switch Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) diff --git a/m3-react/storybook/components/M3TextField.mdx b/m3-react/storybook/components/M3TextField.mdx index f746e38..be61cc9 100644 --- a/m3-react/storybook/components/M3TextField.mdx +++ b/m3-react/storybook/components/M3TextField.mdx @@ -10,7 +10,18 @@ import * as M3TextFieldStories from './M3TextField.stories' Text fields let users enter text into a UI. -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## Accessibility semantics + +- Provide a persistent text label (`label` prop or explicit aria-labelledby). +- Use `aria-invalid` together with helper/support text for validation feedback. +- For long-form input, prefer multiline mode (`textarea`) with the same labeling strategy. + +## Resources + +- [M3 Text Fields overview](https://m3.material.io/components/text-fields/overview) +- [M3 Text Fields guidelines](https://m3.material.io/components/text-fields/guidelines) +- [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +- [WAI Tutorials: Form Labels](https://www.w3.org/WAI/tutorials/forms/labels/) diff --git a/m3-react/storybook/examples/dialog/DialogConfirmation.tsx b/m3-react/storybook/examples/dialog/DialogConfirmation.tsx index 1c5a5f1..83c88c5 100644 --- a/m3-react/storybook/examples/dialog/DialogConfirmation.tsx +++ b/m3-react/storybook/examples/dialog/DialogConfirmation.tsx @@ -8,6 +8,8 @@ import { useState } from 'react' const DialogConfirmation: FC = () => { const [opened, setOpened] = useState(false) + const dialogTitleId = 'dialog-confirmation-title' + const dialogDescriptionId = 'dialog-confirmation-description' return ( <> @@ -21,7 +23,9 @@ const DialogConfirmation: FC = () => { @@ -29,10 +33,12 @@ const DialogConfirmation: FC = () => { -

Permanently delete?

+

Permanently delete?

- Deleting the selected messages will also remove them from all synced devices. +

+ Deleting the selected messages will also remove them from all synced devices. +

{ const [target, setTarget] = useTarget() + const tooltipId = 'delete-tooltip-description' return ( <> - + Delete - + Deleting item diff --git a/m3-vue/storybook/components/M3Button.mdx b/m3-vue/storybook/components/M3Button.mdx index 13c6818..da1b2eb 100644 --- a/m3-vue/storybook/components/M3Button.mdx +++ b/m3-vue/storybook/components/M3Button.mdx @@ -9,6 +9,12 @@ import * as M3ButtonStories from './M3Button.stories' Common buttons prompt most actions in a UI +## Accessibility semantics + +- Use a clear text label for action buttons. +- For icon-only actions, provide `aria-label`. +- Keep disabled actions unavailable through the `disabled` state. +

@@ -24,3 +30,9 @@ Common buttons prompt most actions in a UI

+ +## Resources + +- [M3 Buttons overview](https://m3.material.io/components/buttons/overview) +- [M3 Buttons guidelines](https://m3.material.io/components/buttons/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3Card.mdx b/m3-vue/storybook/components/M3Card.mdx index a458dad..51b4af4 100644 --- a/m3-vue/storybook/components/M3Card.mdx +++ b/m3-vue/storybook/components/M3Card.mdx @@ -12,7 +12,11 @@ import { defineComponent, h } from 'vue' Cards display content and actions about a single subject -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## Accessibility semantics + +- Use non-interactive cards as plain content containers. +- For interactive cards, expose actions with semantic controls (`button` / `link`). +- Keep a clear heading hierarchy inside cards for screen reader navigation. h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '16px' } }, [ @@ -20,3 +24,10 @@ Cards display content and actions about a single subject h(LiveMusic2), ]), })} /> + +## Resources + +- [M3 Cards overview](https://m3.material.io/components/cards/overview) +- [M3 Cards guidelines](https://m3.material.io/components/cards/guidelines) +- [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3Checkbox.mdx b/m3-vue/storybook/components/M3Checkbox.mdx index 459511e..59c08f1 100644 --- a/m3-vue/storybook/components/M3Checkbox.mdx +++ b/m3-vue/storybook/components/M3Checkbox.mdx @@ -9,9 +9,11 @@ import * as M3CheckboxStories from './M3Checkbox.stories' Checkboxes let users select one or more items from a list, or turn an item on or off -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## Accessibility semantics -[Guidelines](https://m3.material.io/components/checkbox/guidelines) +- Every checkbox needs a visible text label. +- Group related checkboxes under a shared group label (`fieldset/legend` or `aria-labelledby`). +- Use indeterminate state only for partial parent-selection states. ### Regular list @@ -42,3 +44,10 @@ Checkboxes let users select one or more items from a list, or turn an item on or value: 'monthly', }], }]} /> + +## Resources + +- [M3 Checkbox overview](https://m3.material.io/components/checkbox/overview) +- [M3 Checkbox guidelines](https://m3.material.io/components/checkbox/guidelines) +- [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +- [WAI-ARIA APG: Checkbox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) diff --git a/m3-vue/storybook/components/M3Dialog.mdx b/m3-vue/storybook/components/M3Dialog.mdx index c7b63f8..26731a0 100644 --- a/m3-vue/storybook/components/M3Dialog.mdx +++ b/m3-vue/storybook/components/M3Dialog.mdx @@ -5,8 +5,23 @@ import DialogConfirmation from '../examples/dialog/DialogConfirmation.vue' # Dialogs +Dialogs communicate important information and block the underlying interface until the user responds. + +## Accessibility semantics + +- Set `role="dialog"` (or `alertdialog` for urgent confirmations). +- Provide `aria-modal="true"` for modal flows. +- Connect title and description with `aria-labelledby` and `aria-describedby`. +- Keep focus inside the dialog while it is opened. +
-
\ No newline at end of file + + +## Resources + +- [M3 Dialogs overview](https://m3.material.io/components/dialogs/overview) +- [M3 Dialogs guidelines](https://m3.material.io/components/dialogs/guidelines) +- [WAI-ARIA APG: Modal Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) diff --git a/m3-vue/storybook/components/M3FabButton.mdx b/m3-vue/storybook/components/M3FabButton.mdx index 02b7e0f..aa2a8a6 100644 --- a/m3-vue/storybook/components/M3FabButton.mdx +++ b/m3-vue/storybook/components/M3FabButton.mdx @@ -9,11 +9,13 @@ import * as M3FabButtonStories from './M3FabButton.stories' Floating action buttons (FABs) help people take primary actions -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## Accessibility semantics -### Standard FABs +- Icon-only FABs require an accessible name via `aria-label`. +- Keep FAB usage focused on the primary action on a given surface. +- Extended FABs should keep a short visible label. -[Guidelines](https://m3.material.io/components/floating-action-button/guidelines) +### Standard FABs

@@ -24,11 +26,18 @@ Floating action buttons (FABs) help people take primary actions ### Extended FABs -[Guidelines](https://m3.material.io/components/extended-fab/guidelines) -

+ +## Resources + +- [M3 FAB overview](https://m3.material.io/components/floating-action-button/overview) +- [M3 FAB guidelines](https://m3.material.io/components/floating-action-button/guidelines) +- [M3 Extended FAB overview](https://m3.material.io/components/extended-fab/overview) +- [M3 Extended FAB guidelines](https://m3.material.io/components/extended-fab/guidelines) +- [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3IconButton.mdx b/m3-vue/storybook/components/M3IconButton.mdx index 8bbafb0..17b8c71 100644 --- a/m3-vue/storybook/components/M3IconButton.mdx +++ b/m3-vue/storybook/components/M3IconButton.mdx @@ -12,7 +12,11 @@ import { M3IconButton } from '@/components/icon-button' Icon buttons help people take minor actions with one tap -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## Accessibility semantics + +- Icon-only controls must include an accessible name (`aria-label`). +- Toggleable icon buttons should expose state with `aria-pressed`. +- Use icon buttons for minor actions, not for primary destructive actions. [ - h(M3IconButton, { appearance: 'filled' }, icon), + h(M3IconButton, { + appearance: 'filled', + 'aria-label': 'Mark as favorite', + }, icon), h(M3IconButton, { appearance: 'filled', selected: selected.value, toggleable: true, + 'aria-label': 'Toggle favorite', + 'aria-pressed': selected.value ? 'true' : 'false', onClick: () => selected.value = !selected.value, }, icon) ] }, })} /> + +## Resources + +- [M3 Icon Buttons overview](https://m3.material.io/components/icon-buttons/overview) +- [M3 Icon Buttons guidelines](https://m3.material.io/components/icon-buttons/guidelines) +- [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3Navigation.mdx b/m3-vue/storybook/components/M3Navigation.mdx index 856bd5a..d6f4660 100644 --- a/m3-vue/storybook/components/M3Navigation.mdx +++ b/m3-vue/storybook/components/M3Navigation.mdx @@ -4,3 +4,20 @@ import * as M3NavigationStories from './M3Navigation.stories' # Navigation + +Navigation components help users move between top-level destinations. + +## Accessibility semantics + +- Treat the container as a navigation landmark and provide a clear label when needed. +- Keep destination labels concise and unique. +- Expose active destination state consistently in routed integrations. + +## Resources + +- [M3 Navigation bar overview](https://m3.material.io/components/navigation-bar/overview) +- [M3 Navigation rail overview](https://m3.material.io/components/navigation-rail/overview) +- [M3 Navigation drawer overview](https://m3.material.io/components/navigation-drawer/overview) +- [Storybook: NavigationDrawer](?path=/story/components-m3navigation--navigation-drawer) +- [Storybook: NavigationRail](?path=/story/components-m3navigation--navigation-rail) +- [WAI-ARIA APG: Landmark Regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) diff --git a/m3-vue/storybook/components/M3RichTooltip.mdx b/m3-vue/storybook/components/M3RichTooltip.mdx index 8579073..8d02f3a 100644 --- a/m3-vue/storybook/components/M3RichTooltip.mdx +++ b/m3-vue/storybook/components/M3RichTooltip.mdx @@ -5,8 +5,22 @@ import DeleteTooltip from '../examples/rich-tooltip/DeleteTooltip.vue' # Rich tooltip +Rich tooltips provide contextual, supplementary information near a trigger element. + +## Accessibility semantics + +- Link trigger and tooltip with `aria-describedby`. +- Keep tooltip text concise and task-relevant. +- Avoid using tooltips as the only way to convey critical information. +
+ +## Resources + +- [M3 Tooltips overview](https://m3.material.io/components/tooltips/overview) +- [M3 Tooltips guidelines](https://m3.material.io/components/tooltips/guidelines) +- [WAI-ARIA APG: Tooltip Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) diff --git a/m3-vue/storybook/components/M3Select.mdx b/m3-vue/storybook/components/M3Select.mdx index 134429c..b16b8f6 100644 --- a/m3-vue/storybook/components/M3Select.mdx +++ b/m3-vue/storybook/components/M3Select.mdx @@ -1,4 +1,4 @@ -import { Meta } from '@storybook/addon-docs/blocks' +import { Canvas, Meta } from '@storybook/addon-docs/blocks' import * as M3SelectStories from './M3Select.stories' @@ -6,3 +6,21 @@ import * as M3SelectStories from './M3Select.stories' # M3Select Text field augmented with dropdown menu + +## Accessibility semantics + +- `M3Select` exposes combobox semantics with a listbox popup and option items. +- Provide a visible label through `label` or an explicit aria-label/aria-labelledby strategy. +- Keep option labels unique and meaningful for screen readers. + +## Demo + + + + +## Resources + +- [M3 Menus overview](https://m3.material.io/components/menus/overview) +- [M3 Menus guidelines](https://m3.material.io/components/menus/guidelines) +- [WAI-ARIA APG: Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) +- [WAI-ARIA APG: Listbox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) diff --git a/m3-vue/storybook/components/M3Slider.mdx b/m3-vue/storybook/components/M3Slider.mdx index 8c0592f..f055726 100644 --- a/m3-vue/storybook/components/M3Slider.mdx +++ b/m3-vue/storybook/components/M3Slider.mdx @@ -1,4 +1,4 @@ -import { Meta } from '@storybook/addon-docs/blocks' +import { Canvas, Meta } from '@storybook/addon-docs/blocks' import * as M3SliderStories from './M3Slider.stories' @@ -6,3 +6,21 @@ import * as M3SliderStories from './M3Slider.stories' # M3Slider Sliders let users make selections from a range of values + +## Accessibility semantics + +- Each slider handle must have an accessible name (`ariaHandle`, `ariaHandleMin`, `ariaHandleMax`). +- Keep numeric ranges and steps predictable and documented for users. +- Support keyboard adjustments (Arrow, Home, End) for all handles. + +## Demo + + + + +## Resources + +- [M3 Sliders overview](https://m3.material.io/components/sliders/overview) +- [M3 Sliders guidelines](https://m3.material.io/components/sliders/guidelines) +- [WAI-ARIA APG: Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) +- [WAI-ARIA APG: Multi-Thumb Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/) diff --git a/m3-vue/storybook/components/M3Switch.mdx b/m3-vue/storybook/components/M3Switch.mdx index 0e22f38..9ced597 100644 --- a/m3-vue/storybook/components/M3Switch.mdx +++ b/m3-vue/storybook/components/M3Switch.mdx @@ -16,6 +16,14 @@ import * as M3SwitchStories from './M3Switch.stories' * Make sure the switch’s selection (on or off) is visible at a glance +## Accessibility semantics + + + * Keep a visible label next to each switch control. + * Expose checked state via `role="switch"` and `aria-checked`. + * Use switches for immediate on/off state changes, not for multi-choice selections. + + ### Demo @@ -25,9 +33,11 @@ import * as M3SwitchStories from './M3Switch.stories'
-### Resources +## Resources * [Guidelines](https://m3.material.io/components/switch/guidelines) + * [M3 Switch overview](https://m3.material.io/components/switch/overview) * [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) + * [WAI-ARIA APG: Switch Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) diff --git a/m3-vue/storybook/components/M3TextField.mdx b/m3-vue/storybook/components/M3TextField.mdx index 0fdf76c..900260b 100644 --- a/m3-vue/storybook/components/M3TextField.mdx +++ b/m3-vue/storybook/components/M3TextField.mdx @@ -14,7 +14,11 @@ import { M3TextField } from '@/components/text-field' Text fields let users enter text into a UI -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## Accessibility semantics + +- Provide a persistent text label (`label` prop or explicit aria-labelledby). +- Use `aria-invalid` together with helper/support text for validation feedback. +- For long-form input, prefer multiline mode (`textarea`) with the same labeling strategy. + +## Resources + +- [M3 Text Fields overview](https://m3.material.io/components/text-fields/overview) +- [M3 Text Fields guidelines](https://m3.material.io/components/text-fields/guidelines) +- [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +- [WAI Tutorials: Form Labels](https://www.w3.org/WAI/tutorials/forms/labels/) diff --git a/m3-vue/storybook/examples/dialog/DialogConfirmation.vue b/m3-vue/storybook/examples/dialog/DialogConfirmation.vue index e309f6b..7f356d9 100644 --- a/m3-vue/storybook/examples/dialog/DialogConfirmation.vue +++ b/m3-vue/storybook/examples/dialog/DialogConfirmation.vue @@ -9,17 +9,23 @@ - Deleting the selected messages will also remove them from all synced devices. +

+ Deleting the selected messages will also remove them from all synced devices. +

+ + diff --git a/m3-vue/storybook/patterns/LocalTheming.stories.ts b/m3-vue/storybook/patterns/LocalTheming.stories.ts new file mode 100644 index 0000000..2b5b4c5 --- /dev/null +++ b/m3-vue/storybook/patterns/LocalTheming.stories.ts @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import LocalThemeShowcase from '../examples/local-theme/LocalThemeShowcase.vue' + +const meta = { + title: 'Patterns/Local Theming', + + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const DangerActionScope: Story = { + render: () => ({ + components: { + LocalThemeShowcase, + }, + + template: ` + + `, + }), +} + +export const WarmAlertScope: Story = { + render: () => ({ + components: { + LocalThemeShowcase, + }, + + template: ` + + `, + }), +} + +export const SuccessScope: Story = { + render: () => ({ + components: { + LocalThemeShowcase, + }, + + template: ` + + `, + }), +} + +export const BrandMutedScope: Story = { + render: () => ({ + components: { + LocalThemeShowcase, + }, + + template: ` + + `, + }), +} + +export const NestedLocalScopes: Story = { + render: () => ({ + components: { + LocalThemeShowcase, + }, + + template: ` + + `, + }), +} diff --git a/m3-vue/storybook/preview.ts b/m3-vue/storybook/preview.ts index 1a402d0..b749950 100644 --- a/m3-vue/storybook/preview.ts +++ b/m3-vue/storybook/preview.ts @@ -2,8 +2,7 @@ import type { Preview, VueRenderer } from '@storybook/vue3' import '@modulify/m3-foundation/assets/stylesheets/normalize.scss' import '@modulify/m3-foundation/assets/stylesheets/index.scss' - -import './stylesheets/utils.scss' +import '@modulify/m3-foundation/assets/stylesheets/storybook/utils.scss' import { withThemeByClassName } from '@storybook/addon-themes' import { addons } from 'storybook/preview-api' diff --git a/m3-vue/storybook/stylesheets/utils.scss b/m3-vue/storybook/stylesheets/utils.scss deleted file mode 100644 index 6b1f7f2..0000000 --- a/m3-vue/storybook/stylesheets/utils.scss +++ /dev/null @@ -1,62 +0,0 @@ -.d-block { display: block !important; } -.d-inline { display: inline !important; } -.d-inline-block { display: inline-block !important; } -.d-none { display: none !important; } -.d-flex { display: flex !important; } - -.flex-wrap { - flex-wrap: wrap !important; -} - -.flex-container { - @extend .d-flex; - @extend .flex-wrap; -} - -.flex-row { - @extend .d-flex; - align-items: center; -} - -.mt-auto, .my-auto, .m-auto { margin-top: auto !important; } -.mb-auto, .my-auto, .m-auto { margin-bottom: auto !important; } -.ml-auto, .mx-auto, .m-auto { margin-left: auto !important; } -.mr-auto, .mx-auto, .m-auto { margin-right: auto !important; } - -.mt-0, .my-0, .m-0 { margin-top: 0 !important; } -.mb-0, .my-0, .m-0 { margin-bottom: 0 !important; } -.ml-0, .mx-0, .m-0 { margin-left: 0 !important; } -.mr-0, .mx-0, .m-0 { margin-right: 0 !important; } - -.pt-0, .py-0, .p-0 { padding-top: 0 !important; } -.pb-0, .py-0, .p-0 { padding-bottom: 0 !important; } -.pl-0, .px-0, .p-0 { padding-left: 0 !important; } -.pr-0, .px-0, .p-0 { padding-right: 0 !important; } - -$base: 8px; -$multipliers: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10; - -@each $value in $multipliers { - .mt-#{$value}, .my-#{$value}, .m-#{$value} { margin-top: 0.5 * $value * $base !important; } - .mb-#{$value}, .my-#{$value}, .m-#{$value} { margin-bottom: 0.5 * $value * $base !important; } - .ml-#{$value}, .mx-#{$value}, .m-#{$value} { margin-left: 0.5 * $value * $base !important; } - .mr-#{$value}, .mx-#{$value}, .m-#{$value} { margin-right: 0.5 * $value * $base !important; } - - .mt-n#{$value}, .my-n#{$value}, .m-n#{$value} { margin-top: -0.5 * $value * $base !important; } - .mb-n#{$value}, .my-n#{$value}, .m-n#{$value} { margin-bottom: -0.5 * $value * $base !important; } - .ml-n#{$value}, .mx-n#{$value}, .m-n#{$value} { margin-left: -0.5 * $value * $base !important; } - .mr-n#{$value}, .mx-n#{$value}, .m-n#{$value} { margin-right: -0.5 * $value * $base !important; } - - .pt-#{$value}, .py-#{$value}, .p-#{$value} { padding-top: 0.5 * $value * $base !important; } - .pb-#{$value}, .py-#{$value}, .p-#{$value} { padding-bottom: 0.5 * $value * $base !important; } - .pl-#{$value}, .px-#{$value}, .p-#{$value} { padding-left: 0.5 * $value * $base !important; } - .pr-#{$value}, .px-#{$value}, .p-#{$value} { padding-right: 0.5 * $value * $base !important; } -} - -.mw-100 { - max-width: 100% !important; -} - -.w-100 { - width: 100% !important; -} \ No newline at end of file From e1da694091d791feda300fbf10154092b3ec0ad7 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 18:15:46 +0400 Subject: [PATCH 34/37] feat: Runtime analysis recipes were added --- AGENTS.md | 10 + docs/en/index.md | 1 + docs/en/runtime-analysis-recipes.md | 103 +++ docs/ru/index.md | 1 + docs/ru/runtime-analysis-recipes.md | 105 +++ recipes/research.mk | 218 ++++++ scripts/playwright-capture-diff.mjs | 210 ++++++ scripts/playwright-capture-matrix.mjs | 336 +++++++++ scripts/playwright-research.mjs | 999 ++++++++++++++++++++++++++ 9 files changed, 1983 insertions(+) create mode 100644 docs/en/runtime-analysis-recipes.md create mode 100644 docs/ru/runtime-analysis-recipes.md create mode 100644 scripts/playwright-capture-diff.mjs create mode 100644 scripts/playwright-capture-matrix.mjs create mode 100644 scripts/playwright-research.mjs diff --git a/AGENTS.md b/AGENTS.md index 267506d..cab7d21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,16 @@ make help its output to see whether an existing recipe already covers the task. - If a suitable recipe exists, prefer it over ad hoc commands to reduce extra work, keep workflows standardized, and avoid unnecessary escalations. +- The project includes a Playwright container and make recipes for screenshot + capture; use them when visual analysis of Storybook pages, component states, + or other UI behavior is helpful. +- The project also includes runtime-analysis research recipes for DOM, styles, + layout metrics, a11y snapshots, traces, network/performance logs, token + diffs, and screenshot matrices; use them to reduce uncertainty and to + understand what is going wrong before guessing at visual or runtime issues. + Read `docs/en/runtime-analysis-recipes.md` first when the task involves + visual regressions, layout ambiguity, token/theme uncertainty, unclear + animation behavior, or other runtime issues where these recipes may help. - Run eslint before handoff or commit preparation only when changed files include code covered by eslint rules (for example `*.js`, `*.ts`, and similar source files). Do not run eslint for markdown-only changes. diff --git a/docs/en/index.md b/docs/en/index.md index 3d3428c..012396c 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -3,6 +3,7 @@ ## Contents - [Contributing Guide](./contributing.md) - Development workflow, core commands, and CI quality gates. +- [Runtime Analysis Recipes](./runtime-analysis-recipes.md) - Playwright-based recipes for visual, layout, style, motion, and runtime investigation. - [TSConfig Layering](./tsconfig-layering.md) - How TypeScript configs are split between editor DX and CI checks. ## Translations diff --git a/docs/en/runtime-analysis-recipes.md b/docs/en/runtime-analysis-recipes.md new file mode 100644 index 0000000..7bed121 --- /dev/null +++ b/docs/en/runtime-analysis-recipes.md @@ -0,0 +1,103 @@ +# Runtime Analysis Recipes + +This document describes the Playwright-based research recipes available in +`m3-web` for reducing uncertainty when visual or runtime behavior is unclear. + +## When To Use + +Use these recipes when: +- a Storybook page looks wrong and screenshots are not enough; +- a layout shifts, overlaps, or animates unexpectedly; +- local theming or CSS tokens appear to be ignored; +- React and Vue parity is unclear; +- visual regressions need before/after or matrix comparison; +- runtime errors may be hidden in console, network, or accessibility state. + +## Core Principle + +Prefer these recipes before guessing. They are intended to turn “something is +off” into concrete evidence: screenshots, computed styles, layout metrics, +accessibility trees, traces, and diffs. + +## Most Useful Recipes + +- `make research-capture url='...'` - Single screenshot with metadata. +- `make research-capture-batch in=urls.txt out_dir=drafts/screenshots/...` - + Batch screenshot capture from a URL list. +- `make research-style-dump url='...' selector='...'` - Computed styles and CSS + custom properties for one element. +- `make research-layout-metrics url='...' selectors='.a||.b||.c'` - Bounding + boxes, scroll metrics, offsets, and layout details for multiple elements. +- `make research-token-diff url='...' selector='.left' compare_selector='.right'` + - CSS variable diff between two scopes. +- `make research-console-capture url='...'` - Console messages, page errors, + and failed requests. +- `make research-a11y-snapshot url='...' selector='...'` - Accessibility tree + for the page or a subtree. +- `make research-trace url='...' action_selector='...' interaction=click` - + Playwright trace for a page and optional interaction. +- `make research-motion-sample url='...' action_selector='...' interaction=click` + - Timed frame sequence for motion and transitions. +- `make research-capture-diff left=before.png right=after.png out=diff.png` - + Pixel diff for two images or directories. +- `make research-capture-matrix ...` - Capture one URL across multiple themes, + globals, args, and viewports. +- `make research-story-props story_id='...' themes='light,dark' args_sets='...'` + - Storybook-oriented matrix capture for story args and globals. + +## Typical Workflows + +## Investigate A Broken Layout + +1. Capture the current page: + `make research-capture url='...'` +2. Dump layout metrics: + `make research-layout-metrics url='...' selectors='.host||.panel||.scrim'` +3. Dump computed styles for the suspicious node: + `make research-style-dump url='...' selector='.panel'` + +## Investigate A Theme Or Token Problem + +1. Capture the page in both themes: + `make research-capture-matrix story_id='...' themes='light,dark'` +2. Compare token scopes: + `make research-token-diff url='...' selector='.default-scope' compare_selector='.local-scope'` +3. Inspect computed variables: + `make research-style-dump url='...' selector='.local-scope' var_prefixes='--m3-sys-,--m3-state-layers-'` + +## Investigate Animation Or Motion + +1. Capture a motion sequence: + `make research-motion-sample url='...' action_selector='...' interaction=click` +2. If behavior is still unclear, capture a trace: + `make research-trace url='...' action_selector='...' interaction=click` + +## Compare Two Runtime States + +1. Capture both states into separate directories. +2. Run: + `make research-capture-diff left=dir-a right=dir-b out_dir=drafts/research/diff` + +## Storybook Notes + +- Storybook pages can be opened directly with: + `http://m3-vue.modulify.test/?path=/story/...&globals=theme:dark` +- For systematic capture across states, prefer `research-story-props` and + `research-capture-matrix` over manually building many URLs. + +## Output Conventions + +- Screenshots are written under `drafts/screenshots/...` unless overridden. +- Runtime inspection outputs default to `drafts/research/...`. +- Most recipes emit machine-readable JSON so results can be inspected or + compared later. + +## Recommendation + +When a bug report is vague, start with: +1. `research-capture` +2. `research-style-dump` +3. `research-layout-metrics` + +That combination usually clarifies whether the problem is geometry, styling, +token inheritance, or runtime state. diff --git a/docs/ru/index.md b/docs/ru/index.md index 6a36c7c..e5f7526 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -3,4 +3,5 @@ ## Содержание - [Руководство по участию](./contributing.md) - Процесс разработки, ключевые команды и проверки в CI. +- [Рецепты runtime-анализа](./runtime-analysis-recipes.md) - Playwright-рецепты для исследования visual, layout, style, motion и runtime-проблем. - [Слои TSConfig](./tsconfig-layering.md) - Разделение TypeScript-конфигов для IDE и package-level typecheck. diff --git a/docs/ru/runtime-analysis-recipes.md b/docs/ru/runtime-analysis-recipes.md new file mode 100644 index 0000000..32cd720 --- /dev/null +++ b/docs/ru/runtime-analysis-recipes.md @@ -0,0 +1,105 @@ +# Рецепты runtime-анализа + +Этот документ описывает Playwright-рецепты исследования, доступные в +`m3-web`, чтобы снимать неопределённость, когда визуальное или runtime-поведение +непонятно. + +## Когда применять + +Используйте эти рецепты, когда: +- Storybook-страница выглядит неправильно, и одного скриншота недостаточно; +- layout сдвигается, наползает или анимируется неожиданно; +- локальная тема или CSS-токены как будто игнорируются; +- неясно, есть ли parity между React и Vue; +- нужно сравнить состояния до/после или матрицу сценариев; +- runtime-ошибки могут прятаться в console, network или accessibility-состоянии. + +## Базовый принцип + +Предпочитайте эти рецепты угадыванию. Их задача — превращать расплывчатое +«что-то не так» в конкретные артефакты: скриншоты, computed styles, layout +метрики, accessibility tree, traces и diffs. + +## Самые полезные рецепты + +- `make research-capture url='...'` - Один скриншот с метаданными. +- `make research-capture-batch in=urls.txt out_dir=drafts/screenshots/...` - + Пакетное снятие скриншотов по списку URL. +- `make research-style-dump url='...' selector='...'` - Computed styles и CSS + custom properties для одного элемента. +- `make research-layout-metrics url='...' selectors='.a||.b||.c'` - Bounding + boxes, scroll-метрики, offsets и layout-детали для нескольких элементов. +- `make research-token-diff url='...' selector='.left' compare_selector='.right'` + - Diff CSS-переменных между двумя scope. +- `make research-console-capture url='...'` - Console messages, page errors и + failed requests. +- `make research-a11y-snapshot url='...' selector='...'` - Accessibility tree + для страницы или subtree. +- `make research-trace url='...' action_selector='...' interaction=click` - + Playwright trace для страницы и опционального взаимодействия. +- `make research-motion-sample url='...' action_selector='...' interaction=click` + - Последовательность кадров для motion и transitions. +- `make research-capture-diff left=before.png right=after.png out=diff.png` - + Pixel diff для двух изображений или каталогов. +- `make research-capture-matrix ...` - Съёмка одного URL по нескольким themes, + globals, args и viewports. +- `make research-story-props story_id='...' themes='light,dark' args_sets='...'` + - Storybook-oriented matrix capture для story args и globals. + +## Типовые сценарии + +## Разобрать сломанный layout + +1. Снять текущую страницу: + `make research-capture url='...'` +2. Снять layout metrics: + `make research-layout-metrics url='...' selectors='.host||.panel||.scrim'` +3. Снять computed styles для подозрительного узла: + `make research-style-dump url='...' selector='.panel'` + +## Разобрать проблему темы или токенов + +1. Снять страницу в обеих темах: + `make research-capture-matrix story_id='...' themes='light,dark'` +2. Сравнить token scope: + `make research-token-diff url='...' selector='.default-scope' compare_selector='.local-scope'` +3. Посмотреть computed variables: + `make research-style-dump url='...' selector='.local-scope' var_prefixes='--m3-sys-,--m3-state-layers-'` + +## Разобрать анимацию или motion + +1. Снять последовательность кадров: + `make research-motion-sample url='...' action_selector='...' interaction=click` +2. Если поведение всё ещё неясно, снять trace: + `make research-trace url='...' action_selector='...' interaction=click` + +## Сравнить два runtime-состояния + +1. Снять оба состояния в разные каталоги. +2. Запустить: + `make research-capture-diff left=dir-a right=dir-b out_dir=drafts/research/diff` + +## Примечания по Storybook + +- Storybook-страницы можно открывать напрямую так: + `http://m3-vue.modulify.test/?path=/story/...&globals=theme:dark` +- Для систематической съёмки по состояниям лучше использовать + `research-story-props` и `research-capture-matrix`, а не собирать множество + URL вручную. + +## Формат артефактов + +- Скриншоты по умолчанию пишутся в `drafts/screenshots/...`. +- Runtime inspection outputs по умолчанию пишутся в `drafts/research/...`. +- Большинство рецептов сохраняет machine-readable JSON, чтобы результаты можно + было потом изучать и сравнивать. + +## Рекомендация + +Когда баг-репорт расплывчатый, начинайте с: +1. `research-capture` +2. `research-style-dump` +3. `research-layout-metrics` + +Эта комбинация обычно быстро показывает, проблема в геометрии, styling, +наследовании токенов или runtime-состоянии. diff --git a/recipes/research.mk b/recipes/research.mk index b05676b..de1439c 100644 --- a/recipes/research.mk +++ b/recipes/research.mk @@ -90,3 +90,221 @@ research-capture-batch: ## [Research][docker][playwright][capture] Captures scre $(if $(full_page),--full-page "$(full_page)",) \ $(if $(wait_selector),--wait-selector "$(wait_selector)",) $(TARGET_OK) + +.PHONY: research-dom-snapshot +research-dom-snapshot: ## [Research][docker][playwright][inspect] Saves outerHTML/text snapshot for a page selector + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action dom-snapshot \ + --url "$(url)" \ + $(if $(selector),--selector "$(selector)",) \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-style-dump +research-style-dump: ## [Research][docker][playwright][inspect] Saves computed styles and CSS variables for a selector + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action style-dump \ + --url "$(url)" \ + $(if $(selector),--selector "$(selector)",) \ + $(if $(props),--props "$(props)",) \ + $(if $(var_prefixes),--var-prefixes "$(var_prefixes)",) \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-layout-metrics +research-layout-metrics: ## [Research][docker][playwright][inspect] Saves bounding boxes and layout metrics for selectors + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action layout-metrics \ + --url "$(url)" \ + $(if $(selector),--selector "$(selector)",) \ + $(if $(selectors),--selectors "$(selectors)",) \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-a11y-snapshot +research-a11y-snapshot: ## [Research][docker][playwright][a11y] Saves Playwright accessibility snapshot for a page or selector + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action a11y-snapshot \ + --url "$(url)" \ + $(if $(selector),--selector "$(selector)",) \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-console-capture +research-console-capture: ## [Research][docker][playwright][inspect] Saves console messages, page errors, and failed requests + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action console-capture \ + --url "$(url)" \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-trace +research-trace: ## [Research][docker][playwright][trace] Saves Playwright trace for a page and optional interaction + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action trace \ + --url "$(url)" \ + $(if $(action_selector),--action-selector "$(action_selector)",) \ + $(if $(interaction),--interaction "$(interaction)",) \ + $(if $(post_action_wait_ms),--post-action-wait-ms "$(post_action_wait_ms)",) \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-network-log +research-network-log: ## [Research][docker][playwright][inspect] Saves request/response timeline for a page load + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action network-log \ + --url "$(url)" \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-perf-marks +research-perf-marks: ## [Research][docker][playwright][inspect] Saves Performance API entries for a page + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action perf-marks \ + --url "$(url)" \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-token-diff +research-token-diff: ## [Research][docker][playwright][inspect] Compares computed CSS custom properties between two selectors + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action token-diff \ + --url "$(url)" \ + $(if $(selector),--selector "$(selector)",) \ + $(if $(compare_selector),--compare-selector "$(compare_selector)",) \ + $(if $(var_prefixes),--var-prefixes "$(var_prefixes)",) \ + $(if $(out),--out "$(out)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-motion-sample +research-motion-sample: ## [Research][docker][playwright][capture] Captures a timed frame sequence for page motion and optional interaction + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-research.mjs \ + --action motion-sample \ + --url "$(url)" \ + $(if $(action_selector),--action-selector "$(action_selector)",) \ + $(if $(interaction),--interaction "$(interaction)",) \ + $(if $(count),--count "$(count)",) \ + $(if $(interval_ms),--interval-ms "$(interval_ms)",) \ + $(if $(full_page),--full-page "$(full_page)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(width),--width "$(width)",) \ + $(if $(height),--height "$(height)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) + $(TARGET_OK) + +.PHONY: research-capture-diff +research-capture-diff: ## [Research][docker][inspect][capture] Creates image diffs for two screenshots or two screenshot directories + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-capture-diff.mjs \ + --left "$(left)" \ + --right "$(right)" \ + $(if $(out),--out "$(out)",) \ + $(if $(summary),--summary "$(summary)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(threshold),--threshold "$(threshold)",) + $(TARGET_OK) + +.PHONY: research-capture-matrix +research-capture-matrix: ## [Research][docker][playwright][capture] Captures URL or Storybook matrices across themes, globals, args, and viewports + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-capture-matrix.mjs \ + $(if $(url),--url "$(url)",) \ + $(if $(base_url),--base-url "$(base_url)",) \ + $(if $(story_id),--story-id "$(story_id)",) \ + $(if $(globals),--globals "$(globals)",) \ + $(if $(globals_sets),--globals-sets "$(globals_sets)",) \ + $(if $(args),--args "$(args)",) \ + $(if $(args_sets),--args-sets "$(args_sets)",) \ + $(if $(themes),--themes "$(themes)",) \ + $(if $(viewports),--viewports "$(viewports)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) \ + $(if $(full_page),--full-page "$(full_page)",) + $(TARGET_OK) + +.PHONY: research-story-props +research-story-props: ## [Research][docker][playwright][capture] Captures Storybook story matrices for args/globals/theme combinations + $(TARGET_HEADER) + @$(PLAYWRIGHT_NODE_CMD) scripts/playwright-capture-matrix.mjs \ + $(if $(base_url),--base-url "$(base_url)",) \ + --story-id "$(story_id)" \ + $(if $(globals),--globals "$(globals)",) \ + $(if $(globals_sets),--globals-sets "$(globals_sets)",) \ + $(if $(args),--args "$(args)",) \ + $(if $(args_sets),--args-sets "$(args_sets)",) \ + $(if $(themes),--themes "$(themes)",) \ + $(if $(viewports),--viewports "$(viewports)",) \ + $(if $(out_dir),--out-dir "$(out_dir)",) \ + $(if $(wait_ms),--wait-ms "$(wait_ms)",) \ + $(if $(wait_until),--wait-until "$(wait_until)",) \ + $(if $(wait_selector),--wait-selector "$(wait_selector)",) \ + $(if $(full_page),--full-page "$(full_page)",) + $(TARGET_OK) diff --git a/scripts/playwright-capture-diff.mjs b/scripts/playwright-capture-diff.mjs new file mode 100644 index 0000000..ee2fcfe --- /dev/null +++ b/scripts/playwright-capture-diff.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node + +import path from 'node:path' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { PNG } from 'pngjs' +import pixelmatch from 'pixelmatch' + +function usage() { + console.log([ + 'usage:', + ' node scripts/playwright-capture-diff.mjs --left path.png --right path.png [--out diff.png] [--summary summary.json]', + ' node scripts/playwright-capture-diff.mjs --left dir-a --right dir-b [--out-dir diffs]', + '', + 'options:', + ' --left PATH', + ' --right PATH', + ' --out FILE diff PNG path for single-image mode', + ' --summary FILE JSON summary path for single-image mode', + ' --out-dir DIR output directory for directory mode', + ' --threshold N pixelmatch threshold (default: 0.1)', + ].join('\n')) +} + +function parseArgs(argv) { + const args = {} + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + + if (!token.startsWith('--')) { + continue + } + + const key = token.slice(2) + const next = argv[index + 1] + const value = !next || next.startsWith('--') ? 'true' : next + args[key] = value + + if (value !== 'true') { + index += 1 + } + } + + return args +} + +async function statKind(targetPath) { + try { + const { stat } = await import('node:fs/promises') + const metadata = await stat(targetPath) + + if (metadata.isDirectory()) { + return 'dir' + } + + if (metadata.isFile()) { + return 'file' + } + + return 'other' + } catch { + return null + } +} + +async function readPng(filePath) { + const content = await readFile(filePath) + + return PNG.sync.read(content) +} + +async function writePng(filePath, png) { + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, PNG.sync.write(png)) +} + +async function diffPair(leftPath, rightPath, outPath, threshold) { + const left = await readPng(leftPath) + const right = await readPng(rightPath) + + if (left.width !== right.width || left.height !== right.height) { + return { + left_path: leftPath, + right_path: rightPath, + out_path: outPath ?? null, + size_mismatch: { + left: { width: left.width, height: left.height }, + right: { width: right.width, height: right.height }, + }, + different_pixels: null, + total_pixels: null, + mismatch_ratio: null, + } + } + + const diff = new PNG({ width: left.width, height: left.height }) + const differentPixels = pixelmatch(left.data, right.data, diff.data, left.width, left.height, { threshold }) + const totalPixels = left.width * left.height + + if (outPath) { + await writePng(outPath, diff) + } + + return { + left_path: leftPath, + right_path: rightPath, + out_path: outPath ?? null, + size_mismatch: null, + different_pixels: differentPixels, + total_pixels: totalPixels, + mismatch_ratio: totalPixels === 0 ? 0 : differentPixels / totalPixels, + } +} + +async function listPngFiles(dirPath) { + const files = await readdir(dirPath) + + return files.filter((fileName) => fileName.toLowerCase().endsWith('.png')).sort() +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const helpRequested = args.help === 'true' + + if (helpRequested || !args.left || !args.right) { + usage() + process.exit(helpRequested ? 0 : 2) + } + + const leftPath = args.left + const rightPath = args.right + const leftKind = await statKind(leftPath) + const rightKind = await statKind(rightPath) + const threshold = Number(args.threshold ?? 0.1) + + if (!leftKind || !rightKind) { + throw new Error('expected existing --left and --right paths') + } + + if (leftKind !== rightKind) { + throw new Error('--left and --right must both be files or both be directories') + } + + if (leftKind === 'file') { + const outPath = args.out + const summaryPath = args.summary + ?? (outPath ? outPath.replace(/\.png$/i, '.json') : path.join('drafts', 'research', 'capture-diff', 'summary.json')) + const result = await diffPair(leftPath, rightPath, outPath, threshold) + + await mkdir(path.dirname(summaryPath), { recursive: true }) + await writeFile(summaryPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8') + + if (outPath) { + console.log(outPath) + } + console.log(summaryPath) + return + } + + const outDir = args['out-dir'] ?? path.join('drafts', 'research', 'capture-diff') + const leftFiles = await listPngFiles(leftPath) + const rightFiles = await listPngFiles(rightPath) + const leftSet = new Set(leftFiles) + const rightSet = new Set(rightFiles) + const common = leftFiles.filter((fileName) => rightSet.has(fileName)) + const leftOnly = leftFiles.filter((fileName) => !rightSet.has(fileName)) + const rightOnly = rightFiles.filter((fileName) => !leftSet.has(fileName)) + const results = [] + + for (const fileName of common) { + const result = await diffPair( + path.join(leftPath, fileName), + path.join(rightPath, fileName), + path.join(outDir, fileName), + threshold, + ) + + results.push({ + file_name: fileName, + ...result, + }) + } + + const summary = { + compared_at: new Date().toISOString(), + left_dir: leftPath, + right_dir: rightPath, + compared_files: results.length, + left_only: leftOnly, + right_only: rightOnly, + results, + } + const summaryPath = path.join(outDir, 'summary.json') + + await mkdir(outDir, { recursive: true }) + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + + console.log(outDir) + console.log(summaryPath) +} + +main().catch((error) => { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(String(error)) + } + + process.exit(1) +}) diff --git a/scripts/playwright-capture-matrix.mjs b/scripts/playwright-capture-matrix.mjs new file mode 100644 index 0000000..4594b5c --- /dev/null +++ b/scripts/playwright-capture-matrix.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto' +import path from 'node:path' +import { mkdir, writeFile } from 'node:fs/promises' +import { chromium } from 'playwright' + +function usage() { + console.log([ + 'usage:', + ' node scripts/playwright-capture-matrix.mjs --url https://... [--themes dark,light] [--viewports desktop:1440x1024,mobile:390x844]', + ' node scripts/playwright-capture-matrix.mjs --base-url http://m3-vue.modulify.test/ --story-id patterns-local-theming--danger-action-scope', + '', + 'options:', + ' --url URL', + ' --base-url URL default: http://m3-vue.modulify.test/', + ' --story-id ID', + ' --globals KEY:VALUE;KEY:VALUE', + ' --globals-sets SET1||SET2', + ' --args KEY:VALUE;KEY:VALUE', + ' --args-sets SET1||SET2', + ' --themes dark,light', + ' --viewports NAME:WIDTHxHEIGHT,NAME:WIDTHxHEIGHT', + ' --out-dir DIR', + ' --wait-ms MS', + ' --wait-until STATE', + ' --wait-selector CSS', + ' --full-page BOOL', + ].join('\n')) +} + +function parseArgs(argv) { + const args = {} + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + + if (!token.startsWith('--')) { + continue + } + + const key = token.slice(2) + const next = argv[index + 1] + const value = !next || next.startsWith('--') ? 'true' : next + args[key] = value + + if (value !== 'true') { + index += 1 + } + } + + return args +} + +function parseHttpUrl(value) { + let parsed + + try { + parsed = new URL(value) + } catch { + throw new Error(`Invalid URL: ${value}`) + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Unsupported URL protocol: ${value}`) + } + + return parsed +} + +function toBool(value, fallback = true) { + if (value == null) { + return fallback + } + + return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase()) +} + +function toNumber(value, fallback) { + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : fallback +} + +function sanitizePart(value) { + return value + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function defaultFileName(url, variant) { + const parsed = new URL(url) + const base = sanitizePart(`${parsed.hostname}${parsed.pathname || '/index'}-${variant}`.toLowerCase()) || 'capture' + const hash = crypto.createHash('sha1').update(`${url}:${variant}`).digest('hex').slice(0, 8) + return `${base}-${hash}.png` +} + +function splitSetList(value) { + if (!value) { + return [''] + } + + return String(value) + .split('||') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function parseThemes(value) { + if (!value) { + return [''] + } + + return String(value) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function parseViewports(value) { + if (!value) { + return [{ + name: 'desktop', + width: 1440, + height: 1024, + }] + } + + return String(value) + .split(',') + .map((entry, index) => { + const trimmed = entry.trim() + const [namePart, sizePartRaw] = trimmed.includes(':') + ? trimmed.split(':') + : [`viewport-${index + 1}`, trimmed] + const sizePart = sizePartRaw.trim() + const match = sizePart.match(/^(\d+)x(\d+)$/i) + + if (!match) { + throw new Error(`Invalid viewport entry: ${trimmed}`) + } + + return { + name: sanitizePart(namePart.trim()) || `viewport-${index + 1}`, + width: Number(match[1]), + height: Number(match[2]), + } + }) +} + +function mergeQueryGroup(baseGroup, extraGroup) { + const parts = [] + + if (baseGroup) { + parts.push(...baseGroup.split(';').map((entry) => entry.trim()).filter(Boolean)) + } + + if (extraGroup) { + parts.push(...extraGroup.split(';').map((entry) => entry.trim()).filter(Boolean)) + } + + return parts.join(';') +} + +function buildStoryUrl(args, theme, globalsSet, argSet) { + const baseUrl = parseHttpUrl(args['base-url'] ?? 'http://m3-vue.modulify.test/') + const storyId = args['story-id'] + + if (!storyId) { + throw new Error('expected --story-id when --url is not provided') + } + + const url = new URL(baseUrl.toString()) + url.searchParams.set('path', `/story/${storyId}`) + + const globals = mergeQueryGroup(args.globals ?? '', mergeQueryGroup(theme ? `theme:${theme}` : '', globalsSet)) + const storyArgs = mergeQueryGroup(args.args ?? '', argSet) + + if (globals) { + url.searchParams.set('globals', globals) + } + + if (storyArgs) { + url.searchParams.set('args', storyArgs) + } + + return url.toString() +} + +function buildDirectUrl(args, theme, globalsSet) { + const baseUrl = new URL(parseHttpUrl(args.url).toString()) + const currentGlobals = baseUrl.searchParams.get('globals') ?? '' + const globals = mergeQueryGroup(currentGlobals, mergeQueryGroup(args.globals ?? '', mergeQueryGroup(theme ? `theme:${theme}` : '', globalsSet))) + + if (globals) { + baseUrl.searchParams.set('globals', globals) + } + + return baseUrl.toString() +} + +async function captureOne({ url, outPath, viewport, waitMs, waitUntil, waitSelector, fullPage }) { + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ + viewport: { + width: viewport.width, + height: viewport.height, + }, + }) + const page = await context.newPage() + + try { + await page.goto(url, { + waitUntil, + timeout: 45000, + }) + + if (waitSelector) { + await page.waitForSelector(waitSelector, { timeout: 45000 }) + } + + if (waitMs > 0) { + await page.waitForTimeout(waitMs) + } + + await mkdir(path.dirname(outPath), { recursive: true }) + await page.screenshot({ + path: outPath, + fullPage, + }) + + const metaPath = outPath.replace(/\.png$/i, '.meta.json') + await writeFile(metaPath, `${JSON.stringify({ + captured_at: new Date().toISOString(), + url_requested: url, + url_final: page.url(), + title: await page.title(), + viewport, + wait_ms: waitMs, + wait_until: waitUntil, + wait_selector: waitSelector || null, + full_page: fullPage, + image_path: outPath, + }, null, 2)}\n`, 'utf8') + + return { + image_path: outPath, + meta_path: metaPath, + final_url: page.url(), + title: await page.title(), + } + } finally { + await page.close().catch(() => {}) + await context.close().catch(() => {}) + await browser.close().catch(() => {}) + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const helpRequested = args.help === 'true' + + if (helpRequested || (!args.url && !args['story-id'])) { + usage() + process.exit(helpRequested ? 0 : 2) + } + + const themes = parseThemes(args.themes) + const globalsSets = splitSetList(args['globals-sets']) + const argsSets = splitSetList(args['args-sets']) + const viewports = parseViewports(args.viewports) + const outDir = args['out-dir'] ?? path.join('drafts', 'screenshots', 'capture-matrix') + const waitMs = toNumber(args['wait-ms'] ?? '1200', 1200) + const waitUntil = args['wait-until'] ?? 'networkidle' + const waitSelector = args['wait-selector'] ?? '' + const fullPage = toBool(args['full-page'], false) + const results = [] + + for (const theme of themes) { + for (const globalsSet of globalsSets) { + for (const argSet of argsSets) { + for (const viewport of viewports) { + const url = args.url + ? buildDirectUrl(args, theme, globalsSet) + : buildStoryUrl(args, theme, globalsSet, argSet) + const variant = [ + theme || 'default-theme', + globalsSet ? sanitizePart(globalsSet) : 'base-globals', + argSet ? sanitizePart(argSet) : 'base-args', + viewport.name, + ].join('-') + const outPath = path.join(outDir, defaultFileName(url, variant)) + const capture = await captureOne({ + url, + outPath, + viewport, + waitMs, + waitUntil, + waitSelector, + fullPage, + }) + + results.push({ + theme: theme || null, + globals_set: globalsSet || null, + args_set: argSet || null, + viewport, + url, + ...capture, + }) + } + } + } + } + + const summaryPath = path.join(outDir, 'summary.json') + await mkdir(outDir, { recursive: true }) + await writeFile(summaryPath, `${JSON.stringify({ + captured_at: new Date().toISOString(), + results, + }, null, 2)}\n`, 'utf8') + + console.log(outDir) + console.log(summaryPath) +} + +main().catch((error) => { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(String(error)) + } + + process.exit(1) +}) diff --git a/scripts/playwright-research.mjs b/scripts/playwright-research.mjs new file mode 100644 index 0000000..421c074 --- /dev/null +++ b/scripts/playwright-research.mjs @@ -0,0 +1,999 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto' +import path from 'node:path' +import { mkdir, writeFile } from 'node:fs/promises' +import { chromium } from 'playwright' + +function usage() { + console.log([ + 'usage:', + ' node scripts/playwright-research.mjs --action style-dump --url https://... [options]', + '', + 'actions:', + ' dom-snapshot', + ' style-dump', + ' layout-metrics', + ' a11y-snapshot', + ' console-capture', + ' trace', + ' network-log', + ' perf-marks', + ' token-diff', + ' motion-sample', + '', + 'common options:', + ' --url URL', + ' --out FILE', + ' --out-dir DIR', + ' --width PX default: 1440', + ' --height PX default: 1024', + ' --wait-ms MS default: 1200', + ' --wait-until STATE default: networkidle', + ' --wait-selector CSS', + ' --timeout MS default: 45000', + '', + 'selector options:', + ' --selector CSS', + ' --selectors CSS1||CSS2||CSS3', + ' --compare-selector CSS', + '', + 'action-specific options:', + ' --props display,color,gap', + ' --var-prefix --m3-sys- may be repeated', + ' --var-prefixes --m3-sys-,--m3-state-layers-', + ' --action-selector CSS', + ' --interaction click|hover|focus', + ' --count N motion-sample only; default: 6', + ' --interval-ms MS motion-sample only; default: 120', + ].join('\n')) +} + +function parseArgs(argv) { + const args = {} + const optionsWithValues = new Set([ + 'action', + 'url', + 'out', + 'out-dir', + 'width', + 'height', + 'wait-ms', + 'wait-until', + 'wait-selector', + 'timeout', + 'selector', + 'selectors', + 'compare-selector', + 'props', + 'var-prefix', + 'var-prefixes', + 'action-selector', + 'interaction', + 'count', + 'interval-ms', + 'post-action-wait-ms', + 'full-page', + ]) + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + + if (!token.startsWith('--')) { + continue + } + + const inlineSeparatorIndex = token.indexOf('=') + const hasInlineValue = inlineSeparatorIndex !== -1 + const key = hasInlineValue ? token.slice(2, inlineSeparatorIndex) : token.slice(2) + const next = argv[index + 1] + const expectsValue = optionsWithValues.has(key) + const value = hasInlineValue + ? token.slice(inlineSeparatorIndex + 1) + : (!expectsValue || next == null ? 'true' : next) + + if (Object.hasOwn(args, key)) { + const current = Array.isArray(args[key]) ? args[key] : [args[key]] + current.push(value) + args[key] = current + } else { + args[key] = value + } + + if (!hasInlineValue && value !== 'true') { + index += 1 + } + } + + return args +} + +function getArg(args, key, fallback = undefined) { + const value = args[key] + + if (Array.isArray(value)) { + return value[value.length - 1] ?? fallback + } + + return value ?? fallback +} + +function getArgs(args, key) { + const value = args[key] + + if (value == null) { + return [] + } + + return Array.isArray(value) ? value : [value] +} + +function toBool(value, fallback = true) { + if (value == null) { + return fallback + } + + return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase()) +} + +function toNumber(value, fallback) { + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : fallback +} + +function sanitizePart(value) { + return value + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function parseHttpUrl(value) { + let parsed + + try { + parsed = new URL(value) + } catch { + throw new Error(`Invalid --url value: ${value}`) + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Unsupported URL protocol in --url: ${value}`) + } + + return parsed +} + +function defaultStem(action, parsedUrl, rawUrl) { + const base = sanitizePart(`${parsedUrl.hostname}${parsedUrl.pathname || '/index'}`.toLowerCase()) || 'page' + const hash = crypto.createHash('sha1').update(`${action}:${rawUrl}`).digest('hex').slice(0, 8) + return `${base}-${action}-${hash}` +} + +function defaultOutputPath({ action, parsedUrl, rawUrl, out, outDir, extension }) { + if (out) { + return out + } + + const baseDir = outDir ?? path.join('drafts', 'research', action) + + return path.join(baseDir, `${defaultStem(action, parsedUrl, rawUrl)}.${extension}`) +} + +function splitDelimitedValues(value, delimiter = '||') { + if (!value) { + return [] + } + + return String(value) + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function resolveSelectors(args) { + const directSelectors = getArgs(args, 'selector') + const groupedSelectors = splitDelimitedValues(getArg(args, 'selectors', '')) + + const selectors = [...directSelectors, ...groupedSelectors].filter(Boolean) + + return selectors.length > 0 ? selectors : ['body'] +} + +function resolveVarPrefixes(args) { + const directPrefixes = getArgs(args, 'var-prefix') + const groupedPrefixes = String(getArg(args, 'var-prefixes', '--m3-sys-')) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + + const prefixes = [...directPrefixes, ...groupedPrefixes] + + return prefixes.length > 0 ? prefixes : ['--m3-sys-'] +} + +function resolveCssProps(args) { + return String(getArg( + args, + 'props', + 'display,position,width,height,min-width,min-height,max-width,max-height,margin,padding,gap,font-size,line-height,color,background-color,border-radius,z-index', + )) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +async function createPage(args) { + const width = toNumber(getArg(args, 'width', '1440'), 1440) + const height = toNumber(getArg(args, 'height', '1024'), 1024) + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ + viewport: { width, height }, + }) + const page = await context.newPage() + + return { + browser, + context, + page, + viewport: { width, height }, + } +} + +async function navigate(page, args) { + const url = getArg(args, 'url') + const timeout = toNumber(getArg(args, 'timeout', '45000'), 45000) + const waitUntil = getArg(args, 'wait-until', 'networkidle') + const waitSelector = getArg(args, 'wait-selector', '') + const waitMs = toNumber(getArg(args, 'wait-ms', '1200'), 1200) + + await page.goto(url, { waitUntil, timeout }) + + if (waitSelector) { + await page.waitForSelector(waitSelector, { timeout }) + } + + if (waitMs > 0) { + await page.waitForTimeout(waitMs) + } + + return { + finalUrl: page.url(), + title: await page.title(), + waitUntil, + waitSelector: waitSelector || null, + waitMs, + timeout, + } +} + +async function writeJson(outPath, payload) { + await mkdir(path.dirname(outPath), { recursive: true }) + await writeFile(outPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') +} + +async function collectElementSnapshot(page, selector, fn, errorLabel = 'selector') { + return page.evaluate(({ selector: targetSelector, label, evaluateFnSource }) => { + const element = document.querySelector(targetSelector) + + if (!element) { + throw new Error(`${label} not found: ${targetSelector}`) + } + + const evaluateFn = new Function('element', `return (${evaluateFnSource})(element)`) + + return evaluateFn(element) + }, { selector, label: errorLabel, evaluateFnSource: fn.toString() }) +} + +async function runDomSnapshot(page, args, meta) { + const selector = getArg(args, 'selector', 'body') + const outPath = defaultOutputPath({ + action: 'dom-snapshot', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const snapshot = await collectElementSnapshot(page, selector, (element) => { + const htmlElement = element + + return { + tag_name: htmlElement.tagName.toLowerCase(), + id: htmlElement.id || null, + class_name: htmlElement.className || '', + text_content: htmlElement.textContent ?? '', + outer_html: htmlElement.outerHTML, + } + }) + + await writeJson(outPath, { + action: 'dom-snapshot', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: meta.navigation.finalUrl, + title: meta.navigation.title, + selector, + snapshot, + }) + + console.log(outPath) +} + +async function runStyleDump(page, args, meta) { + const selector = getArg(args, 'selector', 'body') + const cssProps = resolveCssProps(args) + const varPrefixes = resolveVarPrefixes(args) + const outPath = defaultOutputPath({ + action: 'style-dump', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const styles = await page.evaluate(({ targetSelector, cssProps: props, prefixes }) => { + const element = document.querySelector(targetSelector) + + if (!element) { + throw new Error(`selector not found: ${targetSelector}`) + } + + const style = getComputedStyle(element) + const cssProps = Object.fromEntries(props.map((prop) => [prop, style.getPropertyValue(prop).trim()])) + const variables = {} + + for (const property of style) { + if (prefixes.some((prefix) => property.startsWith(prefix))) { + variables[property] = style.getPropertyValue(property).trim() + } + } + + return { + tag_name: element.tagName.toLowerCase(), + class_name: element.className || '', + css_props: cssProps, + variables, + } + }, { targetSelector: selector, cssProps, prefixes: varPrefixes }) + + await writeJson(outPath, { + action: 'style-dump', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: meta.navigation.finalUrl, + title: meta.navigation.title, + selector, + css_props: cssProps, + var_prefixes: varPrefixes, + styles, + }) + + console.log(outPath) +} + +async function runLayoutMetrics(page, args, meta) { + const selectors = resolveSelectors(args) + const outPath = defaultOutputPath({ + action: 'layout-metrics', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const metrics = await page.evaluate((requestedSelectors) => { + const describeElement = (element) => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + const offsetParent = element.offsetParent + + return { + tag_name: element.tagName.toLowerCase(), + class_name: element.className || '', + rect: { + x: rect.x, + y: rect.y, + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }, + client: { + width: element.clientWidth, + height: element.clientHeight, + }, + offset: { + width: element.offsetWidth, + height: element.offsetHeight, + top: element.offsetTop, + left: element.offsetLeft, + }, + scroll: { + width: element.scrollWidth, + height: element.scrollHeight, + top: element.scrollTop, + left: element.scrollLeft, + }, + computed: { + display: style.display, + position: style.position, + box_sizing: style.boxSizing, + margin: style.margin, + padding: style.padding, + gap: style.gap, + z_index: style.zIndex, + }, + offset_parent: offsetParent + ? { + tag_name: offsetParent.tagName.toLowerCase(), + class_name: offsetParent.className || '', + } + : null, + } + } + + return { + viewport: { + inner_width: window.innerWidth, + inner_height: window.innerHeight, + scroll_x: window.scrollX, + scroll_y: window.scrollY, + }, + selectors: requestedSelectors.map((selector) => { + const element = document.querySelector(selector) + + return { + selector, + found: Boolean(element), + metrics: element ? describeElement(element) : null, + } + }), + } + }, selectors) + + await writeJson(outPath, { + action: 'layout-metrics', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: meta.navigation.finalUrl, + title: meta.navigation.title, + selectors, + metrics, + }) + + console.log(outPath) +} + +async function runA11ySnapshot(page, args, meta) { + const selector = getArg(args, 'selector') + const timeout = toNumber(getArg(args, 'timeout', '45000'), 45000) + const outPath = defaultOutputPath({ + action: 'a11y-snapshot', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const targetSelector = selector ?? 'body' + const locator = page.locator(targetSelector) + const elementCount = await locator.count() + + if (elementCount === 0) { + throw new Error(`selector not found: ${targetSelector}`) + } + + const snapshotYaml = await locator.first().ariaSnapshot({ timeout }) + const snapshot = snapshotYaml + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0) + + await writeJson(outPath, { + action: 'a11y-snapshot', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: meta.navigation.finalUrl, + title: meta.navigation.title, + selector: targetSelector, + matched_count: elementCount, + snapshot_yaml: snapshotYaml, + snapshot, + }) + + console.log(outPath) +} + +async function runConsoleCapture(page, args, meta) { + const outPath = defaultOutputPath({ + action: 'console-capture', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const consoleEntries = [] + const pageErrors = [] + const requestFailures = [] + + page.on('console', (message) => { + consoleEntries.push({ + type: message.type(), + text: message.text(), + location: message.location(), + }) + }) + + page.on('pageerror', (error) => { + pageErrors.push({ + message: error.message, + stack: error.stack ?? null, + }) + }) + + page.on('requestfailed', (request) => { + requestFailures.push({ + url: request.url(), + method: request.method(), + resource_type: request.resourceType(), + failure_text: request.failure()?.errorText ?? null, + }) + }) + + const navigation = await navigate(page, args) + + await writeJson(outPath, { + action: 'console-capture', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: navigation.finalUrl, + title: navigation.title, + console: consoleEntries, + page_errors: pageErrors, + request_failures: requestFailures, + }) + + console.log(outPath) +} + +async function runTrace(page, context, args, meta) { + const outPath = defaultOutputPath({ + action: 'trace', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'zip', + }) + const actionSelector = getArg(args, 'action-selector', '') + const interaction = getArg(args, 'interaction', 'click') + + await mkdir(path.dirname(outPath), { recursive: true }) + await context.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, + }) + + const navigation = await navigate(page, args) + + if (actionSelector) { + const locator = page.locator(actionSelector) + + if (interaction === 'hover') { + await locator.hover() + } else if (interaction === 'focus') { + await locator.focus() + } else { + await locator.click() + } + + await page.waitForTimeout(toNumber(getArg(args, 'post-action-wait-ms', '400'), 400)) + } + + await context.tracing.stop({ path: outPath }) + + const metaPath = outPath.replace(/\.zip$/i, '.meta.json') + + await writeJson(metaPath, { + action: 'trace', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: navigation.finalUrl, + title: navigation.title, + interaction: actionSelector + ? { + selector: actionSelector, + type: interaction, + } + : null, + trace_path: outPath, + }) + + console.log(outPath) + console.log(metaPath) +} + +async function runNetworkLog(page, args, meta) { + const outPath = defaultOutputPath({ + action: 'network-log', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const entries = [] + const requestIndex = new Map() + let nextId = 1 + + page.on('request', (request) => { + const entry = { + id: nextId, + url: request.url(), + method: request.method(), + resource_type: request.resourceType(), + started_at: new Date().toISOString(), + status: null, + ok: null, + failed: false, + failure_text: null, + } + + nextId += 1 + entries.push(entry) + requestIndex.set(request, entry) + }) + + page.on('response', (response) => { + const entry = requestIndex.get(response.request()) + + if (!entry) { + return + } + + entry.status = response.status() + entry.ok = response.ok() + entry.ended_at = new Date().toISOString() + }) + + page.on('requestfailed', (request) => { + const entry = requestIndex.get(request) + + if (!entry) { + return + } + + entry.failed = true + entry.failure_text = request.failure()?.errorText ?? null + entry.ended_at = new Date().toISOString() + }) + + const navigation = await navigate(page, args) + + await writeJson(outPath, { + action: 'network-log', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: navigation.finalUrl, + title: navigation.title, + entries, + }) + + console.log(outPath) +} + +async function runPerfMarks(page, args, meta) { + const outPath = defaultOutputPath({ + action: 'perf-marks', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + + const navigation = await navigate(page, args) + const perf = await page.evaluate(() => { + const serializeEntry = (entry) => ({ + name: entry.name, + entry_type: entry.entryType, + start_time: entry.startTime, + duration: entry.duration, + initiator_type: 'initiatorType' in entry ? entry.initiatorType : undefined, + transfer_size: 'transferSize' in entry ? entry.transferSize : undefined, + encoded_body_size: 'encodedBodySize' in entry ? entry.encodedBodySize : undefined, + decoded_body_size: 'decodedBodySize' in entry ? entry.decodedBodySize : undefined, + render_blocking_status: 'renderBlockingStatus' in entry ? entry.renderBlockingStatus : undefined, + response_end: 'responseEnd' in entry ? entry.responseEnd : undefined, + dom_complete: 'domComplete' in entry ? entry.domComplete : undefined, + dom_content_loaded: 'domContentLoadedEventEnd' in entry ? entry.domContentLoadedEventEnd : undefined, + load_event_end: 'loadEventEnd' in entry ? entry.loadEventEnd : undefined, + }) + + return { + time_origin: performance.timeOrigin, + now: performance.now(), + navigation: performance.getEntriesByType('navigation').map(serializeEntry), + paints: performance.getEntriesByType('paint').map(serializeEntry), + marks: performance.getEntriesByType('mark').map(serializeEntry), + measures: performance.getEntriesByType('measure').map(serializeEntry), + resources: performance.getEntriesByType('resource').slice(0, 200).map(serializeEntry), + memory: 'memory' in performance + ? { + js_heap_size_limit: performance.memory.jsHeapSizeLimit, + total_js_heap_size: performance.memory.totalJSHeapSize, + used_js_heap_size: performance.memory.usedJSHeapSize, + } + : null, + } + }) + + await writeJson(outPath, { + action: 'perf-marks', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: navigation.finalUrl, + title: navigation.title, + perf, + }) + + console.log(outPath) +} + +async function runTokenDiff(page, args, meta) { + const selector = getArg(args, 'selector') + const compareSelector = getArg(args, 'compare-selector') + + if (!selector || !compareSelector) { + throw new Error('token-diff requires --selector and --compare-selector') + } + + const outPath = defaultOutputPath({ + action: 'token-diff', + parsedUrl: meta.parsedUrl, + rawUrl: meta.url, + out: getArg(args, 'out'), + outDir: getArg(args, 'out-dir'), + extension: 'json', + }) + const prefixes = resolveVarPrefixes(args) + + const diff = await page.evaluate(({ leftSelector, rightSelector, varPrefixes }) => { + const collectVariables = (element) => { + const style = getComputedStyle(element) + const variables = {} + + for (const property of style) { + if (varPrefixes.some((prefix) => property.startsWith(prefix))) { + variables[property] = style.getPropertyValue(property).trim() + } + } + + return variables + } + + const left = document.querySelector(leftSelector) + const right = document.querySelector(rightSelector) + + if (!left) { + throw new Error(`selector not found: ${leftSelector}`) + } + + if (!right) { + throw new Error(`compare selector not found: ${rightSelector}`) + } + + const leftVariables = collectVariables(left) + const rightVariables = collectVariables(right) + const names = [...new Set([...Object.keys(leftVariables), ...Object.keys(rightVariables)])].sort() + const changed = {} + + for (const name of names) { + if ((leftVariables[name] ?? null) !== (rightVariables[name] ?? null)) { + changed[name] = { + left: leftVariables[name] ?? null, + right: rightVariables[name] ?? null, + } + } + } + + return { + left_selector: leftSelector, + right_selector: rightSelector, + prefixes: varPrefixes, + changed, + left_variables: leftVariables, + right_variables: rightVariables, + } + }, { leftSelector: selector, rightSelector: compareSelector, varPrefixes: prefixes }) + + await writeJson(outPath, { + action: 'token-diff', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: meta.navigation.finalUrl, + title: meta.navigation.title, + diff, + }) + + console.log(outPath) +} + +async function runMotionSample(page, args, meta) { + const interactionSelector = getArg(args, 'action-selector', '') + const interaction = getArg(args, 'interaction', interactionSelector ? 'click' : '') + const count = toNumber(getArg(args, 'count', '6'), 6) + const intervalMs = toNumber(getArg(args, 'interval-ms', '120'), 120) + const fullPage = toBool(getArg(args, 'full-page', 'false'), false) + const outDir = getArg(args, 'out-dir') + ?? path.join('drafts', 'research', 'motion-sample', defaultStem('motion-sample', meta.parsedUrl, meta.url)) + + await mkdir(outDir, { recursive: true }) + + const navigation = await navigate(page, args) + + if (interactionSelector) { + const locator = page.locator(interactionSelector) + + if (interaction === 'hover') { + await locator.hover() + } else if (interaction === 'focus') { + await locator.focus() + } else { + await locator.click() + } + } + + const frames = [] + + for (let index = 0; index < count; index += 1) { + const filePath = path.join(outDir, `frame-${String(index + 1).padStart(2, '0')}.png`) + await page.screenshot({ + path: filePath, + fullPage, + }) + + frames.push({ + index: index + 1, + elapsed_ms: index * intervalMs, + image_path: filePath, + }) + + if (index < count - 1 && intervalMs > 0) { + await page.waitForTimeout(intervalMs) + } + } + + const metaPath = path.join(outDir, 'meta.json') + + await writeJson(metaPath, { + action: 'motion-sample', + captured_at: new Date().toISOString(), + url_requested: meta.url, + url_final: navigation.finalUrl, + title: navigation.title, + interaction: interactionSelector + ? { + selector: interactionSelector, + type: interaction || null, + } + : null, + count, + interval_ms: intervalMs, + frames, + }) + + console.log(outDir) + console.log(metaPath) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const helpRequested = toBool(getArg(args, 'help', 'false'), false) + + if (helpRequested || !getArg(args, 'action')) { + usage() + process.exit(helpRequested ? 0 : 2) + } + + const action = getArg(args, 'action') + const requestedUrl = getArg(args, 'url') + + if (!requestedUrl) { + throw new Error('expected --url') + } + + const parsedUrl = parseHttpUrl(requestedUrl) + const url = parsedUrl.toString() + const runtime = await createPage(args) + const meta = { + action, + url, + parsedUrl, + viewport: runtime.viewport, + navigation: null, + } + + try { + if (action === 'console-capture') { + await runConsoleCapture(runtime.page, args, meta) + return + } + + if (action === 'network-log') { + await runNetworkLog(runtime.page, args, meta) + return + } + + if (action === 'trace') { + await runTrace(runtime.page, runtime.context, args, meta) + return + } + + if (action === 'perf-marks') { + await runPerfMarks(runtime.page, args, meta) + return + } + + if (action === 'motion-sample') { + await runMotionSample(runtime.page, args, meta) + return + } + + meta.navigation = await navigate(runtime.page, args) + + if (action === 'dom-snapshot') { + await runDomSnapshot(runtime.page, args, meta) + return + } + + if (action === 'style-dump') { + await runStyleDump(runtime.page, args, meta) + return + } + + if (action === 'layout-metrics') { + await runLayoutMetrics(runtime.page, args, meta) + return + } + + if (action === 'a11y-snapshot') { + await runA11ySnapshot(runtime.page, args, meta) + return + } + + if (action === 'token-diff') { + await runTokenDiff(runtime.page, args, meta) + return + } + + throw new Error(`Unsupported --action value: ${action}`) + } finally { + await runtime.page.close().catch(() => {}) + await runtime.context.close().catch(() => {}) + await runtime.browser.close().catch(() => {}) + } +} + +main().catch((error) => { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(String(error)) + } + + process.exit(1) +}) From cad953bf2809e07abb925b4b39b715155060274c Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 20:40:48 +0400 Subject: [PATCH 35/37] feat: M3 Radio component was added Closes #20 --- .../assets/stylesheets/components/index.scss | 1 + .../stylesheets/components/radio/index.scss | 142 ++++++++++++++++++ m3-react/src/components/radio/M3Radio.tsx | 114 ++++++++++++++ m3-react/src/components/radio/index.ts | 6 + m3-react/src/index.ts | 9 ++ m3-react/storybook/components/M3Radio.mdx | 70 +++++++++ .../storybook/components/M3Radio.stories.tsx | 99 ++++++++++++ .../storybook/examples/radio/RadioGroup.tsx | 78 ++++++++++ m3-react/tests/M3Radio.test.tsx | 44 ++++++ m3-vue/src/components/radio/M3Radio.vue | 117 +++++++++++++++ m3-vue/src/components/radio/index.ts | 1 + m3-vue/src/index.ts | 4 + m3-vue/storybook/components/M3Radio.mdx | 67 +++++++++ .../storybook/components/M3Radio.stories.ts | 103 +++++++++++++ .../storybook/examples/radio/RadioGroup.vue | 75 +++++++++ m3-vue/tests/M3Radio.test.ts | 42 ++++++ 16 files changed, 972 insertions(+) create mode 100644 m3-foundation/assets/stylesheets/components/radio/index.scss create mode 100644 m3-react/src/components/radio/M3Radio.tsx create mode 100644 m3-react/src/components/radio/index.ts create mode 100644 m3-react/storybook/components/M3Radio.mdx create mode 100644 m3-react/storybook/components/M3Radio.stories.tsx create mode 100644 m3-react/storybook/examples/radio/RadioGroup.tsx create mode 100644 m3-react/tests/M3Radio.test.tsx create mode 100644 m3-vue/src/components/radio/M3Radio.vue create mode 100644 m3-vue/src/components/radio/index.ts create mode 100644 m3-vue/storybook/components/M3Radio.mdx create mode 100644 m3-vue/storybook/components/M3Radio.stories.ts create mode 100644 m3-vue/storybook/examples/radio/RadioGroup.vue create mode 100644 m3-vue/tests/M3Radio.test.ts diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index 0fb04f0..7e6c9db 100644 --- a/m3-foundation/assets/stylesheets/components/index.scss +++ b/m3-foundation/assets/stylesheets/components/index.scss @@ -10,6 +10,7 @@ @use 'navigation'; @use 'plane-tooltip'; @use 'popper'; +@use 'radio'; @use 'rich-tooltip'; @use 'ripple'; @use 'scrim'; diff --git a/m3-foundation/assets/stylesheets/components/radio/index.scss b/m3-foundation/assets/stylesheets/components/radio/index.scss new file mode 100644 index 0000000..7530151 --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/radio/index.scss @@ -0,0 +1,142 @@ +@use "../../basics/motion" as m3-motion; +@use "../../basics/shape" as m3-shape; + +.m3-radio { + --state-color: var(--m3-sys-primary); + --outline-color: var(--m3-sys-on-surface-variant); + --dot-scale: 0; + + --m3-ripple-color: var(--m3-sys-primary); + --m3-ripple-effect-duration: 0.75s; + --m3-ripple-opacity: 0.2; + + display: inline-block; + flex-grow: 0; + flex-shrink: 0; + width: 40px; + height: 40px; + padding: 8px; + border-radius: 50%; + overflow: hidden; + cursor: pointer; + outline: none; + position: relative; + + @include m3-shape.reset-box-sizing; + + &_checked { + --outline-color: var(--state-color); + --dot-scale: 1; + } + + &_invalid { + --state-color: var(--m3-sys-error); + --outline-color: var(--m3-sys-error); + --m3-ripple-color: var(--m3-sys-error); + } + + &__input { + width: 100%; + height: 100%; + appearance: none; + cursor: inherit; + outline: none; + position: absolute; + left: 0; + top: 0; + margin: 0; + z-index: 1; + } + + &__icon { + display: inline-block; + width: 24px; + height: 24px; + position: relative; + z-index: 0; + + &::before, + &::after { + content: ' '; + display: inline-block; + position: absolute; + border-radius: 50%; + transition: + m3-motion.timing-standard(background-color), + m3-motion.timing-standard(border-color), + m3-motion.timing-standard(transform) + ; + } + + &::before { + width: 20px; + height: 20px; + border: 2px solid var(--outline-color); + left: 2px; + top: 2px; + box-sizing: border-box; + } + + &::after { + width: 10px; + height: 10px; + left: 7px; + top: 7px; + background-color: var(--state-color); + transform: scale(var(--dot-scale)); + } + } + + &__state { + display: inline-block; + width: 100%; + height: 100%; + border-radius: inherit; + opacity: 0; + overflow: hidden; + animation: m3-motion.timing-standard(m3-animation-zoom-out); + animation-fill-mode: forwards; + z-index: 0; + position: absolute; + left: 0; + top: 0; + + &::before { + content: ' '; + width: 100%; + height: 100%; + background: var(--state-color); + border-radius: inherit; + opacity: 0; + position: absolute; + left: 0; + top: 0; + transition: m3-motion.timing-standard(opacity); + } + } + + &__input:hover ~ &__state, + &__input:focus ~ &__state { + animation: m3-motion.timing-standard(m3-animation-zoom-in); + animation-fill-mode: forwards; + } + + &__input:hover ~ &__state::before { opacity: 0.08; } + &__input:focus ~ &__state::before { opacity: 0.12; } + + &_disabled { + cursor: default; + } + + &_disabled &__icon::before { + border-color: color-mix(in srgb, var(--outline-color) 38%, transparent); + } + + &_checked#{&}_disabled &__icon::after { + background-color: color-mix(in srgb, var(--state-color) 38%, transparent); + } + + &_disabled &__state::before { + background: transparent; + } +} diff --git a/m3-react/src/components/radio/M3Radio.tsx b/m3-react/src/components/radio/M3Radio.tsx new file mode 100644 index 0000000..94495da --- /dev/null +++ b/m3-react/src/components/radio/M3Radio.tsx @@ -0,0 +1,114 @@ +import type { + ForwardRefRenderFunction, + HTMLAttributes, +} from 'react' + +import type { + Clickable, + Focusable, +} from '@modulify/m3-foundation' + +import type { M3RippleMethods } from '@/components/ripple' + +import { M3Ripple } from '@/components/ripple' + +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' + +import { + useElementEffect, + useId, + useTarget, +} from '@/hooks' + +import { toClassName } from '@/utils/styling' + +export interface M3RadioProps extends HTMLAttributes { + id?: string; + name?: string; + model?: unknown; + value?: unknown; + invalid?: boolean; + disabled?: boolean; + equalsFn?: (a: unknown, b: unknown) => boolean; + onChange?: (value: unknown) => void; +} + +export interface M3RadioMethods extends Clickable, Focusable {} + +const M3Radio: ForwardRefRenderFunction< + M3RadioMethods, + M3RadioProps +> = ({ + id, + name, + model, + value = true, + invalid = false, + disabled = false, + equalsFn = (a: unknown, b: unknown): boolean => a === b, + className = '', + onChange = (_: unknown) => {}, + ...args +}, ref) => { + const root = useRef(null) + const input = useRef(null) + const ripple = useRef(null) + const [rippleTarget, setRippleTarget] = useTarget() + + useImperativeHandle(ref, () => ({ + click: () => input.current?.click(), + focus: () => input.current?.focus(), + blur: () => input.current?.blur(), + })) + + useElementEffect(root, setRippleTarget) + + const checked = useMemo(() => equalsFn(model, value), [equalsFn, model, value]) + const inputId = useId(id, 'm3-radio') + + const handleChange = useCallback((nextChecked: boolean) => { + if (nextChecked) { + onChange(value) + } + }, [onChange, value]) + + return ( + + + + handleChange(event.currentTarget.checked)} + /> + + + + + ) +} + +export default forwardRef(M3Radio) diff --git a/m3-react/src/components/radio/index.ts b/m3-react/src/components/radio/index.ts new file mode 100644 index 0000000..130f2c3 --- /dev/null +++ b/m3-react/src/components/radio/index.ts @@ -0,0 +1,6 @@ +export type { + M3RadioMethods, + M3RadioProps, +} from './M3Radio' + +export { default as M3Radio } from './M3Radio' diff --git a/m3-react/src/index.ts b/m3-react/src/index.ts index 2f47713..a48cb10 100644 --- a/m3-react/src/index.ts +++ b/m3-react/src/index.ts @@ -46,6 +46,11 @@ export type { M3PopperCloserOptions, } from '@/components/popper' +export type { + M3RadioMethods, + M3RadioProps, +} from '@/components/radio' + export type { M3RichTooltipMethods, M3RichTooltipProps, @@ -143,6 +148,10 @@ export { useM3PopperCloserEffect, } from '@/components/popper' +export { + M3Radio, +} from '@/components/radio' + export { M3RichTooltip, } from '@/components/rich-tooltip' diff --git a/m3-react/storybook/components/M3Radio.mdx b/m3-react/storybook/components/M3Radio.mdx new file mode 100644 index 0000000..7fa8d36 --- /dev/null +++ b/m3-react/storybook/components/M3Radio.mdx @@ -0,0 +1,70 @@ +import { Meta, Unstyled } from '@storybook/addon-docs/blocks' + +import RadioGroup from '../examples/radio/RadioGroup' +import * as M3RadioStories from './M3Radio.stories' + + + +# Radio buttons + +Radio buttons let users choose exactly one option from a related set. Unlike checkboxes, they are not meant for independent multi-selection or parent-child aggregate states. + +## API + +### When to use + +Use `M3Radio` when the user must choose one option from a small, visible list. If multiple options can be selected together, prefer `M3Checkbox`. + +### Selection model + +This implementation treats each radio as a single-value option. A radio is checked when `model` equals `value`, and selecting it emits that `value`. + +### Grouping + +For native keyboard and form semantics, related radios should share the same `name`. Wrap related options in a `fieldset` with a visible `legend` whenever possible. + +## Accessibility semantics + +- Every radio needs a visible text label. +- Related radios should be grouped under a shared label. +- Use `name` consistently across one group so browser navigation behaves predictably. +- Mark the group or options invalid only when the selection is required and unresolved. + +### Preference group + + + + + +## Story guide + +- [Standard](?path=/story/components-m3radio--standard) +- [Preference Group](?path=/story/components-m3radio--preference-group) +- [Invalid Group](?path=/story/components-m3radio--invalid-group) + +## Usage guidance + +### Keep option sets short and explicit + +Radio groups work best when all options are visible at once and labels are mutually exclusive. + +### Do not use radios for toggles + +If the choice is simply on/off, prefer a switch or checkbox. Radios communicate one-of-many selection. + +## Resources + +- [M3 Radio button overview](https://m3.material.io/components/radio-button/overview) +- [WAI-ARIA APG: Radio Group Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) diff --git a/m3-react/storybook/components/M3Radio.stories.tsx b/m3-react/storybook/components/M3Radio.stories.tsx new file mode 100644 index 0000000..edce17a --- /dev/null +++ b/m3-react/storybook/components/M3Radio.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { M3Radio } from '@/components/radio' +import RadioGroup from '../examples/radio/RadioGroup' + +import { useState } from 'react' +import { useId } from '@/hooks' + +const meta = { + title: 'Components/M3Radio', + + component: M3Radio, + + argTypes: { + invalid: { + control: 'boolean', + }, + + disabled: { + control: 'boolean', + }, + }, + + args: { + invalid: false, + disabled: false, + }, + + render: (args) => { + const name = useId(null, 'm3-radio-group') + const id = useId(null, 'm3-radio') + const [model, setModel] = useState('choice') + + return ( + + ) + }, + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Standard: Story = {} + +export const PreferenceGroup: Story = { + render: () => ( + + ), +} + +export const InvalidGroup: Story = { + render: () => ( + + ), +} diff --git a/m3-react/storybook/examples/radio/RadioGroup.tsx b/m3-react/storybook/examples/radio/RadioGroup.tsx new file mode 100644 index 0000000..eb2bfed --- /dev/null +++ b/m3-react/storybook/examples/radio/RadioGroup.tsx @@ -0,0 +1,78 @@ +import { M3Radio } from '@/components/radio' + +import { useId } from '@/hooks' +import { useState } from 'react' + +export interface RadioOption { + label: string; + value: string; + disabled?: boolean; +} + +export interface RadioGroupProps { + legend?: string; + options: RadioOption[]; + invalid?: boolean; +} + +const RadioGroup = ({ + legend = 'Selection', + options, + invalid = false, +}: RadioGroupProps) => { + const name = useId(null, 'm3-radio-group') + const [model, setModel] = useState(options[0]?.value) + + return ( +
+ + {legend} + + + {options.map(option => { + const id = `${name}-${option.value}` + + return ( + + ) + })} +
+ ) +} + +export default RadioGroup diff --git a/m3-react/tests/M3Radio.test.tsx b/m3-react/tests/M3Radio.test.tsx new file mode 100644 index 0000000..59c6d63 --- /dev/null +++ b/m3-react/tests/M3Radio.test.tsx @@ -0,0 +1,44 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' + +import { M3Radio } from '@/components/radio' + +describe('m3-react/radio', () => { + test('reflects checked state and emits selected value', () => { + const onChange = vi.fn() + + render( + + ) + + const input = screen.getByRole('radio') as HTMLInputElement + + expect(input.getAttribute('aria-invalid')).toBe('true') + expect(input.checked).toBe(false) + + fireEvent.click(input) + + expect(onChange).toHaveBeenCalledWith('email') + }) + + test('supports custom equality', () => { + render( + (a as { id: number }).id === (b as { id: number }).id} + /> + ) + + expect((screen.getByRole('radio') as HTMLInputElement).checked).toBe(true) + }) +}) diff --git a/m3-vue/src/components/radio/M3Radio.vue b/m3-vue/src/components/radio/M3Radio.vue new file mode 100644 index 0000000..948a918 --- /dev/null +++ b/m3-vue/src/components/radio/M3Radio.vue @@ -0,0 +1,117 @@ + + + diff --git a/m3-vue/src/components/radio/index.ts b/m3-vue/src/components/radio/index.ts new file mode 100644 index 0000000..2c7fa93 --- /dev/null +++ b/m3-vue/src/components/radio/index.ts @@ -0,0 +1 @@ +export { default as M3Radio } from './M3Radio.vue' diff --git a/m3-vue/src/index.ts b/m3-vue/src/index.ts index b8ad7e4..5f0ef64 100644 --- a/m3-vue/src/index.ts +++ b/m3-vue/src/index.ts @@ -58,6 +58,10 @@ export { vM3PopperCloser, } from '@/components/popper' +export { + M3Radio, +} from '@/components/radio' + export { M3RichTooltip, } from '@/components/rich-tooltip' diff --git a/m3-vue/storybook/components/M3Radio.mdx b/m3-vue/storybook/components/M3Radio.mdx new file mode 100644 index 0000000..475fdb2 --- /dev/null +++ b/m3-vue/storybook/components/M3Radio.mdx @@ -0,0 +1,67 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import Inline from './Inline' +import RadioGroup from '../examples/radio/RadioGroup.vue' +import * as M3RadioStories from './M3Radio.stories' + + + +# Radio buttons + +Radio buttons let users choose exactly one option from a related set. Unlike checkboxes, they are not meant for independent multi-selection or parent-child aggregate states. + +## API + +### When to use + +Use `M3Radio` when the user must choose one option from a small, visible list. If multiple options can be selected together, prefer `M3Checkbox`. + +### Selection model + +This implementation treats each radio as a single-value option. A radio is checked when `model` equals `value`, and selecting it emits that `value`. + +### Grouping + +For native keyboard and form semantics, related radios should share the same `name`. Wrap related options in a `fieldset` with a visible `legend` whenever possible. + +## Accessibility semantics + +- Every radio needs a visible text label. +- Related radios should be grouped under a shared label. +- Use `name` consistently across one group so browser navigation behaves predictably. +- Mark the group or options invalid only when the selection is required and unresolved. + +### Preference group + +
+ +
+ +## Story guide + +- [Standard](?path=/story/components-m3radio--standard) +- [Preference Group](?path=/story/components-m3radio--preference-group) +- [Invalid Group](?path=/story/components-m3radio--invalid-group) + +## Usage guidance + +### Keep option sets short and explicit + +Radio groups work best when all options are visible at once and labels are mutually exclusive. + +### Do not use radios for toggles + +If the choice is simply on/off, prefer a switch or checkbox. Radios communicate one-of-many selection. + +## Resources + +- [M3 Radio button overview](https://m3.material.io/components/radio-button/overview) +- [WAI-ARIA APG: Radio Group Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) diff --git a/m3-vue/storybook/components/M3Radio.stories.ts b/m3-vue/storybook/components/M3Radio.stories.ts new file mode 100644 index 0000000..72c8da7 --- /dev/null +++ b/m3-vue/storybook/components/M3Radio.stories.ts @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3Radio } from '@/components/radio' +import RadioGroup from '../examples/radio/RadioGroup.vue' +import { ref } from 'vue' + +import useId from '@/composables/id' + +const meta = { + title: 'Components/M3Radio', + + component: M3Radio, + + args: { + invalid: false, + disabled: false, + }, + + render: (args: unknown) => ({ + components: { + M3Radio, + }, + + setup: () => ({ + id: useId('m3-radio'), + name: useId('m3-radio-group'), + args, + model: ref('choice'), + }), + + template: ` + + `, + }), + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Standard: Story = {} + +export const PreferenceGroup: Story = { + render: () => ({ + components: { + RadioGroup, + }, + + template: ` + + `, + }), +} + +export const InvalidGroup: Story = { + render: () => ({ + components: { + RadioGroup, + }, + + template: ` + + `, + }), +} diff --git a/m3-vue/storybook/examples/radio/RadioGroup.vue b/m3-vue/storybook/examples/radio/RadioGroup.vue new file mode 100644 index 0000000..a6e403d --- /dev/null +++ b/m3-vue/storybook/examples/radio/RadioGroup.vue @@ -0,0 +1,75 @@ + + + diff --git a/m3-vue/tests/M3Radio.test.ts b/m3-vue/tests/M3Radio.test.ts new file mode 100644 index 0000000..2c88e64 --- /dev/null +++ b/m3-vue/tests/M3Radio.test.ts @@ -0,0 +1,42 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/vue' + +import { M3Radio } from '@/components/radio' + +describe('m3-vue/radio', () => { + test('reflects checked state and emits selected value', async () => { + const view = render(M3Radio, { + props: { + name: 'channel', + model: 'push', + value: 'email', + invalid: true, + } as Record, + }) + + const input = screen.getByRole('radio') as HTMLInputElement + + expect(input.getAttribute('aria-invalid')).toBe('true') + expect(input.checked).toBe(false) + + await fireEvent.click(input) + + expect(view.emitted().change?.[0]).toEqual(['email']) + expect(view.emitted()['update:model']?.[0]).toEqual(['email']) + }) + + test('supports custom equality', () => { + render(M3Radio, { + props: { + model: { id: 2 }, + value: { id: 2 }, + equalsFn: (a: { id: number }, b: { id: number }) => a.id === b.id, + } as Record, + }) + + expect((screen.getByRole('radio') as HTMLInputElement).checked).toBe(true) + }) +}) From 3ac34caee6218e35494ec424eb29a200b2b2cda6 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 23:50:43 +0400 Subject: [PATCH 36/37] feat: M3 Chip component was added Closes #15 --- .../stylesheets/components/chip/index.scss | 215 ++++++++++++++++++ .../assets/stylesheets/components/index.scss | 1 + m3-foundation/types/components/chip.d.ts | 1 + m3-react/src/components/chip/M3Chip.tsx | 181 +++++++++++++++ m3-react/src/components/chip/index.ts | 5 + m3-react/src/index.ts | 9 + m3-react/storybook/components/M3Chip.mdx | 75 ++++++ .../storybook/components/M3Chip.stories.tsx | 90 ++++++++ .../storybook/examples/chip/ChipShowcase.tsx | 89 ++++++++ m3-react/tests/M3Chip.test.tsx | 60 +++++ m3-vue/src/components/chip/M3Chip.vue | 210 +++++++++++++++++ m3-vue/src/components/chip/index.ts | 1 + m3-vue/src/components/chip/properties.ts | 13 ++ m3-vue/src/components/chip/values.ts | 3 + m3-vue/src/index.ts | 4 + m3-vue/storybook/components/M3Chip.mdx | 75 ++++++ m3-vue/storybook/components/M3Chip.stories.ts | 98 ++++++++ .../storybook/examples/chip/ChipShowcase.vue | 87 +++++++ m3-vue/tests/M3Chip.test.ts | 63 +++++ 19 files changed, 1280 insertions(+) create mode 100644 m3-foundation/assets/stylesheets/components/chip/index.scss create mode 100644 m3-foundation/types/components/chip.d.ts create mode 100644 m3-react/src/components/chip/M3Chip.tsx create mode 100644 m3-react/src/components/chip/index.ts create mode 100644 m3-react/storybook/components/M3Chip.mdx create mode 100644 m3-react/storybook/components/M3Chip.stories.tsx create mode 100644 m3-react/storybook/examples/chip/ChipShowcase.tsx create mode 100644 m3-react/tests/M3Chip.test.tsx create mode 100644 m3-vue/src/components/chip/M3Chip.vue create mode 100644 m3-vue/src/components/chip/index.ts create mode 100644 m3-vue/src/components/chip/properties.ts create mode 100644 m3-vue/src/components/chip/values.ts create mode 100644 m3-vue/storybook/components/M3Chip.mdx create mode 100644 m3-vue/storybook/components/M3Chip.stories.ts create mode 100644 m3-vue/storybook/examples/chip/ChipShowcase.vue create mode 100644 m3-vue/tests/M3Chip.test.ts diff --git a/m3-foundation/assets/stylesheets/components/chip/index.scss b/m3-foundation/assets/stylesheets/components/chip/index.scss new file mode 100644 index 0000000..27bef0a --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/chip/index.scss @@ -0,0 +1,215 @@ +@use '../../basics/motion' as m3-motion; +@use '../../basics/typography' as m3-typography; + +.m3-chip { + --m3-icon-size: 18px; + --m3-ripple-color: var(--m3-sys-on-surface-variant); + --m3-ripple-opacity: 0.16; + --chip-container-color: transparent; + --chip-outline-color: var(--m3-sys-outline); + --chip-label-color: var(--m3-sys-on-surface); + --chip-state-color: var(--m3-sys-on-surface); + --chip-shadow: none; + + display: inline-flex; + align-items: stretch; + min-height: 32px; + max-width: 100%; + border-radius: 8px; + background: var(--chip-container-color); + box-shadow: var(--chip-shadow); + position: relative; + overflow: hidden; + vertical-align: middle; + transition: + m3-motion.timing-standard(background-color), + m3-motion.timing-standard(box-shadow) + ; + + &, + *, + *::before, + *::after { + box-sizing: border-box; + } + + &::before { + content: ' '; + border: 1px solid var(--chip-outline-color); + border-radius: inherit; + position: absolute; + inset: 0; + transition: m3-motion.timing-standard(border-color); + pointer-events: none; + } + + &_assist { + --m3-ripple-color: var(--m3-sys-primary); + --chip-state-color: var(--m3-sys-primary); + } + + &_filter { + --m3-ripple-color: var(--m3-sys-on-surface); + --chip-state-color: var(--m3-sys-on-surface); + } + + &_filter#{&}_selected { + --m3-ripple-color: var(--m3-sys-on-secondary-container); + --chip-container-color: var(--m3-sys-secondary-container); + --chip-outline-color: transparent; + --chip-label-color: var(--m3-sys-on-secondary-container); + --chip-state-color: var(--m3-sys-on-secondary-container); + } + + &_input { + --chip-container-color: var(--m3-sys-surface-container-low); + --chip-label-color: var(--m3-sys-on-surface); + --chip-state-color: var(--m3-sys-on-surface); + } + + &_suggestion { + --chip-container-color: var(--m3-sys-surface-container-lowest); + --chip-label-color: var(--m3-sys-on-surface); + --chip-state-color: var(--m3-sys-on-surface); + --chip-shadow: var(--m3-elevation-1); + } + + &_dismissible &__action { + padding-right: 10px; + } + + &__action, + &__dismiss { + @include m3-typography.label-large; + + appearance: none; + display: inline-flex; + align-items: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + margin: 0; + outline: none; + position: relative; + z-index: 0; + overflow: hidden; + } + + &__action { + min-height: 32px; + min-width: 0; + padding: 0 16px; + color: var(--chip-label-color); + flex: 1 1 auto; + border-radius: inherit; + } + + &_has-leading-icon &__action, + &_has-checkmark &__action { + padding-left: 12px; + } + + &_has-trailing-icon &__action { + padding-right: 12px; + } + + &__dismiss { + width: 32px; + min-height: 32px; + justify-content: center; + color: var(--chip-label-color); + flex: 0 0 32px; + border-radius: 0 8px 8px 0; + } + + &_dismissible &__action { + border-radius: 8px 0 0 8px; + } + + &_dismissible &__dismiss::before { + content: ' '; + width: 1px; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + background: color-mix(in srgb, var(--chip-outline-color) 60%, transparent); + } + + &__state { + width: 100%; + height: 100%; + background: var(--chip-state-color); + border-radius: inherit; + overflow: hidden; + opacity: 0; + position: absolute; + inset: 0; + transition: + m3-motion.timing-standard(background-color), + m3-motion.timing-standard(opacity) + ; + pointer-events: none; + } + + &__content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + position: relative; + z-index: 1; + } + + &__icon, + &__label { + display: inline-flex; + align-items: center; + } + + &__icon { + flex: 0 0 auto; + } + + &__label { + min-width: 0; + padding-top: 1px; + white-space: nowrap; + } + + &__action:hover &__state, + &__dismiss:hover &__state { + opacity: 0.08; + } + + &__action:focus-visible &__state, + &__dismiss:focus-visible &__state, + &__action:active &__state, + &__dismiss:active &__state { + opacity: 0.12; + } + + &_disabled { + --m3-ripple-opacity: 0; + --chip-container-color: color-mix(in srgb, var(--chip-container-color) 100%, transparent); + --chip-outline-color: color-mix(in srgb, var(--m3-sys-on-surface) 12%, transparent); + --chip-label-color: color-mix(in srgb, var(--m3-sys-on-surface) 38%, transparent); + --chip-state-color: transparent; + --chip-shadow: none; + } + + &_disabled &__action, + &_disabled &__dismiss { + cursor: default; + } + + &_disabled &__dismiss::before { + background: color-mix(in srgb, var(--m3-sys-on-surface) 12%, transparent); + } + + &_disabled &__state { + opacity: 0; + } +} diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index 7e6c9db..c0fd8c2 100644 --- a/m3-foundation/assets/stylesheets/components/index.scss +++ b/m3-foundation/assets/stylesheets/components/index.scss @@ -1,6 +1,7 @@ @use 'badge'; @use 'button'; @use 'card'; +@use 'chip'; @use 'checkbox'; @use 'dialog'; @use 'fab-button'; diff --git a/m3-foundation/types/components/chip.d.ts b/m3-foundation/types/components/chip.d.ts new file mode 100644 index 0000000..a4499d8 --- /dev/null +++ b/m3-foundation/types/components/chip.d.ts @@ -0,0 +1 @@ +export type Variant = 'assist' | 'filter' | 'input' | 'suggestion' diff --git a/m3-react/src/components/chip/M3Chip.tsx b/m3-react/src/components/chip/M3Chip.tsx new file mode 100644 index 0000000..49e2b7e --- /dev/null +++ b/m3-react/src/components/chip/M3Chip.tsx @@ -0,0 +1,181 @@ +import type { + ButtonHTMLAttributes, + ForwardRefRenderFunction, + MouseEventHandler, + ReactNode, +} from 'react' + +import type { Variant } from '@modulify/m3-foundation/types/components/chip' +import type { + Clickable, + Focusable, +} from '@modulify/m3-foundation' + +import type { M3RippleMethods } from '@/components/ripple' + +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' + +import { M3Icon } from '@/components/icon' +import { M3Ripple } from '@/components/ripple' + +import { + useElementEffect, + useTarget, +} from '@/hooks' + +import { normalize } from '@/utils/content' +import { toClassName } from '@/utils/styling' + +export interface M3ChipProps extends Omit, 'onToggle'> { + variant?: Variant; + selected?: boolean; + showCheckmark?: boolean; + dismissible?: boolean; + dismissLabel?: string; + onToggle?: (selected: boolean) => void; + onDismiss?: () => void; +} + +export interface M3ChipMethods extends Clickable, Focusable {} + +const M3Chip: ForwardRefRenderFunction = ({ + type = 'button', + variant = 'assist', + selected = false, + showCheckmark = true, + dismissible = false, + dismissLabel = 'Remove', + disabled = false, + className = '', + style, + children = [], + onClick = () => {}, + onKeyUp = () => {}, + onToggle = () => {}, + onDismiss, + ...actionAttrs +}, ref) => { + const action = useRef(null) + const dismiss = useRef(null) + const actionRipple = useRef(null) + const dismissRipple = useRef(null) + + const [actionRippleTarget, setActionRippleTarget] = useTarget() + const [dismissRippleTarget, setDismissRippleTarget] = useTarget() + + useElementEffect(action, setActionRippleTarget) + useElementEffect(dismiss, setDismissRippleTarget) + + useImperativeHandle(ref, () => ({ + click: () => action.current?.click(), + focus: () => action.current?.focus(), + blur: () => action.current?.blur(), + })) + + const content = useMemo(() => normalize(children), [children]) + + const hasText = useMemo(() => content.some(([, isIcon]) => !isIcon), [content]) + const [, hasLeadingIcon] = content[0] ?? [null, false] + const [, hasTrailingIcon] = content[content.length - 1] ?? [null, false] + + const hasCheckmark = variant === 'filter' && selected && showCheckmark && !hasLeadingIcon + const hasDismiss = dismissible || typeof onDismiss === 'function' + + const renderItem = useCallback((child: ReactNode, isIcon: boolean, key: string) => ( + + {child} + + ), []) + + return ( + + + + {hasDismiss ? ( + + ) : null} + + ) +} + +export default forwardRef(M3Chip) diff --git a/m3-react/src/components/chip/index.ts b/m3-react/src/components/chip/index.ts new file mode 100644 index 0000000..06c82da --- /dev/null +++ b/m3-react/src/components/chip/index.ts @@ -0,0 +1,5 @@ +export { default as M3Chip } from './M3Chip' +export type { + M3ChipMethods, + M3ChipProps, +} from './M3Chip' diff --git a/m3-react/src/index.ts b/m3-react/src/index.ts index a48cb10..4956b2d 100644 --- a/m3-react/src/index.ts +++ b/m3-react/src/index.ts @@ -15,6 +15,11 @@ export type { M3CheckboxProps, } from '@/components/checkbox' +export type { + M3ChipMethods, + M3ChipProps, +} from '@/components/chip' + export type { M3IconProps, } from '@/components/icon' @@ -113,6 +118,10 @@ export { M3Checkbox, } from '@/components/checkbox' +export { + M3Chip, +} from '@/components/chip' + export { M3FabButton, } from '@/components/fab-button' diff --git a/m3-react/storybook/components/M3Chip.mdx b/m3-react/storybook/components/M3Chip.mdx new file mode 100644 index 0000000..b8d45e8 --- /dev/null +++ b/m3-react/storybook/components/M3Chip.mdx @@ -0,0 +1,75 @@ +import { Meta, Unstyled } from '@storybook/addon-docs/blocks' + +import ChipShowcase from '../examples/chip/ChipShowcase' +import * as M3ChipStories from './M3Chip.stories' + + + +# Chips + +Chips help people enter information, make selections, filter content, or trigger actions. Material 3 distinguishes four chip types: assist, filter, input, and suggestion. + +## API + +### When to use + +Use `M3Chip` for compact, contextual actions or selections that should stay visible inside the surrounding content. If the action needs stronger emphasis or more copy, prefer buttons. + +### Variants + +- `assist` for quick contextual actions such as "Remind later" +- `filter` for toggleable filters that stay visible as active constraints +- `input` for entered entities or tokens that can usually be removed +- `suggestion` for lightweight recommendations that help users continue a flow + +### Selection and dismissal + +This implementation keeps `filter` chips controlled through `selected` and `onToggle`. Input chips can expose a separate dismiss affordance through `dismissible` and `onDismiss`. + +## Accessibility semantics + +- There is no special ARIA chip role here; the primary surface uses native button semantics. +- Filter chips expose toggle state through `aria-pressed`. +- If a chip exposes a remove affordance, the trailing dismiss button should keep an explicit accessible label. +- Chip labels should stay short and scannable because they are often read in dense horizontal clusters. + +### Variant panel + + + + + +### Filter set + + + + + +### Input tokens + + + + + +## Story guide + +- [Standard](?path=/story/components-m3chip--standard) +- [Variant Matrix](?path=/story/components-m3chip--variant-matrix) +- [Filter Set](?path=/story/components-m3chip--filter-set) +- [Input Tokens](?path=/story/components-m3chip--input-tokens) + +## Usage guidance + +### Keep chip copy brief + +Chips work best with short labels that can be scanned in rows. If the label turns into a sentence, the interaction usually wants a button or list row instead. + +### Match semantics to intent + +Use assist and suggestion chips as action affordances. Use filter chips only when the selected state matters. Use input chips when the item itself becomes part of the current form or scope. + +## Resources + +- [M3 Chips overview](https://m3.material.io/components/chips/overview) +- [M3 Chips guidelines](https://m3.material.io/components/chips/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-react/storybook/components/M3Chip.stories.tsx b/m3-react/storybook/components/M3Chip.stories.tsx new file mode 100644 index 0000000..aed4ae0 --- /dev/null +++ b/m3-react/storybook/components/M3Chip.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { M3Chip } from '@/components/chip' +import { M3Icon } from '@/components/icon' +import ChipShowcase from '../examples/chip/ChipShowcase' + +import { + useEffect, + useState, +} from 'react' + +const meta = { + title: 'Components/M3Chip', + + component: M3Chip, + + argTypes: { + variant: { + control: 'select', + options: ['assist', 'filter', 'input', 'suggestion'], + }, + + selected: { + control: 'boolean', + }, + + disabled: { + control: 'boolean', + }, + + dismissible: { + control: 'boolean', + }, + + showCheckmark: { + control: 'boolean', + }, + }, + + args: { + variant: 'assist', + selected: false, + disabled: false, + dismissible: false, + showCheckmark: true, + }, + + render: (args) => { + const [selected, setSelected] = useState(args.selected) + + useEffect(() => { + setSelected(args.selected) + }, [args.selected]) + + return ( + + {args.variant === 'assist' || args.variant === 'suggestion' + ? + : null} + {args.variant === 'input' ? 'Project Alpha' : 'Remind later'} + + ) + }, + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Standard: Story = {} + +export const VariantMatrix: Story = { + render: () => , +} + +export const FilterSet: Story = { + render: () => , +} + +export const InputTokens: Story = { + render: () => , +} diff --git a/m3-react/storybook/examples/chip/ChipShowcase.tsx b/m3-react/storybook/examples/chip/ChipShowcase.tsx new file mode 100644 index 0000000..6e2f292 --- /dev/null +++ b/m3-react/storybook/examples/chip/ChipShowcase.tsx @@ -0,0 +1,89 @@ +import type { FC } from 'react' + +import { M3Chip } from '@/components/chip' +import { M3Icon } from '@/components/icon' + +import { useState } from 'react' + +export interface ChipShowcaseProps { + mode?: 'matrix' | 'filters' | 'inputs'; +} + +const wrapStyle = { + display: 'flex', + flexWrap: 'wrap' as const, + gap: '12px', +} + +const ChipShowcase: FC = ({ + mode = 'matrix', +}) => { + const [filters, setFilters] = useState(['Assigned to me', 'Urgent']) + const [tokens, setTokens] = useState(['Onboarding', 'Billing', 'Design review']) + + if (mode === 'filters') { + const options = ['Assigned to me', 'Urgent', 'Needs review'] + + return ( +
+ {options.map(option => ( + { + setFilters(current => { + return selected + ? [...current, option] + : current.filter(value => value !== option) + }) + }} + > + {option} + + ))} +
+ ) + } + + if (mode === 'inputs') { + return ( +
+ {tokens.map(token => ( + setTokens(current => current.filter(value => value !== token))} + > + {token} + + ))} +
+ ) + } + + return ( +
+ + + Remind later + + + + Updates + + + + Project Alpha + + + + + Draft summary + +
+ ) +} + +export default ChipShowcase diff --git a/m3-react/tests/M3Chip.test.tsx b/m3-react/tests/M3Chip.test.tsx new file mode 100644 index 0000000..1e506f8 --- /dev/null +++ b/m3-react/tests/M3Chip.test.tsx @@ -0,0 +1,60 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' + +import { M3Chip } from '@/components/chip' + +describe('m3-react/chip', () => { + test('toggles selected state for filter chips via aria-pressed', () => { + const onToggle = vi.fn() + + render( + + Updates + + ) + + const button = screen.getByRole('button', { name: 'Updates' }) + + expect(button.getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(button) + + expect(onToggle).toHaveBeenCalledWith(true) + }) + + test('renders dismiss affordance independently from the main action', () => { + const onClick = vi.fn() + const onDismiss = vi.fn() + + render( + + Project Alpha + + ) + + const dismiss = screen.getByRole('button', { name: 'Remove' }) + + fireEvent.click(dismiss) + + expect(onDismiss).toHaveBeenCalledTimes(1) + expect(onClick).not.toHaveBeenCalled() + }) + + test('shows selection icon for selected filter chips without a leading icon', () => { + const { container } = render( + + Assigned to me + + ) + + expect(container.querySelector('.m3-chip__icon_selection')).not.toBeNull() + }) +}) diff --git a/m3-vue/src/components/chip/M3Chip.vue b/m3-vue/src/components/chip/M3Chip.vue new file mode 100644 index 0000000..d48545b --- /dev/null +++ b/m3-vue/src/components/chip/M3Chip.vue @@ -0,0 +1,210 @@ + + + diff --git a/m3-vue/src/components/chip/index.ts b/m3-vue/src/components/chip/index.ts new file mode 100644 index 0000000..03bc42e --- /dev/null +++ b/m3-vue/src/components/chip/index.ts @@ -0,0 +1 @@ +export { default as M3Chip } from './M3Chip.vue' diff --git a/m3-vue/src/components/chip/properties.ts b/m3-vue/src/components/chip/properties.ts new file mode 100644 index 0000000..b481ace --- /dev/null +++ b/m3-vue/src/components/chip/properties.ts @@ -0,0 +1,13 @@ +import type { + Prop, + PropType, +} from 'vue' +import type { Variant } from '@modulify/m3-foundation/types/components/chip' + +import { variants } from './values' + +export const variant: Prop = { + type: String as PropType, + validator: (value: string) => variants.includes(value as Variant), + default: 'assist', +} diff --git a/m3-vue/src/components/chip/values.ts b/m3-vue/src/components/chip/values.ts new file mode 100644 index 0000000..0837eb8 --- /dev/null +++ b/m3-vue/src/components/chip/values.ts @@ -0,0 +1,3 @@ +import type { Variant } from '@modulify/m3-foundation/types/components/chip' + +export const variants: Variant[] = ['assist', 'filter', 'input', 'suggestion'] diff --git a/m3-vue/src/index.ts b/m3-vue/src/index.ts index 5f0ef64..33d67eb 100644 --- a/m3-vue/src/index.ts +++ b/m3-vue/src/index.ts @@ -17,6 +17,10 @@ export { M3Checkbox, } from '@/components/checkbox' +export { + M3Chip, +} from '@/components/chip' + export { M3FabButton, } from '@/components/fab-button' diff --git a/m3-vue/storybook/components/M3Chip.mdx b/m3-vue/storybook/components/M3Chip.mdx new file mode 100644 index 0000000..8ffd6a1 --- /dev/null +++ b/m3-vue/storybook/components/M3Chip.mdx @@ -0,0 +1,75 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import Inline from './Inline' +import ChipShowcase from '../examples/chip/ChipShowcase.vue' +import * as M3ChipStories from './M3Chip.stories' + + + +# Chips + +Chips help people enter information, make selections, filter content, or trigger actions. Material 3 distinguishes four chip types: assist, filter, input, and suggestion. + +## API + +### When to use + +Use `M3Chip` for compact, contextual actions or selections that should stay visible inside the surrounding content. If the action needs stronger emphasis or more copy, prefer buttons. + +### Variants + +- `assist` for quick contextual actions such as "Remind later" +- `filter` for toggleable filters that stay visible as active constraints +- `input` for entered entities or tokens that can usually be removed +- `suggestion` for lightweight recommendations that help users continue a flow + +### Selection and dismissal + +This implementation keeps `filter` chips controlled through `selected` and `update:selected`. Input chips can expose a separate dismiss affordance through `dismissible` and `dismiss`. + +## Accessibility semantics + +- There is no special ARIA chip role here; the primary surface uses native button semantics. +- Filter chips expose toggle state through `aria-pressed`. +- If a chip exposes a remove affordance, the trailing dismiss button should keep an explicit accessible label. +- Chip labels should stay short and scannable because they are often read in dense horizontal clusters. + +### Variant panel + +
+ +
+ +### Filter set + +
+ +
+ +### Input tokens + +
+ +
+ +## Story guide + +- [Standard](?path=/story/components-m3chip--standard) +- [Variant Matrix](?path=/story/components-m3chip--variant-matrix) +- [Filter Set](?path=/story/components-m3chip--filter-set) +- [Input Tokens](?path=/story/components-m3chip--input-tokens) + +## Usage guidance + +### Keep chip copy brief + +Chips work best with short labels that can be scanned in rows. If the label turns into a sentence, the interaction usually wants a button or list row instead. + +### Match semantics to intent + +Use assist and suggestion chips as action affordances. Use filter chips only when the selected state matters. Use input chips when the item itself becomes part of the current form or scope. + +## Resources + +- [M3 Chips overview](https://m3.material.io/components/chips/overview) +- [M3 Chips guidelines](https://m3.material.io/components/chips/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3Chip.stories.ts b/m3-vue/storybook/components/M3Chip.stories.ts new file mode 100644 index 0000000..b80d1be --- /dev/null +++ b/m3-vue/storybook/components/M3Chip.stories.ts @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3Chip } from '@/components/chip' +import { M3Icon } from '@/components/icon' +import ChipShowcase from '../examples/chip/ChipShowcase.vue' + +import { ref, watch } from 'vue' + +const standardTemplate = ` + + + + {{ args.variant === 'input' ? 'Project Alpha' : 'Remind later' }} + +` + +const renderStandard = (args: Record) => ({ + components: { + M3Chip, + M3Icon, + }, + + setup: () => { + const selected = ref(Boolean(args.selected)) + + watch(() => args.selected, value => selected.value = Boolean(value), { immediate: true }) + + return { + args, + selected, + } + }, + + template: standardTemplate, +}) + +const meta = { + title: 'Components/M3Chip', + + component: M3Chip, + + args: { + variant: 'assist', + selected: false, + disabled: false, + dismissible: false, + showCheckmark: true, + }, + + render: renderStandard, + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Standard: Story = {} + +export const VariantMatrix: Story = { + render: () => ({ + components: { + ChipShowcase, + }, + + template: '', + }), +} + +export const FilterSet: Story = { + render: () => ({ + components: { + ChipShowcase, + }, + + template: '', + }), +} + +export const InputTokens: Story = { + render: () => ({ + components: { + ChipShowcase, + }, + + template: '', + }), +} diff --git a/m3-vue/storybook/examples/chip/ChipShowcase.vue b/m3-vue/storybook/examples/chip/ChipShowcase.vue new file mode 100644 index 0000000..a66378c --- /dev/null +++ b/m3-vue/storybook/examples/chip/ChipShowcase.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/m3-vue/tests/M3Chip.test.ts b/m3-vue/tests/M3Chip.test.ts new file mode 100644 index 0000000..533a23f --- /dev/null +++ b/m3-vue/tests/M3Chip.test.ts @@ -0,0 +1,63 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/vue' + +import { M3Chip } from '@/components/chip' + +describe('m3-vue/chip', () => { + test('toggles selected state for filter chips via aria-pressed', async () => { + const view = render(M3Chip, { + props: { + variant: 'filter', + selected: false, + }, + slots: { + default: 'Updates', + }, + }) + + const button = screen.getByRole('button', { name: 'Updates' }) + + expect(button.getAttribute('aria-pressed')).toBe('false') + + await fireEvent.click(button) + + expect(view.emitted().toggle?.[0]).toEqual([true]) + expect(view.emitted()['update:selected']?.[0]).toEqual([true]) + }) + + test('renders dismiss affordance independently from the main action', async () => { + const view = render(M3Chip, { + props: { + variant: 'input', + dismissible: true, + }, + slots: { + default: 'Project Alpha', + }, + }) + + const dismiss = screen.getByRole('button', { name: 'Remove' }) + + await fireEvent.click(dismiss) + + expect(view.emitted().dismiss?.length).toBe(1) + expect(view.emitted().click).toBeUndefined() + }) + + test('shows selection icon for selected filter chips without a leading icon', () => { + const { container } = render(M3Chip, { + props: { + variant: 'filter', + selected: true, + }, + slots: { + default: 'Assigned to me', + }, + }) + + expect(container.querySelector('.m3-chip__icon_selection')).not.toBeNull() + }) +}) From 8cc8a507568c57462910a3ac533141985c8378dc Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 23:50:57 +0400 Subject: [PATCH 37/37] chore: Eslint max-lines limit was raised --- m3-foundation/eslint.config.js | 2 +- m3-react/eslint.config.js | 2 +- m3-vue/eslint.config.js | 2 +- m3-vue/src/components/button/M3Button.ts | 1 - m3-vue/src/components/fab-button/M3FabButton.ts | 1 - m3-vue/storybook/components/M3Button.stories.ts | 1 - m3-vue/storybook/components/M3Card.stories.ts | 2 -- m3-vue/storybook/components/M3FabButton.stories.ts | 1 - m3-vue/storybook/components/M3IconButton.stories.ts | 1 - m3-vue/storybook/components/M3Navigation.stories.ts | 1 - m3-vue/storybook/components/M3ScrollRail.stories.ts | 1 - m3-vue/storybook/components/M3Select.stories.ts | 2 -- 12 files changed, 3 insertions(+), 14 deletions(-) diff --git a/m3-foundation/eslint.config.js b/m3-foundation/eslint.config.js index 8f49a1c..9b63781 100644 --- a/m3-foundation/eslint.config.js +++ b/m3-foundation/eslint.config.js @@ -47,7 +47,7 @@ export default [ SwitchCase: 1, }], 'linebreak-style': [2, 'unix'], - 'max-lines-per-function': ['error', 30], + 'max-lines-per-function': ['error', 50], 'max-nested-callbacks': ['error', 3], 'no-console': 'off', 'no-constant-condition': 'off', diff --git a/m3-react/eslint.config.js b/m3-react/eslint.config.js index e2bac2b..a6ff438 100644 --- a/m3-react/eslint.config.js +++ b/m3-react/eslint.config.js @@ -53,7 +53,7 @@ export default [ SwitchCase: 1, }], 'linebreak-style': [2, 'unix'], - 'max-lines-per-function': ['error', 30], + 'max-lines-per-function': ['error', 50], 'max-nested-callbacks': ['error', 3], 'no-console': 'off', 'no-constant-condition': 'off', diff --git a/m3-vue/eslint.config.js b/m3-vue/eslint.config.js index d1083e4..e119d9b 100644 --- a/m3-vue/eslint.config.js +++ b/m3-vue/eslint.config.js @@ -50,7 +50,7 @@ export default [ SwitchCase: 1, }], 'linebreak-style': [2, 'unix'], - 'max-lines-per-function': ['error', 30], + 'max-lines-per-function': ['error', 50], 'max-nested-callbacks': ['error', 3], 'no-console': 'off', 'no-constant-condition': 'off', diff --git a/m3-vue/src/components/button/M3Button.ts b/m3-vue/src/components/button/M3Button.ts index f6a1c9f..1f63282 100644 --- a/m3-vue/src/components/button/M3Button.ts +++ b/m3-vue/src/components/button/M3Button.ts @@ -48,7 +48,6 @@ export default defineComponent({ }, }, - // eslint-disable-next-line max-lines-per-function setup (props, { attrs, expose, slots }) { const root = ref(null) const rootElement = computed(() => root.value?.el() ?? null) diff --git a/m3-vue/src/components/fab-button/M3FabButton.ts b/m3-vue/src/components/fab-button/M3FabButton.ts index 0d24775..774c0e5 100644 --- a/m3-vue/src/components/fab-button/M3FabButton.ts +++ b/m3-vue/src/components/fab-button/M3FabButton.ts @@ -54,7 +54,6 @@ export default defineComponent({ }, }, - // eslint-disable-next-line max-lines-per-function setup (props, { attrs, expose, slots }) { const root = ref(null) const rootElement = computed(() => root.value?.el() ?? null) diff --git a/m3-vue/storybook/components/M3Button.stories.ts b/m3-vue/storybook/components/M3Button.stories.ts index 369f569..eaf4524 100644 --- a/m3-vue/storybook/components/M3Button.stories.ts +++ b/m3-vue/storybook/components/M3Button.stories.ts @@ -70,7 +70,6 @@ export const WithLeadingIcon: Story = { } export const AppearanceMatrix: Story = { - // eslint-disable-next-line max-lines-per-function render: () => ({ components: { M3Button, diff --git a/m3-vue/storybook/components/M3Card.stories.ts b/m3-vue/storybook/components/M3Card.stories.ts index 7084269..b1b44a6 100644 --- a/m3-vue/storybook/components/M3Card.stories.ts +++ b/m3-vue/storybook/components/M3Card.stories.ts @@ -92,7 +92,6 @@ export const LandscapeWithoutMedia: Story = { } export const Portrait: Story = { - // eslint-disable-next-line max-lines-per-function render: (args: unknown) => ({ components: { M3Button, @@ -134,7 +133,6 @@ export const Portrait: Story = { } export const AppearanceMatrix: Story = { - // eslint-disable-next-line max-lines-per-function render: () => ({ components: { M3Card, diff --git a/m3-vue/storybook/components/M3FabButton.stories.ts b/m3-vue/storybook/components/M3FabButton.stories.ts index 6e22250..d8dc9c5 100644 --- a/m3-vue/storybook/components/M3FabButton.stories.ts +++ b/m3-vue/storybook/components/M3FabButton.stories.ts @@ -83,7 +83,6 @@ export const Extended: Story = { } export const VariantMatrix: Story = { - // eslint-disable-next-line max-lines-per-function render: () => ({ components: { M3FabButton, diff --git a/m3-vue/storybook/components/M3IconButton.stories.ts b/m3-vue/storybook/components/M3IconButton.stories.ts index 330e77d..05c0890 100644 --- a/m3-vue/storybook/components/M3IconButton.stories.ts +++ b/m3-vue/storybook/components/M3IconButton.stories.ts @@ -92,7 +92,6 @@ export const Toggleable: Story = { } export const AppearanceMatrix: Story = { - // eslint-disable-next-line max-lines-per-function render: () => ({ components: { M3Icon, diff --git a/m3-vue/storybook/components/M3Navigation.stories.ts b/m3-vue/storybook/components/M3Navigation.stories.ts index 28b5779..b884bce 100644 --- a/m3-vue/storybook/components/M3Navigation.stories.ts +++ b/m3-vue/storybook/components/M3Navigation.stories.ts @@ -137,7 +137,6 @@ export const NavigationRail: Story = { } export const ModalNavigationDrawer: Story = { - // eslint-disable-next-line max-lines-per-function render: (args: unknown) => ({ name: 'M3ModalNavigationDrawerStory', diff --git a/m3-vue/storybook/components/M3ScrollRail.stories.ts b/m3-vue/storybook/components/M3ScrollRail.stories.ts index fccaa15..b729526 100644 --- a/m3-vue/storybook/components/M3ScrollRail.stories.ts +++ b/m3-vue/storybook/components/M3ScrollRail.stories.ts @@ -22,7 +22,6 @@ const meta = { disabled: false, }, - // eslint-disable-next-line max-lines-per-function render: (args: unknown) => ({ name: 'M3ScrollRailStory', diff --git a/m3-vue/storybook/components/M3Select.stories.ts b/m3-vue/storybook/components/M3Select.stories.ts index 0593983..2729635 100644 --- a/m3-vue/storybook/components/M3Select.stories.ts +++ b/m3-vue/storybook/components/M3Select.stories.ts @@ -29,7 +29,6 @@ const meta = { }, }, - // eslint-disable-next-line max-lines-per-function render: (args: unknown) => ({ name: 'M3SelectStory', @@ -83,7 +82,6 @@ export const WithIcons: Story = { label: 'Country', }, - // eslint-disable-next-line max-lines-per-function render: (args: unknown) => ({ name: 'M3SelectStory',