From b687bceed52c7515be1655af9e407c48e8f20901 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 7 Oct 2026 21:32:30 +0400 Subject: [PATCH 01/20] feat(m3-foundation): Search styles and layout primitives were added --- .../assets/stylesheets/components/index.scss | 1 + .../stylesheets/components/search/index.scss | 319 ++++++++++++++++++ m3-foundation/lib/search.ts | 55 +++ m3-foundation/tests/search.test.ts | 50 +++ m3-foundation/types/components/search.d.ts | 30 ++ 5 files changed, 455 insertions(+) create mode 100644 m3-foundation/assets/stylesheets/components/search/index.scss create mode 100644 m3-foundation/lib/search.ts create mode 100644 m3-foundation/tests/search.test.ts create mode 100644 m3-foundation/types/components/search.d.ts diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index bb14fd6..26d5d7e 100644 --- a/m3-foundation/assets/stylesheets/components/index.scss +++ b/m3-foundation/assets/stylesheets/components/index.scss @@ -17,6 +17,7 @@ @use 'rich-tooltip'; @use 'ripple'; @use 'scrim'; +@use 'search'; @use 'scroll-box'; @use 'scroll-rail'; @use 'select'; diff --git a/m3-foundation/assets/stylesheets/components/search/index.scss b/m3-foundation/assets/stylesheets/components/search/index.scss new file mode 100644 index 0000000..3b7e026 --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/search/index.scss @@ -0,0 +1,319 @@ +@use '../../basics/motion' as m3-motion; +@use '../../basics/typography' as m3-typography; + +.m3-search { + display: block; + inline-size: 100%; + max-inline-size: 720px; + color: var(--m3-sys-on-surface); + + &__bar { + display: flex; + block-size: 56px; + box-sizing: border-box; + align-items: center; + border-radius: 28px; + background: var(--m3-sys-surface-container-high); + overflow: hidden; + } + + &_open &__bar { + visibility: hidden; + } + + &__trigger { + display: flex; + min-inline-size: 0; + block-size: 100%; + padding-block: 0; + padding-inline: 4px; + border: 0; + flex: 1 1 auto; + align-items: center; + gap: 4px; + background: transparent; + color: var(--m3-sys-on-surface-variant); + cursor: text; + text-align: start; + @include m3-typography.body-large; + } + + &__trigger:hover { + background: color-mix(in srgb, var(--m3-sys-on-surface) 8%, transparent); + } + + &__trigger:active { + background: color-mix(in srgb, var(--m3-sys-on-surface) 12%, transparent); + } + + &__trigger:focus-visible { + outline: 2px solid var(--m3-sys-secondary); + outline-offset: -4px; + } + + &__trigger-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__trigger-text_filled { + color: var(--m3-sys-on-surface); + } + + &__trigger-icon { + display: flex; + inline-size: 48px; + block-size: 48px; + flex: 0 0 48px; + align-items: center; + justify-content: center; + } + + &__leading, + &__trailing, + &__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + } + + &__leading .m3-icon-button, + &__trailing .m3-icon-button, + &__actions .m3-icon-button, + &__header > .m3-icon-button { + inline-size: 48px; + block-size: 48px; + } + + &__leading { + min-inline-size: 52px; + padding-inline-start: 4px; + justify-content: center; + } + + &__trailing { + padding-inline-end: 4px; + } + + &__trailing > .m3-icon { + margin-inline: 12px; + } + + &__overlay { + pointer-events: none; + position: fixed; + inset: 0; + z-index: var(--m3-search-z-index, 1200); + } + + &__scrim { + background: color-mix(in srgb, var(--m3-sys-on-surface) 32%, transparent); + pointer-events: auto; + position: absolute; + inset: 0; + } + + &__view { + display: flex; + box-sizing: border-box; + min-inline-size: 0; + max-block-size: 100dvh; + flex-direction: column; + background: var(--m3-sys-surface-container-high); + box-shadow: var(--m3-elevation-3); + color: var(--m3-sys-on-surface); + overflow: hidden; + pointer-events: auto; + position: absolute; + } + + &__view_full-screen { + inline-size: 100%; + block-size: 100%; + inset: 0; + } + + &__view_docked { + inline-size: var(--m3-search-docked-width, 360px); + min-block-size: min(240px, calc(100dvh - var(--m3-search-docked-top, 12px) - 12px)); + max-block-size: min(66.667dvh, calc(100dvh - var(--m3-search-docked-top, 12px) - 12px)); + border-radius: 28px; + left: var(--m3-search-docked-left, 12px); + top: var(--m3-search-docked-top, 12px); + } + + &__view_contained { + gap: 2px; + clip-path: inset(0 0 0 0 round 0px); + } + + &__view_contained.m3-search__view_full-screen { + padding: 12px; + background: var(--m3-sys-surface-container-low); + box-shadow: none; + } + + &__view_contained.m3-search__view_docked { + background: transparent; + box-shadow: none; + } + + &__header { + display: flex; + block-size: 56px; + min-block-size: 56px; + padding-inline: 4px; + align-items: center; + background: var(--m3-sys-surface-container-high); + } + + &__view_contained &__header { + border-radius: 28px; + } + + &__view_divided.m3-search__view_full-screen &__header { + block-size: 72px; + min-block-size: 72px; + } + + &__input { + min-inline-size: 0; + block-size: 100%; + padding-block: 0; + padding-inline: 4px; + border: 0; + flex: 1 1 auto; + background: transparent; + color: var(--m3-sys-on-surface); + outline: 0; + @include m3-typography.body-large; + } + + &__input::placeholder { + color: var(--m3-sys-on-surface-variant); + opacity: 1; + } + + &__input::-webkit-search-cancel-button { + display: none; + } + + &__results { + min-block-size: 0; + padding-block: 8px; + flex: 1 1 auto; + overflow: auto; + overscroll-behavior: contain; + outline: 0; + } + + &__results .m3-list { + inline-size: 100%; + background: transparent; + } + + &__view_contained &__results { + border-radius: 16px; + background: var(--m3-sys-surface-container-high); + } + + &__view_divided &__results { + border-block-start: 1px solid var(--m3-sys-outline); + } + + &__status { + inline-size: 1px; + block-size: 1px; + overflow: hidden; + position: absolute; + clip-path: inset(50%); + white-space: nowrap; + } +} + +.m3-search-enter .m3-search__view_divided, +.m3-search-enter-from .m3-search__view_divided, +.m3-search-exit-active .m3-search__view_divided, +.m3-search-leave-to .m3-search__view_divided { + opacity: 0; + transform: translateY(-8px) scale(0.98); +} + +.m3-search-enter-active:not(.m3-search-enter-from) .m3-search__view_divided { + opacity: 1; + transform: none; +} + +.m3-search-enter-active .m3-search__view_divided, +.m3-search-exit-active .m3-search__view_divided, +.m3-search-leave-active .m3-search__view_divided { + transition: + opacity #{m3-motion.duration('medium2')} #{m3-motion.easing('standard')}, + transform #{m3-motion.duration('medium2')} #{m3-motion.easing('standard')}; +} + +.m3-search-enter .m3-search__view_contained.m3-search__view_full-screen, +.m3-search-enter-from .m3-search__view_contained.m3-search__view_full-screen, +.m3-search-exit-active .m3-search__view_contained.m3-search__view_full-screen, +.m3-search-leave-to .m3-search__view_contained.m3-search__view_full-screen { + clip-path: inset(12px var(--m3-search-origin-right) calc(100% - 68px) var(--m3-search-origin-left) round 28px); + transform: translateY(var(--m3-search-origin-offset-y)); +} + +.m3-search-enter .m3-search__view_contained.m3-search__view_docked, +.m3-search-enter-from .m3-search__view_contained.m3-search__view_docked, +.m3-search-exit-active .m3-search__view_contained.m3-search__view_docked, +.m3-search-leave-to .m3-search__view_contained.m3-search__view_docked { + clip-path: inset(0px var(--m3-search-origin-right) calc(100% - 56px) var(--m3-search-origin-left) round 28px); + transform: translateY(var(--m3-search-origin-offset-y)); +} + +.m3-search-enter-active:not(.m3-search-enter-from) .m3-search__view_contained.m3-search__view_full-screen, +.m3-search-enter-active:not(.m3-search-enter-from) .m3-search__view_contained.m3-search__view_docked { + clip-path: inset(0 0 0 0 round 0px); + transform: none; +} + +.m3-search-enter-active .m3-search__view_contained, +.m3-search-exit-active .m3-search__view_contained, +.m3-search-leave-active .m3-search__view_contained { + transition: + clip-path #{m3-motion.duration('medium2')} #{m3-motion.easing('standard')}, + transform #{m3-motion.duration('medium2')} #{m3-motion.easing('standard')}; +} + +.m3-search-enter .m3-search__scrim, +.m3-search-enter-from .m3-search__scrim, +.m3-search-exit-active .m3-search__scrim, +.m3-search-leave-to .m3-search__scrim { + opacity: 0; +} + +.m3-search-enter-active:not(.m3-search-enter-from) .m3-search__scrim { + opacity: 1; +} + +.m3-search-enter-active .m3-search__scrim, +.m3-search-exit-active .m3-search__scrim, +.m3-search-leave-active .m3-search__scrim { + transition: m3-motion.timing-standard(opacity, 'short4'); +} + +.m3-search-exit-active .m3-search__view, +.m3-search-exit-active .m3-search__scrim, +.m3-search-leave-active .m3-search__view, +.m3-search-leave-active .m3-search__scrim { + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .m3-search-enter-active .m3-search__view, + .m3-search-exit-active .m3-search__view, + .m3-search-leave-active .m3-search__view, + .m3-search-enter-active .m3-search__scrim, + .m3-search-exit-active .m3-search__scrim, + .m3-search-leave-active .m3-search__scrim { + transition-duration: 0ms; + } +} diff --git a/m3-foundation/lib/search.ts b/m3-foundation/lib/search.ts new file mode 100644 index 0000000..d9bcfd3 --- /dev/null +++ b/m3-foundation/lib/search.ts @@ -0,0 +1,55 @@ +import type { + ResolvedSearchLayout, + SearchAnchorRect, + SearchBreakpoint, + SearchDockedPosition, + SearchLayout, + SearchRevealOrigin, + SearchViewport, +} from '../types/components/search' + +const DOCKED_MARGIN = 12 +const DOCKED_MIN_WIDTH = 360 +const DOCKED_MAX_WIDTH = 720 +const DOCKED_MIN_HEIGHT = 240 + +export const resolveSearchLayout = (layout: SearchLayout, breakpoint: SearchBreakpoint): ResolvedSearchLayout => layout === 'auto' + ? breakpoint === 'compact' ? 'full-screen' : 'docked' + : layout + +export const getSearchDockedPosition = ( + anchor: SearchAnchorRect, + viewport: SearchViewport +): SearchDockedPosition => { + const availableWidth = Math.max(0, viewport.width - 2 * DOCKED_MARGIN) + const width = Math.min(availableWidth, DOCKED_MAX_WIDTH, Math.max(DOCKED_MIN_WIDTH, anchor.width + 2 * DOCKED_MARGIN)) + const left = Math.min( + Math.max(DOCKED_MARGIN, anchor.left - (width - anchor.width) / 2), + Math.max(DOCKED_MARGIN, viewport.width - width - DOCKED_MARGIN) + ) + const top = Math.min( + Math.max(DOCKED_MARGIN, anchor.top), + Math.max(DOCKED_MARGIN, viewport.height - DOCKED_MIN_HEIGHT - DOCKED_MARGIN) + ) + + return { left, top, width } +} + +export const getSearchRevealOrigin = ( + anchor: SearchAnchorRect, + viewport: SearchViewport, + position: SearchDockedPosition, + layout: ResolvedSearchLayout +): SearchRevealOrigin => { + const viewLeft = layout === 'docked' ? position.left : 0 + const viewTop = layout === 'docked' ? position.top : 0 + const viewWidth = layout === 'docked' ? position.width : viewport.width + const headerInset = layout === 'full-screen' ? 12 : 0 + const left = anchor.left - viewLeft + + return { + left, + right: viewWidth - left - anchor.width, + offsetY: anchor.top - viewTop - headerInset, + } +} diff --git a/m3-foundation/tests/search.test.ts b/m3-foundation/tests/search.test.ts new file mode 100644 index 0000000..d18af3c --- /dev/null +++ b/m3-foundation/tests/search.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { + getSearchDockedPosition, + getSearchRevealOrigin, + resolveSearchLayout, +} from '../lib/search' + +describe('Search layout', () => { + it('uses full-screen only at the compact breakpoint in auto mode', () => { + expect(resolveSearchLayout('auto', 'compact')).toBe('full-screen') + expect(resolveSearchLayout('auto', 'medium')).toBe('docked') + expect(resolveSearchLayout('auto', 'expanded')).toBe('docked') + expect(resolveSearchLayout('auto', 'large')).toBe('docked') + expect(resolveSearchLayout('full-screen', 'large')).toBe('full-screen') + expect(resolveSearchLayout('docked', 'compact')).toBe('docked') + }) + + it('keeps the docked view within a narrow viewport', () => { + expect(getSearchDockedPosition( + { left: 24, top: 80, width: 340 }, + { width: 400, height: 700 } + )).toEqual({ left: 12, top: 80, width: 364 }) + }) + + it('keeps a maximum-width bar aligned with its docked view', () => { + expect(getSearchDockedPosition( + { left: 60, top: 80, width: 720 }, + { width: 1000, height: 800 } + )).toEqual({ left: 60, top: 80, width: 720 }) + }) + + it('clamps the view at the right and bottom edges', () => { + expect(getSearchDockedPosition( + { left: 1100, top: 700, width: 760 }, + { width: 1200, height: 800 } + )).toEqual({ left: 468, top: 548, width: 720 }) + }) + + it('starts the contained reveal at the collapsed bar in either layout', () => { + const anchor = { left: 24, top: 80, width: 342 } + const viewport = { width: 390, height: 760 } + const position = { left: 12, top: 80, width: 366 } + + expect(getSearchRevealOrigin(anchor, viewport, position, 'full-screen')) + .toEqual({ left: 24, right: 24, offsetY: 68 }) + expect(getSearchRevealOrigin(anchor, viewport, position, 'docked')) + .toEqual({ left: 12, right: 12, offsetY: 0 }) + }) +}) diff --git a/m3-foundation/types/components/search.d.ts b/m3-foundation/types/components/search.d.ts new file mode 100644 index 0000000..2ba26cb --- /dev/null +++ b/m3-foundation/types/components/search.d.ts @@ -0,0 +1,30 @@ +import type { Breakpoint } from '../breakpoint' + +export type SearchAppearance = 'contained' | 'divided' +export type SearchLayout = 'auto' | 'full-screen' | 'docked' +export type ResolvedSearchLayout = Exclude + +export interface SearchDockedPosition { + left: number; + top: number; + width: number; +} + +export interface SearchAnchorRect { + left: number; + top: number; + width: number; +} + +export interface SearchViewport { + width: number; + height: number; +} + +export interface SearchRevealOrigin { + left: number; + right: number; + offsetY: number; +} + +export type SearchBreakpoint = Breakpoint From 5bf2026f93ae988d7bb04807e058dac0692350c7 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 7 Oct 2026 21:32:48 +0400 Subject: [PATCH 02/20] feat(m3-react): Search component and examples were added --- m3-react/src/components/index.ts | 1 + m3-react/src/components/search/M3Search.tsx | 330 ++++++++++++++++++ m3-react/src/components/search/index.ts | 2 + m3-react/storybook/components/M3Search.mdx | 155 ++++++++ .../storybook/components/M3Search.stories.tsx | 54 +++ .../examples/search/SearchExample.tsx | 125 +++++++ m3-react/tests/M3Search.e2e.tsx | 49 +++ m3-react/tests/M3Search.test.tsx | 106 ++++++ 8 files changed, 822 insertions(+) create mode 100644 m3-react/src/components/search/M3Search.tsx create mode 100644 m3-react/src/components/search/index.ts create mode 100644 m3-react/storybook/components/M3Search.mdx create mode 100644 m3-react/storybook/components/M3Search.stories.tsx create mode 100644 m3-react/storybook/examples/search/SearchExample.tsx create mode 100644 m3-react/tests/M3Search.e2e.tsx create mode 100644 m3-react/tests/M3Search.test.tsx diff --git a/m3-react/src/components/index.ts b/m3-react/src/components/index.ts index e6eb0ba..58419af 100644 --- a/m3-react/src/components/index.ts +++ b/m3-react/src/components/index.ts @@ -18,6 +18,7 @@ export * from './radio' export * from './rich-tooltip' export * from './ripple' export * from './scroll-rail' +export * from './search' export * from './select' export * from './side-sheet' export * from './snackbar' diff --git a/m3-react/src/components/search/M3Search.tsx b/m3-react/src/components/search/M3Search.tsx new file mode 100644 index 0000000..1213454 --- /dev/null +++ b/m3-react/src/components/search/M3Search.tsx @@ -0,0 +1,330 @@ +import type { ComponentSetupContext } from '@/utils/component' +import type { + CSSProperties, + FC, + FormEvent, + HTMLAttributes, + ReactNode, + Ref, +} from 'react' +import type { + SearchAnchorRect, + SearchAppearance, + SearchDockedPosition, + SearchLayout, + SearchViewport, +} from '@modulify/m3-foundation/types/components/search' + +import { activateModalFocus } from '@modulify/m3-foundation/lib/modal' +import { createPortal } from 'react-dom' +import { CSSTransition } from 'react-transition-group' +import { durations } from '@modulify/m3-foundation/lib/motion' +import { + getSearchDockedPosition, + getSearchRevealOrigin, + resolveSearchLayout, +} from '@modulify/m3-foundation/lib/search' +import { + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { M3Icon } from '@/components/icon' +import { M3IconButton } from '@/components/icon-button' + +import defineComponent from '@/utils/component' +import { defineSlot, distinct } from '@/utils/content' +import { toClassName } from '@/utils/styling' +import { useBreakpoint, useId } from '@/hooks' + +export interface SearchResultsContext { + query: string; + close(): void; + submit(): void; +} + +interface ResultsProps { + render?: (context: SearchResultsContext) => ReactNode; + children?: ReactNode; +} + +type SearchViewStyle = CSSProperties & { + '--m3-search-docked-left'?: string; + '--m3-search-docked-top'?: string; + '--m3-search-docked-width'?: string; + '--m3-search-origin-left'?: string; + '--m3-search-origin-right'?: string; + '--m3-search-origin-offset-y'?: string; +} + +export interface M3SearchProps extends Omit, 'onSubmit'> { + ref?: Ref; + query: string; + label: string; + status: string; + open: boolean; + hint?: string; + backLabel?: string; + clearLabel?: string; + appearance?: SearchAppearance; + layout?: SearchLayout; + onQueryChange: (query: string) => void; + onOpenChange: (open: boolean) => void; + onSubmit?: (query: string) => void; +} + +export interface M3SearchExposed { + open(): void; + close(): void; + focus(): void; +} + +const Leading = defineSlot('M3Search.Leading') +const Trailing = defineSlot('M3Search.Trailing') +const Actions = defineSlot('M3Search.Actions') +const Results: FC = defineSlot('M3Search.Results', () => null) + +const resultSelector = 'a[href], button:not(:disabled), [tabindex="0"]' +const SEARCH_TRANSITION_MS = durations.medium2 + +export default defineComponent(function M3Search({ + ref: _ref, + id, + query, + label, + status, + open, + hint = label, + backLabel = 'Back', + clearLabel = 'Clear search', + appearance = 'contained', + layout = 'auto', + className = '', + children = [], + onQueryChange, + onOpenChange, + onSubmit, + ...attrs +}: M3SearchProps, { expose }: ComponentSetupContext) { + const breakpoint = useBreakpoint() + const resolvedLayout = resolveSearchLayout(layout, breakpoint.name) + const rootId = useId(id, 'm3-search') + const viewId = rootId + '-view' + const resultsId = rootId + '-results' + const accessibleName = hint === label ? label : `${hint} ${label}` + + const bar = useRef(null) + const overlay = useRef(null) + const dialog = useRef(null) + const input = useRef(null) + const results = useRef(null) + const scrim = useRef(null) + const callbacks = useRef({ onOpenChange, onQueryChange, onSubmit }) + callbacks.current = { onOpenChange, onQueryChange, onSubmit } + + const [position, setPosition] = useState({ left: 12, top: 12, width: 360 }) + const [anchor, setAnchor] = useState({ left: 12, top: 12, width: 360 }) + const [viewport, setViewport] = useState({ width: 384, height: 640 }) + const parsed = useMemo(() => distinct(children, { + slots: { leading: Leading, trailing: Trailing, actions: Actions, results: Results }, + }), [children]) + + const close = () => callbacks.current.onOpenChange(false) + const submit = () => { + callbacks.current.onSubmit?.(query) + results.current?.focus() + } + + expose({ + open: () => callbacks.current.onOpenChange(true), + close, + focus: () => open ? input.current?.focus() : bar.current?.querySelector('button')?.focus(), + }) + + useLayoutEffect(() => { + if (!open || !bar.current) return + + const update = () => { + if (!bar.current) return + const rect = bar.current.getBoundingClientRect() + const nextViewport = { + width: window.innerWidth, + height: window.innerHeight, + } + setAnchor({ left: rect.left, top: rect.top, width: rect.width }) + setViewport(nextViewport) + if (resolvedLayout === 'docked') setPosition(getSearchDockedPosition(rect, nextViewport)) + } + + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update) + observer?.observe(bar.current) + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + update() + + return () => { + observer?.disconnect() + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [open, resolvedLayout]) + + useLayoutEffect(() => { + if (!open || !dialog.current) return + + const deactivate = activateModalFocus({ + dialog: dialog.current, + exempt: () => [scrim.current], + onEscape: () => callbacks.current.onOpenChange(false), + }) + input.current?.focus() + return deactivate + }, [open]) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + submit() + } + + const focusResult = (last: boolean) => { + const items = results.current?.querySelectorAll(resultSelector) + const target = last ? items?.[items.length - 1] : items?.[0] + target?.focus() + } + + const origin = getSearchRevealOrigin(anchor, viewport, position, resolvedLayout) + const viewStyle: SearchViewStyle = { + '--m3-search-docked-left': `${position.left}px`, + '--m3-search-docked-top': `${position.top}px`, + '--m3-search-docked-width': `${position.width}px`, + '--m3-search-origin-left': `${origin.left}px`, + '--m3-search-origin-right': `${origin.right}px`, + '--m3-search-origin-offset-y': `${origin.offsetY}px`, + } + + const context: SearchResultsContext = { query, close, submit } + const resultsSlot = parsed.slots.results + const resultsContent = resultsSlot + ? resultsSlot.props.render?.(context) ?? resultsSlot.props.children + : parsed.content + + return ( + <> +
+
+ {parsed.slots.leading ?
{parsed.slots.leading}
: null} + + {parsed.slots.trailing ?
{parsed.slots.trailing}
: null} +
+
+ + {typeof document !== 'undefined' ? createPortal( + +
+ {resolvedLayout === 'docked' ? ( +
+ ) : null} + +
+ , + document.body + ) : null} + + ) +}, { + slots: { Leading, Trailing, Actions, Results }, +}) diff --git a/m3-react/src/components/search/index.ts b/m3-react/src/components/search/index.ts new file mode 100644 index 0000000..134c11b --- /dev/null +++ b/m3-react/src/components/search/index.ts @@ -0,0 +1,2 @@ +export { default as M3Search } from './M3Search' +export type { M3SearchExposed, M3SearchProps, SearchResultsContext } from './M3Search' diff --git a/m3-react/storybook/components/M3Search.mdx b/m3-react/storybook/components/M3Search.mdx new file mode 100644 index 0000000..6168b5b --- /dev/null +++ b/m3-react/storybook/components/M3Search.mdx @@ -0,0 +1,155 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import { Localized } from '../i18n' +import * as M3SearchStories from './M3Search.stories' + + + + + +# Search + +Search combines a persistent entry bar with an expanded area for suggestions or results. Use it to help people find items in a large collection. Place the bar near the content it searches. The [Material 3 Search guidelines](https://m3.material.io/components/search/guidelines) recommend the contained style for new interfaces; the divided baseline style remains available. + +## Guide + +### Appearance and layout + +The contained style keeps a rounded filled bar when search opens. Divided uses a divider between the input header and results. `layout="auto"` opens full screen below 600 px and docked at wider breakpoints. Choose a fixed layout only when the surrounding product calls for it. The docked surface has a scrim; both expanded layouts behave as modal dialogs in this web implementation. + +Search starts with an empty results container. Put an `M3List` or custom content in `M3Search.Results`. Show recent queries before typing if they help; suggestions may appear while typing, and results may appear after submission. Keep filtering, async requests, ranking, empty states, and result navigation in your application. Group distinct result sections with gaps and clear headings. + +### Query, focus, and keyboard + +`query` and `open` are controlled. Update them in `onQueryChange` and `onOpenChange`; `onSubmit` receives the current query. Submitting leaves the query visible and moves focus from the input to the results area. The back button, Escape, and the docked scrim request closing; focus returns to the control that opened search. Arrow Down/Up moves from the input into the result controls and between them. `Tab` follows the dialog's interactive controls. + +Provide a localized `label` describing the searched content, not just “Search.” `hint` is the visible prompt; by default it matches `label`. When they differ, the control's accessible name includes both. Translate `backLabel` and `clearLabel`. Pass a short localized `status` whenever suggestions or results change, such as “3 results”; the polite live region announces it without reading the whole list. Name every custom icon button. A simple list of results uses list/link/button semantics; do not put a rich list under `role="listbox"` without implementing the corresponding combobox pattern. + +### Avoid + +- Do not use M3Search as a replacement for a small static navigation list. +- Do not place more than two trailing actions in the expanded bar; when clear is visible, count it as one. +- Do not let a late async response reopen a closed search or move focus back into its input. +- Do not treat `type="search"` on M3TextField as the complete Search pattern. + +## API + +| Export | Purpose | +| --- | --- | +| `M3Search` | Entry bar and modal expanded search view. | +| `M3SearchProps`, `M3SearchExposed`, `SearchResultsContext` | Typed component contract, imperative methods, and results render context. | + +| Prop / callback | Default | Effect | +| --- | --- | --- | +| `query`, `onQueryChange` | required | Controlled input value and update request. | +| `open`, `onOpenChange` | required | Controlled expanded state and request from trigger/back/Escape/scrim. | +| `label`, `status` | required | Accessible search name and polite result announcement. | +| `hint` | `label` | Visible prompt when query is empty. | +| `appearance` | `contained` | `contained` or `divided`. | +| `layout` | `auto` | `auto`, `full-screen`, or `docked`. | +| `backLabel`, `clearLabel` | English labels | Accessible names for built-in icon buttons; translate them. | +| `onSubmit` | — | Called with the query on Enter. | +| HTML attributes | — | Passed to the collapsed root. | + +| Named child | Purpose | +| --- | --- | +| `M3Search.Leading` | Optional collapsed-bar leading control; without it a decorative search icon appears in the trigger. | +| `M3Search.Trailing` | Optional actions at the end of the collapsed bar. | +| `M3Search.Actions` | Optional expanded-bar trailing actions after the clear button. | +| `M3Search.Results` | Results content; pass `render={({ query, close, submit }) => ...}` to use the current context. Unslotted children are used when Results is absent. | + +The ref exposes `open()`, `close()`, and `focus()`. These request controlled state changes or focus the visible search control; `open()` and `close()` do not mutate parent state. + +```tsx +const [query, setQuery] = useState('') +const [open, setOpen] = useState(false) + + + ( + {results.map(item => ( + { select(item); close() }} /> + ))} + )} /> + +``` + +## Sources + +- [Material Search overview](https://m3.material.io/components/search/overview) +- [Material Search specs](https://m3.material.io/components/search/specs) +- [Material Search guidelines](https://m3.material.io/components/search/guidelines) +- [Material Search accessibility](https://m3.material.io/components/search/accessibility) +- [WAI-ARIA Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) + + + + + +# Поиск + +Search объединяет постоянную строку входа и раскрытую область подсказок или результатов. Используйте его для поиска в большом наборе объектов и ставьте рядом с искомым содержимым. [Material 3](https://m3.material.io/components/search/guidelines) рекомендует contained для новых интерфейсов; baseline divided остаётся доступен. + +## Руководство + +### Вид и расположение + +Contained сохраняет округлую заполненную строку после раскрытия. Divided разделяет заголовок и результаты линией. `layout="auto"` выбирает полный экран при ширине меньше 600 px и docked при большей. Фиксируйте layout, когда этого требует компоновка продукта. У docked есть затемнение фона; оба раскрытых layout реализованы здесь как модальные диалоги. + +Контейнер результатов по умолчанию пуст. Передайте `M3List` или своё содержимое в `M3Search.Results`. До ввода можно показывать историю; во время ввода — подсказки; после отправки — результаты. Фильтрация, асинхронные запросы, сортировка, пустое состояние и переход к результату принадлежат приложению. Разделяйте группы результатов промежутками и понятными заголовками. + +### Запрос, фокус и клавиатура + +`query` и `open` — управляемые значения. Обновляйте их через `onQueryChange` и `onOpenChange`; `onSubmit` получает текущий запрос. После Enter текст остаётся видимым, а фокус переходит из поля в область результатов. Кнопка назад, Escape и нажатие на затемнение docked запрашивают закрытие; фокус возвращается к инициатору. Стрелки вниз/вверх переводят фокус к действиям результатов и между ними. `Tab` идёт по интерактивным элементам диалога. + +Передайте локализованную `label`, называющую область поиска, а не просто «Поиск». `hint` — видимая подсказка; по умолчанию совпадает с `label`. Если они различаются, доступное имя включает оба текста. Переведите `backLabel` и `clearLabel`. При изменении подсказок/результатов обновляйте короткий `status`, например «3 результата»: polite live region объявит его, не читая весь список. Давайте названия всем своим кнопкам с иконкой. Для богатого списка используйте семантику списка/ссылок/кнопок; `listbox` требует отдельной реализации соответствующего combobox-паттерна. + +### Чего избегать + +- Не заменяйте Search небольшой статический список навигации. +- Не помещайте больше двух trailing-действий в раскрытую строку; clear считается одним из них. +- Не позволяйте запоздалому ответу сервера вновь открыть закрытый поиск или перевести фокус в его поле. +- Не считайте `type="search"` у M3TextField полноценным Search. + +## API + +| Экспорт | Назначение | +| --- | --- | +| `M3Search` | Строка входа и модальная раскрытая область. | +| `M3SearchProps`, `M3SearchExposed`, `SearchResultsContext` | Типизированный контракт, методы и контекст результатов. | + +| Свойство / callback | По умолчанию | Действие | +| --- | --- | --- | +| `query`, `onQueryChange` | обязательны | Управляемый текст и запрос обновления. | +| `open`, `onOpenChange` | обязательны | Управляемое раскрытие и запрос от trigger/back/Escape/scrim. | +| `label`, `status` | обязательны | Доступное имя и объявление результата. | +| `hint` | `label` | Видимая подсказка при пустом запросе. | +| `appearance` | `contained` | `contained` или `divided`. | +| `layout` | `auto` | `auto`, `full-screen` или `docked`. | +| `backLabel`, `clearLabel` | английский текст | Доступные имена встроенных кнопок; переведите их. | +| `onSubmit` | — | Вызывается по Enter с текущим запросом. | +| HTML-атрибуты | — | Передаются корню свёрнутого поиска. | + +| Именованный потомок | Назначение | +| --- | --- | +| `M3Search.Leading` | Необязательное действие в начале свёрнутой строки; иначе у trigger декоративная лупа. | +| `M3Search.Trailing` | Действия в конце свёрнутой строки. | +| `M3Search.Actions` | Действия раскрытой строки после clear. | +| `M3Search.Results` | Содержимое; `render={({ query, close, submit }) => ...}` получает текущий контекст. Без Results используются обычные children. | + +Ref открывает методы `open()`, `close()` и `focus()`. Первые два запрашивают смену управляемого состояния; родитель обновляет его сам. + +## Источники + +- [Обзор Material Search](https://m3.material.io/components/search/overview) +- [Размеры и токены](https://m3.material.io/components/search/specs) +- [Рекомендации](https://m3.material.io/components/search/guidelines) +- [Доступность](https://m3.material.io/components/search/accessibility) + + diff --git a/m3-react/storybook/components/M3Search.stories.tsx b/m3-react/storybook/components/M3Search.stories.tsx new file mode 100644 index 0000000..ba9262e --- /dev/null +++ b/m3-react/storybook/components/M3Search.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { M3Search } from '@/components/search' + +import SearchExample from '../examples/search/SearchExample' + +const meta = { + title: 'Components/M3Search', + component: M3Search, + args: { + appearance: 'contained', + layout: 'auto', + query: '', + label: 'Search your messages', + status: '', + open: false, + onQueryChange: () => {}, + onOpenChange: () => {}, + }, + argTypes: { + appearance: { control: 'select', options: ['contained', 'divided'] }, + layout: { control: 'select', options: ['auto', 'full-screen', 'docked'] }, + query: { control: false }, + open: { control: false }, + status: { control: false }, + onQueryChange: { control: false }, + onOpenChange: { control: false }, + onSubmit: { control: false }, + }, + render: (args, { globals }) => ( + + ), + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const AdaptiveContained: Story = {} +export const DockedContained: Story = { args: { layout: 'docked' } } +export const FullScreenContained: Story = { args: { layout: 'full-screen' } } +export const DockedDivided: Story = { args: { layout: 'docked', appearance: 'divided' } } +export const FullScreenDivided: Story = { args: { layout: 'full-screen', appearance: 'divided' } } +export const ProgrammaticOpen: Story = { + render: (args, { globals }) => ( + + ), +} +export const InitiallyOpen: Story = { + render: (args, { globals }) => ( + + ), +} diff --git a/m3-react/storybook/examples/search/SearchExample.tsx b/m3-react/storybook/examples/search/SearchExample.tsx new file mode 100644 index 0000000..1dc516e --- /dev/null +++ b/m3-react/storybook/examples/search/SearchExample.tsx @@ -0,0 +1,125 @@ +import type { + SearchAppearance, + SearchLayout, +} from '@modulify/m3-foundation/types/components/search' + +import { useState } from 'react' + +import { M3Button } from '@/components/button' +import { M3Icon } from '@/components/icon' +import { M3IconButton } from '@/components/icon-button' +import { M3List, M3ListItem } from '@/components/list' +import { M3Search } from '@/components/search' + +import { localize } from '../../i18n' + +const messages = { + 'en-US': { + heading: 'Mail', + hint: 'Search your messages', + back: 'Back to mail', + clear: 'Clear search', + example: 'Use a recent query', + open: 'Open search programmatically', + recent: 'Recent searches', + suggestions: 'Suggestions', + results: 'Results', + inbox: 'Inbox', + cards: ['Planning the next release', 'Notes from yesterday', 'Design review', 'Project status'], + }, + 'ru-RU': { + heading: 'Почта', + hint: 'Поиск по сообщениям', + back: 'Назад к почте', + clear: 'Очистить поиск', + example: 'Вставить недавний запрос', + open: 'Открыть поиск программно', + recent: 'Недавние запросы', + suggestions: 'Подсказки', + results: 'Результаты', + inbox: 'Входящие', + cards: ['План следующего выпуска', 'Заметки за вчера', 'Обзор дизайна', 'Состояние проекта'], + }, +} + +interface SearchExampleProps { + appearance?: SearchAppearance; + layout?: SearchLayout; + locale: string; + initialOpen?: boolean; + externalTrigger?: boolean; +} + +export default function SearchExample({ + appearance = 'contained', + layout = 'auto', + locale, + initialOpen = false, + externalTrigger = false, +}: SearchExampleProps) { + const text = localize(locale, messages) + const [query, setQuery] = useState('') + const [open, setOpen] = useState(initialOpen) + const [submitted, setSubmitted] = useState(false) + const matching = text.cards.filter(item => item.toLocaleLowerCase(locale).includes(query.toLocaleLowerCase(locale))) + const entries = query ? matching : text.cards.slice(0, 3) + const section = submitted ? text.results : query ? text.suggestions : text.recent + const status = `${section}: ${entries.length}` + + const updateQuery = (value: string) => { + setQuery(value) + setSubmitted(false) + } + const choose = (value: string) => { + setQuery(value) + } + + return ( +
+
+
+

