From d6b2257177a3c60ef8880edfeb6a7e5030eb8b43 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Sun, 22 Feb 2026 20:19:12 +0400 Subject: [PATCH 01/34] 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/34] 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/34] 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) +})