diff --git a/.gitignore b/.gitignore index e3009e6..7e65074 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ node_modules/ .pnp.loader.mjs .yarnrc.yml *storybook.log +.env diff --git a/evidence/popper.md b/evidence/popper.md new file mode 100644 index 0000000..b0468ac --- /dev/null +++ b/evidence/popper.md @@ -0,0 +1,70 @@ +# Popper Animation Evidence +Date: 2026-02-22 + +## Sources +### Primary references +- User-provided design reference video: + - https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Fmhlk1jwz-GM3_Menus_Guidelines%2001_IA_v01.mp4?alt=media&token=2e7dfcc9-2447-4808-8234-83c313d82df8 +- Material 3 motion pages: + - https://m3.material.io/styles/motion/overview + - https://m3.material.io/foundations/motion/applying-easing-and-duration + - https://m3.material.io/styles/motion/easing-and-duration/tokens-specs + +### Token/easing references used as practical source of truth +- Flutter generated motion tokens: + - https://flutter.googlesource.com/mirrors/packages/+/refs/tags/google_maps_flutter-v2.7.0/packages/flutter/lib/src/material/motion.dart +- Flutter API docs for durations and easing: + - https://api.flutter.dev/flutter/material/Durations-class.html + - https://api.flutter.dev/flutter/material/Easing/standard-constant.html + - https://api.flutter.dev/flutter/material/Easing/standardAccelerate-constant.html + - https://api.flutter.dev/flutter/material/Easing/standardDecelerate-constant.html + - https://api.flutter.dev/flutter/material/Easing/emphasizedAccelerate-constant.html + - https://api.flutter.dev/flutter/material/Easing/emphasizedDecelerate-constant.html + +### Internal empirical source +- Implementation and local validation log in `draft.txt`: + - wrapper split (`.m3-popper-positioner` + `.m3-popper`) + - updated E2E assertions + - local check commands and outcomes + +## Extracted Facts +### Direct facts +1. Positioning and animation transforms conflict if both are applied to the same element. +2. Splitting responsibilities into two elements is technically viable: + - outer element handles geometry (`absolute/top/left/transform` from floating-ui), + - inner element handles visual animation (`transform/opacity/visibility`). +3. Show ordering matters: first position (`await adjust`), then reveal content. +4. Animation direction must use the effective side after flip (actual placement), not requested placement. +5. Headless access to `m3.material.io` pages may be limited (JS-required shell), so token tables were taken from official generated/tokenized sources. +6. Local verification was reported as green after stabilization: + - `tsc` for foundation/react/vue, + - `eslint`, + - unit and e2e checks for popper, + - combined coverage run (`80.11%`). + +### Motion token facts (from token references) +1. Duration token scale: + - short: 50/100/150/200 ms, + - medium: 250/300/350/400 ms, + - long: 450/500/550/600 ms, + - extra long: 700/800/900/1000 ms. +2. Easing token curves: + - standard: `cubic-bezier(0.2, 0.0, 0.0, 1.0)`, + - standardAccelerate: `cubic-bezier(0.3, 0.0, 1.0, 1.0)`, + - standardDecelerate: `cubic-bezier(0.0, 0.0, 0.0, 1.0)`, + - emphasizedAccelerate: `cubic-bezier(0.3, 0.0, 0.8, 0.15)`, + - emphasizedDecelerate: `cubic-bezier(0.05, 0.7, 0.1, 1.0)`, + - linear: `cubic-bezier(0.0, 0.0, 1.0, 1.0)`. + +### Inferences used for implementation tuning +1. Enter animation should prefer decelerate-family easing, exit should prefer accelerate-family easing. +2. Perceived "menu unfolding from anchor point" depends on: + - side-aware `transform-origin`, + - axis-dominant scale (uncollapse) with small translation offset. +3. Candidate presets for iterative tuning: + - `short3/short1` or `short4/short2`, + - decelerate for enter + accelerate for exit. + +## Notes for future work +- Keep parity of popper behavior checks in React and Vue E2E. +- Freeze chosen duration/easing pair in tests via computed-style assertions to prevent regressions. 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/package.json b/m3-react/package.json index 4f2df8e..337a555 100644 --- a/m3-react/package.json +++ b/m3-react/package.json @@ -75,6 +75,7 @@ "eslint-plugin-unused-imports": "^4.4.1", "flag-icons": "^7.5.0", "globals": "^17.3.0", + "highlight.js": "^11.11.1", "jsdom": "^28.1.0", "playwright": "^1.55.0", "react": "^18.2.0", 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/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/M3Button.mdx b/m3-react/storybook/components/M3Button.mdx index 62b448a..5374a45 100644 --- a/m3-react/storybook/components/M3Button.mdx +++ b/m3-react/storybook/components/M3Button.mdx @@ -10,6 +10,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. +

