diff --git a/m3-foundation/assets/stylesheets/basics/layers.scss b/m3-foundation/assets/stylesheets/basics/layers.scss new file mode 100644 index 00000000..58ee5f5a --- /dev/null +++ b/m3-foundation/assets/stylesheets/basics/layers.scss @@ -0,0 +1,17 @@ +@use 'sass:map'; + +// UI groups repeat inside each isolated modal layer. +$levels: ( + 'floating': 10, + 'feedback': 50, + 'popover': 100, + 'modal': 1000, +); + +@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 new file mode 100644 index 00000000..0902b6f8 --- /dev/null +++ b/m3-foundation/assets/stylesheets/components/app-bar/index.scss @@ -0,0 +1,296 @@ +@use '../../basics/layers' as m3-layers; +@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: m3-layers.level('floating'); + 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/dialog/index.scss b/m3-foundation/assets/stylesheets/components/dialog/index.scss index e5eac5c1..dcc45e2f 100644 --- a/m3-foundation/assets/stylesheets/components/dialog/index.scss +++ b/m3-foundation/assets/stylesheets/components/dialog/index.scss @@ -1,23 +1,6 @@ @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: 1000; - position: fixed; - inset-inline-start: 0; - inset-block-start: 0; - - .m3-scrim { - position: absolute; - z-index: 0; - } -} - .m3-dialog-appear-enter { transform: translateY(-50%); } @@ -92,4 +75,4 @@ justify-content: flex-end; gap: 8px; } -} \ No newline at end of file +} diff --git a/m3-foundation/assets/stylesheets/components/index.scss b/m3-foundation/assets/stylesheets/components/index.scss index bb14fd6b..33325daf 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'; @@ -8,6 +9,7 @@ @use 'fab-button'; @use 'icon'; @use 'icon-button'; +@use 'layer'; @use 'list'; @use 'menu'; @use 'navigation'; @@ -17,12 +19,14 @@ @use 'rich-tooltip'; @use 'ripple'; @use 'scrim'; +@use 'search'; @use 'scroll-box'; @use 'scroll-rail'; @use 'select'; @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/layer/index.scss b/m3-foundation/assets/stylesheets/components/layer/index.scss new file mode 100644 index 00000000..4279d769 --- /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/menu/index.scss b/m3-foundation/assets/stylesheets/components/menu/index.scss index 6c9f188f..8d9588cc 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/navigation/index.scss b/m3-foundation/assets/stylesheets/components/navigation/index.scss index 13c2b150..0664102a 100644 --- a/m3-foundation/assets/stylesheets/components/navigation/index.scss +++ b/m3-foundation/assets/stylesheets/components/navigation/index.scss @@ -51,6 +51,7 @@ inset-inline-start: 0; inset-block-end: 0; overflow: hidden; + pointer-events: auto; @include m3-shape.reset-box-sizing; @@ -63,7 +64,7 @@ m3-motion.timing-emphasized(padding), m3-motion.timing-emphasized(gap) ; - z-index: 1000; + z-index: 1; } &_rail { diff --git a/m3-foundation/assets/stylesheets/components/plane-tooltip/index.scss b/m3-foundation/assets/stylesheets/components/plane-tooltip/index.scss index 112df02a..d596a33d 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 6592731a..6ddf62ef 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 f6767b19..2a02cef7 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 aedf6149..c8d4dc60 100644 --- a/m3-foundation/assets/stylesheets/components/scrim/index.scss +++ b/m3-foundation/assets/stylesheets/components/scrim/index.scss @@ -5,7 +5,8 @@ inline-size: 100%; block-size: 100%; background: rgba($m3-palette-neutral-variant-20, 0.4); - z-index: 1000; + 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 new file mode 100644 index 00000000..5817eda3 --- /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: 1; + } + + &__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/assets/stylesheets/components/side-sheet/index.scss b/m3-foundation/assets/stylesheets/components/side-sheet/index.scss index abd821ff..0569ed9e 100644 --- a/m3-foundation/assets/stylesheets/components/side-sheet/index.scss +++ b/m3-foundation/assets/stylesheets/components/side-sheet/index.scss @@ -14,7 +14,7 @@ position: fixed; inset-block-start: 0; right: 0; - z-index: 1000; + 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 75c809b2..b2ec3c0a 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('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 5608cc7f..b18c4c14 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 new file mode 100644 index 00000000..940aae12 --- /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('floating'); + 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/appBar.ts b/m3-foundation/lib/appBar.ts new file mode 100644 index 00000000..14e4ced6 --- /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/lib/layers.ts b/m3-foundation/lib/layers.ts new file mode 100644 index 00000000..7b62f599 --- /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 4942de1f..5b54610d 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/lib/search.ts b/m3-foundation/lib/search.ts new file mode 100644 index 00000000..d9bcfd39 --- /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/lib/toolbar.ts b/m3-foundation/lib/toolbar.ts new file mode 100644 index 00000000..2daff7da --- /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/appBar.test.ts b/m3-foundation/tests/appBar.test.ts new file mode 100644 index 00000000..26fb2ae1 --- /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/tests/search.test.ts b/m3-foundation/tests/search.test.ts new file mode 100644 index 00000000..d18af3c5 --- /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/tests/toolbar.test.ts b/m3-foundation/tests/toolbar.test.ts new file mode 100644 index 00000000..36c12834 --- /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/app-bar.d.ts b/m3-foundation/types/components/app-bar.d.ts new file mode 100644 index 00000000..cff57609 --- /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/types/components/search.d.ts b/m3-foundation/types/components/search.d.ts new file mode 100644 index 00000000..2ba26cbe --- /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 diff --git a/m3-foundation/types/components/toolbar.d.ts b/m3-foundation/types/components/toolbar.d.ts new file mode 100644 index 00000000..08199ee8 --- /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 86dfb489..46a1c002 100644 --- a/m3-foundation/vite.config.ts +++ b/m3-foundation/vite.config.ts @@ -7,8 +7,10 @@ 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/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'), @@ -23,9 +25,11 @@ 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'), + 'lib/toolbar': resolve(__dirname, 'lib/toolbar.ts'), } const externalPackages = Object.keys(dependencies) 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 00000000..ed743cf0 --- /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 00000000..aa1a4c87 --- /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/dialog/M3Dialog.tsx b/m3-react/src/components/dialog/M3Dialog.tsx index bceec4d3..e45afc49 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/index.ts b/m3-react/src/components/index.ts index e6eb0ba7..be2158ed 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' @@ -18,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' @@ -25,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/menu/M3Menu.tsx b/m3-react/src/components/menu/M3Menu.tsx index 1abd6b81..84c52fc2 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 71a2c27c..8af33ea3 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 762b1037..70aec26f 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 c9f0a891..78725cf0 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 new file mode 100644 index 00000000..9ca6e427 --- /dev/null +++ b/m3-react/src/components/search/M3Search.tsx @@ -0,0 +1,336 @@ +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, useLayerHost } 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 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]) + + 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 || !layerHost) return + + const deactivate = activateModalFocus({ + dialog: dialog.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) => { + 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} +
+
+ + {layerHost ? createPortal( + setOverlayMounted(false)} + > +
+ {resolvedLayout === 'docked' ? ( +
+ ) : null} + +
+ , + layerHost + ) : 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 00000000..134c11b0 --- /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/src/components/side-sheet/M3SideSheet.tsx b/m3-react/src/components/side-sheet/M3SideSheet.tsx index d41fb5db..f6144834 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 5d1db797..d4ae20c9 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 <> +