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