Edit @@ -27,3 +33,19 @@ Common buttons prompt most actions in a UI Edit

+ +## Code example + +```tsx +export const EditButton = () => ( + + Edit + +) +``` + +## 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-react/storybook/components/M3Card.mdx b/m3-react/storybook/components/M3Card.mdx index eb1265a..eefef04 100644 --- a/m3-react/storybook/components/M3Card.mdx +++ b/m3-react/storybook/components/M3Card.mdx @@ -10,9 +10,20 @@ import * as M3CardStories from './M3Card.stories' 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. + +## 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-react/storybook/components/M3Checkbox.mdx b/m3-react/storybook/components/M3Checkbox.mdx index b1cbe34..3746709 100644 --- a/m3-react/storybook/components/M3Checkbox.mdx +++ b/m3-react/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 @@ -50,3 +52,10 @@ Checkboxes let users select one or more items from a list, or turn an item on or }]} /> + +## 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-react/storybook/components/M3Dialog.mdx b/m3-react/storybook/components/M3Dialog.mdx index 5051a17..ce7d43c 100644 --- a/m3-react/storybook/components/M3Dialog.mdx +++ b/m3-react/storybook/components/M3Dialog.mdx @@ -3,8 +3,23 @@ import DialogConfirmation from '../examples/dialog/DialogConfirmation' # 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-react/storybook/components/M3FabButton.mdx b/m3-react/storybook/components/M3FabButton.mdx index 70a9400..8862943 100644 --- a/m3-react/storybook/components/M3FabButton.mdx +++ b/m3-react/storybook/components/M3FabButton.mdx @@ -10,11 +10,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

@@ -27,8 +29,6 @@ Floating action buttons (FABs) help people take primary actions ### Extended FABs -[Guidelines](https://m3.material.io/components/extended-fab/guidelines) -

New task @@ -37,3 +37,12 @@ Floating action buttons (FABs) help people take primary actions New task

+ +## 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-react/storybook/components/M3IconButton.mdx b/m3-react/storybook/components/M3IconButton.mdx index deb322b..824d5ba 100644 --- a/m3-react/storybook/components/M3IconButton.mdx +++ b/m3-react/storybook/components/M3IconButton.mdx @@ -10,11 +10,30 @@ import * as M3IconButtonStories from './M3IconButton.stories' 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.

- - + + + +