{text.heading}

+ {externalTrigger ? setOpen(true)}>{text.open} : null} +
+ setSubmitted(true)} + > + + updateQuery(text.cards[0])}> + + ( + <> +

{section}

+ + {entries.map(entry => ( + { choose(entry); close() }}> + + + ))} + + + )} /> +
+

{text.inbox}

+
+ {text.cards.map(card => ( +
+ {card} +
+ ))} +
+
+
+ ) +} diff --git a/m3-react/tests/M3Search.e2e.tsx b/m3-react/tests/M3Search.e2e.tsx new file mode 100644 index 0000000..b2a1ae9 --- /dev/null +++ b/m3-react/tests/M3Search.e2e.tsx @@ -0,0 +1,49 @@ +import { act, fireEvent } from '@testing-library/react' +import { page } from 'vitest/browser' +import { render, screen } from '@testing-library/react' +import { useState } from 'react' +import { waitFor } from '@testing-library/react' + +import { M3Search } from '@/components/search' + +const SearchHarness = () => { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + + return +} + +test('adapts an open search without losing the query or overflowing the viewport', async () => { + await page.viewport(390, 760) + const view = render() + + try { + fireEvent.click(screen.getByRole('button', { name: 'Search messages' })) + const input = screen.getByRole('searchbox', { name: 'Search messages' }) as HTMLInputElement + fireEvent.change(input, { target: { value: 'notes' } }) + + const dialog = screen.getByRole('dialog', { name: 'Search messages' }) + expect(dialog.classList.contains('m3-search__view_full-screen')).toBe(true) + expect(getComputedStyle(document.querySelector('.m3-search__bar')!).visibility).toBe('hidden') + expect(dialog.offsetWidth).toBe(390) + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390) + + await act(async () => { await page.viewport(900, 760) }) + await waitFor(() => expect(dialog.classList.contains('m3-search__view_docked')).toBe(true)) + const docked = dialog.getBoundingClientRect() + expect(docked.width).toBeLessThanOrEqual(720) + expect(docked.left).toBeGreaterThanOrEqual(12) + expect(docked.right).toBeLessThanOrEqual(888) + expect(input.value).toBe('notes') + expect(document.activeElement).toBe(input) + } finally { + view.unmount() + } +}) diff --git a/m3-react/tests/M3Search.test.tsx b/m3-react/tests/M3Search.test.tsx new file mode 100644 index 0000000..cd3094d --- /dev/null +++ b/m3-react/tests/M3Search.test.tsx @@ -0,0 +1,106 @@ +import type { M3SearchExposed } from '@/components/search' + +import { act } from '@testing-library/react' +import { createRef } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' + +import { M3Search } from '@/components/search' + +const SearchHarness = ({ onSubmit = () => {} }: { onSubmit?: (query: string) => void }) => { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + + return ( + <> + + + ( +
+ + +
+ )} /> +
+ + ) +} + +describe('m3-react/search', () => { + test('opens from the bar, edits and submits while keeping the query visible', () => { + const onSubmit = vi.fn() + render() + const trigger = screen.getByRole('button', { name: 'Search messages' }) + trigger.focus() + fireEvent.click(trigger) + + const dialog = screen.getByRole('dialog', { name: 'Search messages' }) + const input = screen.getByRole('searchbox', { name: 'Search messages' }) as HTMLInputElement + expect(dialog.getAttribute('aria-modal')).toBe('true') + expect(document.activeElement).toBe(input) + expect(screen.getByRole('status').textContent).toBe('2 suggestions') + + fireEvent.change(input, { target: { value: 'plan' } }) + expect(input.value).toBe('plan') + expect(screen.getByRole('status').textContent).toBe('1 result') + fireEvent.submit(input.closest('form')!) + expect(onSubmit).toHaveBeenCalledWith('plan') + expect(input.value).toBe('plan') + expect(document.activeElement).toBe(dialog.querySelector('.m3-search__results')) + }) + + test('navigates results, clears input and returns focus after Escape', async () => { + render() + const trigger = screen.getByRole('button', { name: 'Search messages' }) + trigger.focus() + fireEvent.click(trigger) + const input = screen.getByRole('searchbox', { name: 'Search messages' }) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'First result' })) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Second result' })) + + fireEvent.change(input, { target: { value: 'draft' } }) + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect((input as HTMLInputElement).value).toBe('') + expect(document.activeElement).toBe(input) + + fireEvent.keyDown(document, { key: 'Escape' }) + await act(async () => { await Promise.resolve() }) + expect(document.activeElement).toBe(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) + + test('selects a forced layout and exposes programmatic requests', () => { + const ref = createRef() + const onOpenChange = vi.fn() + render( + {}} + onOpenChange={onOpenChange} + /> + ) + + expect(document.querySelector('.m3-search__view_divided.m3-search__view_docked')).not.toBeNull() + act(() => ref.current?.close()) + act(() => ref.current?.open()) + expect(onOpenChange).toHaveBeenNthCalledWith(1, false) + expect(onOpenChange).toHaveBeenNthCalledWith(2, true) + }) +}) From b47d9acbdcee6d0065e175774c98ea5ec1126dc0 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 7 Oct 2026 21:33:03 +0400 Subject: [PATCH 03/20] feat(m3-vue): Search component and examples were added Closes #21 --- m3-vue/src/components/index.ts | 1 + m3-vue/src/components/search/M3Search.vue | 295 ++++++++++++++++++ m3-vue/src/components/search/index.ts | 2 + m3-vue/src/components/search/types.ts | 11 + m3-vue/storybook/components/M3Search.mdx | 156 +++++++++ .../storybook/components/M3Search.stories.ts | 52 +++ .../examples/search/SearchExample.vue | 148 +++++++++ m3-vue/tests/M3Search.e2e.ts | 66 ++++ m3-vue/tests/M3Search.test.ts | 88 ++++++ 9 files changed, 819 insertions(+) create mode 100644 m3-vue/src/components/search/M3Search.vue create mode 100644 m3-vue/src/components/search/index.ts create mode 100644 m3-vue/src/components/search/types.ts create mode 100644 m3-vue/storybook/components/M3Search.mdx create mode 100644 m3-vue/storybook/components/M3Search.stories.ts create mode 100644 m3-vue/storybook/examples/search/SearchExample.vue create mode 100644 m3-vue/tests/M3Search.e2e.ts create mode 100644 m3-vue/tests/M3Search.test.ts diff --git a/m3-vue/src/components/index.ts b/m3-vue/src/components/index.ts index 167bc89..de0b141 100644 --- a/m3-vue/src/components/index.ts +++ b/m3-vue/src/components/index.ts @@ -19,6 +19,7 @@ export * from './radio' export * from './rich-tooltip' export * from './ripple' export * from './scroll-rail' +export * from './search' export * from './select' export * from './side-sheet' export * from './snackbar' diff --git a/m3-vue/src/components/search/M3Search.vue b/m3-vue/src/components/search/M3Search.vue new file mode 100644 index 0000000..5cfc046 --- /dev/null +++ b/m3-vue/src/components/search/M3Search.vue @@ -0,0 +1,295 @@ + + + diff --git a/m3-vue/src/components/search/index.ts b/m3-vue/src/components/search/index.ts new file mode 100644 index 0000000..cb5365d --- /dev/null +++ b/m3-vue/src/components/search/index.ts @@ -0,0 +1,2 @@ +export { default as M3Search } from './M3Search.vue' +export type { M3SearchMethods, SearchResultsContext } from './types' diff --git a/m3-vue/src/components/search/types.ts b/m3-vue/src/components/search/types.ts new file mode 100644 index 0000000..859acd5 --- /dev/null +++ b/m3-vue/src/components/search/types.ts @@ -0,0 +1,11 @@ +export interface SearchResultsContext { + query: string; + close(): void; + submit(): void; +} + +export interface M3SearchMethods { + open(): void; + close(): void; + focus(): void; +} diff --git a/m3-vue/storybook/components/M3Search.mdx b/m3-vue/storybook/components/M3Search.mdx new file mode 100644 index 0000000..ed62c40 --- /dev/null +++ b/m3-vue/storybook/components/M3Search.mdx @@ -0,0 +1,156 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import { Localized } from '../i18n' +import * as M3SearchStories from './M3Search.stories' + + + + + +# Search + +Search combines a persistent entry bar with an expanded area for suggestions or results. Use it for a large collection and place it near the content it searches. The [Material 3 guidelines](https://m3.material.io/components/search/guidelines) recommend the contained style for new interfaces; the divided baseline style remains available. + +## Guide + +### Appearance and layout + +Contained keeps a rounded filled bar in the expanded view. Divided separates the input header and results with a divider. `layout="auto"` opens full screen below 600 px and docked on wider windows. Both expanded layouts are modal in this web implementation; docked has a scrim. Use a fixed layout only if the surrounding product needs one. + +The results container starts empty. Add an `M3List` or your own content in `#results`. Recent queries may appear before typing; suggestions while typing; results after submission. The application handles data requests, filtering, ranking, grouping, empty/error states, and result navigation. + +### Query, focus, and accessibility + +Use `v-model:query` and `v-model:open`. `submit` emits the current query. Submitting retains the query and moves focus from the input to the results area. Back, Escape, and the docked scrim request closing; focus returns to the original control. Arrow Down/Up enters and navigates result controls. `Tab` follows the dialog's controls. + +Give `label` a localized description of the searchable content. `hint` is the visible prompt and defaults to `label`. When they differ, the control's accessible name includes both. Translate `backLabel` and `clearLabel`. Update the short localized `status` when suggestions/results change; its polite live region announces the change without speaking the whole list. Label custom icon buttons. A rich list with links and buttons should keep native list/link/button semantics, not pretend to be a listbox without the complete combobox behavior. + +### Avoid + +- Do not substitute Search for a small static navigation list. +- Do not place more than two trailing actions in the expanded bar; count clear as one. +- Do not let an outdated async response reopen a closed search or refocus its input. +- Do not treat `type="search"` on M3TextField as the complete Search pattern. + +## API + +| Export | Purpose | +| --- | --- | +| `M3Search` | Entry bar and modal expanded search view. | +| `M3SearchMethods`, `SearchResultsContext` | Exposed methods and typed result slot properties. | + +| Prop / event | Default | Effect | +| --- | --- | --- | +| `query`, `update:query` | required | Controlled input value and update. | +| `open`, `update:open` | required | Controlled expanded state, trigger/back/Escape/scrim update. | +| `label`, `status` | required | Accessible name and polite result announcement. | +| `hint` | `label` | Visible prompt when the query is empty. | +| `appearance` | `contained` | `contained` or `divided`. | +| `layout` | `auto` | `auto`, `full-screen`, or `docked`. | +| `backLabel`, `clearLabel` | English labels | Accessible built-in button names; translate them. | +| `submit` | — | Emits the current query on Enter. | +| HTML attributes | — | Passed to the collapsed root. | + +| Slot | Purpose | +| --- | --- | +| `#leading` | Optional collapsed-bar leading control; otherwise the trigger contains a decorative search icon. | +| `#trailing` | Collapsed-bar trailing actions. | +| `#actions` | Expanded-bar trailing actions after clear. | +| `#results="{ query, close, submit }"` | Results content and controls. The default slot is used when `#results` is absent. | + +The template ref exposes `open()`, `close()`, and `focus()`. The first two emit `update:open`; the parent owns the state. + +```vue + + + +``` + +## Sources + +- [Material Search overview](https://m3.material.io/components/search/overview) +- [Material Search specs](https://m3.material.io/components/search/specs) +- [Material Search guidelines](https://m3.material.io/components/search/guidelines) +- [Material Search accessibility](https://m3.material.io/components/search/accessibility) +- [WAI-ARIA Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) + + + + + +# Поиск + +Search объединяет постоянную строку входа и раскрытую область подсказок или результатов. Используйте его для большого набора объектов и ставьте рядом с искомым содержимым. [Material 3](https://m3.material.io/components/search/guidelines) рекомендует contained для новых интерфейсов; baseline divided остаётся доступен. + +## Руководство + +### Вид и расположение + +Contained сохраняет округлую заполненную строку после раскрытия. Divided отделяет заголовок от результатов линией. `layout="auto"` выбирает полный экран при ширине меньше 600 px и docked при большей. Оба раскрытых layout в этой веб-реализации модальные; docked затемняет фон. Фиксируйте layout, когда этого требует компоновка продукта. + +Контейнер результатов по умолчанию пуст. Передайте `M3List` или своё содержимое в `#results`. До ввода можно показывать историю, во время ввода — подсказки, после отправки — результаты. Запросы к серверу, фильтрация, сортировка, группы, пустое/ошибочное состояние и переход к результату принадлежат приложению. + +### Запрос, фокус и доступность + +Используйте `v-model:query` и `v-model:open`. Событие `submit` передаёт текущий запрос. После Enter текст остаётся видимым, а фокус переходит из поля в область результатов. Кнопка назад, Escape и затемнение docked запрашивают закрытие; фокус возвращается к инициатору. Стрелки вниз/вверх переводят фокус к действиям результатов и между ними. `Tab` идёт по элементам диалога. + +Локализованная `label` должна описывать содержимое поиска. `hint` — видимая подсказка, по умолчанию совпадающая с `label`. Если они различаются, доступное имя включает оба текста. Переведите `backLabel` и `clearLabel`. Обновляйте краткий локализованный `status` при изменении подсказок/результатов: polite live region объявит его без чтения всего списка. Дайте имена своим кнопкам с иконками. Богатому списку со ссылками и кнопками нужна семантика списка/ссылок/кнопок, а не неполный `listbox`. + +### Чего избегать + +- Не заменяйте Search небольшой статический список навигации. +- Не помещайте больше двух trailing-действий в раскрытую строку; clear — одно из них. +- Не позволяйте запоздалому ответу сервера открыть закрытый поиск или сфокусировать его поле. +- Не считайте `type="search"` у M3TextField полноценным Search. + +## API + +| Экспорт | Назначение | +| --- | --- | +| `M3Search` | Строка входа и модальная раскрытая область. | +| `M3SearchMethods`, `SearchResultsContext` | Открытые методы и типы свойств слота результатов. | + +| Свойство / событие | По умолчанию | Действие | +| --- | --- | --- | +| `query`, `update:query` | обязательны | Управляемый текст и его обновление. | +| `open`, `update:open` | обязательны | Управляемое раскрытие и обновление от trigger/back/Escape/scrim. | +| `label`, `status` | обязательны | Доступное имя и объявление результатов. | +| `hint` | `label` | Видимая подсказка при пустом запросе. | +| `appearance` | `contained` | `contained` или `divided`. | +| `layout` | `auto` | `auto`, `full-screen` или `docked`. | +| `backLabel`, `clearLabel` | английский текст | Доступные имена встроенных кнопок; переведите их. | +| `submit` | — | Передаёт запрос по Enter. | +| HTML-атрибуты | — | Передаются корню свёрнутого поиска. | + +| Слот | Назначение | +| --- | --- | +| `#leading` | Необязательное действие в начале свёрнутой строки; иначе в trigger есть декоративная лупа. | +| `#trailing` | Действия в конце свёрнутой строки. | +| `#actions` | Действия раскрытой строки после clear. | +| `#results="{ query, close, submit }"` | Содержимое и действия результатов; при отсутствии используется default. | + +Template ref открывает `open()`, `close()` и `focus()`. Первые два посылают `update:open`; состояние остаётся у родителя. + +## Источники + +- [Обзор Material Search](https://m3.material.io/components/search/overview) +- [Размеры и токены](https://m3.material.io/components/search/specs) +- [Рекомендации](https://m3.material.io/components/search/guidelines) +- [Доступность](https://m3.material.io/components/search/accessibility) + + diff --git a/m3-vue/storybook/components/M3Search.stories.ts b/m3-vue/storybook/components/M3Search.stories.ts new file mode 100644 index 0000000..307c264 --- /dev/null +++ b/m3-vue/storybook/components/M3Search.stories.ts @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3Search } from '@/components/search' + +import SearchExample from '../examples/search/SearchExample.vue' + +const meta = { + title: 'Components/M3Search', + component: M3Search, + args: { + appearance: 'contained', + layout: 'auto', + query: '', + open: false, + label: 'Search your messages', + status: '', + }, + argTypes: { + appearance: { control: 'select', options: ['contained', 'divided'] }, + layout: { control: 'select', options: ['auto', 'full-screen', 'docked'] }, + }, + render: (args: Record, { globals }) => ({ + components: { SearchExample }, + setup: () => ({ appearance: args.appearance, layout: args.layout, locale: globals.locale }), + template: '', + }), + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const AdaptiveContained: Story = {} +export const DockedContained: Story = { args: { layout: 'docked' } } +export const FullScreenContained: Story = { args: { layout: 'full-screen' } } +export const DockedDivided: Story = { args: { layout: 'docked', appearance: 'divided' } } +export const FullScreenDivided: Story = { args: { layout: 'full-screen', appearance: 'divided' } } +export const ProgrammaticOpen: Story = { + render: (args: Record, { globals }) => ({ + components: { SearchExample }, + setup: () => ({ appearance: args.appearance, layout: args.layout, locale: globals.locale }), + template: '', + }), +} +export const InitiallyOpen: Story = { + render: (args: Record, { globals }) => ({ + components: { SearchExample }, + setup: () => ({ appearance: args.appearance, layout: args.layout, locale: globals.locale }), + template: '', + }), +} diff --git a/m3-vue/storybook/examples/search/SearchExample.vue b/m3-vue/storybook/examples/search/SearchExample.vue new file mode 100644 index 0000000..083bd18 --- /dev/null +++ b/m3-vue/storybook/examples/search/SearchExample.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/m3-vue/tests/M3Search.e2e.ts b/m3-vue/tests/M3Search.e2e.ts new file mode 100644 index 0000000..f43f015 --- /dev/null +++ b/m3-vue/tests/M3Search.e2e.ts @@ -0,0 +1,66 @@ +import { createApp, h, nextTick } from 'vue' +import { page } from 'vitest/browser' +import { ref } from 'vue' + +import { M3Search } from '@/components/search' + +const waitFor = async (assertion: () => void) => { + const startedAt = performance.now() + + while (performance.now() - startedAt < 2000) { + try { + assertion() + return + } catch { + await new Promise(resolve => setTimeout(resolve, 16)) + } + } + + assertion() +} + +test('adapts an open search without losing the query or overflowing the viewport', async () => { + await page.viewport(390, 760) + const query = ref('') + const open = ref(false) + const mountPoint = document.createElement('div') + document.body.append(mountPoint) + const app = createApp({ + render: () => h(M3Search, { + query: query.value, + open: open.value, + label: 'Search messages', + status: 'Results', + 'onUpdate:query': (value: string) => query.value = value, + 'onUpdate:open': (value: boolean) => open.value = value, + }), + }) + app.mount(mountPoint) + + try { + (mountPoint.querySelector('.m3-search__trigger') as HTMLButtonElement).click() + await nextTick() + const input = document.querySelector('.m3-search__input') as HTMLInputElement + input.value = 'notes' + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + + const dialog = document.querySelector('.m3-search__view') as HTMLElement + expect(dialog.classList.contains('m3-search__view_full-screen')).toBe(true) + expect(getComputedStyle(document.querySelector('.m3-search__bar')!).visibility).toBe('hidden') + expect(dialog.offsetWidth).toBe(390) + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390) + + await page.viewport(900, 760) + await waitFor(() => expect(dialog.classList.contains('m3-search__view_docked')).toBe(true)) + const docked = dialog.getBoundingClientRect() + expect(docked.width).toBeLessThanOrEqual(720) + expect(docked.left).toBeGreaterThanOrEqual(12) + expect(docked.right).toBeLessThanOrEqual(888) + expect(input.value).toBe('notes') + expect(document.activeElement).toBe(input) + } finally { + app.unmount() + mountPoint.remove() + } +}) diff --git a/m3-vue/tests/M3Search.test.ts b/m3-vue/tests/M3Search.test.ts new file mode 100644 index 0000000..581b208 --- /dev/null +++ b/m3-vue/tests/M3Search.test.ts @@ -0,0 +1,88 @@ +import { defineComponent } from 'vue' +import { fireEvent } from '@testing-library/vue' +import { h, nextTick, ref } from 'vue' +import { render, screen } from '@testing-library/vue' + +import { M3Search } from '@/components/search' + +const mountSearch = (onSubmit = vi.fn()) => { + const query = ref('') + const open = ref(false) + render(defineComponent({ + setup: () => () => h('div', [ + h('button', 'Outside'), + h(M3Search, { + query: query.value, + open: open.value, + label: 'Search messages', + status: query.value ? '1 result' : '2 suggestions', + 'onUpdate:query': (value: string) => query.value = value, + 'onUpdate:open': (value: boolean) => open.value = value, + onSubmit, + }, { + results: () => h('div', [h('button', 'First result'), h('button', 'Second result')]), + }), + ]), + })) + return { open, onSubmit } +} + +describe('m3-vue/search', () => { + test('opens, edits and submits without losing the query', async () => { + const { onSubmit } = mountSearch() + const trigger = screen.getByRole('button', { name: 'Search messages' }) + trigger.focus() + await fireEvent.click(trigger) + await nextTick() + + const dialog = screen.getByRole('dialog', { name: 'Search messages' }) + const input = screen.getByRole('searchbox', { name: 'Search messages' }) as HTMLInputElement + expect(dialog.getAttribute('aria-modal')).toBe('true') + expect(document.activeElement).toBe(input) + expect(screen.getByRole('status').textContent).toBe('2 suggestions') + + await fireEvent.update(input, 'plan') + expect(input.value).toBe('plan') + expect(screen.getByRole('status').textContent).toBe('1 result') + await fireEvent.submit(input.closest('form')!) + expect(onSubmit).toHaveBeenCalledWith('plan') + expect(input.value).toBe('plan') + expect(document.activeElement).toBe(dialog.querySelector('.m3-search__results')) + }) + + test('navigates results, clears and closes with focus restoration', async () => { + const { open } = mountSearch() + const trigger = screen.getByRole('button', { name: 'Search messages' }) + trigger.focus() + await fireEvent.click(trigger) + await nextTick() + const input = screen.getByRole('searchbox', { name: 'Search messages' }) as HTMLInputElement + + await fireEvent.keyDown(input, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'First result' })) + await fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Second result' })) + + await fireEvent.update(input, 'draft') + await fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(input.value).toBe('') + expect(document.activeElement).toBe(input) + + await fireEvent.keyDown(document, { key: 'Escape' }) + await nextTick() + await Promise.resolve() + expect(open.value).toBe(false) + expect(document.activeElement).toBe(trigger) + }) + + test('renders divided docked geometry when explicitly selected', async () => { + render(M3Search, { + props: { + query: '', open: true, label: 'Search messages', status: '', + appearance: 'divided', layout: 'docked', + }, + }) + await nextTick() + expect(document.querySelector('.m3-search__view_divided.m3-search__view_docked')).not.toBeNull() + }) +}) From eae334363226d95f4f35c56bb9a5bd2a661f3775 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 7 Oct 2026 21:37:38 +0400 Subject: [PATCH 04/20] test(m3-react): Adaptive viewport assertions were stabilized --- m3-react/tests/M3NavigationBar.e2e.tsx | 4 ++-- m3-react/tests/M3Search.e2e.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/m3-react/tests/M3NavigationBar.e2e.tsx b/m3-react/tests/M3NavigationBar.e2e.tsx index 826d0d7..49222a0 100644 --- a/m3-react/tests/M3NavigationBar.e2e.tsx +++ b/m3-react/tests/M3NavigationBar.e2e.tsx @@ -351,7 +351,7 @@ test('can exclude bar from automatic navigation and open a hidden modal rail', a try { const nav = query('nav.m3-navigation') - expect(nav.classList.contains('m3-navigation_rail-hidden')).toBe(true) + await waitFor(() => expect(nav.classList.contains('m3-navigation_rail-hidden')).toBe(true)) expect(nav.classList.contains('m3-navigation_bar')).toBe(false) view.rerender(navigation(true)) @@ -386,7 +386,7 @@ test('keeps the vertical bar when it is the allowed bar form', async () => { try { const nav = query('nav.m3-navigation') - expect(nav.classList.contains('m3-navigation_bar')).toBe(true) + await waitFor(() => expect(nav.classList.contains('m3-navigation_bar')).toBe(true)) expect(nav.classList.contains('m3-navigation_bar-vertical')).toBe(true) expect(query('.m3-navigation-tab').classList.contains('m3-navigation-tab_in-bar')).toBe(true) diff --git a/m3-react/tests/M3Search.e2e.tsx b/m3-react/tests/M3Search.e2e.tsx index b2a1ae9..6ec8e8f 100644 --- a/m3-react/tests/M3Search.e2e.tsx +++ b/m3-react/tests/M3Search.e2e.tsx @@ -30,7 +30,7 @@ test('adapts an open search without losing the query or overflowing the viewport fireEvent.change(input, { target: { value: 'notes' } }) const dialog = screen.getByRole('dialog', { name: 'Search messages' }) - expect(dialog.classList.contains('m3-search__view_full-screen')).toBe(true) + await waitFor(() => expect(dialog.classList.contains('m3-search__view_full-screen')).toBe(true)) expect(getComputedStyle(document.querySelector('.m3-search__bar')!).visibility).toBe('hidden') expect(dialog.offsetWidth).toBe(390) expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390) From 41d59cd30a89979313b06c06ea065661bf009cdd Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Wed, 7 Oct 2026 21:37:53 +0400 Subject: [PATCH 05/20] test(m3-vue): Adaptive viewport assertions were stabilized --- m3-vue/tests/M3NavigationBar.e2e.ts | 2 +- m3-vue/tests/M3Search.e2e.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/m3-vue/tests/M3NavigationBar.e2e.ts b/m3-vue/tests/M3NavigationBar.e2e.ts index e799e41..de57f0b 100644 --- a/m3-vue/tests/M3NavigationBar.e2e.ts +++ b/m3-vue/tests/M3NavigationBar.e2e.ts @@ -475,7 +475,7 @@ test('keeps the vertical bar when it is the allowed bar form', async () => { try { const nav = query('nav.m3-navigation') - expect(nav.classList.contains('m3-navigation_bar')).toBe(true) + await waitFor(() => expect(nav.classList.contains('m3-navigation_bar')).toBe(true)) expect(nav.classList.contains('m3-navigation_bar-vertical')).toBe(true) expect(query('.m3-navigation-tab').classList.contains('m3-navigation-tab_in-bar')).toBe(true) diff --git a/m3-vue/tests/M3Search.e2e.ts b/m3-vue/tests/M3Search.e2e.ts index f43f015..34176e6 100644 --- a/m3-vue/tests/M3Search.e2e.ts +++ b/m3-vue/tests/M3Search.e2e.ts @@ -46,7 +46,7 @@ test('adapts an open search without losing the query or overflowing the viewport await nextTick() const dialog = document.querySelector('.m3-search__view') as HTMLElement - expect(dialog.classList.contains('m3-search__view_full-screen')).toBe(true) + await waitFor(() => expect(dialog.classList.contains('m3-search__view_full-screen')).toBe(true)) expect(getComputedStyle(document.querySelector('.m3-search__bar')!).visibility).toBe('hidden') expect(dialog.offsetWidth).toBe(390) expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390) From 613741d3a54b979dd5ca717821200ee7f01ae1da Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Thu, 8 Oct 2026 14:41:38 +0400 Subject: [PATCH 06/20] feat(m3-foundation): App bar styles and scroll primitives were added --- .../stylesheets/components/app-bar/index.scss | 295 ++++++++++++++++++ .../assets/stylesheets/components/index.scss | 1 + m3-foundation/lib/appBar.ts | 28 ++ m3-foundation/tests/appBar.test.ts | 23 ++ m3-foundation/types/components/app-bar.d.ts | 16 + m3-foundation/vite.config.ts | 1 + 6 files changed, 364 insertions(+) create mode 100644 m3-foundation/assets/stylesheets/components/app-bar/index.scss create mode 100644 m3-foundation/lib/appBar.ts create mode 100644 m3-foundation/tests/appBar.test.ts create mode 100644 m3-foundation/types/components/app-bar.d.ts diff --git a/m3-foundation/assets/stylesheets/components/app-bar/index.scss b/m3-foundation/assets/stylesheets/components/app-bar/index.scss new file mode 100644 index 0000000..e8d2c6b --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/app-bar/index.scss @@ -0,0 +1,295 @@ +@use '../../basics/motion' as m3-motion; +@use '../../basics/typography' as m3-typography; + +.m3-app-bar { + display: grid; + box-sizing: border-box; + inline-size: 100%; + block-size: 64px; + grid-template-columns: 56px minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr); + align-items: center; + background: var(--m3-sys-surface); + color: var(--m3-sys-on-surface); + position: sticky; + inset-block-start: 0; + z-index: 10; + transition: + #{m3-motion.timing-standard(background-color, 'short4')}, + #{m3-motion.timing-standard(block-size, 'medium2')}, + #{m3-motion.timing-standard(transform, 'medium2')}; + + &_under { + background: var(--m3-sys-surface-container); + } + + &_transparent#{&}_under { + background: transparent; + } + + &_hidden { + transform: translateY(-100%); + } + + &__leading, + &__trailing { + display: flex; + min-inline-size: 0; + align-items: center; + } + + &__leading { + grid-column: 1; + grid-row: 1; + margin-inline-start: 4px; + } + + &__trailing { + grid-column: 3; + grid-row: 1; + justify-content: flex-end; + margin-inline-end: 4px; + } + + &__trailing > * { + flex: 0 0 auto; + } + + &__leading .m3-icon-button, + &__trailing .m3-icon-button { + inline-size: 48px; + block-size: 48px; + } + + &__leading .m3-icon-button__content, + &__trailing .m3-icon-button__content { + padding: 12px; + } + + &__leading .m3-icon-button:where(:not(.m3-icon-button_filled, .m3-icon-button_tonal)) { + --content-color: var(--m3-sys-on-surface); + --state-color: var(--m3-sys-on-surface); + --m3-ripple-color: var(--m3-sys-on-surface); + } + + &__title { + display: flex; + min-inline-size: 0; + grid-column: 2; + grid-row: 1; + flex-direction: column; + justify-content: center; + overflow: hidden; + } + + &__headline { + @include m3-typography.title-large; + + margin: 0; + overflow-wrap: normal; + } + + &__image { + display: flex; + min-block-size: 0; + align-items: center; + } + + &_center &__image { + justify-content: center; + } + + &__image img { + display: block; + max-inline-size: 100%; + max-block-size: 40px; + object-fit: contain; + } + + &__subtitle { + @include m3-typography.label-medium; + + margin: 0; + color: var(--m3-sys-on-surface-variant); + } + + &__search { + min-inline-size: 0; + inline-size: min(100%, max(312px, 50%)); + grid-column: 2; + grid-row: 1; + } + + &__search .m3-search { + max-inline-size: none; + } + + &__search .m3-search__bar { + background: var(--m3-sys-surface-container); + } + + &_under &__search .m3-search__bar { + background: var(--m3-sys-surface-container-highest); + } + + &_search { + column-gap: 8px; + } + + &_search &__trailing { + gap: 4px; + } + + &_search#{&}_center .m3-search__trigger { + justify-content: center; + text-align: center; + } + + &_center &__title { + text-align: center; + } + + &_small &__headline { + overflow: hidden; + text-overflow: clip; + white-space: nowrap; + } + + &_small#{&}_center &__title { + justify-self: center; + } + + &_small#{&}_center, + &_collapsed#{&}_center { + grid-template-columns: minmax(104px, 1fr) minmax(0, max-content) minmax(104px, 1fr); + } + + &_medium, + &_medium-flexible, + &_large, + &_large-flexible { + grid-template-rows: 56px minmax(0, 1fr); + } + + &_medium &__title, + &_medium-flexible &__title, + &_large &__title, + &_large-flexible &__title { + grid-column: 1 / -1; + grid-row: 2; + align-self: end; + padding-inline: 16px; + padding-block-end: 12px; + } + + &_medium, + &_medium-flexible { + block-size: 112px; + } + + &_medium &__headline { + @include m3-typography.headline-small; + } + + &_large { + block-size: 152px; + } + + &_large &__headline { + @include m3-typography.headline-medium; + } + + &_medium-flexible &__headline { + @include m3-typography.headline-medium; + } + + &_medium-flexible, + &_large-flexible { + block-size: var(--m3-app-bar-measured-height, auto); + grid-template-rows: 56px minmax(min-content, 1fr); + } + + &_medium-flexible &__title, + &_large-flexible &__title { + overflow: visible; + } + + &_medium-flexible &__subtitle { + @include m3-typography.label-large; + } + + &_medium-flexible#{&}_has-subtitle { + min-block-size: 136px; + } + + &_large-flexible { + min-block-size: 120px; + } + + &_large-flexible &__headline { + @include m3-typography.display-small; + } + + &_large-flexible &__subtitle { + @include m3-typography.title-medium; + } + + &_large-flexible#{&}_has-subtitle { + min-block-size: 152px; + } + + &_medium-flexible { + min-block-size: 112px; + } + + &_medium &__title { padding-block-end: 24px; } + &_large &__title { padding-block-end: 28px; } + + &_medium-flexible &__headline, + &_large-flexible &__headline { + text-wrap: balance; + } + + &_collapsed { + block-size: 64px; + min-block-size: 64px; + grid-template-rows: minmax(0, 1fr); + } + + &_collapsed#{&}_has-subtitle { + min-block-size: 64px; + } + + &_collapsed &__title { + grid-column: 2; + grid-row: 1; + align-self: center; + padding: 0; + } + + &_collapsed &__subtitle { + display: none; + } + + &_collapsed &__headline { + @include m3-typography.title-large; + + display: block; + overflow: hidden; + text-overflow: clip; + white-space: nowrap; + } + + &_collapsed#{&}_center &__title { + justify-self: center; + } + + &_transparent#{&}_under &__leading .m3-icon-button, + &_transparent#{&}_under &__trailing .m3-icon-button { + background: var(--m3-sys-surface-container); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index 26d5d7e..f8b09aa 100644 --- a/m3-foundation/assets/stylesheets/components/index.scss +++ b/m3-foundation/assets/stylesheets/components/index.scss @@ -1,3 +1,4 @@ +@use 'app-bar'; @use 'badge'; @use 'button'; @use 'card'; diff --git a/m3-foundation/lib/appBar.ts b/m3-foundation/lib/appBar.ts new file mode 100644 index 0000000..14e4ced --- /dev/null +++ b/m3-foundation/lib/appBar.ts @@ -0,0 +1,28 @@ +import type { + AppBarAppearance, + AppBarScroll, + AppBarScrollState, +} from '../types/components/app-bar' + +export const getFlexibleAppBarHeight = ( + appearance: AppBarAppearance, + subtitle: boolean, + contentHeight: number +): number => { + const minimum = appearance === 'large-flexible' + ? subtitle ? 152 : 120 + : subtitle ? 136 : 112 + + return Math.max(minimum, 56 + contentHeight) +} + +export const getAppBarScrollState = ( + previous: number, + current: number, + hidden: boolean, + behavior: AppBarScroll +): AppBarScrollState => ({ + under: current > 0, + hidden: behavior === 'hide' && current > 0 && (current > previous + 1 || hidden && current >= previous - 1), + collapsed: behavior === 'collapse' && current > 0, +}) diff --git a/m3-foundation/tests/appBar.test.ts b/m3-foundation/tests/appBar.test.ts new file mode 100644 index 0000000..26fb2ae --- /dev/null +++ b/m3-foundation/tests/appBar.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { getAppBarScrollState, getFlexibleAppBarHeight } from '../lib/appBar' + +describe('getAppBarScrollState', () => { + it('separates scroll-under from hiding', () => { + expect(getAppBarScrollState(0, 20, false, 'pinned')).toEqual({ under: true, hidden: false, collapsed: false }) + expect(getAppBarScrollState(20, 30, false, 'hide')).toEqual({ under: true, hidden: true, collapsed: false }) + expect(getAppBarScrollState(30, 20, true, 'hide')).toEqual({ under: true, hidden: false, collapsed: false }) + expect(getAppBarScrollState(20, 0, true, 'hide')).toEqual({ under: false, hidden: false, collapsed: false }) + }) + + it('collapses flexible bars while the page is away from the top', () => { + expect(getAppBarScrollState(0, 1, false, 'collapse')).toEqual({ under: true, hidden: false, collapsed: true }) + expect(getAppBarScrollState(30, 0, false, 'collapse')).toEqual({ under: false, hidden: false, collapsed: false }) + }) + + it('lets multi-line flexible content grow past its nominal minimum', () => { + expect(getFlexibleAppBarHeight('medium-flexible', false, 48)).toBe(112) + expect(getFlexibleAppBarHeight('medium-flexible', true, 100)).toBe(156) + expect(getFlexibleAppBarHeight('large-flexible', true, 124)).toBe(180) + }) +}) diff --git a/m3-foundation/types/components/app-bar.d.ts b/m3-foundation/types/components/app-bar.d.ts new file mode 100644 index 0000000..cff5760 --- /dev/null +++ b/m3-foundation/types/components/app-bar.d.ts @@ -0,0 +1,16 @@ +export type AppBarAppearance = + | 'search' + | 'small' + | 'medium' + | 'medium-flexible' + | 'large' + | 'large-flexible' + +export type AppBarAlignment = 'start' | 'center' +export type AppBarScroll = 'pinned' | 'hide' | 'collapse' | 'transparent' + +export interface AppBarScrollState { + under: boolean; + hidden: boolean; + collapsed: boolean; +} diff --git a/m3-foundation/vite.config.ts b/m3-foundation/vite.config.ts index 86dfb48..db65dd5 100644 --- a/m3-foundation/vite.config.ts +++ b/m3-foundation/vite.config.ts @@ -7,6 +7,7 @@ import { dependencies } from './package.json' const entries = { 'lib/Scheduler': resolve(__dirname, 'lib/Scheduler.ts'), + 'lib/appBar': resolve(__dirname, 'lib/appBar.ts'), 'lib/calendar': resolve(__dirname, 'lib/calendar/index.ts'), 'lib/dom': resolve(__dirname, 'lib/dom.ts'), 'lib/motion': resolve(__dirname, 'lib/motion/index.ts'), From 9418872ce026061c02e89262be41ba6f373f0c93 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Thu, 8 Oct 2026 14:41:58 +0400 Subject: [PATCH 07/20] feat(m3-react): App bar component and examples were added --- m3-react/src/components/app-bar/M3AppBar.tsx | 146 ++++++++++++++++ m3-react/src/components/app-bar/index.ts | 2 + m3-react/src/components/index.ts | 1 + m3-react/storybook/components/M3AppBar.mdx | 156 ++++++++++++++++++ .../storybook/components/M3AppBar.stories.tsx | 44 +++++ .../examples/app-bar/AppBarExample.tsx | 108 ++++++++++++ m3-react/tests/M3AppBar.e2e.tsx | 85 ++++++++++ m3-react/tests/M3AppBar.test.tsx | 79 +++++++++ 8 files changed, 621 insertions(+) create mode 100644 m3-react/src/components/app-bar/M3AppBar.tsx create mode 100644 m3-react/src/components/app-bar/index.ts create mode 100644 m3-react/storybook/components/M3AppBar.mdx create mode 100644 m3-react/storybook/components/M3AppBar.stories.tsx create mode 100644 m3-react/storybook/examples/app-bar/AppBarExample.tsx create mode 100644 m3-react/tests/M3AppBar.e2e.tsx create mode 100644 m3-react/tests/M3AppBar.test.tsx diff --git a/m3-react/src/components/app-bar/M3AppBar.tsx b/m3-react/src/components/app-bar/M3AppBar.tsx new file mode 100644 index 0000000..ed743cf --- /dev/null +++ b/m3-react/src/components/app-bar/M3AppBar.tsx @@ -0,0 +1,146 @@ +import type { + AppBarAlignment, + AppBarAppearance, + AppBarScroll, + AppBarScrollState, +} from '@modulify/m3-foundation/types/components/app-bar' +import type { ComponentSetupContext } from '@/utils/component' +import type { ElementReference } from '@modulify/m3-foundation/types/dom' +import type { HTMLAttributes, ReactNode, Ref } from 'react' + +import { + getAppBarScrollState, + getFlexibleAppBarHeight, +} from '@modulify/m3-foundation/lib/appBar' +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' + +import defineComponent from '@/utils/component' +import { defineSlot, distinct } from '@/utils/content' +import { toClassName } from '@/utils/styling' +import { useElementReference, useResizeObserver } from '@/hooks' + +export interface M3AppBarProps extends HTMLAttributes { + ref?: Ref; + appearance?: AppBarAppearance; + alignment?: AppBarAlignment; + scroll?: AppBarScroll; + scrollTarget?: HTMLElement | Window | null; + headline?: ReactNode; + subtitle?: ReactNode; + headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; + tag?: 'header' | 'div'; +} + +export interface M3AppBarExposed extends ElementReference {} + +const Leading = defineSlot('M3AppBar.Leading') +const Headline = defineSlot('M3AppBar.Headline') +const Image = defineSlot('M3AppBar.Image') +const Subtitle = defineSlot('M3AppBar.Subtitle') +const Trailing = defineSlot('M3AppBar.Trailing') +const Search = defineSlot('M3AppBar.Search') + +const EMPTY_SCROLL_STATE: AppBarScrollState = { under: false, hidden: false, collapsed: false } + +export default defineComponent(function M3AppBar({ + ref: _ref, + appearance = 'small', + alignment = 'start', + scroll = 'pinned', + scrollTarget, + headline, + subtitle, + headingLevel = 1, + tag: Tag = 'header', + className = '', + children = [], + ...attrs +}: M3AppBarProps, { expose }: ComponentSetupContext) { + const root = useRef(null) + const title = useRef(null) + const [scrollState, setScrollState] = useState(EMPTY_SCROLL_STATE) + const [slots, content] = useMemo(() => distinct(children, { + leading: Leading, + headline: Headline, + image: Image, + subtitle: Subtitle, + trailing: Trailing, + search: Search, + }), [children]) + + expose(useElementReference(root)) + + useEffect(() => { + const target = scrollTarget ?? window + const position = () => target === window ? window.scrollY : (target as HTMLElement).scrollTop + const controller = new AbortController() + let previous = position() + + const update = () => { + const current = position() + setScrollState(state => { + const next = getAppBarScrollState(previous, current, state.hidden, scroll) + return state.under === next.under && state.hidden === next.hidden && state.collapsed === next.collapsed + ? state + : next + }) + previous = current + } + + update() + target.addEventListener('scroll', update, { passive: true, signal: controller.signal }) + return () => controller.abort('App bar scroll listener disposed') + }, [scroll, scrollTarget]) + + const isFlexible = appearance === 'medium-flexible' || appearance === 'large-flexible' + const isCollapsed = isFlexible && scrollState.collapsed + const heading = slots.headline ?? headline ?? content + const supporting = slots.subtitle ?? subtitle + const hasSubtitle = appearance !== 'search' && appearance !== 'medium' && appearance !== 'large' + && supporting !== undefined && supporting !== null + const HeadingTag = `h${headingLevel}` as const + + const syncExpandedHeight = () => { + if (!isFlexible || isCollapsed || !root.current || !title.current) return + const height = getFlexibleAppBarHeight(appearance, hasSubtitle, title.current.scrollHeight) + root.current.style.setProperty('--m3-app-bar-measured-height', `${height}px`) + } + + useResizeObserver(title, syncExpandedHeight) + useLayoutEffect(syncExpandedHeight, [appearance, hasSubtitle, isCollapsed]) + + return ( + { root.current = element }} + className={toClassName([className, { + 'm3-app-bar': true, + [`m3-app-bar_${appearance}`]: true, + [`m3-app-bar_${alignment}`]: true, + 'm3-app-bar_has-subtitle': hasSubtitle, + 'm3-app-bar_under': scrollState.under, + 'm3-app-bar_hidden': scrollState.hidden, + 'm3-app-bar_collapsed': isCollapsed, + 'm3-app-bar_transparent': scroll === 'transparent', + }])} + {...attrs} + > + {slots.leading &&
{slots.leading}
} + {appearance === 'search' + ?
{slots.search ?? content}
+ :
+ {isFlexible && !isCollapsed && slots.image &&
{slots.image}
} + {heading != null && {heading}} + {hasSubtitle &&
{supporting}
} +
} + {slots.trailing &&
{slots.trailing}
} +
+ ) +}, { + slots: { Leading, Headline, Image, Subtitle, Trailing, Search }, +}) diff --git a/m3-react/src/components/app-bar/index.ts b/m3-react/src/components/app-bar/index.ts new file mode 100644 index 0000000..aa1a4c8 --- /dev/null +++ b/m3-react/src/components/app-bar/index.ts @@ -0,0 +1,2 @@ +export { default as M3AppBar } from './M3AppBar' +export type { M3AppBarExposed, M3AppBarProps } from './M3AppBar' diff --git a/m3-react/src/components/index.ts b/m3-react/src/components/index.ts index 58419af..05e3616 100644 --- a/m3-react/src/components/index.ts +++ b/m3-react/src/components/index.ts @@ -1,4 +1,5 @@ export * from './adaptive' +export * from './app-bar' export * from './badge' export * from './button' export * from './card' diff --git a/m3-react/storybook/components/M3AppBar.mdx b/m3-react/storybook/components/M3AppBar.mdx new file mode 100644 index 0000000..0c9d130 --- /dev/null +++ b/m3-react/storybook/components/M3AppBar.mdx @@ -0,0 +1,156 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import { Localized } from '../i18n' +import * as M3AppBarStories from './M3AppBar.stories' + + + + + +# App bar + +An app bar identifies the current page and provides navigation and one or two essential actions. This library implements all six variants documented in the current [Material App bars specification](https://m3.material.io/components/app-bars/specs). The current Material 3 Expressive set and the older baseline set have different recommendations: + +| Appearance | Material 3 Expressive | Use | +| --- | --- | --- | +| `search` | Yes | A prominent search entry point on a home page. | +| `small` | Yes | Dense pages and the collapsed state of a flexible bar. Centered text is an alignment option, not a separate variant. | +| `medium-flexible` | Yes | A larger title, optional subtitle, and optional collapse on scroll. | +| `large-flexible` | Yes | The strongest title emphasis, optional subtitle, and optional collapse on scroll. | +| `medium` | **No: baseline M3** | Existing designs that still need the former medium geometry. Material recommends medium flexible for new Expressive designs. | +| `large` | **No: baseline M3** | Existing designs that still need the former large geometry. Material recommends large flexible for new Expressive designs. | + +## Guide + +### Content and actions + +Keep the headline brief and specific to the page. `small` keeps it on one line; flexible variants can grow with a second line and must not be deliberately truncated. If a translated headline cannot fit, revise the copy or select a flexible appearance. Subtitles belong to small and flexible variants. For a logo or custom headline, use the named headline child. Flexible bars can also show an image above the title through `M3AppBar.Image`; it disappears in the collapsed state. Provide a real page heading; adjust `headingLevel` if the page already contains an earlier heading. Center alignment moves only the headline and subtitle; leading and trailing actions stay at the app bar edges. + +Pass a leading Back or Menu button in `M3AppBar.Leading`, and up to two page actions in `M3AppBar.Trailing`. Name icon buttons by their action. A Menu button can control an external `M3Navigation` with `appearance="rail"`, `collapse="hidden"`, and `expansion="modal"`; the app bar does not own the navigation state. Use one filled or tonal action if it needs extra emphasis. Put larger action collections in a toolbar. [Material guidelines](https://m3.material.io/components/app-bars/guidelines). + +### Search and scrolling + +For `appearance="search"`, put the existing `M3Search` in `M3AppBar.Search` with `layout="full-screen"`. Search keeps ownership of its query, expanded view, focus, and results. The app bar positions its collapsed trigger and adapts its width: it fills the available space up to 312 px, then grows at 50% of that space. The hint should include “Search.” A logo in this variant should not open the expanded rail. + +`scroll="pinned"` keeps the bar visible and changes its container from surface to surface-container after scrolling. `hide` hides on forward scroll and reveals on reverse scroll. `collapse` reduces a flexible bar to the small layout until the scroll position returns to the top. `transparent` keeps controls visible over content, giving icon buttons their own filled surfaces. Pass `scrollTarget` when an element rather than `window` scrolls; use that same element to contain the sticky bar. A scroll mode is independent of the selected appearance, but `collapse` changes layout only for flexible appearances. The motion respects reduced-motion preferences. + +The app bar occupies the width of its containing layout. Place it in the page shell beside a rail, and provide the surrounding layout in the application. Use `tag="div"` if the page already has a banner landmark; a body-level `
` normally creates one. [WAI banner guidance](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/banner.html). + +## API + +| Export | Purpose | +| --- | --- | +| `M3AppBar` | Six app bar appearances, content zones, and scroll response. | +| `M3AppBarProps`, `M3AppBarExposed` | Public props and element reference. | + +| Prop | Default | Effect | +| --- | --- | --- | +| `appearance` | `small` | `search`, `small`, `medium`, `medium-flexible`, `large`, `large-flexible`. | +| `alignment` | `start` | `start` or `center` for the headline/search hint. Centered baseline medium/large is outside the Expressive recommendation. | +| `headline`, `subtitle` | — | Title content; subtitle is rendered for small/flexible appearances. Named children override these props. | +| `headingLevel` | `1` | Native heading level from 1 to 6. | +| `scroll` | `pinned` | `pinned`, `hide`, `collapse`, or `transparent`. | +| `scrollTarget` | `window` | Scrollable `HTMLElement` or `Window` to observe. | +| `tag` | `header` | Root `header` or `div` to fit page landmark structure. | +| HTML attributes | — | Passed to the root element. | + +| Named child | Purpose | +| --- | --- | +| `M3AppBar.Leading` | Back, Menu, or appropriate leading control. | +| `M3AppBar.Headline` | Custom headline content inside the semantic heading. | +| `M3AppBar.Image` | Optional image above a flexible headline; hidden while collapsed. | +| `M3AppBar.Subtitle` | Supporting text for small/flexible variants. | +| `M3AppBar.Trailing` | Page actions, ordered in the supplied DOM sequence. | +| `M3AppBar.Search` | Search trigger and view owner for the search variant. | + +The ref exposes `el`. Unslotted children act as the headline for ordinary appearances and as search content for `search`. + +```tsx + + + setNavigationOpen(true)}> + + + + + + + +``` + +## Sources + +- [Material App bars overview](https://m3.material.io/components/app-bars/overview) +- [Specs](https://m3.material.io/components/app-bars/specs) +- [Guidelines](https://m3.material.io/components/app-bars/guidelines) +- [Accessibility](https://m3.material.io/components/app-bars/accessibility) + + + + + +# Верхняя панель приложения + +App bar показывает, какая страница открыта, и содержит навигацию и одно-два важнейших действия. Библиотека реализует все шесть вариантов из текущей [спецификации Material](https://m3.material.io/components/app-bars/specs). К актуальному Material 3 Expressive относятся не все: + +| Вид | Входит в M3 Expressive | Применение | +| --- | --- | --- | +| `search` | Да | Заметная точка входа в поиск на главной странице. | +| `small` | Да | Плотная компоновка и свёрнутое состояние flexible. Центрирование заголовка — настройка, а не отдельный вид. | +| `medium-flexible` | Да | Более крупный заголовок, подзаголовок и возможное сворачивание при прокрутке. | +| `large-flexible` | Да | Максимальный акцент на заголовке, подзаголовок и возможное сворачивание. | +| `medium` | **Нет: базовый M3** | Совместимость с прежней геометрией; для нового Expressive интерфейса Material рекомендует medium flexible. | +| `large` | **Нет: базовый M3** | Совместимость с прежней геометрией; для нового Expressive интерфейса Material рекомендует large flexible. | + +## Руководство + +### Содержимое и действия + +Заголовок должен коротко описывать текущую страницу. В `small` он однострочный; flexible может вырасти для второй строки без намеренного усечения. Если перевод не помещается, пересмотрите формулировку или выберите flexible вид. Подзаголовок поддерживается в small и flexible. Для особой разметки заголовка используйте `M3AppBar.Headline`. Изображение над заголовком flexible передайте через `M3AppBar.Image`; при сворачивании оно скрывается. Сам заголовок остаётся настоящим heading; `headingLevel` позволяет согласовать его с иерархией страницы. При центрировании смещаются только заголовок и подзаголовок: ведущие и завершающие действия остаются у краёв панели. + +В `M3AppBar.Leading` разместите Back или Menu, в `M3AppBar.Trailing` — максимум два действия страницы. Дайте иконочным кнопкам названия по их действию. Menu может управлять внешним `M3Navigation` с `appearance="rail"`, `collapse="hidden"`, `expansion="modal"`; app bar не владеет состоянием навигации. Для акцента используйте одну filled/tonal кнопку. Большое число действий вынесите в toolbar. [Рекомендации Material](https://m3.material.io/components/app-bars/guidelines). + +### Поиск и прокрутка + +Для `appearance="search"` передайте существующий `M3Search` в `M3AppBar.Search` с `layout="full-screen"`. Search управляет запросом, раскрытым видом, фокусом и результатами; app bar только размещает свёрнутую строку. До ширины 312 px она занимает всё доступное место, затем растёт как 50% доступного места. В подсказке должно быть слово «Поиск». Логотип в этом виде не должен открывать раскрытый rail. + +`scroll="pinned"` оставляет панель на экране и после прокрутки меняет `surface` на `surface-container`. `hide` скрывает её при прокрутке вперёд и возвращает при обратной. `collapse` сворачивает flexible в small до возвращения страницы к началу. `transparent` оставляет действия поверх контента на собственных заполненных поверхностях. Если прокручивается элемент, а не `window`, передайте его через `scrollTarget` и поместите липкую панель в тот же контейнер. Режим `collapse` меняет layout только у flexible видов. При ограничении движения анимация отключается. + +Панель занимает ширину своего контейнера. Приложение размещает её в компоновке страницы рядом с rail. Если в документе уже есть landmark `banner`, используйте `tag="div"`: `
` на уровне страницы обычно создаёт такой landmark. [WAI о banner](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/banner.html). + +## API + +| Экспорт | Назначение | +| --- | --- | +| `M3AppBar` | Шесть вариантов панели, области содержимого и реакция на прокрутку. | +| `M3AppBarProps`, `M3AppBarExposed` | Типы props и ссылки на элемент. | + +| Prop | Значение по умолчанию | Действие | +| --- | --- | --- | +| `appearance` | `small` | `search`, `small`, `medium`, `medium-flexible`, `large`, `large-flexible`. | +| `alignment` | `start` | `start` или `center` для заголовка/подсказки. Центрирование baseline medium/large вне рекомендации Expressive. | +| `headline`, `subtitle` | — | Текст заголовка и подзаголовка; последний рисуется только в small/flexible. Именованные элементы имеют приоритет. | +| `headingLevel` | `1` | Уровень настоящего заголовка от 1 до 6. | +| `scroll` | `pinned` | `pinned`, `hide`, `collapse` или `transparent`. | +| `scrollTarget` | `window` | Прокручиваемый `HTMLElement` или `Window`. | +| `tag` | `header` | Корневой `header` или `div` для структуры landmarks страницы. | +| HTML-атрибуты | — | Передаются корневому элементу. | + +| Именованный элемент | Назначение | +| --- | --- | +| `M3AppBar.Leading` | Back, Menu или другое уместное ведущее действие. | +| `M3AppBar.Headline` | Своя разметка внутри семантического заголовка. | +| `M3AppBar.Image` | Изображение над заголовком flexible, скрываемое при сворачивании. | +| `M3AppBar.Subtitle` | Контекст для small/flexible. | +| `M3AppBar.Trailing` | Действия страницы в заданном порядке DOM. | +| `M3AppBar.Search` | Поисковая строка и управляющий Search. | + +Ref предоставляет `el`. Дочерние элементы без имени становятся заголовком обычного варианта или содержимым поисковой области для `search`. + +## Источники + +- [Обзор Material App bars](https://m3.material.io/components/app-bars/overview) +- [Спецификация](https://m3.material.io/components/app-bars/specs) +- [Рекомендации](https://m3.material.io/components/app-bars/guidelines) +- [Доступность](https://m3.material.io/components/app-bars/accessibility) + + diff --git a/m3-react/storybook/components/M3AppBar.stories.tsx b/m3-react/storybook/components/M3AppBar.stories.tsx new file mode 100644 index 0000000..98233f3 --- /dev/null +++ b/m3-react/storybook/components/M3AppBar.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { M3AppBar } from '@/components/app-bar' + +import AppBarExample from '../examples/app-bar/AppBarExample' + +const meta = { + title: 'Components/M3AppBar', + component: M3AppBar, + args: { appearance: 'small', alignment: 'start', scroll: 'pinned' }, + argTypes: { + appearance: { control: 'select', options: ['search', 'small', 'medium', 'medium-flexible', 'large', 'large-flexible'] }, + alignment: { control: 'select', options: ['start', 'center'] }, + scroll: { control: 'select', options: ['pinned', 'hide', 'collapse', 'transparent'] }, + scrollTarget: { control: false }, + headline: { control: false }, + subtitle: { control: false }, + headingLevel: { control: false }, + }, + render: (args, { globals }) => ( + + ), + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Search: Story = { args: { appearance: 'search' } } +export const Small: Story = {} +export const SmallCentered: Story = { args: { alignment: 'center' } } +export const Medium: Story = { args: { appearance: 'medium' } } +export const MediumFlexible: Story = { args: { appearance: 'medium-flexible', scroll: 'collapse' } } +export const Large: Story = { args: { appearance: 'large' } } +export const LargeFlexible: Story = { args: { appearance: 'large-flexible', scroll: 'collapse' } } +export const HideOnScroll: Story = { args: { scroll: 'hide' } } +export const TransparentOnScroll: Story = { args: { scroll: 'transparent' } } diff --git a/m3-react/storybook/examples/app-bar/AppBarExample.tsx b/m3-react/storybook/examples/app-bar/AppBarExample.tsx new file mode 100644 index 0000000..1b7c306 --- /dev/null +++ b/m3-react/storybook/examples/app-bar/AppBarExample.tsx @@ -0,0 +1,108 @@ +import type { + AppBarAppearance, + AppBarScroll, +} from '@modulify/m3-foundation/types/components/app-bar' + +import { useState } from 'react' + +import { M3AppBar } from '@/components/app-bar' +import { M3Icon } from '@/components/icon' +import { M3IconButton } from '@/components/icon-button' +import { M3Navigation, M3NavigationTab } from '@/components/navigation' +import { M3Search } from '@/components/search' + +import { localize } from '../../i18n' + +const messages = { + 'en-US': { + title: 'Your library', subtitle: 'Recently added', search: 'Search your library', menu: 'Open navigation', + back: 'Back to library', close: 'Clear search', favorite: 'Favorites', notifications: 'Notifications', + home: 'Home', library: 'Library', settings: 'Settings', section: 'Recent items', + cards: ['Reading list', 'Travel ideas', 'Project notes', 'Saved links', 'Garden plans', 'Music for later'], + }, + 'ru-RU': { + title: 'Библиотека', subtitle: 'Недавно добавлено', search: 'Поиск в библиотеке', menu: 'Открыть навигацию', + back: 'Назад к библиотеке', close: 'Очистить поиск', favorite: 'Избранное', notifications: 'Уведомления', + home: 'Главная', library: 'Библиотека', settings: 'Настройки', section: 'Недавние записи', + cards: ['Список для чтения', 'Идеи поездок', 'Заметки проекта', 'Сохранённые ссылки', 'Планы сада', 'Музыка на потом'], + }, +} + +interface AppBarExampleProps { + appearance: AppBarAppearance; + scroll: AppBarScroll; + centered?: boolean; + subtitle?: boolean; + locale: string; +} + +export default function AppBarExample({ appearance, scroll, centered = false, subtitle = false, locale }: AppBarExampleProps) { + const text = localize(locale, messages) + const [scroller, setScroller] = useState(null) + const [navigationOpen, setNavigationOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [query, setQuery] = useState('') + const results = text.cards.filter(card => card.toLocaleLowerCase(locale).includes(query.toLocaleLowerCase(locale))) + + return ( +
+ + + setNavigationOpen(true)}> + + {appearance === 'search' && + + +
{results.join(' · ')}
+
+
+
} + + + + +
+ + + + + + + +
+

{text.section}

+
+ {text.cards.concat(text.cards, text.cards).map((card, index) => ( +
+ {card} +
+ ))} +
+
+
+ ) +} diff --git a/m3-react/tests/M3AppBar.e2e.tsx b/m3-react/tests/M3AppBar.e2e.tsx new file mode 100644 index 0000000..9eac0bd --- /dev/null +++ b/m3-react/tests/M3AppBar.e2e.tsx @@ -0,0 +1,85 @@ +import { act } from '@testing-library/react' +import { page } from 'vitest/browser' +import { render, waitFor } from '@testing-library/react' + +import { M3AppBar } from '@/components/app-bar' +import { M3IconButton } from '@/components/icon-button' + +test('renders the six documented heights and grows flexible content without truncation', async () => { + await page.viewport(900, 800) + + const cases = [ + ['search', 64], ['small', 64], ['medium', 112], + ['medium-flexible', 112], ['large', 152], ['large-flexible', 120], + ] as const + + for (const [appearance, height] of cases) { + const view = render() + expect(view.container.querySelector('.m3-app-bar')!.getBoundingClientRect().height).toBe(height) + view.unmount() + } + + const view = render() + try { + await page.viewport(360, 800) + const bar = view.container.querySelector('.m3-app-bar')! + const headline = view.container.querySelector('.m3-app-bar__headline')! + expect(bar.getBoundingClientRect().height).toBeGreaterThanOrEqual(152) + expect(headline.scrollHeight).toBeLessThanOrEqual(headline.clientHeight) + } finally { + view.unmount() + } +}) + +test.each(['ltr', 'rtl'] as const)('centers a small title while keeping actions at the %s edges', direction => { + const view = render( + ← + + ★ + ⚙ + + ) + + try { + const bar = view.container.querySelector('.m3-app-bar')!.getBoundingClientRect() + const title = view.container.querySelector('.m3-app-bar__title')!.getBoundingClientRect() + const leading = view.container.querySelector('.m3-app-bar__leading .m3-icon-button')!.getBoundingClientRect() + const trailing = view.container.querySelector('.m3-app-bar__trailing .m3-icon-button:last-child')!.getBoundingClientRect() + + expect(Math.abs((title.left + title.right) / 2 - (bar.left + bar.right) / 2)).toBeLessThan(1) + expect(direction === 'ltr' ? leading.left - bar.left : bar.right - leading.right).toBe(4) + expect(direction === 'ltr' ? bar.right - trailing.right : trailing.left - bar.left).toBe(4) + expect(leading.width).toBe(48) + expect(trailing.width).toBe(48) + } finally { + view.unmount() + } +}) + +test('collapses a flexible bar against its own scroll container and expands at the top', async () => { + await page.viewport(900, 800) + const scroller = document.createElement('main') + Object.assign(scroller.style, { height: '300px', overflowY: 'auto' }) + document.body.append(scroller) + const view = render(<> + +
+ , { container: scroller }) + + try { + const bar = scroller.querySelector('.m3-app-bar')! + expect(bar.getBoundingClientRect().height).toBe(136) + const transitions = new Set() + bar.addEventListener('transitionrun', event => transitions.add(event.propertyName)) + await act(async () => { scroller.scrollTop = 100 }) + await waitFor(() => expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(true)) + await waitFor(() => expect(transitions.has('height')).toBe(true)) + await waitFor(() => expect(bar.getBoundingClientRect().height).toBe(64)) + await act(async () => { scroller.scrollTop = 0 }) + await waitFor(() => expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(false)) + await waitFor(() => expect(bar.getBoundingClientRect().height).toBe(136)) + } finally { + view.unmount() + scroller.remove() + } +}) diff --git a/m3-react/tests/M3AppBar.test.tsx b/m3-react/tests/M3AppBar.test.tsx new file mode 100644 index 0000000..74e9383 --- /dev/null +++ b/m3-react/tests/M3AppBar.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' + +import { M3AppBar } from '@/components/app-bar' +import { M3Search } from '@/components/search' + +const appearances = ['search', 'small', 'medium', 'medium-flexible', 'large', 'large-flexible'] as const + +describe('m3-react/app-bar', () => { + test.each(appearances)('renders %s with one appropriate title', appearance => { + const view = render( + + + + + ) + + const bar = view.container.querySelector('.m3-app-bar')! + expect(bar.classList.contains(`m3-app-bar_${appearance}`)).toBe(true) + expect(screen.getByRole('button', { name: 'Menu' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'Favorite' })).not.toBeNull() + expect(screen.queryByRole('heading', { name: 'Library' }) !== null).toBe(appearance !== 'search') + expect(view.container.querySelector('.m3-app-bar__subtitle') !== null).toBe( + appearance === 'small' || appearance.includes('flexible') + ) + }) + + test('tracks a supplied scroll root and re-expands flexible content at the top', () => { + const scroller = document.createElement('div') + const view = render() + const bar = view.container.querySelector('.m3-app-bar')! + + scroller.scrollTop = 40 + fireEvent.scroll(scroller) + expect(bar.classList.contains('m3-app-bar_under')).toBe(true) + expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(true) + + scroller.scrollTop = 0 + fireEvent.scroll(scroller) + expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(false) + expect(bar.classList.contains('m3-app-bar_under')).toBe(false) + }) + + test('shows a flexible image until the bar collapses', () => { + const scroller = document.createElement('div') + const view = render( + + ) + + expect(view.container.querySelector('.m3-app-bar__image img')).not.toBeNull() + scroller.scrollTop = 40 + fireEvent.scroll(scroller) + expect(view.container.querySelector('.m3-app-bar__image')).toBeNull() + }) + + test('keeps one search view owner when composed with M3Search', () => { + const Example = () => { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + return + + + + + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Search library' })) + expect(screen.getAllByRole('dialog', { name: 'Search library' })).toHaveLength(1) + }) +}) From 3b2514e7dbd761e0ba31cd358397b43e60f9f543 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Thu, 8 Oct 2026 14:42:12 +0400 Subject: [PATCH 08/20] feat(m3-vue): App bar component and examples were added Closes #26 --- m3-vue/src/components/app-bar/M3AppBar.vue | 181 ++++++++++++++++++ m3-vue/src/components/app-bar/index.ts | 3 + m3-vue/src/components/index.ts | 1 + m3-vue/storybook/components/M3AppBar.mdx | 156 +++++++++++++++ .../storybook/components/M3AppBar.stories.ts | 46 +++++ .../examples/app-bar/AppBarExample.vue | 156 +++++++++++++++ m3-vue/tests/M3AppBar.e2e.ts | 119 ++++++++++++ m3-vue/tests/M3AppBar.test.ts | 89 +++++++++ 8 files changed, 751 insertions(+) create mode 100644 m3-vue/src/components/app-bar/M3AppBar.vue create mode 100644 m3-vue/src/components/app-bar/index.ts create mode 100644 m3-vue/storybook/components/M3AppBar.mdx create mode 100644 m3-vue/storybook/components/M3AppBar.stories.ts create mode 100644 m3-vue/storybook/examples/app-bar/AppBarExample.vue create mode 100644 m3-vue/tests/M3AppBar.e2e.ts create mode 100644 m3-vue/tests/M3AppBar.test.ts diff --git a/m3-vue/src/components/app-bar/M3AppBar.vue b/m3-vue/src/components/app-bar/M3AppBar.vue new file mode 100644 index 0000000..6768ae9 --- /dev/null +++ b/m3-vue/src/components/app-bar/M3AppBar.vue @@ -0,0 +1,181 @@ + + + diff --git a/m3-vue/src/components/app-bar/index.ts b/m3-vue/src/components/app-bar/index.ts new file mode 100644 index 0000000..c22655f --- /dev/null +++ b/m3-vue/src/components/app-bar/index.ts @@ -0,0 +1,3 @@ +import M3AppBar from './M3AppBar.vue' + +export { M3AppBar } diff --git a/m3-vue/src/components/index.ts b/m3-vue/src/components/index.ts index de0b141..6bf8f3e 100644 --- a/m3-vue/src/components/index.ts +++ b/m3-vue/src/components/index.ts @@ -1,4 +1,5 @@ export * from './adaptive' +export * from './app-bar' export * from './badge' export * from './button' export * from './card' diff --git a/m3-vue/storybook/components/M3AppBar.mdx b/m3-vue/storybook/components/M3AppBar.mdx new file mode 100644 index 0000000..b613ee6 --- /dev/null +++ b/m3-vue/storybook/components/M3AppBar.mdx @@ -0,0 +1,156 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import { Localized } from '../i18n' +import * as M3AppBarStories from './M3AppBar.stories' + + + + + +# App bar + +An app bar identifies the current page and provides navigation and one or two essential actions. This library implements all six variants documented in the current [Material App bars specification](https://m3.material.io/components/app-bars/specs). The current Material 3 Expressive set and the older baseline set have different recommendations: + +| Appearance | Material 3 Expressive | Use | +| --- | --- | --- | +| `search` | Yes | A prominent search entry point on a home page. | +| `small` | Yes | Dense pages and the collapsed state of a flexible bar. Centered text is an alignment option, not a separate variant. | +| `medium-flexible` | Yes | A larger title, optional subtitle, and optional collapse on scroll. | +| `large-flexible` | Yes | The strongest title emphasis, optional subtitle, and optional collapse on scroll. | +| `medium` | **No: baseline M3** | Existing designs that still need the former medium geometry. Material recommends medium flexible for new Expressive designs. | +| `large` | **No: baseline M3** | Existing designs that still need the former large geometry. Material recommends large flexible for new Expressive designs. | + +## Guide + +### Content and actions + +Keep the headline brief and specific to the page. `small` keeps it on one line; flexible variants can grow with a second line and must not be deliberately truncated. If a translated headline cannot fit, revise the copy or select a flexible appearance. Subtitles belong to small and flexible variants. For a logo or custom headline, use the `#headline` slot. Flexible bars can also show an image above the title through `#image`; it disappears in the collapsed state. Provide a real page heading; adjust `headingLevel` if the page already contains an earlier heading. Center alignment moves only the headline and subtitle; leading and trailing actions stay at the app bar edges. + +Pass a leading Back or Menu button in `#leading`, and up to two page actions in `#trailing`. Name icon buttons by their action. A Menu button can control an external `M3Navigation` with `appearance="rail"`, `collapse="hidden"`, and `expansion="modal"`; the app bar does not own the navigation state. Use one filled or tonal action if it needs extra emphasis. Put larger action collections in a toolbar. [Material guidelines](https://m3.material.io/components/app-bars/guidelines). + +### Search and scrolling + +For `appearance="search"`, put the existing `M3Search` in `#search` with `layout="full-screen"`. Search keeps ownership of its query, expanded view, focus, and results. The app bar positions its collapsed trigger and adapts its width: it fills the available space up to 312 px, then grows at 50% of that space. The hint should include “Search.” A logo in this variant should not open the expanded rail. + +`scroll="pinned"` keeps the bar visible and changes its container from surface to surface-container after scrolling. `hide` hides on forward scroll and reveals on reverse scroll. `collapse` reduces a flexible bar to the small layout until the scroll position returns to the top. `transparent` keeps controls visible over content, giving icon buttons their own filled surfaces. Pass `scrollTarget` when an element rather than `window` scrolls; use that same element to contain the sticky bar. A scroll mode is independent of the selected appearance, but `collapse` changes layout only for flexible appearances. The motion respects reduced-motion preferences. + +The app bar occupies the width of its containing layout. Place it in the page shell beside a rail, and provide the surrounding layout in the application. Use `tag="div"` if the page already has a banner landmark; a body-level `
` normally creates one. [WAI banner guidance](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/banner.html). + +## API + +| Export | Purpose | +| --- | --- | +| `M3AppBar` | Six app bar appearances, content zones, and scroll response. | +| Vue props and exposed `el` | Public controls and element reference. | + +| Prop | Default | Effect | +| --- | --- | --- | +| `appearance` | `small` | `search`, `small`, `medium`, `medium-flexible`, `large`, `large-flexible`. | +| `alignment` | `start` | `start` or `center` for the headline/search hint. Centered baseline medium/large is outside the Expressive recommendation. | +| `headline`, `subtitle` | — | Title content; subtitle is rendered for small/flexible appearances. Named children override these props. | +| `headingLevel` | `1` | Native heading level from 1 to 6. | +| `scroll` | `pinned` | `pinned`, `hide`, `collapse`, or `transparent`. | +| `scrollTarget` | `window` | Scrollable `HTMLElement` or `Window` to observe. | +| `tag` | `header` | Root `header` or `div` to fit page landmark structure. | +| HTML attributes | — | Passed to the root element. | + +| Named child | Purpose | +| --- | --- | +| `#leading` | Back, Menu, or appropriate leading control. | +| `#headline` | Custom headline content inside the semantic heading. | +| `#image` | Optional image above a flexible headline; hidden while collapsed. | +| `#subtitle` | Supporting text for small/flexible variants. | +| `#trailing` | Page actions, ordered in the supplied DOM sequence. | +| `#search` | Search trigger and view owner for the search variant. | + +The ref exposes `el`. Unslotted children act as the headline for ordinary appearances and as search content for `search`. + +```vue + + + + +``` + +## Sources + +- [Material App bars overview](https://m3.material.io/components/app-bars/overview) +- [Specs](https://m3.material.io/components/app-bars/specs) +- [Guidelines](https://m3.material.io/components/app-bars/guidelines) +- [Accessibility](https://m3.material.io/components/app-bars/accessibility) + + + + + +# Верхняя панель приложения + +App bar показывает, какая страница открыта, и содержит навигацию и одно-два важнейших действия. Библиотека реализует все шесть вариантов из текущей [спецификации Material](https://m3.material.io/components/app-bars/specs). К актуальному Material 3 Expressive относятся не все: + +| Вид | Входит в M3 Expressive | Применение | +| --- | --- | --- | +| `search` | Да | Заметная точка входа в поиск на главной странице. | +| `small` | Да | Плотная компоновка и свёрнутое состояние flexible. Центрирование заголовка — настройка, а не отдельный вид. | +| `medium-flexible` | Да | Более крупный заголовок, подзаголовок и возможное сворачивание при прокрутке. | +| `large-flexible` | Да | Максимальный акцент на заголовке, подзаголовок и возможное сворачивание. | +| `medium` | **Нет: базовый M3** | Совместимость с прежней геометрией; для нового Expressive интерфейса Material рекомендует medium flexible. | +| `large` | **Нет: базовый M3** | Совместимость с прежней геометрией; для нового Expressive интерфейса Material рекомендует large flexible. | + +## Руководство + +### Содержимое и действия + +Заголовок должен коротко описывать текущую страницу. В `small` он однострочный; flexible может вырасти для второй строки без намеренного усечения. Если перевод не помещается, пересмотрите формулировку или выберите flexible вид. Подзаголовок поддерживается в small и flexible. Для особой разметки заголовка используйте `#headline`. Изображение над заголовком flexible передайте через `#image`; при сворачивании оно скрывается. Сам заголовок остаётся настоящим heading; `headingLevel` позволяет согласовать его с иерархией страницы. При центрировании смещаются только заголовок и подзаголовок: ведущие и завершающие действия остаются у краёв панели. + +В `#leading` разместите Back или Menu, в `#trailing` — максимум два действия страницы. Дайте иконочным кнопкам названия по их действию. Menu может управлять внешним `M3Navigation` с `appearance="rail"`, `collapse="hidden"`, `expansion="modal"`; app bar не владеет состоянием навигации. Для акцента используйте одну filled/tonal кнопку. Большое число действий вынесите в toolbar. [Рекомендации Material](https://m3.material.io/components/app-bars/guidelines). + +### Поиск и прокрутка + +Для `appearance="search"` передайте существующий `M3Search` в `#search` с `layout="full-screen"`. Search управляет запросом, раскрытым видом, фокусом и результатами; app bar только размещает свёрнутую строку. До ширины 312 px она занимает всё доступное место, затем растёт как 50% доступного места. В подсказке должно быть слово «Поиск». Логотип в этом виде не должен открывать раскрытый rail. + +`scroll="pinned"` оставляет панель на экране и после прокрутки меняет `surface` на `surface-container`. `hide` скрывает её при прокрутке вперёд и возвращает при обратной. `collapse` сворачивает flexible в small до возвращения страницы к началу. `transparent` оставляет действия поверх контента на собственных заполненных поверхностях. Если прокручивается элемент, а не `window`, передайте его через `scrollTarget` и поместите липкую панель в тот же контейнер. Режим `collapse` меняет layout только у flexible видов. При ограничении движения анимация отключается. + +Панель занимает ширину своего контейнера. Приложение размещает её в компоновке страницы рядом с rail. Если в документе уже есть landmark `banner`, используйте `tag="div"`: `
` на уровне страницы обычно создаёт такой landmark. [WAI о banner](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/banner.html). + +## API + +| Экспорт | Назначение | +| --- | --- | +| `M3AppBar` | Шесть вариантов панели, области содержимого и реакция на прокрутку. | +| Props Vue и `el` | Публичные настройки и ссылка на элемент. | + +| Prop | Значение по умолчанию | Действие | +| --- | --- | --- | +| `appearance` | `small` | `search`, `small`, `medium`, `medium-flexible`, `large`, `large-flexible`. | +| `alignment` | `start` | `start` или `center` для заголовка/подсказки. Центрирование baseline medium/large вне рекомендации Expressive. | +| `headline`, `subtitle` | — | Текст заголовка и подзаголовка; последний рисуется только в small/flexible. Именованные элементы имеют приоритет. | +| `headingLevel` | `1` | Уровень настоящего заголовка от 1 до 6. | +| `scroll` | `pinned` | `pinned`, `hide`, `collapse` или `transparent`. | +| `scrollTarget` | `window` | Прокручиваемый `HTMLElement` или `Window`. | +| `tag` | `header` | Корневой `header` или `div` для структуры landmarks страницы. | +| HTML-атрибуты | — | Передаются корневому элементу. | + +| Именованный элемент | Назначение | +| --- | --- | +| `#leading` | Back, Menu или другое уместное ведущее действие. | +| `#headline` | Своя разметка внутри семантического заголовка. | +| `#image` | Изображение над заголовком flexible, скрываемое при сворачивании. | +| `#subtitle` | Контекст для small/flexible. | +| `#trailing` | Действия страницы в заданном порядке DOM. | +| `#search` | Поисковая строка и управляющий Search. | + +Ref предоставляет `el`. Дочерние элементы без имени становятся заголовком обычного варианта или содержимым поисковой области для `search`. + +## Источники + +- [Обзор Material App bars](https://m3.material.io/components/app-bars/overview) +- [Спецификация](https://m3.material.io/components/app-bars/specs) +- [Рекомендации](https://m3.material.io/components/app-bars/guidelines) +- [Доступность](https://m3.material.io/components/app-bars/accessibility) + + diff --git a/m3-vue/storybook/components/M3AppBar.stories.ts b/m3-vue/storybook/components/M3AppBar.stories.ts new file mode 100644 index 0000000..5f083b6 --- /dev/null +++ b/m3-vue/storybook/components/M3AppBar.stories.ts @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3AppBar } from '@/components/app-bar' + +import AppBarExample from '../examples/app-bar/AppBarExample.vue' + +const meta = { + title: 'Components/M3AppBar', + component: M3AppBar, + args: { appearance: 'small', alignment: 'start', scroll: 'pinned' }, + argTypes: { + appearance: { control: 'select', options: ['search', 'small', 'medium', 'medium-flexible', 'large', 'large-flexible'] }, + alignment: { control: 'select', options: ['start', 'center'] }, + scroll: { control: 'select', options: ['pinned', 'hide', 'collapse', 'transparent'] }, + scrollTarget: { control: false }, + headline: { control: false }, + subtitle: { control: false }, + headingLevel: { control: false }, + }, + render: (args: Record, { globals }) => ({ + components: { AppBarExample }, + setup: () => ({ + appearance: args.appearance, + scroll: args.scroll, + centered: args.alignment === 'center', + subtitle: args.appearance === 'small' || String(args.appearance).includes('flexible'), + locale: globals.locale, + }), + template: '', + }), + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Search: Story = { args: { appearance: 'search' } } +export const Small: Story = {} +export const SmallCentered: Story = { args: { alignment: 'center' } } +export const Medium: Story = { args: { appearance: 'medium' } } +export const MediumFlexible: Story = { args: { appearance: 'medium-flexible', scroll: 'collapse' } } +export const Large: Story = { args: { appearance: 'large' } } +export const LargeFlexible: Story = { args: { appearance: 'large-flexible', scroll: 'collapse' } } +export const HideOnScroll: Story = { args: { scroll: 'hide' } } +export const TransparentOnScroll: Story = { args: { scroll: 'transparent' } } diff --git a/m3-vue/storybook/examples/app-bar/AppBarExample.vue b/m3-vue/storybook/examples/app-bar/AppBarExample.vue new file mode 100644 index 0000000..567c1ab --- /dev/null +++ b/m3-vue/storybook/examples/app-bar/AppBarExample.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/m3-vue/tests/M3AppBar.e2e.ts b/m3-vue/tests/M3AppBar.e2e.ts new file mode 100644 index 0000000..46410d0 --- /dev/null +++ b/m3-vue/tests/M3AppBar.e2e.ts @@ -0,0 +1,119 @@ +import { createApp, h, nextTick } from 'vue' +import { page } from 'vitest/browser' + +import { M3AppBar } from '@/components/app-bar' +import { M3IconButton } from '@/components/icon-button' + +const waitFor = async (assertion: () => void) => { + const startedAt = performance.now() + while (performance.now() - startedAt < 2000) { + try { + assertion() + return + } catch { + await new Promise(resolve => setTimeout(resolve, 16)) + } + } + assertion() +} + +test('renders the six documented heights and grows flexible content without truncation', async () => { + await page.viewport(900, 800) + const cases = [ + ['search', 64], ['small', 64], ['medium', 112], + ['medium-flexible', 112], ['large', 152], ['large-flexible', 120], + ] as const + + for (const [appearance, height] of cases) { + const mountPoint = document.createElement('div') + document.body.append(mountPoint) + const app = createApp(M3AppBar, { appearance, headline: 'Library' }) + app.mount(mountPoint) + expect(mountPoint.querySelector('.m3-app-bar')!.getBoundingClientRect().height).toBe(height) + app.unmount() + mountPoint.remove() + } + + const mountPoint = document.createElement('div') + document.body.append(mountPoint) + const app = createApp(M3AppBar, { + appearance: 'large-flexible', + headline: 'A long title that needs two lines across a compact window', + subtitle: 'Additional context', + }) + app.mount(mountPoint) + try { + await page.viewport(360, 800) + const bar = mountPoint.querySelector('.m3-app-bar')! + const headline = mountPoint.querySelector('.m3-app-bar__headline')! + expect(bar.getBoundingClientRect().height).toBeGreaterThanOrEqual(152) + expect(headline.scrollHeight).toBeLessThanOrEqual(headline.clientHeight) + } finally { + app.unmount() + mountPoint.remove() + } +}) + +test.each(['ltr', 'rtl'] as const)('centers a small title while keeping actions at the %s edges', direction => { + const mountPoint = document.createElement('div') + document.body.append(mountPoint) + const app = createApp({ + render: () => h(M3AppBar, { headline: 'Library', subtitle: 'Recent', alignment: 'center', dir: direction }, { + leading: () => h(M3IconButton, { 'aria-label': 'Back' }, () => '←'), + trailing: () => [ + h(M3IconButton, { 'aria-label': 'Favorites' }, () => '★'), + h(M3IconButton, { 'aria-label': 'Settings' }, () => '⚙'), + ], + }), + }) + app.mount(mountPoint) + + try { + const bar = mountPoint.querySelector('.m3-app-bar')!.getBoundingClientRect() + const title = mountPoint.querySelector('.m3-app-bar__title')!.getBoundingClientRect() + const leading = mountPoint.querySelector('.m3-app-bar__leading .m3-icon-button')!.getBoundingClientRect() + const trailing = mountPoint.querySelector('.m3-app-bar__trailing .m3-icon-button:last-child')!.getBoundingClientRect() + + expect(Math.abs((title.left + title.right) / 2 - (bar.left + bar.right) / 2)).toBeLessThan(1) + expect(direction === 'ltr' ? leading.left - bar.left : bar.right - leading.right).toBe(4) + expect(direction === 'ltr' ? bar.right - trailing.right : trailing.left - bar.left).toBe(4) + expect(leading.width).toBe(48) + expect(trailing.width).toBe(48) + } finally { + app.unmount() + mountPoint.remove() + } +}) + +test('collapses a flexible bar against its own scroll container and expands at the top', async () => { + await page.viewport(900, 800) + const scroller = document.createElement('main') + Object.assign(scroller.style, { height: '300px', overflowY: 'auto' }) + document.body.append(scroller) + const app = createApp({ + render: () => [ + h(M3AppBar, { appearance: 'medium-flexible', headline: 'Library', subtitle: 'Recent items', scroll: 'collapse', scrollTarget: scroller }), + h('div', { style: { height: '1000px' } }), + ], + }) + app.mount(scroller) + + try { + const bar = scroller.querySelector('.m3-app-bar')! + expect(bar.getBoundingClientRect().height).toBe(136) + const transitions = new Set() + bar.addEventListener('transitionrun', event => transitions.add(event.propertyName)) + scroller.scrollTop = 100 + await nextTick() + await waitFor(() => expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(true)) + await waitFor(() => expect(transitions.has('height')).toBe(true)) + await waitFor(() => expect(bar.getBoundingClientRect().height).toBe(64)) + scroller.scrollTop = 0 + await nextTick() + await waitFor(() => expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(false)) + await waitFor(() => expect(bar.getBoundingClientRect().height).toBe(136)) + } finally { + app.unmount() + scroller.remove() + } +}) diff --git a/m3-vue/tests/M3AppBar.test.ts b/m3-vue/tests/M3AppBar.test.ts new file mode 100644 index 0000000..ddaa621 --- /dev/null +++ b/m3-vue/tests/M3AppBar.test.ts @@ -0,0 +1,89 @@ +import { defineComponent } from 'vue' +import { fireEvent } from '@testing-library/vue' +import { h, nextTick, ref } from 'vue' +import { render, screen } from '@testing-library/vue' + +import { M3AppBar } from '@/components/app-bar' +import { M3Search } from '@/components/search' + +const appearances = ['search', 'small', 'medium', 'medium-flexible', 'large', 'large-flexible'] as const + +describe('m3-vue/app-bar', () => { + test.each(appearances)('renders %s with one appropriate title', appearance => { + const view = render(M3AppBar, { + props: { appearance, headline: 'Library', subtitle: 'Recent items' }, + slots: { + leading: () => h('button', 'Menu'), + trailing: () => h('button', 'Favorite'), + }, + }) + + const bar = view.container.querySelector('.m3-app-bar')! + expect(bar.classList.contains(`m3-app-bar_${appearance}`)).toBe(true) + expect(screen.getByRole('button', { name: 'Menu' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'Favorite' })).not.toBeNull() + expect(screen.queryByRole('heading', { name: 'Library' }) !== null).toBe(appearance !== 'search') + expect(view.container.querySelector('.m3-app-bar__subtitle') !== null).toBe( + appearance === 'small' || appearance.includes('flexible') + ) + }) + + test('tracks a supplied scroll root and re-expands flexible content at the top', async () => { + const scroller = document.createElement('div') + const view = render(M3AppBar, { + props: { appearance: 'medium-flexible', headline: 'Library', scroll: 'collapse', scrollTarget: scroller }, + }) + const bar = view.container.querySelector('.m3-app-bar')! + + scroller.scrollTop = 40 + await fireEvent.scroll(scroller) + await nextTick() + expect(bar.classList.contains('m3-app-bar_under')).toBe(true) + expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(true) + + scroller.scrollTop = 0 + await fireEvent.scroll(scroller) + await nextTick() + expect(bar.classList.contains('m3-app-bar_collapsed')).toBe(false) + expect(bar.classList.contains('m3-app-bar_under')).toBe(false) + }) + + test('shows a flexible image until the bar collapses', async () => { + const scroller = document.createElement('div') + const view = render(M3AppBar, { + props: { appearance: 'large-flexible', headline: 'Library', scroll: 'collapse', scrollTarget: scroller }, + slots: { image: () => h('img', { src: '/library.svg', alt: '' }) }, + }) + + expect(view.container.querySelector('.m3-app-bar__image img')).not.toBeNull() + scroller.scrollTop = 40 + await fireEvent.scroll(scroller) + await nextTick() + expect(view.container.querySelector('.m3-app-bar__image')).toBeNull() + }) + + test('keeps one search view owner when composed with M3Search', async () => { + const Example = defineComponent({ + setup () { + const open = ref(false) + const query = ref('') + return () => h(M3AppBar, { appearance: 'search' }, { + search: () => h(M3Search, { + query: query.value, + open: open.value, + label: 'Search library', + status: '', + layout: 'full-screen', + 'onUpdate:query': (value: string) => query.value = value, + 'onUpdate:open': (value: boolean) => open.value = value, + }), + }) + }, + }) + + render(Example) + await fireEvent.click(screen.getByRole('button', { name: 'Search library' })) + await nextTick() + expect(screen.getAllByRole('dialog', { name: 'Search library' })).toHaveLength(1) + }) +}) From 3f142de1c1729bde5871f7c428cc259c6f727173 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Fri, 9 Oct 2026 11:38:34 +0400 Subject: [PATCH 09/20] refactor(m3-foundation): Page layer levels were centralized --- .../assets/stylesheets/basics/layers.scss | 20 +++++++++++++++++++ .../stylesheets/components/app-bar/index.scss | 3 ++- .../stylesheets/components/dialog/index.scss | 5 +++-- .../components/navigation/index.scss | 3 ++- .../components/plane-tooltip/index.scss | 3 +-- .../stylesheets/components/popper/index.scss | 4 +++- .../components/rich-tooltip/index.scss | 3 +-- .../stylesheets/components/scrim/index.scss | 3 ++- .../stylesheets/components/search/index.scss | 3 ++- .../components/side-sheet/index.scss | 3 ++- .../components/snackbar/index.scss | 3 ++- 11 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 m3-foundation/assets/stylesheets/basics/layers.scss diff --git a/m3-foundation/assets/stylesheets/basics/layers.scss b/m3-foundation/assets/stylesheets/basics/layers.scss new file mode 100644 index 0000000..b5cb6e2 --- /dev/null +++ b/m3-foundation/assets/stylesheets/basics/layers.scss @@ -0,0 +1,20 @@ +@use 'sass:map'; + +// Page-level layers. Small z-index values inside a component remain local. +$levels: ( + 'app-bar': 10, + 'toolbar': 100, + 'snackbar': 900, + 'scrim': 1000, + 'modal': 1001, + 'popover': 1100, + 'search': 1200, +); + +@function level($name) { + @if not map.has-key($levels, $name) { + @error 'Unknown Material layer: #{$name}'; + } + + @return map.get($levels, $name); +} diff --git a/m3-foundation/assets/stylesheets/components/app-bar/index.scss b/m3-foundation/assets/stylesheets/components/app-bar/index.scss index e8d2c6b..6dc9e1c 100644 --- a/m3-foundation/assets/stylesheets/components/app-bar/index.scss +++ b/m3-foundation/assets/stylesheets/components/app-bar/index.scss @@ -1,3 +1,4 @@ +@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/typography' as m3-typography; @@ -13,7 +14,7 @@ color: var(--m3-sys-on-surface); position: sticky; inset-block-start: 0; - z-index: 10; + z-index: m3-layers.level('app-bar'); transition: #{m3-motion.timing-standard(background-color, 'short4')}, #{m3-motion.timing-standard(block-size, 'medium2')}, diff --git a/m3-foundation/assets/stylesheets/components/dialog/index.scss b/m3-foundation/assets/stylesheets/components/dialog/index.scss index e5eac5c..fc869bf 100644 --- a/m3-foundation/assets/stylesheets/components/dialog/index.scss +++ b/m3-foundation/assets/stylesheets/components/dialog/index.scss @@ -1,3 +1,4 @@ +@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/shape' as m3-shape; @@ -7,7 +8,7 @@ block-size: 100%; align-items: center; justify-content: center; - z-index: 1000; + z-index: m3-layers.level('modal'); position: fixed; inset-inline-start: 0; inset-block-start: 0; @@ -92,4 +93,4 @@ justify-content: flex-end; gap: 8px; } -} \ No newline at end of file +} diff --git a/m3-foundation/assets/stylesheets/components/navigation/index.scss b/m3-foundation/assets/stylesheets/components/navigation/index.scss index 13c2b15..fa9463e 100644 --- a/m3-foundation/assets/stylesheets/components/navigation/index.scss +++ b/m3-foundation/assets/stylesheets/components/navigation/index.scss @@ -1,4 +1,5 @@ @use '../../basics/layout' as m3-layout; +@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/shape' as m3-shape; @use '../../basics/typography' as m3-typography; @@ -63,7 +64,7 @@ m3-motion.timing-emphasized(padding), m3-motion.timing-emphasized(gap) ; - z-index: 1000; + z-index: m3-layers.level('modal'); } &_rail { diff --git a/m3-foundation/assets/stylesheets/components/plane-tooltip/index.scss b/m3-foundation/assets/stylesheets/components/plane-tooltip/index.scss index 112df02..d596a33 100644 --- a/m3-foundation/assets/stylesheets/components/plane-tooltip/index.scss +++ b/m3-foundation/assets/stylesheets/components/plane-tooltip/index.scss @@ -8,5 +8,4 @@ @include m3-shape.corner('extra-small'); color: var(--m3-sys-inverse-on-surface); @include m3-typography.body-small; - z-index: 11; -} \ 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 6592731..6ddf62e 100644 --- a/m3-foundation/assets/stylesheets/components/popper/index.scss +++ b/m3-foundation/assets/stylesheets/components/popper/index.scss @@ -1,10 +1,12 @@ -@use "../../basics/motion" as m3-motion; +@use '../../basics/layers' as m3-layers; +@use '../../basics/motion' as m3-motion; .m3-popper-positioner { position: absolute; top: 0; left: 0; pointer-events: none; + z-index: m3-layers.level('popover'); } .m3-popper { diff --git a/m3-foundation/assets/stylesheets/components/rich-tooltip/index.scss b/m3-foundation/assets/stylesheets/components/rich-tooltip/index.scss index f6767b1..2a02cef 100644 --- a/m3-foundation/assets/stylesheets/components/rich-tooltip/index.scss +++ b/m3-foundation/assets/stylesheets/components/rich-tooltip/index.scss @@ -13,7 +13,6 @@ background: var(--m3-sys-surface-container); box-shadow: var(--m3-elevation-2); color: var(--m3-sys-on-surface-variant); - z-index: 10; &__content { display: flex; @@ -37,4 +36,4 @@ align-self: stretch; gap: 8px; } -} \ No newline at end of file +} diff --git a/m3-foundation/assets/stylesheets/components/scrim/index.scss b/m3-foundation/assets/stylesheets/components/scrim/index.scss index aedf614..516a100 100644 --- a/m3-foundation/assets/stylesheets/components/scrim/index.scss +++ b/m3-foundation/assets/stylesheets/components/scrim/index.scss @@ -1,3 +1,4 @@ +@use '../../basics/layers' as m3-layers; @use '../../basics/palette' as *; .m3-scrim { @@ -5,7 +6,7 @@ inline-size: 100%; block-size: 100%; background: rgba($m3-palette-neutral-variant-20, 0.4); - z-index: 1000; + z-index: m3-layers.level('scrim'); position: fixed; inset-inline-start: 0; inset-block-start: 0; diff --git a/m3-foundation/assets/stylesheets/components/search/index.scss b/m3-foundation/assets/stylesheets/components/search/index.scss index 3b7e026..a760f8d 100644 --- a/m3-foundation/assets/stylesheets/components/search/index.scss +++ b/m3-foundation/assets/stylesheets/components/search/index.scss @@ -1,3 +1,4 @@ +@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/typography' as m3-typography; @@ -104,7 +105,7 @@ pointer-events: none; position: fixed; inset: 0; - z-index: var(--m3-search-z-index, 1200); + z-index: var(--m3-search-z-index, #{m3-layers.level('search')}); } &__scrim { diff --git a/m3-foundation/assets/stylesheets/components/side-sheet/index.scss b/m3-foundation/assets/stylesheets/components/side-sheet/index.scss index abd821f..dc6b72a 100644 --- a/m3-foundation/assets/stylesheets/components/side-sheet/index.scss +++ b/m3-foundation/assets/stylesheets/components/side-sheet/index.scss @@ -1,3 +1,4 @@ +@use '../../basics/layers' as m3-layers; @use '../../basics/shape' as m3-shape; @use '../../basics/typography' as m3-typography; @@ -14,7 +15,7 @@ position: fixed; inset-block-start: 0; right: 0; - z-index: 1000; + z-index: m3-layers.level('modal'); background: var(--m3-sys-surface-container-low); color: var(--m3-sys-on-surface-variant); diff --git a/m3-foundation/assets/stylesheets/components/snackbar/index.scss b/m3-foundation/assets/stylesheets/components/snackbar/index.scss index 75c809b..5a7255a 100644 --- a/m3-foundation/assets/stylesheets/components/snackbar/index.scss +++ b/m3-foundation/assets/stylesheets/components/snackbar/index.scss @@ -1,4 +1,5 @@ @use '../../basics/layout' as m3-layout; +@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/typography' as m3-typography; @@ -9,7 +10,7 @@ position: fixed; inset-inline-start: var(--m3-snackbar-inset-inline-start); inset-block-end: var(--m3-snackbar-inset-block-end); - z-index: 900; + z-index: m3-layers.level('snackbar'); max-inline-size: calc(100vw - var(--m3-snackbar-inset-inline-start) - 16px); pointer-events: none; From 4f39a36c5fd9dad032dca5998796802fb258f666 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Fri, 9 Oct 2026 11:39:18 +0400 Subject: [PATCH 10/20] feat(m3-foundation): Toolbar styles and layout helpers were added --- .../assets/stylesheets/components/index.scss | 3 +- .../stylesheets/components/menu/index.scss | 2 +- .../stylesheets/components/toolbar/index.scss | 224 ++++++++++++++++++ m3-foundation/lib/toolbar.ts | 26 ++ m3-foundation/tests/toolbar.test.ts | 20 ++ m3-foundation/types/components/toolbar.d.ts | 14 ++ m3-foundation/vite.config.ts | 1 + 7 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 m3-foundation/assets/stylesheets/components/toolbar/index.scss create mode 100644 m3-foundation/lib/toolbar.ts create mode 100644 m3-foundation/tests/toolbar.test.ts create mode 100644 m3-foundation/types/components/toolbar.d.ts diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index f8b09aa..35981a5 100644 --- a/m3-foundation/assets/stylesheets/components/index.scss +++ b/m3-foundation/assets/stylesheets/components/index.scss @@ -25,6 +25,7 @@ @use 'side-sheet'; @use 'snackbar'; @use 'slider'; -@use 'switch'; @use 'surface'; +@use 'switch'; @use 'text-field'; +@use 'toolbar'; diff --git a/m3-foundation/assets/stylesheets/components/menu/index.scss b/m3-foundation/assets/stylesheets/components/menu/index.scss index 6c9f188..8d9588c 100644 --- a/m3-foundation/assets/stylesheets/components/menu/index.scss +++ b/m3-foundation/assets/stylesheets/components/menu/index.scss @@ -12,7 +12,6 @@ flex-direction: column; background: var(--m3-sys-surface-container); box-shadow: var(--m3-elevation-2); - z-index: 3; } .m3-menu-item { @@ -86,5 +85,6 @@ align-items: flex-start; flex: 1 0 0; color: var(--m3-sys-on-surface); + text-align: start; } } diff --git a/m3-foundation/assets/stylesheets/components/toolbar/index.scss b/m3-foundation/assets/stylesheets/components/toolbar/index.scss new file mode 100644 index 0000000..49022a4 --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/toolbar/index.scss @@ -0,0 +1,224 @@ +@use '../../basics/layers' as m3-layers; +@use '../../basics/motion' as m3-motion; + +.m3-toolbar { + --m3-toolbar-container: var(--m3-sys-surface-container); + --m3-toolbar-content: var(--m3-sys-on-surface-variant); + --m3-toolbar-selected-container: var(--m3-sys-secondary-container); + --m3-toolbar-selected-content: var(--m3-sys-on-secondary-container); + + display: flex; + box-sizing: border-box; + align-items: center; + z-index: m3-layers.level('toolbar'); + position: fixed; + transition: m3-motion.timing-standard(transform, 'medium2'); + + &_vibrant { + --m3-toolbar-container: var(--m3-sys-primary-container); + --m3-toolbar-content: var(--m3-sys-on-primary-container); + --m3-toolbar-selected-container: var(--m3-sys-surface-container); + --m3-toolbar-selected-content: var(--m3-sys-on-surface); + } + + &_docked { + inline-size: 100%; + min-block-size: calc(64px + env(safe-area-inset-bottom, 0px)); + padding-block-end: env(safe-area-inset-bottom, 0px); + background: var(--m3-toolbar-container); + inset-inline: 0; + inset-block-end: 0; + } + + &_docked#{&}_hidden { + transform: translateY(110%); + } + + &_floating { + max-inline-size: calc(100vw - 32px); + gap: 8px; + inset-block-end: calc(16px + env(safe-area-inset-bottom, 0px)); + inset-inline-start: 50%; + translate: -50% 0; + } + + &_floating#{&}_hidden { + transform: translateY(calc(100% + 32px)); + } + + &_vertical { + max-inline-size: none; + max-block-size: calc(100vh - 48px); + flex-direction: column; + inset-block-end: auto; + inset-block-start: 50%; + inset-inline-start: auto; + inset-inline-end: 24px; + translate: 0 -50%; + } + + &_vertical#{&}_side-start { + inset-inline-start: 24px; + inset-inline-end: auto; + } + + &_vertical#{&}_hidden { + transform: translateX(calc(100% + 24px)); + } + + &_vertical#{&}_side-start#{&}_hidden { + transform: translateX(calc(-100% - 24px)); + } + + &_vertical#{&}_hidden:dir(rtl) { + transform: translateX(calc(-100% - 24px)); + } + + &_vertical#{&}_side-start#{&}_hidden:dir(rtl) { + transform: translateX(calc(100% + 24px)); + } + + &__container { + display: flex; + box-sizing: border-box; + min-inline-size: 0; + max-inline-size: var(--m3-toolbar-expanded-size, 100vw); + block-size: 64px; + padding-inline: 8px; + align-items: center; + background: var(--m3-toolbar-container); + border-radius: 9999px; + box-shadow: var(--m3-elevation-3); + overflow: hidden; + transition: + m3-motion.timing-standard(max-inline-size, 'medium2'), + m3-motion.timing-standard(max-block-size, 'medium2'), + m3-motion.timing-standard(padding, 'medium2'), + m3-motion.timing-standard(opacity, 'short4'), + m3-motion.timing-standard(transform, 'medium2'); + } + + &_docked &__container { + inline-size: 100%; + max-inline-size: none; + padding-inline: 16px; + border-radius: 0; + box-shadow: none; + } + + &_vertical &__container { + inline-size: 64px; + block-size: auto; + max-block-size: var(--m3-toolbar-expanded-size, calc(100vh - 48px)); + padding-block: 8px; + flex-direction: column; + } + + &_collapsed &__container { + max-inline-size: 0; + padding-inline: 0; + opacity: 0; + transform: scale(0.8); + pointer-events: none; + } + + &_vertical#{&}_collapsed &__container { + max-inline-size: 64px; + max-block-size: 0; + padding-block: 0; + } + + &__actions { + display: flex; + min-inline-size: 0; + align-items: center; + gap: 4px; + } + + &_floating &__actions { + inline-size: max-content; + flex: 0 0 auto; + } + + &_docked &__actions { + inline-size: 100%; + justify-content: space-evenly; + gap: clamp(4px, 2vw, 32px); + } + + &_vertical &__actions { + flex-direction: column; + } + + &__custom { + display: flex; + min-inline-size: 0; + align-items: center; + gap: 4px; + } + + &_vertical &__custom { + flex-direction: column; + } + + &__action { + display: flex; + box-sizing: border-box; + inline-size: 48px; + block-size: 48px; + flex: 0 0 48px; + align-items: center; + justify-content: center; + } + + &__action .m3-icon-button { + --content-color: var(--m3-toolbar-content); + --state-color: var(--m3-toolbar-content); + --m3-ripple-color: var(--m3-toolbar-content); + } + + &__action .m3-icon-button_tonal, + &__action .m3-icon-button_toggleable.m3-icon-button_selected { + --background-color: var(--m3-toolbar-selected-container); + --content-color: var(--m3-toolbar-selected-content); + --state-color: var(--m3-toolbar-selected-content); + --m3-ripple-color: var(--m3-toolbar-selected-content); + } + + &__fab { + display: flex; + flex: 0 0 auto; + } + + &__menu { + inline-size: max-content; + min-inline-size: 200px; + max-inline-size: min(280px, calc(100vw - 32px)); + } + + &__menu .m3-menu-item__body { + display: block; + min-inline-size: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &_standard &__fab .m3-fab-button { + --background-color: var(--m3-sys-secondary-container); + --content-color: var(--m3-sys-on-secondary-container); + } + + &_vibrant &__fab .m3-fab-button { + --background-color: var(--m3-sys-tertiary-container); + --content-color: var(--m3-sys-on-tertiary-container); + } + + @media (prefers-reduced-motion: reduce) { + &, + &__container { + transition: none; + } + } +} diff --git a/m3-foundation/lib/toolbar.ts b/m3-foundation/lib/toolbar.ts new file mode 100644 index 0000000..2daff7d --- /dev/null +++ b/m3-foundation/lib/toolbar.ts @@ -0,0 +1,26 @@ +import type { ToolbarScroll } from '../types/components/toolbar' + +/** Number of 48px actions that fit while reserving a 48px overflow trigger. */ +export const getToolbarVisibleCount = ( + available: number, + count: number, + occupied = 0, + gap = 4 +): number => { + const item = 48 + const room = Math.max(0, available - occupied) + if (count * item + Math.max(0, count - 1) * gap <= room) return count + return Math.min(count - 1, Math.max(0, Math.floor((room - item) / (item + gap)))) +} + +export const getToolbarScrollState = ( + previous: number, + current: number, + active: boolean, + scroll: ToolbarScroll +): boolean => { + if (scroll === 'pinned' || current <= 0) return false + if (current > previous + 1) return true + if (current < previous - 1) return false + return active +} diff --git a/m3-foundation/tests/toolbar.test.ts b/m3-foundation/tests/toolbar.test.ts new file mode 100644 index 0000000..36c1283 --- /dev/null +++ b/m3-foundation/tests/toolbar.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest' + +import { getToolbarScrollState, getToolbarVisibleCount } from '../lib/toolbar' + +describe('toolbar layout', () => { + test('reserves overflow space only when all actions cannot fit', () => { + expect(getToolbarVisibleCount(256, 5)).toBe(5) + expect(getToolbarVisibleCount(200, 5)).toBe(2) + expect(getToolbarVisibleCount(48, 5)).toBe(0) + expect(getToolbarVisibleCount(260, 5, 100)).toBe(2) + }) + + test('retains scroll state until direction changes and resets at the top', () => { + expect(getToolbarScrollState(0, 20, false, 'hide')).toBe(true) + expect(getToolbarScrollState(20, 20, true, 'hide')).toBe(true) + expect(getToolbarScrollState(20, 10, true, 'hide')).toBe(false) + expect(getToolbarScrollState(20, 0, true, 'collapse')).toBe(false) + expect(getToolbarScrollState(0, 20, false, 'pinned')).toBe(false) + }) +}) diff --git a/m3-foundation/types/components/toolbar.d.ts b/m3-foundation/types/components/toolbar.d.ts new file mode 100644 index 0000000..08199ee --- /dev/null +++ b/m3-foundation/types/components/toolbar.d.ts @@ -0,0 +1,14 @@ +export type ToolbarVariant = 'docked' | 'floating' +export type ToolbarOrientation = 'horizontal' | 'vertical' +export type ToolbarColor = 'standard' | 'vibrant' +export type ToolbarScroll = 'pinned' | 'hide' | 'collapse' +export type ToolbarSide = 'start' | 'end' + +export interface ToolbarAction { + id: string; + label: string; + icon: string; + disabled?: boolean; + selected?: boolean; + onClick: () => void; +} diff --git a/m3-foundation/vite.config.ts b/m3-foundation/vite.config.ts index db65dd5..fe288c4 100644 --- a/m3-foundation/vite.config.ts +++ b/m3-foundation/vite.config.ts @@ -27,6 +27,7 @@ const entries = { 'lib/surface/descriptor': resolve(__dirname, 'lib/surface/descriptor.ts'), 'lib/surface/orchestration': resolve(__dirname, 'lib/surface/orchestration.ts'), 'lib/surface/style': resolve(__dirname, 'lib/surface/style.ts'), + 'lib/toolbar': resolve(__dirname, 'lib/toolbar.ts'), } const externalPackages = Object.keys(dependencies) From 85fad2b077aeb927abaa80de0f018d5f8fa398ca Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Fri, 9 Oct 2026 11:40:00 +0400 Subject: [PATCH 11/20] fix(m3-foundation): Search helper export was restored --- m3-foundation/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/m3-foundation/vite.config.ts b/m3-foundation/vite.config.ts index fe288c4..511c479 100644 --- a/m3-foundation/vite.config.ts +++ b/m3-foundation/vite.config.ts @@ -24,6 +24,7 @@ const entries = { 'lib/popper/scheduling': resolve(__dirname, 'lib/popper/scheduling.ts'), 'lib/predicates': resolve(__dirname, 'lib/predicates.ts'), 'lib/scroll': resolve(__dirname, 'lib/scroll.ts'), + 'lib/search': resolve(__dirname, 'lib/search.ts'), 'lib/surface/descriptor': resolve(__dirname, 'lib/surface/descriptor.ts'), 'lib/surface/orchestration': resolve(__dirname, 'lib/surface/orchestration.ts'), 'lib/surface/style': resolve(__dirname, 'lib/surface/style.ts'), From af5e2e4508494dd8730a696197d3790777442b4f Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Fri, 9 Oct 2026 11:40:36 +0400 Subject: [PATCH 12/20] feat(m3-react): Toolbar component and examples were added --- m3-react/src/components/index.ts | 1 + m3-react/src/components/toolbar/M3Toolbar.tsx | 217 ++++++++++++++++++ m3-react/src/components/toolbar/index.ts | 2 + m3-react/storybook/components/M3Toolbar.mdx | 157 +++++++++++++ .../components/M3Toolbar.stories.tsx | 54 +++++ .../examples/toolbar/ToolbarExample.tsx | 87 +++++++ m3-react/tests/M3Toolbar.e2e.tsx | 110 +++++++++ m3-react/tests/M3Toolbar.test.tsx | 87 +++++++ 8 files changed, 715 insertions(+) create mode 100644 m3-react/src/components/toolbar/M3Toolbar.tsx create mode 100644 m3-react/src/components/toolbar/index.ts create mode 100644 m3-react/storybook/components/M3Toolbar.mdx create mode 100644 m3-react/storybook/components/M3Toolbar.stories.tsx create mode 100644 m3-react/storybook/examples/toolbar/ToolbarExample.tsx create mode 100644 m3-react/tests/M3Toolbar.e2e.tsx create mode 100644 m3-react/tests/M3Toolbar.test.tsx diff --git a/m3-react/src/components/index.ts b/m3-react/src/components/index.ts index 05e3616..be2158e 100644 --- a/m3-react/src/components/index.ts +++ b/m3-react/src/components/index.ts @@ -27,3 +27,4 @@ export * from './slider' export * from './surface' export * from './switch' export * from './text-field' +export * from './toolbar' diff --git a/m3-react/src/components/toolbar/M3Toolbar.tsx b/m3-react/src/components/toolbar/M3Toolbar.tsx new file mode 100644 index 0000000..ae6c146 --- /dev/null +++ b/m3-react/src/components/toolbar/M3Toolbar.tsx @@ -0,0 +1,217 @@ +import type { ComponentSetupContext } from '@/utils/component' +import type { ElementReference } from '@modulify/m3-foundation/types/dom' +import type { HTMLAttributes, Ref } from 'react' +import type { + ToolbarAction, + ToolbarColor, + ToolbarOrientation, + ToolbarScroll, + ToolbarSide, + ToolbarVariant, +} from '@modulify/m3-foundation/types/components/toolbar' + +import { + getToolbarScrollState, + getToolbarVisibleCount, +} from '@modulify/m3-foundation/lib/toolbar' +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { M3Icon } from '@/components/icon' +import { M3IconButton } from '@/components/icon-button' +import { M3Menu, M3MenuItem } from '@/components/menu' + +import defineComponent from '@/utils/component' +import { defineSlot, distinct } from '@/utils/content' +import { toClassName } from '@/utils/styling' +import { useElementReference, useResizeObserver } from '@/hooks' + +export interface M3ToolbarProps extends HTMLAttributes { + ref?: Ref; + label: string; + variant?: ToolbarVariant; + orientation?: ToolbarOrientation; + color?: ToolbarColor; + side?: ToolbarSide; + scroll?: ToolbarScroll; + scrollTarget?: HTMLElement | Window | null; + actions?: ToolbarAction[]; + overflowLabel?: string; +} + +export interface M3ToolbarExposed extends ElementReference {} + +const Fab = defineSlot('M3Toolbar.Fab') + +export default defineComponent(function M3Toolbar({ + ref: _ref, + label, + variant = 'docked', + orientation = 'horizontal', + color = 'standard', + side = 'end', + scroll = 'pinned', + scrollTarget, + actions = [], + overflowLabel = 'More actions', + className = '', + children = [], + onKeyDown = () => {}, + onFocusCapture = () => {}, + ...attrs +}: M3ToolbarProps, { expose }: ComponentSetupContext) { + const root = useRef(null) + const actionGroup = useRef(null) + const custom = useRef(null) + const overflow = useRef(null) + const [overflowTarget, setOverflowTarget] = useState(null) + const [visibleCount, setVisibleCount] = useState(actions.length) + const [scrollActive, setScrollActive] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [slots, content] = useMemo(() => distinct(children, { fab: Fab }), [children]) + const vertical = variant === 'floating' && orientation === 'vertical' + const hasFab = variant === 'floating' && !!slots.fab + const collapsed = scroll === 'collapse' && scrollActive && hasFab + const hidden = scroll === 'hide' && scrollActive + const shown = actions.slice(0, visibleCount) + const excess = actions.slice(visibleCount) + + expose(useElementReference(root)) + + const updateMeasurements = () => { + const parent = root.current?.parentElement + if (!parent) return + const axis = vertical ? Math.min(parent.clientHeight || window.innerHeight, window.innerHeight) : parent.clientWidth || window.innerWidth + const outer = variant === 'docked' ? 0 : vertical ? 48 : 32 + const padding = variant === 'docked' ? 32 : 16 + const customSize = vertical + ? Math.max(custom.current?.scrollHeight ?? 0, custom.current?.offsetHeight ?? 0) + : Math.max(custom.current?.scrollWidth ?? 0, custom.current?.offsetWidth ?? 0) + const fabSize = hasFab ? 64 : 0 + setVisibleCount(getToolbarVisibleCount(axis - outer - padding, actions.length, customSize + fabSize)) + if (variant === 'floating' && actionGroup.current) { + const size = (vertical ? actionGroup.current.scrollHeight : actionGroup.current.scrollWidth) + padding + root.current?.style.setProperty('--m3-toolbar-expanded-size', `${size}px`) + } + } + + useResizeObserver(() => [root.current?.parentElement ?? null, custom.current], updateMeasurements) + useLayoutEffect(updateMeasurements, [actions, variant, orientation, hasFab, visibleCount]) + + useEffect(() => { + const target = scrollTarget ?? window + const position = () => target === window ? window.scrollY : (target as HTMLElement).scrollTop + const controller = new AbortController() + let previous = position() + + const update = () => { + const current = position() + setScrollActive(active => root.current?.contains(document.activeElement) + ? false + : getToolbarScrollState(previous, current, active, scroll)) + previous = current + } + + update() + target.addEventListener('scroll', update, { passive: true, signal: controller.signal }) + return () => controller.abort('Toolbar scroll listener disposed') + }, [scroll, scrollTarget]) + + useEffect(() => { + if (excess.length === 0) setMenuOpen(false) + }, [excess.length]) + + return
{ + setScrollActive(false) + onFocusCapture(event) + }} + onKeyDown={event => { + const target = event.target as HTMLElement + if (target.matches('input, textarea, select, [contenteditable="true"]')) { + onKeyDown(event) + return + } + + const first = vertical ? 'ArrowUp' : 'ArrowLeft' + const last = vertical ? 'ArrowDown' : 'ArrowRight' + if (event.key === first || event.key === last || event.key === 'Home' || event.key === 'End') { + const controls = [...(root.current?.querySelectorAll('button:not(:disabled), a[href], input:not(:disabled)') ?? [])] + const index = controls.indexOf(document.activeElement as HTMLElement) + if (index !== -1 && controls.length > 0) { + const rtl = !vertical && getComputedStyle(root.current!).direction === 'rtl' + const direction = event.key === first ? -1 : 1 + const next = event.key === 'Home' ? 0 : event.key === 'End' ? controls.length - 1 + : (index + direction * (rtl ? -1 : 1) + controls.length) % controls.length + controls[next]?.focus() + event.preventDefault() + } + } + onKeyDown(event) + }} + > +
+
+ {shown.map(action => + + + + )} + {content &&
{content}
} + {excess.length > 0 && + { + overflow.current = instance?.el ?? null + setOverflowTarget(current => current === overflow.current ? current : overflow.current) + }} + aria-label={overflowLabel} + aria-expanded={menuOpen} + aria-haspopup="menu" + > + + + } +
+
+ {hasFab &&
{slots.fab}
} + {excess.length > 0 && + {excess.map(action => { + if (!action.disabled) action.onClick() + setMenuOpen(false) + }} + > + + {action.label} + )} + } +
+}, { slots: { Fab } }) diff --git a/m3-react/src/components/toolbar/index.ts b/m3-react/src/components/toolbar/index.ts new file mode 100644 index 0000000..78b347f --- /dev/null +++ b/m3-react/src/components/toolbar/index.ts @@ -0,0 +1,2 @@ +export { default as M3Toolbar } from './M3Toolbar' +export type { M3ToolbarExposed, M3ToolbarProps } from './M3Toolbar' diff --git a/m3-react/storybook/components/M3Toolbar.mdx b/m3-react/storybook/components/M3Toolbar.mdx new file mode 100644 index 0000000..3a5776d --- /dev/null +++ b/m3-react/storybook/components/M3Toolbar.mdx @@ -0,0 +1,157 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import { Localized } from '../i18n' +import * as M3ToolbarStories from './M3Toolbar.stories' + + + + + +# Toolbar + +Toolbars group actions related to the current page or content. `M3Toolbar` implements the two [Material 3 Expressive variants](https://m3.material.io/components/toolbars/overview): **docked** and **floating**. The former bottom app bar is a baseline variant that Material still lists but no longer recommends; use a docked toolbar for a new Expressive design. + +## Guide + +| Variant | Use | Placement | +| --- | --- | --- | +| `docked` | Actions shared across several pages | Full width at the bottom; do not show beside a bottom navigation bar. | +| `floating`, horizontal | Actions for the current page or selection | Above content, at least 16 px from the window edge. A FAB may sit beside it. | +| `floating`, vertical | Compact action group on a larger window | Along a side, at least 24 px from the edge; place it opposite a navigation rail. | + +Use `color="standard"` to keep attention on content and `vibrant` to emphasize controls or a temporary mode. Keep one prominent action. Put an emphasized action inside a docked toolbar; a FAB beside a floating toolbar is a separate slot. The toolbar does not create or manage that FAB. Both orientations support an accessible toolbar label. [Material guidelines](https://m3.material.io/components/toolbars/guidelines). + +### Actions and overflow + +Pass icon actions through `actions` for automatic overflow. Each action needs a stable `id`, a localized `label`, an icon name, and `onClick`; optional `disabled` and `selected` states follow it into the menu. The toolbar measures its containing element and keeps the last actions in the overflow menu when space runs out. Translate `overflowLabel` as well. Actions must remain understandable when shown as text in the menu. + +Use ordinary children for buttons, text fields, images, or other custom content. The toolbar lays them out, but only entries in `actions` participate in automatic overflow. Keep custom content small enough for the available space; manage its responsive presentation yourself. Avoid a vertical toolbar with wide controls and avoid too many unrelated control types. [Material flexibility and resizing](https://m3.material.io/components/toolbars/guidelines). + +### Scrolling and accessibility + +`scroll="pinned"` leaves the toolbar on screen. `hide` moves it offscreen while scrolling forward and restores it while scrolling back. On a floating toolbar with a `M3Toolbar.Fab` child, `collapse` keeps only that primary action visible while scrolling forward. It does not combine collapse with hide. Pass `scrollTarget` for a nested scroll area. Focusing the toolbar restores its controls. When reduced motion is requested, transitions stop. + +The root has `role="toolbar"`, an accessible label and orientation. Tab reaches native controls; the direction's arrow keys move among them, Home/End move to the ends, and arrow keys inside text fields keep their native behavior. Name every icon control. The toolbar has no click behavior of its own. [Material accessibility](https://m3.material.io/components/toolbars/accessibility). + +```tsx + + + + + +``` + +## API + +| Export | Purpose | +| --- | --- | +| `M3Toolbar` | Container, layout, scroll response, and icon-action overflow. | +| `M3ToolbarProps`, `M3ToolbarExposed` | Public React props and element reference. | +| `ToolbarAction`, `ToolbarVariant`, `ToolbarOrientation`, `ToolbarColor`, `ToolbarScroll`, `ToolbarSide` | Shared foundation types. | + +| Prop | Default | Effect | +| --- | --- | --- | +| `label` | Required | Accessible name of this toolbar. | +| `variant` | `docked` | `docked` or `floating`. | +| `orientation` | `horizontal` | `vertical` applies to floating. | +| `color` | `standard` | `standard` or `vibrant` container/action colors. | +| `side` | `end` | `start` or `end` for vertical floating. | +| `scroll` | `pinned` | `pinned`, `hide`, or `collapse` (floating with FAB). | +| `scrollTarget` | `window` | Scrollable element or window to observe. | +| `actions` | `[]` | Icon actions eligible for automatic overflow. | +| `overflowLabel` | `More actions` | Accessible overflow trigger name; translate for the app locale. | +| HTML attributes | — | Passed to the toolbar root. | + +`M3Toolbar.Fab` places a FAB beside the floating container. Unslotted children fill the custom-content area. The ref exposes `el`. + +## Sources + +- [Material overview](https://m3.material.io/components/toolbars/overview) +- [Specs](https://m3.material.io/components/toolbars/specs) +- [Guidelines](https://m3.material.io/components/toolbars/guidelines) +- [Accessibility](https://m3.material.io/components/toolbars/accessibility) + + + + + +# Панель инструментов + +Toolbar объединяет действия текущей страницы или её содержимого. `M3Toolbar` реализует два варианта [Material 3 Expressive](https://m3.material.io/components/toolbars/overview): **docked** и **floating**. Прежний bottom app bar остался в baseline, но Material больше не рекомендует его; для нового Expressive-интерфейса используйте docked toolbar. + +## Руководство + +| Вариант | Когда использовать | Положение | +| --- | --- | --- | +| `docked` | Действия, общие для нескольких страниц | На всю ширину снизу; не показывать одновременно с нижним navigation bar. | +| `floating`, горизонтальный | Действия текущей страницы или выделения | Поверх содержимого, не ближе 16 px к краю. Рядом возможен FAB. | +| `floating`, вертикальный | Компактная группа действий на широком экране | У бокового края с отступом от 24 px; напротив navigation rail. | + +`color="standard"` сохраняет внимание на содержимом; `vibrant` выделяет контролы или временный режим. Делайте акцент на одном действии. В docked добавьте его внутрь панели; FAB рядом с floating передаётся отдельным слотом. Toolbar не управляет действием FAB. Каждому toolbar требуется доступное имя. [Рекомендации Material](https://m3.material.io/components/toolbars/guidelines). + +### Действия и overflow + +Передайте иконочные действия в `actions` для автоматического переноса в overflow. Каждому нужен стабильный `id`, локализованное `label`, имя иконки и `onClick`; `disabled` и `selected` сохраняются в меню. Toolbar измеряет родительский контейнер и переносит последние действия, когда места не хватает. Переведите и `overflowLabel`. Название действия должно быть понятным без иконки. + +Обычные children подходят для кнопок, текстовых полей, изображений и другого произвольного содержимого. Toolbar располагает их, но автоматически переносит в меню **только** элементы `actions`. Для пользовательского содержимого подберите собственную адаптивную компоновку. Избегайте широких элементов в вертикальной панели и множества несвязанных видов контролов. [Гибкость и адаптивность в спеке](https://m3.material.io/components/toolbars/guidelines). + +### Прокрутка и доступность + +`scroll="pinned"` оставляет toolbar на экране. `hide` уводит его при прокрутке вперёд и возвращает при обратной. Для floating с `M3Toolbar.Fab` режим `collapse` оставляет только главное действие. Скрытие и сворачивание не совмещаются. Для вложенной прокрутки укажите `scrollTarget`. При получении фокуса контролы возвращаются. При reduced motion переходы отключены. + +Корень имеет `role="toolbar"`, доступное имя и ориентацию. Tab проходит по обычным контролам, стрелки вдоль оси перемещают фокус, Home/End переводят его к краям. Стрелки внутри текстового поля сохраняют обычное поведение. Давайте названия иконочным кнопкам. Сам контейнер не обрабатывает нажатие. [Доступность Material](https://m3.material.io/components/toolbars/accessibility). + +```tsx + + + + + +``` + +## API + +| Экспорт | Назначение | +| --- | --- | +| `M3Toolbar` | Контейнер, компоновка, реакция на прокрутку и overflow иконочных действий. | +| `M3ToolbarProps`, `M3ToolbarExposed` | Публичные React props и ссылка на элемент. | +| `ToolbarAction`, `ToolbarVariant`, `ToolbarOrientation`, `ToolbarColor`, `ToolbarScroll`, `ToolbarSide` | Общие типы foundation. | + +| Prop | По умолчанию | Назначение | +| --- | --- | --- | +| `label` | Обязателен | Доступное имя toolbar. | +| `variant` | `docked` | `docked` или `floating`. | +| `orientation` | `horizontal` | `vertical` действует для floating. | +| `color` | `standard` | Цветовая схема `standard` или `vibrant`. | +| `side` | `end` | `start` или `end` для вертикального floating. | +| `scroll` | `pinned` | `pinned`, `hide`, `collapse` (floating с FAB). | +| `scrollTarget` | `window` | Наблюдаемое окно или прокручиваемый элемент. | +| `actions` | `[]` | Иконочные действия с автоматическим overflow. | +| `overflowLabel` | `More actions` | Имя кнопки меню; переведите его под язык приложения. | +| HTML-атрибуты | — | Передаются корневому элементу. | + +`M3Toolbar.Fab` располагает FAB рядом с floating-контейнером. Обычные children попадают в область пользовательского содержимого. Ref предоставляет `el`. + +## Источники + +- [Обзор](https://m3.material.io/components/toolbars/overview) +- [Спецификация](https://m3.material.io/components/toolbars/specs) +- [Рекомендации](https://m3.material.io/components/toolbars/guidelines) +- [Доступность](https://m3.material.io/components/toolbars/accessibility) + + diff --git a/m3-react/storybook/components/M3Toolbar.stories.tsx b/m3-react/storybook/components/M3Toolbar.stories.tsx new file mode 100644 index 0000000..3239d31 --- /dev/null +++ b/m3-react/storybook/components/M3Toolbar.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { M3Toolbar } from '@/components/toolbar' + +import ToolbarExample from '../examples/toolbar/ToolbarExample' + +const meta = { + title: 'Components/M3Toolbar', + component: M3Toolbar, + args: { label: 'Editing tools', variant: 'docked', orientation: 'horizontal', color: 'standard', side: 'end', scroll: 'pinned' }, + argTypes: { + variant: { control: 'select', options: ['docked', 'floating'] }, + orientation: { control: 'select', options: ['horizontal', 'vertical'] }, + color: { control: 'select', options: ['standard', 'vibrant'] }, + side: { control: 'select', options: ['start', 'end'] }, + scroll: { control: 'select', options: ['pinned', 'hide', 'collapse'] }, + actions: { control: false }, + scrollTarget: { control: false }, + overflowLabel: { control: false }, + }, + render: (args, { globals }) => , + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Docked: Story = {} +export const DockedVibrant: Story = { args: { color: 'vibrant' } } +export const Floating: Story = { args: { variant: 'floating' } } +export const FloatingVibrant: Story = { args: { variant: 'floating', color: 'vibrant' } } +export const VerticalFloating: Story = { args: { variant: 'floating', orientation: 'vertical' } } +export const HideOnScroll: Story = { args: { variant: 'floating', scroll: 'hide' } } +export const CollapseOnScroll: Story = { args: { variant: 'floating', scroll: 'collapse' } } +export const NarrowOverflow: Story = { + args: { variant: 'floating' }, + render: (args, { globals }) => , +} diff --git a/m3-react/storybook/examples/toolbar/ToolbarExample.tsx b/m3-react/storybook/examples/toolbar/ToolbarExample.tsx new file mode 100644 index 0000000..a78303e --- /dev/null +++ b/m3-react/storybook/examples/toolbar/ToolbarExample.tsx @@ -0,0 +1,87 @@ +import type { + ToolbarColor, + ToolbarOrientation, + ToolbarScroll, + ToolbarSide, + ToolbarVariant, +} from '@modulify/m3-foundation/types/components/toolbar' + +import { useState } from 'react' + +import { M3FabButton } from '@/components/fab-button' +import { M3Icon } from '@/components/icon' +import { M3Toolbar } from '@/components/toolbar' + +import { localize } from '../../i18n' + +const messages = { + 'en-US': { + title: 'Document editor', hint: 'Scroll to see how the toolbar responds.', label: 'Editing tools', more: 'More editing tools', + actions: ['Bold', 'Italic', 'Underline', 'Insert link', 'Insert image', 'Align', 'Comment'], + cards: ['Outline', 'Introduction', 'Research notes', 'References', 'Draft', 'Review', 'Changes', 'Next steps'], + add: 'Add section', last: 'Last action', + }, + 'ru-RU': { + title: 'Редактор документа', hint: 'Прокрутите страницу, чтобы увидеть поведение панели.', label: 'Инструменты редактирования', more: 'Другие инструменты', + actions: ['Полужирный', 'Курсив', 'Подчеркнуть', 'Вставить ссылку', 'Вставить изображение', 'Выровнять', 'Комментировать'], + cards: ['Структура', 'Введение', 'Исследование', 'Источники', 'Черновик', 'Рецензия', 'Изменения', 'Следующие шаги'], + add: 'Добавить раздел', last: 'Последнее действие', + }, +} + +const icons = ['format_bold', 'format_italic', 'format_underlined', 'link', 'image', 'format_align_left', 'comment'] + +interface Props { + variant: ToolbarVariant; + orientation: ToolbarOrientation; + color: ToolbarColor; + scroll: ToolbarScroll; + side: ToolbarSide; + locale: string; + narrow?: boolean; +} + +export default function ToolbarExample({ variant, orientation, color, scroll, side, locale, narrow = false }: Props) { + const text = localize(locale, messages) + const [scroller, setScroller] = useState(null) + const [last, setLast] = useState('—') + const actions = text.actions.map((label, index) => ({ + id: icons[index], label, icon: icons[index], selected: index === 0, + onClick: () => setLast(label), + })) + + return
+
+

