diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 7d2f7475..1bc54c1f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -111,6 +111,8 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. +`--material-snackbar-height` is the height of the snackbar on screen, written on `` by `` while one shows and removed when it goes. `` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `` does the same by wrapping it in `
`. + `--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only. ## Toasts @@ -326,7 +328,7 @@ The snackbar host. Once per layout, near the end of ``: `` (`po materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } }) ``` -`type` (`success`, `error`, `warning`, `info`) picks the announcement role; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out. +`type` (`success`, `error`, `warning`, `info`) picks the announcement role and draws no icon (M3 tells you to avoid one in a snackbar); `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out. - `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON. - `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news: @@ -336,6 +338,7 @@ window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: ' window.addEventListener('app:update', () => location.reload()) ``` +- Escape dismisses a snackbar that holds the focus. - Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes. ### `` diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index f0abf45c..4018d2d3 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.css @@ -11,7 +11,8 @@ * list and matching it position for position. * * `state-transition-fast` is the press/hover/select transition of a control the finger lands on: - * shape and size on the fast spatial spring, colour and elevation on the fast effects spring. + * shape, size and the place it sits on the fast spatial spring (a `fab` button nudges up when a + * snackbar arrives under it), colour and elevation on the fast effects spring. * `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB * turning into a close button. * @@ -26,12 +27,14 @@ */ @utility state-transition-fast { - transition-property: border-radius, padding, margin, background-color, color, box-shadow; + transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow; transition-duration: - var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration); transition-timing-function: - var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), + var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), + var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast); } diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index 87ee4515..ffca2a70 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -35,13 +35,27 @@ document.addEventListener('alpine:init', () => { timer: null, remaining: 0, startedAt: 0, + escape: null, init() { host = this pending.splice(0).forEach((detail) => this.add(detail)) + + // M3: Esc dismisses the focused snackbar. Only the focused one — a key pressed + // anywhere else on the page belongs to whatever has the focus there. + this.escape = (event) => { + if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) { + this.dismiss() + } + } + + document.addEventListener('keydown', this.escape) }, destroy() { + document.removeEventListener('keydown', this.escape) + document.documentElement.style.removeProperty('--material-snackbar-height') + if (host === this) { host = null } @@ -93,6 +107,7 @@ document.addEventListener('alpine:init', () => { clearTimeout(this.timer) this.timer = null this.current = this.queue.shift() ?? this.sticky + this.measure() if (this.current?.timeout) { this.remaining = this.current.timeout @@ -100,6 +115,24 @@ document.addEventListener('alpine:init', () => { } }, + /** + * Publishes the snackbar's height on as `--material-snackbar-height`, and clears it + * when nothing shows. M3 puts a snackbar above a FAB, never in front of or behind one, and + * the FAB is somewhere else in the page: a variable is the only thing the two share. + */ + measure() { + this.$nextTick(() => { + const snackbar = this.$el.querySelector('[data-toast]') + const root = document.documentElement.style + + if (snackbar) { + root.setProperty('--material-snackbar-height', `${Math.round(snackbar.getBoundingClientRect().height)}px`) + } else { + root.removeProperty('--material-snackbar-height') + } + }) + }, + pause() { if (!this.current?.timeout || !this.timer) { return @@ -142,9 +175,5 @@ document.addEventListener('alpine:init', () => { window.dispatchEvent(new CustomEvent(action.event)) } }, - - icon(type) { - return ['success', 'error', 'warning', 'info'].includes(type) - }, })) }) diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index 72f17bd0..4e8fab32 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -35,7 +35,9 @@ `tooltip`, `tooltip-left`, `tooltip-right` and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from - there — one element either way. + there — one element either way. It lifts clear of a bottom bar and of a snackbar on screen + (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a + FAB and never over one. Never pass `hidden`, a display or a position class: the button is `inline-flex` and `relative`, and whichever Tailwind emits last wins. Wrap it instead. --}} @@ -187,7 +189,7 @@ 'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained, 'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained, // The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares. - 'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab, + 'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab, ]; $tag = $isLink ? 'a' : 'button'; diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 496265db..9190b770 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -28,12 +28,19 @@ `@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is still on screen when the next page arrives. + There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon + feels necessary"), and both lines of the message are plain inverse-on-surface — M3 gives the + supporting text no fourth colour and tells the two lines apart by position. The 40px action + and close buttons carry `touch-target`, which reaches M3's 48px without growing the container. + Escape dismisses a snackbar that holds the focus. + M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface, body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3, - 48px for one line. A type draws its state icon in the inverse state colour. `position`: - `bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through - `--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar; - from `medium` it hugs its line length instead, as M3 asks. --}} + 48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts + above a bottom bar through `--material-bottom-bar`, and publishes its own height as + `--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a + FAB, never in front of or behind one. A compact window (below `medium`, 600px) gets the + full-width snackbar; from `medium` it hugs its line length instead, as M3 asks. --}} @props(['position' => 'bottom']) @@ -60,31 +67,17 @@ x-on:focusout="resume()" class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86" > - -

-

+

diff --git a/tests/Feature/Components/ButtonTest.php b/tests/Feature/Components/ButtonTest.php index a0519280..57f3b391 100644 --- a/tests/Feature/Components/ButtonTest.php +++ b/tests/Feature/Components/ButtonTest.php @@ -168,7 +168,9 @@ it('is a FAB on a compact window and a filled button from medium, in one element $html = (string) $this->blade(''); expect(substr_count($html, 'toBe(1) - ->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary'); + ->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary') + // M3 puts a snackbar above a FAB, never over one: the host publishes its height. + ->toContain('max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)]'); }); it('submits a form when asked', function () { diff --git a/tests/Feature/Components/ToastTest.php b/tests/Feature/Components/ToastTest.php index e6de5028..f329267c 100644 --- a/tests/Feature/Components/ToastTest.php +++ b/tests/Feature/Components/ToastTest.php @@ -10,6 +10,17 @@ it('hosts the snackbar queue, kept across wire:navigate', function () { ->toContain('justify-center'); }); +it('draws no state icon, and reaches 48px from its 40px controls', function () { + $html = (string) $this->blade(''); + + // M3 tells you to avoid an icon in a snackbar; the type is left to pick the announcement role. + expect(substr_count($html, 'toBe(1) + ->and($html)->not->toContain('text-inverse-success') + ->not->toContain('opacity-80') + ->toContain('data-toast-action class="state-layer focus-ring touch-target') + ->toContain('state-layer focus-ring touch-target inline-flex size-10'); +}); + it('can sit at the start', function () { expect((string) $this->blade(''))->toContain('justify-start'); });