+ +## 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-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/countries/CountryFlag.tsx b/m3-react/storybook/countries/CountryFlag.tsx new file mode 100644 index 0000000..256fc2f --- /dev/null +++ b/m3-react/storybook/countries/CountryFlag.tsx @@ -0,0 +1,32 @@ +import type { + FC, + SVGAttributes, +} from 'react' + +import type { Code } from './codes' + +import provider from './CountryFlagProvider' + +export interface CountryFlagProps extends SVGAttributes { + code: Code; +} + +const CountryFlag: FC = ({ + code, + style, + ...attrs +}) => { + const Sprite = provider.get(code) + + return ( + + ) +} + +export default CountryFlag diff --git a/m3-react/storybook/countries/CountryFlagProvider.ts b/m3-react/storybook/countries/CountryFlagProvider.ts new file mode 100644 index 0000000..6470a65 --- /dev/null +++ b/m3-react/storybook/countries/CountryFlagProvider.ts @@ -0,0 +1,68 @@ +import type { + FC, + SVGProps, +} from 'react' + +import type { Code } from './codes' + +import ad from 'flag-icons/flags/1x1/ad.svg?react' +import am from 'flag-icons/flags/1x1/am.svg?react' +import by from 'flag-icons/flags/1x1/by.svg?react' +import ch from 'flag-icons/flags/1x1/ch.svg?react' +import cn from 'flag-icons/flags/1x1/cn.svg?react' +import de from 'flag-icons/flags/1x1/de.svg?react' +import fi from 'flag-icons/flags/1x1/fi.svg?react' +import fr from 'flag-icons/flags/1x1/fr.svg?react' +import gb from 'flag-icons/flags/1x1/gb.svg?react' +import ge from 'flag-icons/flags/1x1/ge.svg?react' +import kg from 'flag-icons/flags/1x1/kg.svg?react' +import kz from 'flag-icons/flags/1x1/kz.svg?react' +import ru from 'flag-icons/flags/1x1/ru.svg?react' +import ua from 'flag-icons/flags/1x1/ua.svg?react' +import us from 'flag-icons/flags/1x1/us.svg?react' + +type FlagSprite = FC> + +export class CountryFlagProvider { + private _sprites: Map = new Map() + + has (code: Code): boolean { + return this._sprites.has(code) + } + + get (code: Code): FlagSprite { + if (!this.has(code)) { + throw new Error('Code ' + code + ' has not been registered yet') + } + + return this._sprites.get(code) as FlagSprite + } + + add (code: Code, sprite: FlagSprite): void { + if (this.has(code)) { + throw new Error('Code ' + code + ' has been already registered') + } + + this._sprites.set(code, sprite) + } +} + +const provider = new CountryFlagProvider() + +provider.add('ad', ad) +provider.add('am', am) +provider.add('by', by) +provider.add('ch', ch) +provider.add('cn', cn) +provider.add('de', de) +provider.add('fi', fi) +provider.add('fr', fr) +provider.add('gb', gb) +provider.add('ge', ge) +provider.add('kg', kg) +provider.add('kz', kz) +provider.add('ru', ru) +provider.add('ua', ua) +provider.add('us', us) + +export default provider diff --git a/m3-react/storybook/countries/codes.ts b/m3-react/storybook/countries/codes.ts new file mode 100644 index 0000000..6766d43 --- /dev/null +++ b/m3-react/storybook/countries/codes.ts @@ -0,0 +1,49 @@ +export type ad = 'ad' // Andorra +export type am = 'am' // Armenia / Հայաստան +export type by = 'by' // Belarus / Беларусь +export type ch = 'ch' // Switzerland +export type cn = 'cn' // China / 中國 +export type de = 'de' // Germany / Deutschland +export type fi = 'fi' // Finland / Suomi +export type fr = 'fr' // France +export type gb = 'gb' // Britain +export type ge = 'ge' // Georgia / საქართველო +export type kg = 'kg' // Kyrgyzstan / Кыргызстан +export type kz = 'kz' // Kazakhstan / Қазақстан +export type ru = 'ru' // Russia / Россия +export type ua = 'ua' // Ukraine / Україна +export type us = 'us' // United States of America + +export type Code = ad + | am + | by + | ch + | cn + | de + | fi + | fr + | gb + | ge + | kg + | kz + | ru + | ua + | us + +export default [ + 'ad', + 'am', + 'by', + 'ch', + 'cn', + 'de', + 'fi', + 'fr', + 'gb', + 'ge', + 'kg', + 'kz', + 'ru', + 'ua', + 'us', +] as Code[] diff --git a/m3-react/storybook/countries/names.json b/m3-react/storybook/countries/names.json new file mode 100644 index 0000000..1348bb4 --- /dev/null +++ b/m3-react/storybook/countries/names.json @@ -0,0 +1,17 @@ +{ + "ad": "Andorra", + "am": "Armenia", + "by": "Belarus", + "ch": "Switzerland", + "cn": "China", + "de": "Germany", + "fi": "Finland", + "fr": "France", + "gb": "Great Britain", + "ge": "Georgia", + "kg": "Kyrgyzstan", + "kz": "Kazakhstan", + "ru": "Russia", + "ua": "Ukraine", + "us": "United States of America" +} 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-react/storybook/preview-head.html b/m3-react/storybook/preview-head.html index 3610f4c..e63338e 100644 --- a/m3-react/storybook/preview-head.html +++ b/m3-react/storybook/preview-head.html @@ -12,6 +12,26 @@ color: var(--m3-sys-on-surface) !important; } + :where(ul:not(.sb-anchor, .sb-unstyled, .sb-unstyled ul)) { + color: var(--m3-sys-on-surface) !important; + } + + :where(ol:not(.sb-anchor, .sb-unstyled, .sb-unstyled ol)) { + color: var(--m3-sys-on-surface) !important; + } + + :where(li:not(.sb-anchor, .sb-unstyled, .sb-unstyled li)) { + color: var(--m3-sys-on-surface) !important; + } + + :where(code:not(.hljs, .hljs *)) { + border-radius: 8px; + background: var(--m3-sys-surface-container-highest) !important; + color: var(--m3-sys-on-surface) !important; + font-size: 0.875em; + padding: 0.125em 0.5em; + } + :where(h2:not(.sb-anchor, .sb-unstyled, .sb-unstyled h2)) { border-color: var(--m3-state-layers-on-surface-opacity-020) !important; } @@ -24,4 +44,16 @@ } } } - \ No newline at end of file + + :where(.sbdocs-content ul, .sbdocs-content ol, .sbdocs-content li) { + color: var(--m3-sys-on-surface) !important; + } + + :where(.sbdocs-content code:not(.hljs, .hljs *)) { + border-radius: 8px; + background: var(--m3-sys-surface-container-highest) !important; + color: var(--m3-sys-on-surface) !important; + font-size: 0.875em; + padding: 0.125em 0.5em; + } + diff --git a/m3-react/storybook/preview.ts b/m3-react/storybook/preview.ts index b613a5c..0759b6d 100644 --- a/m3-react/storybook/preview.ts +++ b/m3-react/storybook/preview.ts @@ -7,6 +7,14 @@ import './stylesheets/utils.scss' import { withThemeByClassName } from '@storybook/addon-themes' import { addons } from 'storybook/preview-api' +import { MdxCodeBlock, MdxCodePreBlock } from './utils/mdxCodeBlock' + +type DocsParameter = NonNullable['docs']> & { + components?: { + code: typeof MdxCodeBlock + pre: typeof MdxCodePreBlock + } +} const themeClassByName = { light: 'm3-theme-light', @@ -15,6 +23,16 @@ const themeClassByName = { const themeClasses = Object.values(themeClassByName) +const docsParameter: DocsParameter = { + components: { + code: MdxCodeBlock, + pre: MdxCodePreBlock, + }, + source: { + language: 'text', + }, +} + const applyThemeClass = (themeName?: unknown): void => { const rootElement = document.documentElement @@ -93,6 +111,7 @@ export default { }, }, backgrounds: { disable: true }, + docs: docsParameter, controls: { matchers: { color: /(background|color)$/i, @@ -100,14 +119,24 @@ export default { }, }, options: { - storySort: (a, b) => { - return a.id.endsWith('docs') && !b.id.endsWith('docs') - ? -1 - : !a.id.endsWith('docs') && b.id.endsWith('docs') - ? 1 - : a.id === b.id - ? 0 - : a.id.localeCompare(b.id, undefined, { numeric: true }) + storySort: (left, right) => { + const withFallback = (story, key) => typeof story[key] === 'string' ? story[key] : '' + const normalize = (entry) => entry && typeof entry === 'object' ? entry : {} + const docsRank = (story) => { + const id = withFallback(story, 'id') + const name = withFallback(story, 'name').toLowerCase() + return story.type === 'docs' || name === 'docs' || id.endsWith('--docs') || id.endsWith('-docs') + ? 0 + : 1 + } + + const a = normalize(left) + const b = normalize(right) + + return withFallback(a, 'title').localeCompare(withFallback(b, 'title'), undefined, { numeric: true }) || + docsRank(a) - docsRank(b) || + withFallback(a, 'name').localeCompare(withFallback(b, 'name'), undefined, { numeric: true }) || + withFallback(a, 'id').localeCompare(withFallback(b, 'id'), undefined, { numeric: true }) }, }, }, diff --git a/m3-react/storybook/stylesheets/mdxCodeBlock.scss b/m3-react/storybook/stylesheets/mdxCodeBlock.scss new file mode 100644 index 0000000..e15c8a0 --- /dev/null +++ b/m3-react/storybook/stylesheets/mdxCodeBlock.scss @@ -0,0 +1,128 @@ +html { + --m3-docs-code-background: var(--m3-sys-surface-container); + --m3-docs-code-chip-background: var(--m3-sys-surface-container-high); + --m3-docs-code-copy-background: var(--m3-sys-secondary-container); + --m3-docs-code-copy-color: var(--m3-sys-on-secondary-container); + --m3-docs-code-divider: var(--m3-state-layers-on-surface-opacity-020); + --m3-docs-code-focus: var(--m3-sys-primary); + --m3-docs-code-plain: var(--m3-sys-on-surface); + --m3-docs-code-token-accent: #7b2cbf; + --m3-docs-code-token-comment: #6b7280; + --m3-docs-code-token-keyword: #0047b3; + --m3-docs-code-token-string: #8f3b1f; +} + +html.m3-theme-dark { + --m3-docs-code-chip-background: var(--m3-sys-surface-container-highest); + --m3-docs-code-token-accent: #c99eff; + --m3-docs-code-token-comment: #9ca3af; + --m3-docs-code-token-keyword: #8fb8ff; + --m3-docs-code-token-string: #ffb59e; +} + +pre.m3-docs-code-block { + margin: 16px 0 !important; + padding: 0 !important; + background: var(--m3-docs-code-background) !important; + border-radius: 16px !important; + box-shadow: inset 0 0 0 1px var(--m3-docs-code-divider); + overflow: hidden; + position: relative; +} + +pre.m3-docs-code-block > code.hljs { + display: block; + overflow-x: auto; + margin: 0; + padding: 52px 16px 16px; + background: transparent; + color: var(--m3-docs-code-plain); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important; + font-size: 13px !important; + line-height: 1.5; + tab-size: 2; + white-space: pre !important; +} + +pre.m3-docs-code-block > code.hljs * { + font-family: inherit !important; + font-size: inherit !important; + line-height: inherit; +} + +pre.m3-docs-code-block .m3-docs-code-language { + position: absolute; + top: 12px; + left: 12px; + border-radius: 999px; + padding: 4px 10px; + background: var(--m3-docs-code-chip-background); + color: var(--m3-docs-code-plain); + font-size: 11px; + font-weight: 500; + line-height: 1; + text-transform: uppercase; + user-select: none; +} + +pre.m3-docs-code-block .m3-docs-code-copy { + position: absolute; + top: 8px; + right: 8px; + border: 0; + border-radius: 999px; + min-height: 32px; + min-width: 92px; + padding: 0 14px; + background: var(--m3-docs-code-copy-background); + color: var(--m3-docs-code-copy-color); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; +} + +pre.m3-docs-code-block .m3-docs-code-copy:hover { + opacity: 0.92; +} + +pre.m3-docs-code-block .m3-docs-code-copy:active { + transform: translateY(1px); +} + +pre.m3-docs-code-block .m3-docs-code-copy:focus-visible { + outline: 2px solid var(--m3-docs-code-focus); + outline-offset: 2px; +} + +pre.m3-docs-code-block .m3-docs-code-copy[data-copied='true'] { + min-width: 82px; +} + +pre.m3-docs-code-block .hljs-comment, +pre.m3-docs-code-block .hljs-quote { + color: var(--m3-docs-code-token-comment); +} + +pre.m3-docs-code-block .hljs-keyword, +pre.m3-docs-code-block .hljs-selector-tag, +pre.m3-docs-code-block .hljs-literal, +pre.m3-docs-code-block .hljs-name { + color: var(--m3-docs-code-token-keyword); +} + +pre.m3-docs-code-block .hljs-string, +pre.m3-docs-code-block .hljs-attr, +pre.m3-docs-code-block .hljs-template-tag, +pre.m3-docs-code-block .hljs-template-variable { + color: var(--m3-docs-code-token-string); +} + +pre.m3-docs-code-block .hljs-title, +pre.m3-docs-code-block .hljs-section, +pre.m3-docs-code-block .hljs-number, +pre.m3-docs-code-block .hljs-symbol, +pre.m3-docs-code-block .hljs-type, +pre.m3-docs-code-block .hljs-variable { + color: var(--m3-docs-code-token-accent); +} diff --git a/m3-react/storybook/utils/mdxCodeBlock.ts b/m3-react/storybook/utils/mdxCodeBlock.ts new file mode 100644 index 0000000..17d22c7 --- /dev/null +++ b/m3-react/storybook/utils/mdxCodeBlock.ts @@ -0,0 +1,451 @@ +import '../stylesheets/mdxCodeBlock.scss' + +import React from 'react' + +import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react' + +import bash from 'highlight.js/lib/languages/bash' +import css from 'highlight.js/lib/languages/css' +import hljs from 'highlight.js/lib/core' +import javascript from 'highlight.js/lib/languages/javascript' +import json from 'highlight.js/lib/languages/json' +import typescript from 'highlight.js/lib/languages/typescript' +import xml from 'highlight.js/lib/languages/xml' + +type MdxCodeBlockProps = ComponentPropsWithoutRef<'code'> +type MdxPreBlockProps = ComponentPropsWithoutRef<'pre'> +type CodeElementProps = ComponentPropsWithoutRef<'code'> & { + 'data-language'?: string +} + +type HighlightPayload = { + html: string + language: string | null +} + +type CodePayload = { + badgeLanguage: string | null + highlightLanguage: string | null + source: string +} + +type RenderCodeBlockProps = { + badgeLanguage: string | null + className?: string + codeLanguage: string | null + copied: boolean + html: string + onCopy: () => void + restProps: Omit +} + +type ResolvedLanguages = { + badgeLanguage: string | null + codeLanguage: string | null +} + +const AUTO_DETECT_MAX_LENGTH = 5000 +const CODE_BLOCK_CLASS = 'm3-docs-code-block' +const COPY_BUTTON_CLASS = 'm3-docs-code-copy' +const COPY_RESET_DELAY_MS = 1500 +const COPY_STATE_ATTRIBUTE = 'data-copied' +const COPY_SUCCESS_TEXT = 'Copied' +const COPY_TEXT = 'Copy code' +const LANGUAGE_BADGE_CLASS = 'm3-docs-code-language' +const LANGUAGE_PREFIXES = ['language-', 'lang-'] +const PANEL_CLASS = 'm3-panel m3-panel_elevated-1' +const NON_SPECIFIC_LANGUAGES = new Set(['plain', 'plaintext', 'text', 'txt']) + +const HIGHLIGHT_LANGUAGE_ALIASES: Record = { + cjs: 'javascript', + html: 'xml', + js: 'javascript', + jsx: 'javascript', + markup: 'xml', + sh: 'bash', + shell: 'bash', + ts: 'typescript', + tsx: 'typescript', + vue: 'xml', +} + +const BADGE_LANGUAGE_ALIASES: Record = { + atom: 'html', + html: 'html', + js: 'javascript', + jsx: 'jsx', + json: 'json', + markup: 'html', + rss: 'html', + sh: 'bash', + shell: 'bash', + svg: 'html', + ts: 'typescript', + tsx: 'tsx', + vue: 'html', + xhtml: 'html', + xml: 'html', +} + +const SUPPORTED_HIGHLIGHT_LANGUAGES = new Set([ + 'atom', + 'bash', + 'css', + 'html', + 'javascript', + 'json', + 'rss', + 'svg', + 'typescript', + 'xhtml', + 'xml', +]) + +let languagesRegistered = false + +const highlightRegistrations = [ + ['bash', bash], + ['css', css], + ['javascript', javascript], + ['json', json], + ['typescript', typescript], + ['xml', xml], +] as const + +function ensureLanguagesRegistered (): void { + if (languagesRegistered) return + + highlightRegistrations.forEach(([name, definition]) => { + hljs.registerLanguage(name, definition) + }) + + languagesRegistered = true +} + +function escapeHtml (value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function normalizeLanguageFromClass (className?: string | null): string | null { + if (!className) return null + + const tokens = className.split(/\s+/u) + for (const token of tokens) { + for (const prefix of LANGUAGE_PREFIXES) { + if (token.startsWith(prefix)) { + return token.slice(prefix.length) + } + } + } + + return null +} + +function normalizeLanguageToken (value?: string | null): string | null { + const normalized = value?.trim().toLowerCase() + return normalized || null +} + +function isSpecificLanguage (language: string | null): language is string { + return Boolean(language && !NON_SPECIFIC_LANGUAGES.has(language)) +} + +function resolveHighlightLanguage (value?: string | null): string | null { + const normalized = normalizeLanguageToken(value) + if (!isSpecificLanguage(normalized)) return null + + const aliasResolved = HIGHLIGHT_LANGUAGE_ALIASES[normalized] || normalized + + return SUPPORTED_HIGHLIGHT_LANGUAGES.has(aliasResolved) ? aliasResolved : null +} + +function resolveBadgeLanguage (value?: string | null): string | null { + const normalized = normalizeLanguageToken(value) + if (!isSpecificLanguage(normalized)) return null + + return BADGE_LANGUAGE_ALIASES[normalized] || normalized +} + +function inferBadgeLanguageFromSource (source: string): string | null { + if (!source.trim()) return null + + const containsMarkup = /<[^>]+>/u.test(source) + const containsJsxComponent = /<[A-Z][A-Za-z0-9_.:-]*/u.test(source) + const containsJsxExpression = /\{[^}]+\}/u.test(source) + + if (containsJsxComponent && containsJsxExpression) return 'tsx' + if (containsMarkup) return 'html' + return null +} + +function extractText (node: ReactNode): string { + if (typeof node === 'string') return node + if (typeof node === 'number' || typeof node === 'boolean') return String(node) + if (Array.isArray(node)) return node.map(extractText).join('') + + if (React.isValidElement<{ children?: ReactNode }>(node)) { + return extractText(node.props.children) + } + + return '' +} + +function findCodeElement (node: ReactNode): ReactElement | null { + if (React.isValidElement(node) && node.type === 'code') { + return node + } + + if (Array.isArray(node)) { + for (const child of node) { + const codeElement = findCodeElement(child) + if (codeElement) return codeElement + } + } + + if (React.isValidElement<{ children?: ReactNode }>(node)) { + return findCodeElement(node.props.children) + } + + return null +} + +function trimTrailingNewline (source: string): string { + return source.replace(/\r?\n$/u, '') +} + +function highlightKnownLanguage (source: string, language: string): HighlightPayload { + return { + html: hljs.highlight(source, { + ignoreIllegals: true, + language, + }).value, + language, + } +} + +function highlightAutoLanguage (source: string): HighlightPayload { + const autoResult = hljs.highlightAuto(source) + + return { + html: autoResult.value, + language: autoResult.language || null, + } +} + +function highlightSource (source: string, language: string | null): HighlightPayload { + ensureLanguagesRegistered() + + if (source.trim() === '') { + return { html: '', language } + } + + try { + if (language) return highlightKnownLanguage(source, language) + if (source.length > AUTO_DETECT_MAX_LENGTH) return { html: escapeHtml(source), language: null } + return highlightAutoLanguage(source) + } catch { + return { html: escapeHtml(source), language } + } +} + +async function copyTextToClipboard (value: string): Promise { + if (!value) return false + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value) + return true + } catch { + // Fallback below for restricted clipboard contexts. + } + } + + if (!document.body || typeof document.execCommand !== 'function') return false + + const textarea = document.createElement('textarea') + textarea.value = value + textarea.setAttribute('readonly', 'true') + textarea.style.left = '-9999px' + textarea.style.opacity = '0' + textarea.style.pointerEvents = 'none' + textarea.style.position = 'fixed' + document.body.appendChild(textarea) + textarea.select() + + try { + return document.execCommand('copy') + } finally { + textarea.remove() + } +} + +function joinClassNames (...parts: Array): string { + return parts.filter(Boolean).join(' ') +} + +function pickRawLanguageToken (children: ReactNode, className?: string): string | null { + const codeElement = findCodeElement(children) + const codeProps = codeElement?.props + + return normalizeLanguageToken( + codeProps?.['data-language'] || + normalizeLanguageFromClass(codeProps?.className) || + normalizeLanguageFromClass(className) + ) +} + +function resolveCodePayload (children: ReactNode, className?: string): CodePayload { + const codeElement = findCodeElement(children) + const source = trimTrailingNewline(extractText(codeElement?.props?.children ?? children)) + const rawLanguage = pickRawLanguageToken(children, className) + const inferredBadgeLanguage = inferBadgeLanguageFromSource(source) + const badgeLanguage = inferredBadgeLanguage || resolveBadgeLanguage(rawLanguage) + const highlightLanguage = resolveHighlightLanguage(badgeLanguage || rawLanguage) + + return { + badgeLanguage, + highlightLanguage, + source, + } +} + +function resolveRenderedLanguages (payload: CodePayload, highlight: HighlightPayload): ResolvedLanguages { + const codeLanguage = highlight.language || payload.highlightLanguage + const badgeLanguage = + payload.badgeLanguage || + resolveBadgeLanguage(codeLanguage) || + inferBadgeLanguageFromSource(payload.source) + + return { + badgeLanguage, + codeLanguage, + } +} + +function useCopyState (source: string): { copied: boolean; onCopy: () => void } { + const [copied, setCopied] = React.useState(false) + const resetTimerRef = React.useRef(null) + + React.useEffect(() => () => { + if (resetTimerRef.current) { + window.clearTimeout(resetTimerRef.current) + } + }, []) + + const onCopy = React.useCallback(() => { + void copyTextToClipboard(source).then((success) => { + if (!success) return + + setCopied(true) + if (resetTimerRef.current) window.clearTimeout(resetTimerRef.current) + + resetTimerRef.current = window.setTimeout(() => { + setCopied(false) + resetTimerRef.current = null + }, COPY_RESET_DELAY_MS) + }) + }, [source]) + + return { copied, onCopy } +} + +function getCodeClassName (codeLanguage: string | null): string { + return codeLanguage ? `hljs language-${codeLanguage}` : 'hljs' +} + +function renderLanguageBadge (badgeLanguage: string | null): React.ReactElement { + return React.createElement( + 'span', + { + className: LANGUAGE_BADGE_CLASS, + }, + badgeLanguage || 'text' + ) +} + +function renderCopyButton (copied: boolean, onCopy: () => void): React.ReactElement { + return React.createElement( + 'button', + { + type: 'button', + className: COPY_BUTTON_CLASS, + 'aria-label': 'Copy code block', + [COPY_STATE_ATTRIBUTE]: copied ? 'true' : 'false', + onClick: onCopy, + }, + copied ? COPY_SUCCESS_TEXT : COPY_TEXT + ) +} + +function renderCodeContent (codeHtml: string, codeLanguage: string | null): React.ReactElement { + return React.createElement('code', { + className: getCodeClassName(codeLanguage), + 'data-language': codeLanguage || undefined, + dangerouslySetInnerHTML: { + __html: codeHtml, + }, + }) +} + +function renderCodeBlock ({ + badgeLanguage, + className, + codeLanguage, + copied, + html, + onCopy, + restProps, +}: RenderCodeBlockProps): React.ReactElement { + return React.createElement( + 'pre', + { + ...restProps, + className: joinClassNames(PANEL_CLASS, CODE_BLOCK_CLASS, className), + 'data-language': codeLanguage || undefined, + }, + renderLanguageBadge(badgeLanguage), + renderCopyButton(copied, onCopy), + renderCodeContent(html, codeLanguage) + ) +} + +export function MdxCodeBlock (props: MdxCodeBlockProps): React.ReactElement { + const { children, ...restProps } = props + return React.createElement('code', restProps, children) +} + +export function MdxCodePreBlock (props: MdxPreBlockProps): React.ReactElement { + const { children, className, ...restProps } = props + const payload = React.useMemo( + () => resolveCodePayload(children, className), + [children, className] + ) + const highlight = React.useMemo( + () => highlightSource(payload.source, payload.highlightLanguage), + [payload.highlightLanguage, payload.source] + ) + const { copied, onCopy } = useCopyState(payload.source) + const languages = React.useMemo( + () => resolveRenderedLanguages(payload, highlight), + [payload, highlight] + ) + + if (payload.source.trim() === '') { + return React.createElement('pre', { className, ...restProps }, children) + } + + return renderCodeBlock({ + badgeLanguage: languages.badgeLanguage, + className, + codeLanguage: languages.codeLanguage, + copied, + html: highlight.html || escapeHtml(payload.source), + onCopy, + restProps, + }) +} 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-react/vitest.config.e2e.ts b/m3-react/vitest.config.e2e.ts index 7e60021..ce0869c 100644 --- a/m3-react/vitest.config.e2e.ts +++ b/m3-react/vitest.config.e2e.ts @@ -19,6 +19,7 @@ export default mergeConfig(viteConfig, defineConfig({ test: { name: 'm3-react-e2e', globals: true, + attachmentsDir: join(__artifacts, 'playwright', 'attachments'), include: [ 'tests/**/*.e2e.tsx', ], diff --git a/m3-react/vitest.config.ts b/m3-react/vitest.config.ts index be1257b..12e14df 100644 --- a/m3-react/vitest.config.ts +++ b/m3-react/vitest.config.ts @@ -1,14 +1,21 @@ import { - defineConfig, - mergeConfig, + defineConfig, + mergeConfig, } from 'vitest/config' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + import viteConfig from './vite.config' +const __parent = fileURLToPath(new URL('../', import.meta.url)) +const __artifacts = join(__parent, 'artifacts', 'm3-react') + export default mergeConfig(viteConfig, defineConfig({ - test: { - name: 'm3-react', - globals: true, - environment: 'jsdom', - }, + test: { + name: 'm3-react', + globals: true, + environment: 'jsdom', + attachmentsDir: join(__artifacts, 'vitest', 'attachments'), + }, })) 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/package.json b/m3-vue/package.json index 94c7355..d5d62da 100644 --- a/m3-vue/package.json +++ b/m3-vue/package.json @@ -70,6 +70,7 @@ "eslint-plugin-vue": "^10.8.0", "flag-icons": "^7.5.0", "globals": "^17.3.0", + "highlight.js": "^11.11.1", "jsdom": "^28.1.0", "playwright": "^1.55.0", "react": "^18.3.1", 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/storybook/components/Inline.ts b/m3-vue/storybook/components/Inline.ts index 3593786..734fc4c 100644 --- a/m3-vue/storybook/components/Inline.ts +++ b/m3-vue/storybook/components/Inline.ts @@ -5,28 +5,53 @@ import { v4 } from 'uuid' import { createApp, h } from 'vue' +const normalizeIdSegment = (value: string): string => { + return value.replace(/[^a-zA-Z0-9_-]/g, '') +} + +const buildInlineIdPrefix = (reactId: string, uuid: string): string => { + return `m3-inline-${normalizeIdSegment(reactId)}-${normalizeIdSegment(uuid)}-` +} + +const mountInlineApp = ({ appIdPrefix, children, is, props, root }) => { + const id = v4() + + const app = createApp({ + mounted () { + if (children) { + ReactDOM.render( + React.createElement(React.Fragment, {}, children), + document.getElementById(id) + ) + } + }, + + render: () => h(is, props), + }) + + app.config.idPrefix = appIdPrefix + app.mount(root) + + return () => app.unmount() +} + export default ({ is, children, tag, ...props }) => { const ref = React.useRef(null) + const reactId = React.useId() + const uuidRef = React.useRef(v4()) + const appIdPrefix = React.useMemo( + () => buildInlineIdPrefix(reactId, uuidRef.current), + [reactId] + ) React.useEffect(() => { - const id = v4() - - const app = createApp({ - mounted () { - if (children) { - ReactDOM.render( - React.createElement(React.Fragment, {}, children), - document.getElementById(id) - ) - } - }, - - render: () => h(is, props), + return mountInlineApp({ + appIdPrefix, + children, + is, + props, + root: ref.current, }) - - app.mount(ref.current) - - return () => app.unmount() }) return React.createElement(tag ?? 'div', { diff --git a/m3-vue/storybook/components/M3Button.mdx b/m3-vue/storybook/components/M3Button.mdx index 13c6818..f838aa3 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,17 @@ Common buttons prompt most actions in a UI

+ +## Code example + +```html + + Edit + +``` + +## 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. +