{text.title}

+

{text.hint} {text.last}: {last}

+
+ {[...text.cards, ...text.cards].map((card, index) =>
+

{card}

+

Material 3

+
)} +
+
+ + {variant === 'floating' && + setLast(text.add)}> + } + +
+} diff --git a/m3-react/tests/M3Toolbar.e2e.tsx b/m3-react/tests/M3Toolbar.e2e.tsx new file mode 100644 index 0000000..58604fa --- /dev/null +++ b/m3-react/tests/M3Toolbar.e2e.tsx @@ -0,0 +1,110 @@ +import { act, fireEvent } from '@testing-library/react' +import { page } from 'vitest/browser' +import { render, screen, waitFor } from '@testing-library/react' + +import { M3FabButton } from '@/components/fab-button' +import { M3Icon } from '@/components/icon' +import { M3Toolbar } from '@/components/toolbar' + +const actions = ['Bold', 'Italic', 'Underline', 'Link', 'Image', 'Align'].map((label, index) => ({ + id: String(index), label, icon: 'edit', onClick: vi.fn(), +})) +const overflowActions = actions.map((action, index) => index === 4 ? { ...action, label: 'Вставить изображение' } : action) + +test('keeps floating actions within the viewport and restores overflowed actions after resize', async () => { + await page.viewport(320, 700) + const view = render() + + try { + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + const container = toolbar.querySelector('.m3-toolbar__container')! + await waitFor(() => expect(screen.getByRole('button', { name: 'More tools' })).not.toBeNull()) + const bounds = container.getBoundingClientRect() + expect(bounds.height).toBe(64) + expect(bounds.left).toBeGreaterThanOrEqual(16) + expect(bounds.right).toBeLessThanOrEqual(304) + + fireEvent.click(screen.getByRole('button', { name: 'More tools' })) + const item = await screen.findByText('Вставить изображение') + const menu = item.closest('.m3-menu')! + await waitFor(() => { + const menuBounds = menu.getBoundingClientRect() + const toolbarBounds = toolbar.getBoundingClientRect() + expect(Math.min(menuBounds.bottom, toolbarBounds.bottom) - Math.max(menuBounds.top, toolbarBounds.top)).toBeGreaterThan(0) + const x = (Math.max(menuBounds.left, toolbarBounds.left) + Math.min(menuBounds.right, toolbarBounds.right)) / 2 + const y = (Math.max(menuBounds.top, toolbarBounds.top) + Math.min(menuBounds.bottom, toolbarBounds.bottom)) / 2 + expect(document.elementFromPoint(x, y)?.closest('.m3-menu')).toBe(menu) + }) + const body = item.closest('.m3-menu-item__body')! + const textStarts = [...menu.querySelectorAll('.m3-menu-item__body')].map(element => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect().left + }) + expect(Math.max(...textStarts) - Math.min(...textStarts)).toBeLessThan(1) + expect(body.scrollWidth).toBeLessThanOrEqual(body.clientWidth) + expect(body.getBoundingClientRect().height).toBeLessThan(parseFloat(getComputedStyle(body).lineHeight) * 1.5) + fireEvent.click(item) + expect(actions[4].onClick).toHaveBeenCalledOnce() + + await act(async () => { await page.viewport(900, 700) }) + await waitFor(() => expect(screen.queryByRole('button', { name: 'More tools' })).toBeNull()) + expect(screen.getByRole('button', { name: 'Вставить изображение' })).not.toBeNull() + } finally { + view.unmount() + } +}) + +test('restores the full floating width after collapsing to the FAB', async () => { + await page.viewport(900, 700) + const scroller = document.createElement('main') + Object.assign(scroller.style, { height: '300px', overflowY: 'auto' }) + document.body.append(scroller) + const view = render(<> + + + + +
+ , { container: scroller }) + + try { + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + const container = toolbar.querySelector('.m3-toolbar__container')! + const expandedWidth = container.getBoundingClientRect().width + scroller.scrollTop = 100 + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(true)) + scroller.scrollTop = 0 + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(false)) + await waitFor(() => expect(container.getBoundingClientRect().width).toBeCloseTo(expandedWidth, 0)) + } finally { + view.unmount() + scroller.remove() + } +}) + +test('positions vertical floating toolbar at the logical end and collapses to FAB on scroll', async () => { + await page.viewport(900, 700) + const scroller = document.createElement('main') + Object.assign(scroller.style, { height: '300px', overflowY: 'auto' }) + document.body.append(scroller) + const view = render(<> + + + +
+ , { container: scroller }) + + try { + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + expect(Math.round(900 - toolbar.getBoundingClientRect().right)).toBe(24) + scroller.scrollTop = 100 + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(true)) + expect(screen.getByRole('button', { name: 'Add' })).not.toBeNull() + scroller.scrollTop = 0 + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(false)) + } finally { + view.unmount() + scroller.remove() + } +}) diff --git a/m3-react/tests/M3Toolbar.test.tsx b/m3-react/tests/M3Toolbar.test.tsx new file mode 100644 index 0000000..9b66eae --- /dev/null +++ b/m3-react/tests/M3Toolbar.test.tsx @@ -0,0 +1,87 @@ +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import { M3Toolbar } from '@/components/toolbar' + +const actions = [ + { id: 'bold', label: 'Bold', icon: 'format_bold', onClick: vi.fn() }, + { id: 'italic', label: 'Italic', icon: 'format_italic', onClick: vi.fn() }, + { id: 'underline', label: 'Underline', icon: 'format_underlined', onClick: vi.fn() }, + { id: 'link', label: 'Link', icon: 'link', onClick: vi.fn() }, + { id: 'image', label: 'Image', icon: 'image', onClick: vi.fn() }, +] + +describe('m3-react/toolbar', () => { + test('renders docked and vertical floating variants with accessible actions', () => { + const { rerender, container } = render( + + ) + expect(screen.getByRole('toolbar', { name: 'Editing' }).getAttribute('aria-orientation')).toBe('horizontal') + expect(screen.getByRole('button', { name: 'Bold' })).not.toBeNull() + expect(container.querySelector('.m3-toolbar_docked')).not.toBeNull() + expect(container.querySelector('.m3-toolbar__fab')).toBeNull() + + rerender( + + ) + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + expect(toolbar.getAttribute('aria-orientation')).toBe('vertical') + expect(toolbar.classList.contains('m3-toolbar_side-start')).toBe(true) + expect(toolbar.classList.contains('m3-toolbar_vibrant')).toBe(true) + expect(container.querySelector('.m3-toolbar__fab')).not.toBeNull() + }) + + test('moves focus along the toolbar without stealing input arrows', () => { + render() + const bold = screen.getByRole('button', { name: 'Bold' }) + bold.focus() + fireEvent.keyDown(bold, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Italic' })) + + const input = screen.getByRole('textbox', { name: 'Find' }) + input.focus() + fireEvent.keyDown(input, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(input) + }) + + test('moves excess actions into an accessible menu at a narrow width', async () => { + const width = window.innerWidth + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 200 }) + try { + render() + expect(screen.getByRole('button', { name: 'Bold' })).not.toBeNull() + expect(screen.queryByRole('button', { name: 'Underline' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'More editing actions' })) + const item = await screen.findByText('Underline') + fireEvent.click(item) + expect(actions[2].onClick).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }) + } + }) + + test('hides or collapses on forward scroll and restores on reverse scroll', async () => { + const scroller = document.createElement('div') + const view = render( + + ) + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + + scroller.scrollTop = 40 + fireEvent.scroll(scroller) + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(true)) + expect(screen.getByRole('button', { name: 'Add' })).not.toBeNull() + + scroller.scrollTop = 20 + fireEvent.scroll(scroller) + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(false)) + view.rerender() + scroller.scrollTop = 60 + fireEvent.scroll(scroller) + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_hidden')).toBe(true)) + }) +}) From 35c17104cde36e791817817de9f61a2b50f88811 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Fri, 9 Oct 2026 11:41:12 +0400 Subject: [PATCH 13/20] feat(m3-vue): Toolbar component and examples were added --- m3-vue/src/components/index.ts | 1 + m3-vue/src/components/toolbar/M3Toolbar.vue | 218 ++++++++++++++++++ m3-vue/src/components/toolbar/index.ts | 3 + m3-vue/storybook/components/M3Toolbar.mdx | 157 +++++++++++++ .../storybook/components/M3Toolbar.stories.ts | 47 ++++ .../examples/toolbar/ToolbarExample.vue | 104 +++++++++ m3-vue/tests/M3Toolbar.e2e.ts | 133 +++++++++++ m3-vue/tests/M3Toolbar.test.ts | 82 +++++++ 8 files changed, 745 insertions(+) create mode 100644 m3-vue/src/components/toolbar/M3Toolbar.vue create mode 100644 m3-vue/src/components/toolbar/index.ts create mode 100644 m3-vue/storybook/components/M3Toolbar.mdx create mode 100644 m3-vue/storybook/components/M3Toolbar.stories.ts create mode 100644 m3-vue/storybook/examples/toolbar/ToolbarExample.vue create mode 100644 m3-vue/tests/M3Toolbar.e2e.ts create mode 100644 m3-vue/tests/M3Toolbar.test.ts diff --git a/m3-vue/src/components/index.ts b/m3-vue/src/components/index.ts index 6bf8f3e..795748b 100644 --- a/m3-vue/src/components/index.ts +++ b/m3-vue/src/components/index.ts @@ -28,3 +28,4 @@ export * from './slider' export * from './surface' export * from './switch' export * from './text-field' +export * from './toolbar' diff --git a/m3-vue/src/components/toolbar/M3Toolbar.vue b/m3-vue/src/components/toolbar/M3Toolbar.vue new file mode 100644 index 0000000..ae2f1ed --- /dev/null +++ b/m3-vue/src/components/toolbar/M3Toolbar.vue @@ -0,0 +1,218 @@ + + + diff --git a/m3-vue/src/components/toolbar/index.ts b/m3-vue/src/components/toolbar/index.ts new file mode 100644 index 0000000..31ee413 --- /dev/null +++ b/m3-vue/src/components/toolbar/index.ts @@ -0,0 +1,3 @@ +import M3Toolbar from './M3Toolbar.vue' + +export { M3Toolbar } diff --git a/m3-vue/storybook/components/M3Toolbar.mdx b/m3-vue/storybook/components/M3Toolbar.mdx new file mode 100644 index 0000000..e54f004 --- /dev/null +++ b/m3-vue/storybook/components/M3Toolbar.mdx @@ -0,0 +1,157 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import { Localized } from '../i18n' +import * as M3ToolbarStories from './M3Toolbar.stories' + + + + + +# Toolbar + +Toolbars group actions related to the current page or content. `M3Toolbar` implements the two [Material 3 Expressive variants](https://m3.material.io/components/toolbars/overview): **docked** and **floating**. The former bottom app bar is a baseline variant that Material still lists but no longer recommends; use a docked toolbar for a new Expressive design. + +## Guide + +| Variant | Use | Placement | +| --- | --- | --- | +| `docked` | Actions shared across several pages | Full width at the bottom; do not show beside a bottom navigation bar. | +| `floating`, horizontal | Actions for the current page or selection | Above content, at least 16 px from the window edge. A FAB may sit beside it. | +| `floating`, vertical | Compact action group on a larger window | Along a side, at least 24 px from the edge; place it opposite a navigation rail. | + +Use `color="standard"` to keep attention on content and `vibrant` to emphasize controls or a temporary mode. Keep one prominent action. Put an emphasized action inside a docked toolbar; a FAB beside a floating toolbar is a separate slot. The toolbar does not create or manage that FAB. Both orientations support an accessible toolbar label. [Material guidelines](https://m3.material.io/components/toolbars/guidelines). + +### Actions and overflow + +Pass icon actions through `actions` for automatic overflow. Each action needs a stable `id`, a localized `label`, an icon name, and `onClick`; optional `disabled` and `selected` states follow it into the menu. The toolbar measures its containing element and keeps the last actions in the overflow menu when space runs out. Translate `overflowLabel` as well. Actions must remain understandable when shown as text in the menu. + +Use the default slot for buttons, text fields, images, or other custom content. The toolbar lays them out, but only entries in `actions` participate in automatic overflow. Keep custom content small enough for the available space; manage its responsive presentation yourself. Avoid a vertical toolbar with wide controls and avoid too many unrelated control types. [Material flexibility and resizing](https://m3.material.io/components/toolbars/guidelines). + +### Scrolling and accessibility + +`scroll="pinned"` leaves the toolbar on screen. `hide` moves it offscreen while scrolling forward and restores it while scrolling back. On a floating toolbar with a `#fab` slot, `collapse` keeps only that primary action visible while scrolling forward. It does not combine collapse with hide. Pass `scrollTarget` for a nested scroll area. Focusing the toolbar restores its controls. When reduced motion is requested, transitions stop. + +The root has `role="toolbar"`, an accessible label and orientation. Tab reaches native controls; the direction's arrow keys move among them, Home/End move to the ends, and arrow keys inside text fields keep their native behavior. Name every icon control. The toolbar has no click behavior of its own. [Material accessibility](https://m3.material.io/components/toolbars/accessibility). + +```vue + + + +``` + +## API + +| Export | Purpose | +| --- | --- | +| `M3Toolbar` | Container, layout, scroll response, and icon-action overflow. | +| Vue props and exposed `el` | Public component controls and element reference. | +| `ToolbarAction`, `ToolbarVariant`, `ToolbarOrientation`, `ToolbarColor`, `ToolbarScroll`, `ToolbarSide` | Shared foundation types. | + +| Prop | Default | Effect | +| --- | --- | --- | +| `label` | Required | Accessible name of this toolbar. | +| `variant` | `docked` | `docked` or `floating`. | +| `orientation` | `horizontal` | `vertical` applies to floating. | +| `color` | `standard` | `standard` or `vibrant` container/action colors. | +| `side` | `end` | `start` or `end` for vertical floating. | +| `scroll` | `pinned` | `pinned`, `hide`, or `collapse` (floating with FAB). | +| `scrollTarget` | `window` | Scrollable element or window to observe. | +| `actions` | `[]` | Icon actions eligible for automatic overflow. | +| `overflowLabel` | `More actions` | Accessible overflow trigger name; translate for the app locale. | +| HTML attributes | — | Passed to the toolbar root. | + +`#fab` places a FAB beside the floating container. The default slot fills the custom-content area. The component instance exposes `el`. + +## Sources + +- [Material overview](https://m3.material.io/components/toolbars/overview) +- [Specs](https://m3.material.io/components/toolbars/specs) +- [Guidelines](https://m3.material.io/components/toolbars/guidelines) +- [Accessibility](https://m3.material.io/components/toolbars/accessibility) + + + + + +# Панель инструментов + +Toolbar объединяет действия текущей страницы или её содержимого. `M3Toolbar` реализует два варианта [Material 3 Expressive](https://m3.material.io/components/toolbars/overview): **docked** и **floating**. Прежний bottom app bar остался в baseline, но Material больше не рекомендует его; для нового Expressive-интерфейса используйте docked toolbar. + +## Руководство + +| Вариант | Когда использовать | Положение | +| --- | --- | --- | +| `docked` | Действия, общие для нескольких страниц | На всю ширину снизу; не показывать одновременно с нижним navigation bar. | +| `floating`, горизонтальный | Действия текущей страницы или выделения | Поверх содержимого, не ближе 16 px к краю. Рядом возможен FAB. | +| `floating`, вертикальный | Компактная группа действий на широком экране | У бокового края с отступом от 24 px; напротив navigation rail. | + +`color="standard"` сохраняет внимание на содержимом; `vibrant` выделяет контролы или временный режим. Делайте акцент на одном действии. В docked добавьте его внутрь панели; FAB рядом с floating передаётся отдельным слотом. Toolbar не управляет действием FAB. Каждому toolbar требуется доступное имя. [Рекомендации Material](https://m3.material.io/components/toolbars/guidelines). + +### Действия и overflow + +Передайте иконочные действия в `actions` для автоматического переноса в overflow. Каждому нужен стабильный `id`, локализованное `label`, имя иконки и `onClick`; `disabled` и `selected` сохраняются в меню. Toolbar измеряет родительский контейнер и переносит последние действия, когда места не хватает. Переведите и `overflowLabel`. Название действия должно быть понятным без иконки. + +Слот по умолчанию подходит для кнопок, текстовых полей, изображений и другого произвольного содержимого. Toolbar располагает их, но автоматически переносит в меню **только** элементы `actions`. Для пользовательского содержимого подберите собственную адаптивную компоновку. Избегайте широких элементов в вертикальной панели и множества несвязанных видов контролов. [Гибкость и адаптивность в спеке](https://m3.material.io/components/toolbars/guidelines). + +### Прокрутка и доступность + +`scroll="pinned"` оставляет toolbar на экране. `hide` уводит его при прокрутке вперёд и возвращает при обратной. Для floating с `#fab` режим `collapse` оставляет только главное действие. Скрытие и сворачивание не совмещаются. Для вложенной прокрутки укажите `scrollTarget`. При получении фокуса контролы возвращаются. При reduced motion переходы отключены. + +Корень имеет `role="toolbar"`, доступное имя и ориентацию. Tab проходит по обычным контролам, стрелки вдоль оси перемещают фокус, Home/End переводят его к краям. Стрелки внутри текстового поля сохраняют обычное поведение. Давайте названия иконочным кнопкам. Сам контейнер не обрабатывает нажатие. [Доступность Material](https://m3.material.io/components/toolbars/accessibility). + +```vue + + + +``` + +## API + +| Экспорт | Назначение | +| --- | --- | +| `M3Toolbar` | Контейнер, компоновка, реакция на прокрутку и overflow иконочных действий. | +| Vue props и exposed `el` | Публичные настройки и ссылка на элемент. | +| `ToolbarAction`, `ToolbarVariant`, `ToolbarOrientation`, `ToolbarColor`, `ToolbarScroll`, `ToolbarSide` | Общие типы foundation. | + +| Prop | По умолчанию | Назначение | +| --- | --- | --- | +| `label` | Обязателен | Доступное имя toolbar. | +| `variant` | `docked` | `docked` или `floating`. | +| `orientation` | `horizontal` | `vertical` действует для floating. | +| `color` | `standard` | Цветовая схема `standard` или `vibrant`. | +| `side` | `end` | `start` или `end` для вертикального floating. | +| `scroll` | `pinned` | `pinned`, `hide`, `collapse` (floating с FAB). | +| `scrollTarget` | `window` | Наблюдаемое окно или прокручиваемый элемент. | +| `actions` | `[]` | Иконочные действия с автоматическим overflow. | +| `overflowLabel` | `More actions` | Имя кнопки меню; переведите его под язык приложения. | +| HTML-атрибуты | — | Передаются корневому элементу. | + +`#fab` располагает FAB рядом с floating-контейнером. Содержимое слота по умолчанию попадает в область пользовательского содержимого. Экземпляр компонента предоставляет `el`. + +## Источники + +- [Обзор](https://m3.material.io/components/toolbars/overview) +- [Спецификация](https://m3.material.io/components/toolbars/specs) +- [Рекомендации](https://m3.material.io/components/toolbars/guidelines) +- [Доступность](https://m3.material.io/components/toolbars/accessibility) + + diff --git a/m3-vue/storybook/components/M3Toolbar.stories.ts b/m3-vue/storybook/components/M3Toolbar.stories.ts new file mode 100644 index 0000000..dacb376 --- /dev/null +++ b/m3-vue/storybook/components/M3Toolbar.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3Toolbar } from '@/components/toolbar' + +import ToolbarExample from '../examples/toolbar/ToolbarExample.vue' + +const meta = { + title: 'Components/M3Toolbar', + component: M3Toolbar, + args: { label: 'Editing tools', variant: 'docked', orientation: 'horizontal', color: 'standard', side: 'end', scroll: 'pinned' }, + argTypes: { + variant: { control: 'select', options: ['docked', 'floating'] }, + orientation: { control: 'select', options: ['horizontal', 'vertical'] }, + color: { control: 'select', options: ['standard', 'vibrant'] }, + side: { control: 'select', options: ['start', 'end'] }, + scroll: { control: 'select', options: ['pinned', 'hide', 'collapse'] }, + actions: { control: false }, + scrollTarget: { control: false }, + overflowLabel: { control: false }, + }, + render: (args: Record, { globals }) => ({ + components: { ToolbarExample }, + setup: () => ({ ...args, locale: globals.locale }), + template: '', + }), + parameters: { layout: 'fullscreen' }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Docked: Story = {} +export const DockedVibrant: Story = { args: { color: 'vibrant' } } +export const Floating: Story = { args: { variant: 'floating' } } +export const FloatingVibrant: Story = { args: { variant: 'floating', color: 'vibrant' } } +export const VerticalFloating: Story = { args: { variant: 'floating', orientation: 'vertical' } } +export const HideOnScroll: Story = { args: { variant: 'floating', scroll: 'hide' } } +export const CollapseOnScroll: Story = { args: { variant: 'floating', scroll: 'collapse' } } +export const NarrowOverflow: Story = { + args: { variant: 'floating' }, + render: (args: Record, { globals }) => ({ + components: { ToolbarExample }, + setup: () => ({ ...args, locale: globals.locale }), + template: '', + }), +} diff --git a/m3-vue/storybook/examples/toolbar/ToolbarExample.vue b/m3-vue/storybook/examples/toolbar/ToolbarExample.vue new file mode 100644 index 0000000..9a6d183 --- /dev/null +++ b/m3-vue/storybook/examples/toolbar/ToolbarExample.vue @@ -0,0 +1,104 @@ + + + diff --git a/m3-vue/tests/M3Toolbar.e2e.ts b/m3-vue/tests/M3Toolbar.e2e.ts new file mode 100644 index 0000000..89904a7 --- /dev/null +++ b/m3-vue/tests/M3Toolbar.e2e.ts @@ -0,0 +1,133 @@ +import { createApp, h, nextTick } from 'vue' +import { page } from 'vitest/browser' + +import { M3FabButton } from '@/components/fab-button' +import { M3Icon } from '@/components/icon' +import { M3Toolbar } from '@/components/toolbar' + +const actions = ['Bold', 'Italic', 'Underline', 'Link', 'Image', 'Align'].map((label, index) => ({ + id: String(index), label, icon: 'edit', onClick: vi.fn(), +})) +const overflowActions = actions.map((action, index) => index === 4 ? { ...action, label: 'Вставить изображение' } : action) + +const waitFor = async (assertion: () => void) => { + const startedAt = performance.now() + while (performance.now() - startedAt < 2000) { + try { assertion(); return } catch { await new Promise(resolve => setTimeout(resolve, 16)) } + } + assertion() +} + +test('keeps floating actions within the viewport and restores overflowed actions after resize', async () => { + await page.viewport(320, 700) + const mountPoint = document.createElement('div') + document.body.append(mountPoint) + const app = createApp(M3Toolbar, { label: 'Editing', variant: 'floating', actions: overflowActions, overflowLabel: 'More tools' }) + app.mount(mountPoint) + + try { + const toolbar = mountPoint.querySelector('.m3-toolbar')! + const container = toolbar.querySelector('.m3-toolbar__container')! + await waitFor(() => expect(mountPoint.querySelector('[aria-label="More tools"]')).not.toBeNull()) + const bounds = container.getBoundingClientRect() + expect(bounds.height).toBe(64) + expect(bounds.left).toBeGreaterThanOrEqual(16) + expect(bounds.right).toBeLessThanOrEqual(304) + + const trigger = mountPoint.querySelector('[aria-label="More tools"]') as HTMLButtonElement + trigger.click() + await waitFor(() => expect([...document.querySelectorAll('.m3-menu-item')].some(item => item.textContent?.includes('Вставить изображение'))).toBe(true)) + const item = [...document.querySelectorAll('.m3-menu-item')].find(element => element.textContent?.includes('Вставить изображение'))! + const menu = item.closest('.m3-menu')! + await waitFor(() => { + const menuBounds = menu.getBoundingClientRect() + const toolbarBounds = toolbar.getBoundingClientRect() + expect(Math.min(menuBounds.bottom, toolbarBounds.bottom) - Math.max(menuBounds.top, toolbarBounds.top)).toBeGreaterThan(0) + const x = (Math.max(menuBounds.left, toolbarBounds.left) + Math.min(menuBounds.right, toolbarBounds.right)) / 2 + const y = (Math.max(menuBounds.top, toolbarBounds.top) + Math.min(menuBounds.bottom, toolbarBounds.bottom)) / 2 + expect(document.elementFromPoint(x, y)?.closest('.m3-menu')).toBe(menu) + }) + const body = item.querySelector('.m3-menu-item__body')! + const textStarts = [...menu.querySelectorAll('.m3-menu-item__body')].map(element => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect().left + }) + expect(Math.max(...textStarts) - Math.min(...textStarts)).toBeLessThan(1) + expect(body.scrollWidth).toBeLessThanOrEqual(body.clientWidth) + expect(body.getBoundingClientRect().height).toBeLessThan(parseFloat(getComputedStyle(body).lineHeight) * 1.5) + item.click() + expect(actions[4].onClick).toHaveBeenCalledOnce() + + await page.viewport(900, 700) + await waitFor(() => expect(mountPoint.querySelector('[aria-label="More tools"]')).toBeNull()) + expect(mountPoint.querySelector('[aria-label="Вставить изображение"]')).not.toBeNull() + } finally { + app.unmount() + mountPoint.remove() + } +}) + +test('restores the full floating width after collapsing to the FAB', async () => { + await page.viewport(900, 700) + const scroller = document.createElement('main') + Object.assign(scroller.style, { height: '300px', overflowY: 'auto' }) + document.body.append(scroller) + const app = createApp({ + render: () => [ + h(M3Toolbar, { + label: 'Editing', variant: 'floating', scroll: 'collapse', scrollTarget: scroller, actions, + }, { + default: () => h('button', 'Custom action'), + fab: () => h(M3FabButton, { 'aria-label': 'Add' }, () => h(M3Icon, { name: 'add' })), + }), + h('div', { style: { height: '1000px' } }), + ], + }) + app.mount(scroller) + + try { + const toolbar = scroller.querySelector('.m3-toolbar')! + const container = toolbar.querySelector('.m3-toolbar__container')! + const expandedWidth = container.getBoundingClientRect().width + scroller.scrollTop = 100 + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(true)) + scroller.scrollTop = 0 + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(false)) + await waitFor(() => expect(container.getBoundingClientRect().width).toBeCloseTo(expandedWidth, 0)) + } finally { + app.unmount() + scroller.remove() + } +}) + +test('positions vertical floating toolbar at the logical end and collapses to FAB on scroll', async () => { + await page.viewport(900, 700) + const scroller = document.createElement('main') + Object.assign(scroller.style, { height: '300px', overflowY: 'auto' }) + document.body.append(scroller) + const app = createApp({ + render: () => [ + h(M3Toolbar, { + label: 'Editing', variant: 'floating', orientation: 'vertical', scroll: 'collapse', scrollTarget: scroller, actions: actions.slice(0, 3), + }, { fab: () => h(M3FabButton, { 'aria-label': 'Add' }, () => h(M3Icon, { name: 'add' })) }), + h('div', { style: { height: '1000px' } }), + ], + }) + app.mount(scroller) + + try { + const toolbar = scroller.querySelector('.m3-toolbar')! + expect(Math.round(900 - toolbar.getBoundingClientRect().right)).toBe(24) + scroller.scrollTop = 100 + await nextTick() + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(true)) + expect(scroller.querySelector('[aria-label="Add"]')).not.toBeNull() + scroller.scrollTop = 0 + await nextTick() + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(false)) + } finally { + app.unmount() + scroller.remove() + } +}) diff --git a/m3-vue/tests/M3Toolbar.test.ts b/m3-vue/tests/M3Toolbar.test.ts new file mode 100644 index 0000000..0527bcb --- /dev/null +++ b/m3-vue/tests/M3Toolbar.test.ts @@ -0,0 +1,82 @@ +import { fireEvent } from '@testing-library/vue' +import { h, nextTick } from 'vue' +import { render, screen, waitFor } from '@testing-library/vue' + +import { M3Toolbar } from '@/components/toolbar' + +const actions = [ + { id: 'bold', label: 'Bold', icon: 'format_bold', onClick: vi.fn() }, + { id: 'italic', label: 'Italic', icon: 'format_italic', onClick: vi.fn() }, + { id: 'underline', label: 'Underline', icon: 'format_underlined', onClick: vi.fn() }, + { id: 'link', label: 'Link', icon: 'link', onClick: vi.fn() }, + { id: 'image', label: 'Image', icon: 'image', onClick: vi.fn() }, +] + +describe('m3-vue/toolbar', () => { + test('renders docked and vertical floating variants with accessible actions', async () => { + const view = render(M3Toolbar, { props: { label: 'Editing', actions }, slots: { fab: () => h('button', 'Add') } }) + expect(screen.getByRole('toolbar', { name: 'Editing' }).getAttribute('aria-orientation')).toBe('horizontal') + expect(screen.getByRole('button', { name: 'Bold' })).not.toBeNull() + expect(view.container.querySelector('.m3-toolbar_docked')).not.toBeNull() + expect(view.container.querySelector('.m3-toolbar__fab')).toBeNull() + + await view.rerender({ label: 'Editing', variant: 'floating', orientation: 'vertical', color: 'vibrant', side: 'start', actions }) + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + expect(toolbar.getAttribute('aria-orientation')).toBe('vertical') + expect(toolbar.classList.contains('m3-toolbar_side-start')).toBe(true) + expect(toolbar.classList.contains('m3-toolbar_vibrant')).toBe(true) + expect(view.container.querySelector('.m3-toolbar__fab')).not.toBeNull() + }) + + test('moves focus along the toolbar without stealing input arrows', async () => { + render(M3Toolbar, { props: { label: 'Editing', actions: actions.slice(0, 2) }, slots: { default: () => h('input', { 'aria-label': 'Find' }) } }) + const bold = screen.getByRole('button', { name: 'Bold' }) + bold.focus() + await fireEvent.keyDown(bold, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Italic' })) + + const input = screen.getByRole('textbox', { name: 'Find' }) + input.focus() + await fireEvent.keyDown(input, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(input) + }) + + test('moves excess actions into an accessible menu at a narrow width', async () => { + const width = window.innerWidth + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 200 }) + try { + render(M3Toolbar, { props: { label: 'Editing', actions, overflowLabel: 'More editing actions' } }) + await nextTick() + expect(screen.getByRole('button', { name: 'Bold' })).not.toBeNull() + expect(screen.queryByRole('button', { name: 'Underline' })).toBeNull() + await fireEvent.click(screen.getByRole('button', { name: 'More editing actions' })) + const item = await screen.findByText('Underline') + await fireEvent.click(item) + expect(actions[2].onClick).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }) + } + }) + + test('hides or collapses on forward scroll and restores on reverse scroll', async () => { + const scroller = document.createElement('div') + const view = render(M3Toolbar, { + props: { label: 'Editing', variant: 'floating', scroll: 'collapse', scrollTarget: scroller, actions }, + slots: { fab: () => h('button', 'Add') }, + }) + const toolbar = screen.getByRole('toolbar', { name: 'Editing' }) + + scroller.scrollTop = 40 + await fireEvent.scroll(scroller) + expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(true) + expect(screen.getByRole('button', { name: 'Add' })).not.toBeNull() + + scroller.scrollTop = 20 + await fireEvent.scroll(scroller) + expect(toolbar.classList.contains('m3-toolbar_collapsed')).toBe(false) + await view.rerender({ label: 'Editing', scroll: 'hide', scrollTarget: scroller }) + scroller.scrollTop = 60 + await fireEvent.scroll(scroller) + await waitFor(() => expect(toolbar.classList.contains('m3-toolbar_hidden')).toBe(true)) + }) +}) From 0c90dcff5abf4742b85cca220c460e7f46430c9d Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Fri, 9 Oct 2026 11:41:54 +0400 Subject: [PATCH 14/20] test: Toolbar package exports were validated Closes #12 --- scripts/check-packages.mjs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs index 481ddd0..1002260 100644 --- a/scripts/check-packages.mjs +++ b/scripts/check-packages.mjs @@ -285,6 +285,8 @@ import { createRef } from 'react' import { ref } from 'vue' import { CalendarDay } from '@modulify/m3-foundation/lib/calendar' +import { getToolbarVisibleCount } from '@modulify/m3-foundation/lib/toolbar' +import type { ToolbarAction } from '@modulify/m3-foundation/types/components/toolbar' import type { ElementReference, Interactable } from '@modulify/m3-foundation/types/dom' import { durations, @@ -312,6 +314,8 @@ import { type M3LinkExposed, type M3NavigationExposed, type M3SideSheetExposed, + M3Toolbar as ReactToolbar, + type M3ToolbarProps, } from '@modulify/m3-react/components' import { useAnimationFrame as useReactAnimationFrame, @@ -331,7 +335,7 @@ import { useBreakpoint as useRootVueBreakpoint } from '@modulify/m3-vue' import { useMutationObserver as useRootVueMutationObserver } from '@modulify/m3-vue' import { useResizeObserver as useRootVueResizeObserver } from '@modulify/m3-vue' import { useTimeout as useRootVueTimeout } from '@modulify/m3-vue' -import { M3Select, type M3SelectOption } from '@modulify/m3-vue/components' +import { M3Select, M3Toolbar as VueToolbar, type M3SelectOption } from '@modulify/m3-vue/components' import { useAnimationFrame as useVueAnimationFrame, useBreakpoint as useVueBreakpoint, @@ -349,6 +353,8 @@ const buttonProps: M3ButtonProps = { appearance: 'filled', ref: buttonRef } const duration: M3MotionDuration = 'medium2' const easingName: M3MotionEasing = 'standard' const options: M3SelectOption[] = [{ value: 1, label: 'One' }] +const toolbarActions: ToolbarAction[] = [{ id: 'save', label: 'Save', icon: 'save', onClick: () => {} }] +const toolbarProps: M3ToolbarProps = { label: 'Editor actions', actions: toolbarActions } const reactObserverTarget = createRef() const reactMutationObserverTargets: Parameters[0] = [reactObserverTarget] const reactResizeObserverTargets: Parameters[0] = [reactObserverTarget] @@ -363,6 +369,8 @@ void [ CalendarDay, M3Button, M3Select, + ReactToolbar, + VueToolbar, RootM3Button, RootM3Select, buttonRef, @@ -377,6 +385,9 @@ void [ linkElement, navigationRef, options, + toolbarActions, + toolbarProps, + getToolbarVisibleCount, reactMutationObserverTargets, reactResizeObserverTargets, sideSheetRef, @@ -420,6 +431,7 @@ import { access } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { CalendarDay } from '@modulify/m3-foundation/lib/calendar' +import { getToolbarVisibleCount } from '@modulify/m3-foundation/lib/toolbar' import { durations, easing, timing } from '@modulify/m3-foundation/lib/motion' import { M3Button as RootM3Button } from '@modulify/m3-react' import { useAnimationFrame as useRootReactAnimationFrame } from '@modulify/m3-react' @@ -431,7 +443,7 @@ import { useInteractable as useRootReactInteractable } from '@modulify/m3-react' import { useMutationObserver as useRootReactMutationObserver } from '@modulify/m3-react' import { useResizeObserver as useRootReactResizeObserver } from '@modulify/m3-react' import { useTimeout as useRootReactTimeout } from '@modulify/m3-react' -import { M3Button } from '@modulify/m3-react/components' +import { M3Button, M3Toolbar as ReactToolbar } from '@modulify/m3-react/components' import { useAnimationFrame as useReactAnimationFrame, useBreakpoint as useReactBreakpoint, @@ -450,7 +462,7 @@ import { useBreakpoint as useRootVueBreakpoint } from '@modulify/m3-vue' import { useMutationObserver as useRootVueMutationObserver } from '@modulify/m3-vue' import { useResizeObserver as useRootVueResizeObserver } from '@modulify/m3-vue' import { useTimeout as useRootVueTimeout } from '@modulify/m3-vue' -import { M3Select } from '@modulify/m3-vue/components' +import { M3Select, M3Toolbar as VueToolbar } from '@modulify/m3-vue/components' import { useAnimationFrame as useVueAnimationFrame, useBreakpoint as useVueBreakpoint, @@ -485,6 +497,9 @@ if ( || !easing || !M3Button || !M3Select + || !ReactToolbar + || !VueToolbar + || getToolbarVisibleCount(200, 3) !== 3 || !RootM3Button || !RootM3Select || !useReactAnimationFrame @@ -524,6 +539,7 @@ if ( `) writeFileSync(resolve(temporaryDirectory, 'consumer.cjs'), ` const { CalendarDay } = require('@modulify/m3-foundation/lib/calendar') +const { getToolbarVisibleCount } = require('@modulify/m3-foundation/lib/toolbar') const { durations, easing, timing } = require('@modulify/m3-foundation/lib/motion') const { M3Button: RootM3Button } = require('@modulify/m3-react') const { useAnimationFrame: useRootReactAnimationFrame } = require('@modulify/m3-react') @@ -535,7 +551,7 @@ const { useInteractable: useRootReactInteractable } = require('@modulify/m3-reac const { useMutationObserver: useRootReactMutationObserver } = require('@modulify/m3-react') const { useResizeObserver: useRootReactResizeObserver } = require('@modulify/m3-react') const { useTimeout: useRootReactTimeout } = require('@modulify/m3-react') -const { M3Button } = require('@modulify/m3-react/components') +const { M3Button, M3Toolbar: ReactToolbar } = require('@modulify/m3-react/components') const { useAnimationFrame: useReactAnimationFrame, useBreakpoint: useReactBreakpoint, @@ -554,7 +570,7 @@ const { useBreakpoint: useRootVueBreakpoint } = require('@modulify/m3-vue') const { useMutationObserver: useRootVueMutationObserver } = require('@modulify/m3-vue') const { useResizeObserver: useRootVueResizeObserver } = require('@modulify/m3-vue') const { useTimeout: useRootVueTimeout } = require('@modulify/m3-vue') -const { M3Select } = require('@modulify/m3-vue/components') +const { M3Select, M3Toolbar: VueToolbar } = require('@modulify/m3-vue/components') const { useAnimationFrame: useVueAnimationFrame, useBreakpoint: useVueBreakpoint, @@ -570,6 +586,9 @@ if ( || !easing || !M3Button || !M3Select + || !ReactToolbar + || !VueToolbar + || getToolbarVisibleCount(200, 3) !== 3 || !RootM3Button || !RootM3Select || !useReactAnimationFrame From 7a33555647fff3b173c3ad6f3029c4650ac1fd6f Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 19 Oct 2026 12:21:19 +0400 Subject: [PATCH 15/20] feat(m3-foundation)!: Shared UI layer management was added Modal focus, popovers, floating content, and feedback now use isolated layer hosts and group levels. BREAKING CHANGE: The layers.scss level names were replaced with floating, feedback, popover, and modal; --m3-search-z-index no longer controls the search overlay. --- .../assets/stylesheets/basics/layers.scss | 13 +- .../stylesheets/components/app-bar/index.scss | 2 +- .../stylesheets/components/dialog/index.scss | 18 --- .../assets/stylesheets/components/index.scss | 1 + .../stylesheets/components/layer/index.scss | 27 ++++ .../components/navigation/index.scss | 4 +- .../stylesheets/components/scrim/index.scss | 4 +- .../stylesheets/components/search/index.scss | 3 +- .../components/side-sheet/index.scss | 3 +- .../components/snackbar/index.scss | 2 +- .../stylesheets/components/surface/index.scss | 2 + .../stylesheets/components/toolbar/index.scss | 2 +- m3-foundation/lib/layers.ts | 146 ++++++++++++++++++ m3-foundation/lib/modal.ts | 33 ++-- m3-foundation/vite.config.ts | 1 + 15 files changed, 208 insertions(+), 53 deletions(-) create mode 100644 m3-foundation/assets/stylesheets/components/layer/index.scss create mode 100644 m3-foundation/lib/layers.ts diff --git a/m3-foundation/assets/stylesheets/basics/layers.scss b/m3-foundation/assets/stylesheets/basics/layers.scss index b5cb6e2..58ee5f5 100644 --- a/m3-foundation/assets/stylesheets/basics/layers.scss +++ b/m3-foundation/assets/stylesheets/basics/layers.scss @@ -1,14 +1,11 @@ @use 'sass:map'; -// Page-level layers. Small z-index values inside a component remain local. +// UI groups repeat inside each isolated modal layer. $levels: ( - 'app-bar': 10, - 'toolbar': 100, - 'snackbar': 900, - 'scrim': 1000, - 'modal': 1001, - 'popover': 1100, - 'search': 1200, + 'floating': 10, + 'feedback': 50, + 'popover': 100, + 'modal': 1000, ); @function level($name) { diff --git a/m3-foundation/assets/stylesheets/components/app-bar/index.scss b/m3-foundation/assets/stylesheets/components/app-bar/index.scss index 6dc9e1c..0902b6f 100644 --- a/m3-foundation/assets/stylesheets/components/app-bar/index.scss +++ b/m3-foundation/assets/stylesheets/components/app-bar/index.scss @@ -14,7 +14,7 @@ color: var(--m3-sys-on-surface); position: sticky; inset-block-start: 0; - z-index: m3-layers.level('app-bar'); + z-index: m3-layers.level('floating'); transition: #{m3-motion.timing-standard(background-color, 'short4')}, #{m3-motion.timing-standard(block-size, 'medium2')}, diff --git a/m3-foundation/assets/stylesheets/components/dialog/index.scss b/m3-foundation/assets/stylesheets/components/dialog/index.scss index fc869bf..dcc45e2 100644 --- a/m3-foundation/assets/stylesheets/components/dialog/index.scss +++ b/m3-foundation/assets/stylesheets/components/dialog/index.scss @@ -1,24 +1,6 @@ -@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/shape' as m3-shape; -.m3-dialog-container { - display: flex; - inline-size: 100%; - block-size: 100%; - align-items: center; - justify-content: center; - z-index: m3-layers.level('modal'); - position: fixed; - inset-inline-start: 0; - inset-block-start: 0; - - .m3-scrim { - position: absolute; - z-index: 0; - } -} - .m3-dialog-appear-enter { transform: translateY(-50%); } diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index 35981a5..33325da 100644 --- a/m3-foundation/assets/stylesheets/components/index.scss +++ b/m3-foundation/assets/stylesheets/components/index.scss @@ -9,6 +9,7 @@ @use 'fab-button'; @use 'icon'; @use 'icon-button'; +@use 'layer'; @use 'list'; @use 'menu'; @use 'navigation'; diff --git a/m3-foundation/assets/stylesheets/components/layer/index.scss b/m3-foundation/assets/stylesheets/components/layer/index.scss new file mode 100644 index 0000000..4279d76 --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/layer/index.scss @@ -0,0 +1,27 @@ +@use '../../basics/layers' as m3-layers; + +.m3-layer { + position: fixed; + inset: 0; + isolation: isolate; + z-index: m3-layers.level('floating'); + pointer-events: none; + + &[data-m3-layer='modal'] { + z-index: m3-layers.level('modal'); + } + + &__feedback { + position: fixed; + inset: 0; + z-index: m3-layers.level('feedback'); + pointer-events: none; + } + + &__popovers { + position: absolute; + inset: 0; + z-index: m3-layers.level('popover'); + pointer-events: none; + } +} diff --git a/m3-foundation/assets/stylesheets/components/navigation/index.scss b/m3-foundation/assets/stylesheets/components/navigation/index.scss index fa9463e..0664102 100644 --- a/m3-foundation/assets/stylesheets/components/navigation/index.scss +++ b/m3-foundation/assets/stylesheets/components/navigation/index.scss @@ -1,5 +1,4 @@ @use '../../basics/layout' as m3-layout; -@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/shape' as m3-shape; @use '../../basics/typography' as m3-typography; @@ -52,6 +51,7 @@ inset-inline-start: 0; inset-block-end: 0; overflow: hidden; + pointer-events: auto; @include m3-shape.reset-box-sizing; @@ -64,7 +64,7 @@ m3-motion.timing-emphasized(padding), m3-motion.timing-emphasized(gap) ; - z-index: m3-layers.level('modal'); + z-index: 1; } &_rail { diff --git a/m3-foundation/assets/stylesheets/components/scrim/index.scss b/m3-foundation/assets/stylesheets/components/scrim/index.scss index 516a100..c8d4dc6 100644 --- a/m3-foundation/assets/stylesheets/components/scrim/index.scss +++ b/m3-foundation/assets/stylesheets/components/scrim/index.scss @@ -1,4 +1,3 @@ -@use '../../basics/layers' as m3-layers; @use '../../basics/palette' as *; .m3-scrim { @@ -6,7 +5,8 @@ inline-size: 100%; block-size: 100%; background: rgba($m3-palette-neutral-variant-20, 0.4); - z-index: m3-layers.level('scrim'); + z-index: 0; + pointer-events: auto; position: fixed; inset-inline-start: 0; inset-block-start: 0; diff --git a/m3-foundation/assets/stylesheets/components/search/index.scss b/m3-foundation/assets/stylesheets/components/search/index.scss index a760f8d..5817eda 100644 --- a/m3-foundation/assets/stylesheets/components/search/index.scss +++ b/m3-foundation/assets/stylesheets/components/search/index.scss @@ -1,4 +1,3 @@ -@use '../../basics/layers' as m3-layers; @use '../../basics/motion' as m3-motion; @use '../../basics/typography' as m3-typography; @@ -105,7 +104,7 @@ pointer-events: none; position: fixed; inset: 0; - z-index: var(--m3-search-z-index, #{m3-layers.level('search')}); + z-index: 1; } &__scrim { diff --git a/m3-foundation/assets/stylesheets/components/side-sheet/index.scss b/m3-foundation/assets/stylesheets/components/side-sheet/index.scss index dc6b72a..0569ed9 100644 --- a/m3-foundation/assets/stylesheets/components/side-sheet/index.scss +++ b/m3-foundation/assets/stylesheets/components/side-sheet/index.scss @@ -1,4 +1,3 @@ -@use '../../basics/layers' as m3-layers; @use '../../basics/shape' as m3-shape; @use '../../basics/typography' as m3-typography; @@ -15,7 +14,7 @@ position: fixed; inset-block-start: 0; right: 0; - z-index: m3-layers.level('modal'); + z-index: 1; background: var(--m3-sys-surface-container-low); color: var(--m3-sys-on-surface-variant); diff --git a/m3-foundation/assets/stylesheets/components/snackbar/index.scss b/m3-foundation/assets/stylesheets/components/snackbar/index.scss index 5a7255a..b2ec3c0 100644 --- a/m3-foundation/assets/stylesheets/components/snackbar/index.scss +++ b/m3-foundation/assets/stylesheets/components/snackbar/index.scss @@ -10,7 +10,7 @@ position: fixed; inset-inline-start: var(--m3-snackbar-inset-inline-start); inset-block-end: var(--m3-snackbar-inset-block-end); - z-index: m3-layers.level('snackbar'); + z-index: m3-layers.level('feedback'); max-inline-size: calc(100vw - var(--m3-snackbar-inset-inline-start) - 16px); pointer-events: none; diff --git a/m3-foundation/assets/stylesheets/components/surface/index.scss b/m3-foundation/assets/stylesheets/components/surface/index.scss index 5608cc7..b18c4c1 100644 --- a/m3-foundation/assets/stylesheets/components/surface/index.scss +++ b/m3-foundation/assets/stylesheets/components/surface/index.scss @@ -29,6 +29,7 @@ $m3-surface-on-surface-color: var(--m3-sys-on-surface, #{light.$m3-sys-light-on- &_modal { max-inline-size: 100vw; max-block-size: 100vh; + pointer-events: auto; } &_dim { background-color: $m3-surface-dim-color; } @@ -50,6 +51,7 @@ $m3-surface-on-surface-color: var(--m3-sys-on-surface, #{light.$m3-sys-light-on- position: fixed; inset: 0; background: color-mix(in srgb, var(--m3-sys-shadow, #{palette.$m3-palette-neutral-0}) 38%, transparent); + pointer-events: auto; } } diff --git a/m3-foundation/assets/stylesheets/components/toolbar/index.scss b/m3-foundation/assets/stylesheets/components/toolbar/index.scss index 49022a4..940aae1 100644 --- a/m3-foundation/assets/stylesheets/components/toolbar/index.scss +++ b/m3-foundation/assets/stylesheets/components/toolbar/index.scss @@ -10,7 +10,7 @@ display: flex; box-sizing: border-box; align-items: center; - z-index: m3-layers.level('toolbar'); + z-index: m3-layers.level('floating'); position: fixed; transition: m3-motion.timing-standard(transform, 'medium2'); diff --git a/m3-foundation/lib/layers.ts b/m3-foundation/lib/layers.ts new file mode 100644 index 0000000..7b62f59 --- /dev/null +++ b/m3-foundation/lib/layers.ts @@ -0,0 +1,146 @@ +interface ModalEntry { + host: HTMLElement; + onEscape: () => void; +} + +interface PopoverEntry { + host: HTMLElement; + onEscape: () => void; +} + +interface LayerManager { + modals: ModalEntry[]; + popovers: PopoverEntry[]; + listeners: AbortController; +} + +const managers = new WeakMap() +export const LAYER_DEACTIVATED_EVENT = 'm3-layer-deactivated' + +function managerFor(document: Document): LayerManager { + const existing = managers.get(document) + if (existing) return existing + + const manager: LayerManager = { + modals: [], + popovers: [], + listeners: new AbortController(), + } + + document.addEventListener('keydown', event => { + if (event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + + const topModal = manager.modals.at(-1) + let topPopover: PopoverEntry | undefined + for (let index = manager.popovers.length - 1; index >= 0; index--) { + const entry = manager.popovers[index] + if (entry.host.isConnected + && (topModal ? topModal.host.contains(entry.host) : !entry.host.closest('[data-m3-layer="modal"]'))) { + topPopover = entry + break + } + } + const onEscape = topPopover?.onEscape ?? topModal?.onEscape + if (!onEscape) return + + event.preventDefault() + event.stopPropagation() + onEscape() + }, { capture: true, signal: manager.listeners.signal }) + + managers.set(document, manager) + return manager +} + +function disposeEmpty(document: Document, manager: LayerManager) { + if (manager.modals.length || manager.popovers.length) return + manager.listeners.abort('UI layer manager disposed') + managers.delete(document) +} + +export function createLayerHost(document: Document): HTMLElement { + const host = document.createElement('div') + host.className = 'm3-layer' + + const feedback = document.createElement('div') + feedback.className = 'm3-layer__feedback' + const popovers = document.createElement('div') + popovers.className = 'm3-layer__popovers' + host.append(feedback, popovers) + + return host +} + +export function mountLayerHost(host: HTMLElement, container: HTMLElement) { + container.append(host) +} + +export function setLayerModal(host: HTMLElement, modal: boolean) { + host.dataset.m3Layer = modal ? 'modal' : 'base' +} + +export function resolvePopoverContainer(target: Element | null, explicit?: string | HTMLElement): HTMLElement { + const document = target?.ownerDocument ?? (typeof explicit === 'string' || !explicit ? globalThis.document : explicit.ownerDocument) + + if (explicit) { + return (typeof explicit === 'string' ? document.querySelector(explicit) : explicit) ?? document.body + } + + const layer = target?.closest('[data-m3-layer="modal"]') + return layer?.querySelector(':scope > .m3-layer__popovers') ?? document.body +} + +export function resolveFeedbackContainer(anchor: Element | null): HTMLElement | null { + return anchor?.closest('[data-m3-layer="modal"]') + ?.querySelector(':scope > .m3-layer__feedback') ?? null +} + +export function mountPopoverHost(host: HTMLElement, target: Element | null, explicit?: string | HTMLElement) { + resolvePopoverContainer(target, explicit).append(host) +} + +export function raisePopover(host: HTMLElement) { + const manager = managers.get(host.ownerDocument) + const index = manager?.popovers.findIndex(entry => entry.host === host) ?? -1 + if (index < 0 || !manager) return + + const [entry] = manager.popovers.splice(index, 1) + manager.popovers.push(entry) + host.parentElement?.append(host) +} + +export function activateModalLayer(host: HTMLElement, onEscape: () => void, raise = true): () => void { + const document = host.ownerDocument + const manager = managerFor(document) + const entry: ModalEntry = { host, onEscape } + + if (raise) host.parentElement?.append(host) + host.dataset.m3LayerActive = 'true' + manager.modals.push(entry) + + return () => { + const index = manager.modals.indexOf(entry) + if (index >= 0) manager.modals.splice(index, 1) + delete host.dataset.m3LayerActive + host.dispatchEvent(new Event(LAYER_DEACTIVATED_EVENT)) + disposeEmpty(document, manager) + } +} + +export function activatePopover(host: HTMLElement, onEscape: () => void): () => void { + const document = host.ownerDocument + const manager = managerFor(document) + const entry: PopoverEntry = { host, onEscape } + const layer = host.closest('[data-m3-layer="modal"]') + + host.parentElement?.append(host) + layer?.addEventListener(LAYER_DEACTIVATED_EVENT, onEscape) + manager.popovers.push(entry) + + return () => { + const index = manager.popovers.indexOf(entry) + if (index >= 0) manager.popovers.splice(index, 1) + layer?.removeEventListener(LAYER_DEACTIVATED_EVENT, onEscape) + disposeEmpty(document, manager) + } +} diff --git a/m3-foundation/lib/modal.ts b/m3-foundation/lib/modal.ts index 4942de1..5b54610 100644 --- a/m3-foundation/lib/modal.ts +++ b/m3-foundation/lib/modal.ts @@ -1,10 +1,14 @@ +import { activateModalLayer } from './layers' + interface ModalFocusOptions { dialog: HTMLElement; + layer?: HTMLElement; exempt?: () => Array; onEscape: () => void; } interface ModalScope extends ModalFocusOptions { + focusRoot: HTMLElement; previousFocus: HTMLElement | null; addedTabIndex: boolean; } @@ -46,7 +50,7 @@ function updateInert(document: Document, manager: ModalManager) { const scope = manager.scopes.at(-1) if (!scope) return - const allowed = [scope.dialog, ...(scope.exempt?.() ?? [])] + const allowed = [scope.focusRoot, ...(scope.exempt?.() ?? [])] .filter((element): element is HTMLElement => !!element && element.isConnected) const paths = new Set() @@ -77,16 +81,9 @@ function registerModalListeners(document: Document, manager: ModalManager) { const scope = manager.scopes.at(-1) if (!scope) return - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - scope.onEscape() - return - } - if (event.key !== 'Tab') return - const items = focusableIn(scope.dialog) + const items = focusableIn(scope.focusRoot) const first = items[0] const last = items.at(-1) const active = document.activeElement @@ -94,10 +91,10 @@ function registerModalListeners(document: Document, manager: ModalManager) { if (!first || !last) { event.preventDefault() scope.dialog.focus() - } else if (event.shiftKey && (active === first || !scope.dialog.contains(active))) { + } else if (event.shiftKey && (active === first || !scope.focusRoot.contains(active))) { event.preventDefault() last.focus() - } else if (!event.shiftKey && (active === last || !scope.dialog.contains(active))) { + } else if (!event.shiftKey && (active === last || !scope.focusRoot.contains(active))) { event.preventDefault() first.focus() } @@ -105,7 +102,7 @@ function registerModalListeners(document: Document, manager: ModalManager) { document.addEventListener('focusin', event => { const scope = manager.scopes.at(-1) - if (scope && !scope.dialog.contains(event.target as Node)) focusFirst(scope.dialog) + if (scope && !scope.focusRoot.contains(event.target as Node)) focusFirst(scope.dialog) }, { capture: true, signal: manager.listeners.signal }) } @@ -128,9 +125,11 @@ function createManager(document: Document): ModalManager { return manager } -function createScope({ dialog, exempt, onEscape }: ModalFocusOptions): ModalScope { +function createScope({ dialog, layer, exempt, onEscape }: ModalFocusOptions): ModalScope { return { dialog, + layer, + focusRoot: layer ?? dialog, exempt, onEscape, previousFocus: dialog.ownerDocument.activeElement instanceof HTMLElement ? dialog.ownerDocument.activeElement : null, @@ -139,10 +138,11 @@ function createScope({ dialog, exempt, onEscape }: ModalFocusOptions): ModalScop } /** Activate modal focus management and return an idempotent disposal function. */ -export function activateModalFocus({ dialog, exempt, onEscape }: ModalFocusOptions): () => void { +export function activateModalFocus({ dialog, layer, exempt, onEscape }: ModalFocusOptions): () => void { const document = dialog.ownerDocument const manager = managers.get(document) ?? createManager(document) - const scope = createScope({ dialog, exempt, onEscape }) + const scope = createScope({ dialog, layer, exempt, onEscape }) + const deactivateLayer = activateModalLayer(scope.focusRoot, onEscape, Boolean(layer)) if (scope.addedTabIndex) dialog.tabIndex = -1 manager.scopes.push(scope) @@ -160,6 +160,7 @@ export function activateModalFocus({ dialog, exempt, onEscape }: ModalFocusOptio const wasTop = index === manager.scopes.length - 1 manager.scopes.splice(index, 1) + deactivateLayer() if (scope.addedTabIndex) dialog.removeAttribute('tabindex') updateInert(document, manager) @@ -170,7 +171,7 @@ export function activateModalFocus({ dialog, exempt, onEscape }: ModalFocusOptio queueMicrotask(() => { if (manager.scopes.at(-1) !== nextTop) return const active = document.activeElement - if (active !== document.body && !dialog.contains(active)) return + if (active !== document.body && !scope.focusRoot.contains(active)) return if (scope.previousFocus?.isConnected && !scope.previousFocus.inert) { scope.previousFocus.focus() diff --git a/m3-foundation/vite.config.ts b/m3-foundation/vite.config.ts index 511c479..46a1c00 100644 --- a/m3-foundation/vite.config.ts +++ b/m3-foundation/vite.config.ts @@ -10,6 +10,7 @@ const entries = { 'lib/appBar': resolve(__dirname, 'lib/appBar.ts'), 'lib/calendar': resolve(__dirname, 'lib/calendar/index.ts'), 'lib/dom': resolve(__dirname, 'lib/dom.ts'), + 'lib/layers': resolve(__dirname, 'lib/layers.ts'), 'lib/motion': resolve(__dirname, 'lib/motion/index.ts'), 'lib/motion/values': resolve(__dirname, 'lib/motion/values.ts'), 'lib/modal': resolve(__dirname, 'lib/modal.ts'), From 781642ae6186c5457114b4c25b0b70ae82eb6995 Mon Sep 17 00:00:00 2001 From: Zaitsev Kirill Date: Mon, 19 Oct 2026 12:21:42 +0400 Subject: [PATCH 16/20] feat(m3-react)!: Components were moved into shared UI layers Modal surfaces own their popovers and feedback. Layer stacking and dismissal are covered by Storybook examples and browser tests. BREAKING CHANGE: M3Surface.zIndex was removed; modal stacking now follows layer activation order. --- m3-react/src/components/dialog/M3Dialog.tsx | 2 - m3-react/src/components/menu/M3Menu.tsx | 2 +- .../components/navigation/M3Navigation.tsx | 18 +- m3-react/src/components/popper/M3Popper.tsx | 34 ++- m3-react/src/components/popper/hooks.ts | 2 +- m3-react/src/components/search/M3Search.tsx | 20 +- .../src/components/side-sheet/M3SideSheet.tsx | 3 - .../components/snackbar/M3SnackbarHost.tsx | 55 ++++- m3-react/src/components/surface/M3Surface.tsx | 24 +-- .../orchestration/useSurfaceSideSheetMorph.ts | 2 - m3-react/src/hooks/index.ts | 1 + m3-react/src/hooks/useLayerHost.ts | 25 +++ m3-react/storybook/components/M3Surface.mdx | 19 ++ .../components/M3Surface.stories.tsx | 5 + .../examples/surface/SurfaceLayerStack.tsx | 83 ++++++++ .../surface/SurfaceNestedDialogsChain.tsx | 6 - .../surface/SurfaceSideSheetAlwaysModal.tsx | 1 - .../SurfaceSideSheetDismissToRemove.tsx | 1 - .../surface/SurfaceSideSheetModalToWindow.tsx | 1 - m3-react/tests/M3Layers.e2e.tsx | 195 ++++++++++++++++++ m3-react/tests/M3SurfaceFocus.e2e.tsx | 2 +- 21 files changed, 451 insertions(+), 50 deletions(-) create mode 100644 m3-react/src/hooks/useLayerHost.ts create mode 100644 m3-react/storybook/examples/surface/SurfaceLayerStack.tsx create mode 100644 m3-react/tests/M3Layers.e2e.tsx diff --git a/m3-react/src/components/dialog/M3Dialog.tsx b/m3-react/src/components/dialog/M3Dialog.tsx index bceec4d..e45afc4 100644 --- a/m3-react/src/components/dialog/M3Dialog.tsx +++ b/m3-react/src/components/dialog/M3Dialog.tsx @@ -37,7 +37,6 @@ const DIALOG_MAX_WIDTH = 560 const DIALOG_ROUNDING = 28 const DIALOG_ENTRY_OFFSET_PX = 24 const DIALOG_ELEVATION = 0 -const DIALOG_Z_INDEX = 1000 const DIALOG_TRANSITION_MS = durations.medium2 const DIALOG_TRANSITION_TIMING = easing.standard @@ -157,7 +156,6 @@ export default defineComponent(function M3Dialog({ maxWidth={fullscreen ? '100vw' : DIALOG_MAX_WIDTH} maxHeight={fullscreen ? '100vh' : null} rounding={fullscreen ? 0 : DIALOG_ROUNDING} - zIndex={DIALOG_Z_INDEX} elevation={DIALOG_ELEVATION} style={dialogStyle} className={toClassName(['m3-dialog', className])} diff --git a/m3-react/src/components/menu/M3Menu.tsx b/m3-react/src/components/menu/M3Menu.tsx index 1abd6b8..84c52fc 100644 --- a/m3-react/src/components/menu/M3Menu.tsx +++ b/m3-react/src/components/menu/M3Menu.tsx @@ -23,7 +23,7 @@ export default defineComponent(function M3Menu({ placement = 'bottom', strategy = 'absolute', boundary = 'clippingAncestors', - container = 'body', + container, offsetMainAxis = 0, offsetCrossAxis = 0, overflow = ['flip', 'shift', 'hide'], diff --git a/m3-react/src/components/navigation/M3Navigation.tsx b/m3-react/src/components/navigation/M3Navigation.tsx index 71a2c27..8af33ea 100644 --- a/m3-react/src/components/navigation/M3Navigation.tsx +++ b/m3-react/src/components/navigation/M3Navigation.tsx @@ -34,7 +34,12 @@ import M3NavigationAppearance from '@/components/navigation/M3NavigationAppearan import defineComponent from '@/utils/component' import { defineSlot, distinct } from '@/utils/content' import { toClassName } from '@/utils/styling' -import { useBreakpoint, useRecord, useWatch } from '@/hooks' +import { + useBreakpoint, + useLayerHost, + useRecord, + useWatch, +} from '@/hooks' import M3NavigationSection from './M3NavigationSection' @@ -119,6 +124,7 @@ export default defineComponent(function M3Navigation({ const modalExpanded = isModalExpansion(appearanceBase, expanded, expansion, breakpoint.ge('large')) const modalActive = isExpandableAppearance(appearanceBase) && (isModalExpansion(appearanceBase, expanded, expansion, breakpoint.ge('large')) || state.transitioning) + const layerHost = useLayerHost(modalActive) const railHidden = isHiddenRail(appearanceBase, expanded, collapse) const railLeaving = isHiddenRail(appearanceBase, expanded, collapse) && state.transitioning @@ -188,14 +194,16 @@ export default defineComponent(function M3Navigation({ useLayoutEffect(() => { const dialog = modalDialog.current - if (!modalActive || !dialog) return + if (!modalActive || !dialog || !layerHost) return return activateModalFocus({ dialog, - exempt: () => [scrim.current], + layer: layerHost, onEscape: () => handlers.onToggle(false), }) - }, [modalActive]) + }, [modalActive, layerHost]) + + if (!layerHost) return null return createPortal( <> @@ -257,7 +265,7 @@ export default defineComponent(function M3Navigation({
, - document.body + layerHost ) }, { slots: { Header, Subheader, Top }, diff --git a/m3-react/src/components/popper/M3Popper.tsx b/m3-react/src/components/popper/M3Popper.tsx index 762b103..70aec26 100644 --- a/m3-react/src/components/popper/M3Popper.tsx +++ b/m3-react/src/components/popper/M3Popper.tsx @@ -2,6 +2,12 @@ import type { CloserEvent } from '@modulify/m3-foundation/types/components/poppe import type { ComponentSetupContext } from '@/utils/component' import type { M3PopperExposed, M3PopperProps } from './types' +import { + activatePopover, + mountPopoverHost, + raisePopover, + resolvePopoverContainer, +} from '@modulify/m3-foundation/lib/layers' import Scheduler from '@modulify/m3-foundation/lib/Scheduler' import { computePosition } from '@modulify/m3-foundation/lib/popper/floating' @@ -9,6 +15,7 @@ import { createPortal } from 'react-dom' import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, } from 'react' @@ -91,8 +98,11 @@ export default defineComponent(function M3Popper({ useWatch(onDispose, onDispose => handlers.onDispose = onDispose) const targetRef = useRef(target) + const containerRef = useRef(container) + containerRef.current = container const positionerRef = useRef(null) const popperRef = useRef(null) + const portalHost = useMemo(() => typeof document === 'undefined' ? null : document.createElement('div'), []) const applyAnimationSide = useCallback((side: 'top' | 'bottom' | 'left' | 'right') => { if (!popperRef.current) { @@ -248,6 +258,10 @@ export default defineComponent(function M3Popper({ } state.hiding = false + if (portalHost) { + mountPopoverHost(portalHost, targetRef.current, containerRef.current) + raisePopover(portalHost) + } state.attached = true scheduler.showing.schedule(doShow, immediately ? 0 : state.showDelay) @@ -332,6 +346,7 @@ export default defineComponent(function M3Popper({ hide(true) state.attached = false + portalHost?.remove() handlers.onDispose() }, []) @@ -368,6 +383,21 @@ export default defineComponent(function M3Popper({ } }) + useEffect(() => { + if (!state.attached || !portalHost) { + portalHost?.remove() + return + } + + const destination = resolvePopoverContainer(targetRef.current, container) + if (portalHost.parentElement !== destination) destination.append(portalHost) + }, [state.attached, target, container, portalHost]) + + useLayoutEffect(() => { + if (!state.shown || !portalHost) return + return activatePopover(portalHost, () => hide(true)) + }, [state.shown, portalHost]) + useWatch(disabled, disabled => { if (disabled) { dispose() @@ -419,7 +449,7 @@ export default defineComponent(function M3Popper({ } }, []) - return state.attached ? createPortal( + return state.attached && portalHost ? createPortal(
, - (typeof container === 'string' ? document.querySelector(container) : container) ?? document.body + portalHost ) : null }) diff --git a/m3-react/src/components/popper/hooks.ts b/m3-react/src/components/popper/hooks.ts index c9f0a89..78725cf 100644 --- a/m3-react/src/components/popper/hooks.ts +++ b/m3-react/src/components/popper/hooks.ts @@ -30,7 +30,7 @@ export const useAutoAdjust = ( if (targetRef.current && popperRef.current) { stop = autoUpdate(targetRef.current, popperRef.current, adjust, { animationFrame: true, - elementResize: true, + elementResize: typeof ResizeObserver !== 'undefined', }) } }, off] diff --git a/m3-react/src/components/search/M3Search.tsx b/m3-react/src/components/search/M3Search.tsx index 1213454..9ca6e42 100644 --- a/m3-react/src/components/search/M3Search.tsx +++ b/m3-react/src/components/search/M3Search.tsx @@ -37,7 +37,7 @@ import { M3IconButton } from '@/components/icon-button' import defineComponent from '@/utils/component' import { defineSlot, distinct } from '@/utils/content' import { toClassName } from '@/utils/styling' -import { useBreakpoint, useId } from '@/hooks' +import { useBreakpoint, useId, useLayerHost } from '@/hooks' export interface SearchResultsContext { query: string; @@ -120,13 +120,14 @@ export default defineComponent(function M3Search({ const dialog = useRef(null) const input = useRef(null) const results = useRef(null) - const scrim = useRef(null) const callbacks = useRef({ onOpenChange, onQueryChange, onSubmit }) callbacks.current = { onOpenChange, onQueryChange, onSubmit } const [position, setPosition] = useState({ left: 12, top: 12, width: 360 }) const [anchor, setAnchor] = useState({ left: 12, top: 12, width: 360 }) const [viewport, setViewport] = useState({ width: 384, height: 640 }) + const [overlayMounted, setOverlayMounted] = useState(open) + const layerHost = useLayerHost(open || overlayMounted) const parsed = useMemo(() => distinct(children, { slots: { leading: Leading, trailing: Trailing, actions: Actions, results: Results }, }), [children]) @@ -172,15 +173,19 @@ export default defineComponent(function M3Search({ }, [open, resolvedLayout]) useLayoutEffect(() => { - if (!open || !dialog.current) return + if (!open || !dialog.current || !layerHost) return const deactivate = activateModalFocus({ dialog: dialog.current, - exempt: () => [scrim.current], + layer: layerHost, onEscape: () => callbacks.current.onOpenChange(false), }) input.current?.focus() return deactivate + }, [open, layerHost]) + + useLayoutEffect(() => { + if (open) setOverlayMounted(true) }, [open]) const handleSubmit = (event: FormEvent) => { @@ -235,7 +240,7 @@ export default defineComponent(function M3Search({
- {typeof document !== 'undefined' ? createPortal( + {layerHost ? createPortal( setOverlayMounted(false)} >
{resolvedLayout === 'docked' ? ( -
+
) : null}
, - document.body + layerHost ) : null} ) diff --git a/m3-react/src/components/side-sheet/M3SideSheet.tsx b/m3-react/src/components/side-sheet/M3SideSheet.tsx index d41fb5d..f614483 100644 --- a/m3-react/src/components/side-sheet/M3SideSheet.tsx +++ b/m3-react/src/components/side-sheet/M3SideSheet.tsx @@ -52,7 +52,6 @@ const DOCKED_WIDTH = 256 const MODAL_MIN_WIDTH = 320 const MODAL_MAX_WIDTH = 400 const MODAL_RADIUS = 16 -const SIDE_SHEET_Z_INDEX = 1000 const SIDE_SHEET_TRANSITION_MS = durations['extra-long2'] export default defineComponent(function M3SideSheet({ @@ -78,7 +77,6 @@ export default defineComponent(function M3SideSheet({ transitionTiming, overflow = 'hidden', role = 'dialog', - zIndex = SIDE_SHEET_Z_INDEX, className = '', style, children = [], @@ -147,7 +145,6 @@ export default defineComponent(function M3SideSheet({ transitionMs={transitionMs} transitionTiming={transitionTiming} overflow={overflow} - zIndex={zIndex} elevation={elevation} variant={variant} style={style} diff --git a/m3-react/src/components/snackbar/M3SnackbarHost.tsx b/m3-react/src/components/snackbar/M3SnackbarHost.tsx index 5d1db79..d4ae20c 100644 --- a/m3-react/src/components/snackbar/M3SnackbarHost.tsx +++ b/m3-react/src/components/snackbar/M3SnackbarHost.tsx @@ -7,8 +7,18 @@ import type { } from './types' import type { SnackbarResult } from '@modulify/m3-foundation/types/components/snackbar' +import { createPortal } from 'react-dom' import { isElement, isHTMLElement } from '@modulify/m3-foundation/lib/predicates' -import { useEffect, useRef, useState } from 'react' +import { + LAYER_DEACTIVATED_EVENT, + resolveFeedbackContainer, +} from '@modulify/m3-foundation/lib/layers' +import { + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' import defineComponent from '@/utils/component' import { toClassName } from '@/utils/styling' @@ -23,6 +33,7 @@ interface Entry { trigger: HTMLElement | null; nextFocus: HTMLElement | null; + feedbackTarget: HTMLElement | null; remaining: number | null; startedAt: number; @@ -73,6 +84,8 @@ export default defineComponent(function M3SnackbarHost({ }: M3SnackbarHostProps, { expose }: ComponentSetupContext) { const [display, setDisplay] = useState(null) const [announcement, setAnnouncement] = useState('') + const [feedbackTarget, setFeedbackTarget] = useState(null) + const anchor = useRef(null) const root = useRef(null) const active = useRef(null) @@ -82,6 +95,7 @@ export default defineComponent(function M3SnackbarHost({ const timerRunning = useRef(false) const paused = useRef({ hover: false, focus: false }) const disposed = useRef(false) + const detachOwner = useRef<() => void>(() => {}) const durationTimeout = useTimeout(() => { timerRunning.current = false @@ -129,8 +143,27 @@ export default defineComponent(function M3SnackbarHost({ } const begin = (entry: Entry) => { + if (entry.feedbackTarget && !entry.feedbackTarget.closest('[data-m3-layer-active="true"]')) { + entry.resolve('disposed') + const next = queue.current.shift() + if (next) begin(next) + else { + setDisplay(null) + setAnnouncement('') + } + return + } + active.current = entry paused.current = { hover: false, focus: false } + detachOwner.current() + const layer = entry.feedbackTarget?.closest('.m3-layer') + if (layer) { + const onDeactivate = () => finish('disposed') + layer.addEventListener(LAYER_DEACTIVATED_EVENT, onDeactivate) + detachOwner.current = () => layer.removeEventListener(LAYER_DEACTIVATED_EVENT, onDeactivate) + } + setFeedbackTarget(entry.feedbackTarget?.isConnected ? entry.feedbackTarget : null) setDisplay({ entry, leaving: false }) setAnnouncement(entry.options.message) if (entry.remaining !== null) { @@ -145,6 +178,8 @@ export default defineComponent(function M3SnackbarHost({ if (!entry) return stopTimer() active.current = null + detachOwner.current() + detachOwner.current = () => {} leaving.current = entry entry.resolve(result) setDisplay({ entry, leaving: true }) @@ -166,6 +201,7 @@ export default defineComponent(function M3SnackbarHost({ resolve, trigger, nextFocus: getNextFocusable(trigger), + feedbackTarget: resolveFeedbackContainer(trigger) ?? resolveFeedbackContainer(anchor.current), remaining: duration, startedAt: 0, } @@ -211,6 +247,15 @@ export default defineComponent(function M3SnackbarHost({ expose({ show, replace, dismiss: () => finish('dismiss'), clear, focus }) + useLayoutEffect(() => { + const update = () => setFeedbackTarget(resolveFeedbackContainer(anchor.current)) + const layer = anchor.current?.closest('.m3-layer') + const observer = layer ? new MutationObserver(update) : null + if (layer) observer?.observe(layer, { attributes: true, attributeFilter: ['data-m3-layer'] }) + update() + return () => observer?.disconnect() + }, []) + useEffect(() => { disposed.current = false const onKeyDown = (event: KeyboardEvent) => { @@ -224,6 +269,7 @@ export default defineComponent(function M3SnackbarHost({ return () => { document.removeEventListener('keydown', onKeyDown) disposed.current = true + detachOwner.current() queueMicrotask(() => { if (!disposed.current) return stopTimer() @@ -239,7 +285,7 @@ export default defineComponent(function M3SnackbarHost({ const options = display?.entry.options const closable = options?.closable ?? Boolean(display?.entry.action || display?.entry.remaining === null) - return ( + const host = (
) + + return <> +