Pin the two-line snackbar, and reach it with Alt+G
Plan step 22, actions.md § Missing (two-line snackbar height, snackbar keyboard shortcut): the container grew organically from `min-h-12` rather than sitting at SnackbarTokens.TwoLinesContainerHeight, and there was no way for a keyboard to reach an actioned snackbar at all. A `description` is M3's second line, so the snackbar is pinned to 68px whenever one is there, and below `medium` a two-line snackbar with an action wraps the action under the text — M3's "two lines with longer action" configuration. Alt+G, the shortcut M3 suggests for the web, moves the focus to a snackbar that carries an action from wherever the page had it; `event.code`, because Alt rewrites `event.key` on some layouts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
6af501c966
commit
44ae9ecea4
@@ -364,7 +364,8 @@ window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: '
|
||||
window.addEventListener('app:update', () => location.reload())
|
||||
```
|
||||
|
||||
- Escape dismisses a snackbar that holds the focus.
|
||||
- `description` is M3's second line: the container goes from 48px to 68px (`SnackbarTokens.TwoLinesContainerHeight`), and below `medium` a two-line snackbar with an action wraps the action under the text.
|
||||
- Escape dismisses a snackbar that holds the focus, and **Alt+G** moves the focus to a snackbar that carries an action from wherever the page had it — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus itself. Say so where your users read about keyboard shortcuts.
|
||||
- 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.
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||
* takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky
|
||||
* toast replaces it. Dismissing it, or pressing its action, lets it go.
|
||||
*
|
||||
* Two keys are watched on the whole document: Escape dismisses the snackbar while the focus is in
|
||||
* it, and Alt+G moves the focus to a snackbar that carries an action from wherever the page had it
|
||||
* — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus
|
||||
* on its own and a keyboard would otherwise have no way to reach the action.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 4000
|
||||
|
||||
@@ -35,25 +40,40 @@ document.addEventListener('alpine:init', () => {
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
startedAt: 0,
|
||||
escape: null,
|
||||
keys: 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) => {
|
||||
this.keys = (event) => {
|
||||
// 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.
|
||||
if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) {
|
||||
this.dismiss()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// M3 asks the web for a documented shortcut that moves the focus to a snackbar
|
||||
// carrying an action, and suggests Alt+G: a snackbar never takes the focus by
|
||||
// itself, so without one the keyboard cannot reach the action at all. `event.code`
|
||||
// rather than `event.key`, which Alt rewrites to another character on some layouts.
|
||||
if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyG') {
|
||||
const action = this.$el.querySelector('[data-toast-action]')
|
||||
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
action.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.escape)
|
||||
document.addEventListener('keydown', this.keys)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('keydown', this.escape)
|
||||
document.removeEventListener('keydown', this.keys)
|
||||
document.documentElement.style.removeProperty('--material-snackbar-height')
|
||||
|
||||
if (host === this) {
|
||||
|
||||
@@ -34,9 +34,18 @@
|
||||
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
|
||||
Escape dismisses a snackbar that holds the focus.
|
||||
|
||||
Alt+G moves the focus to a snackbar that carries an action, from wherever the page had it —
|
||||
M3 asks for a documented shortcut on the web, since a snackbar never takes the focus itself
|
||||
and a keyboard has no other way to reach one. It does nothing when the snackbar on screen has
|
||||
no action.
|
||||
|
||||
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. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
|
||||
48px for one line and 68px for two (SnackbarTokens.TwoLinesContainerHeight; the site's prose
|
||||
says 64dp, and the token is the more precise of the two). A description is that second line,
|
||||
so the container is pinned to 68px whenever one is there rather than left to grow into it. On
|
||||
a compact window a two-line snackbar with an action wraps the action below the text, which is
|
||||
the third of M3's five snackbar configurations ("two lines with longer action"). `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
|
||||
@@ -65,15 +74,16 @@
|
||||
x-on:mouseleave="resume()"
|
||||
x-on:focusin="pause()"
|
||||
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"
|
||||
x-bind:class="current.description ? 'min-h-17' : 'min-h-12'"
|
||||
class="pointer-events-auto flex w-full max-w-[min(100%,36rem)] flex-wrap 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"
|
||||
>
|
||||
<div class="min-w-0 flex-1 py-1.5">
|
||||
<div x-bind:class="current.description && current.action ? 'max-medium:basis-full' : ''" class="min-w-0 flex-1 py-1.5">
|
||||
<p class="type-body-md" x-text="current.title"></p>
|
||||
<p class="type-body-md" x-show="current.description" x-text="current.description"></p>
|
||||
</div>
|
||||
|
||||
<template x-if="current.action">
|
||||
<button type="button" data-toast-action class="state-layer focus-ring touch-target h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
<button type="button" data-toast-action class="state-layer focus-ring touch-target ms-auto h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
</template>
|
||||
|
||||
<template x-if="current.action || ! current.timeout">
|
||||
|
||||
@@ -24,6 +24,19 @@
|
||||
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Two lines, and the keyboard shortcut that reaches them' => <<<'BLADE'
|
||||
<div x-data class="w-full space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<x-button label="Two lines" variant="tonal" x-on:click="materialToast('Upload paused', { description: 'The connection dropped at 64%. It carries on when you are back.' })" />
|
||||
<x-button label="Two lines, with an action" variant="tonal" x-on:click="materialToast('Upload paused', { description: 'The connection dropped at 64%. It carries on when you are back.', action: { label: 'Retry now', handler: () => materialToast('Retrying…') } })" />
|
||||
</div>
|
||||
|
||||
<p class="type-body-md text-on-surface-variant">
|
||||
A description makes the snackbar 68px, M3's two-line height; below <code>medium</code> the action wraps under the text.
|
||||
Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1 type-label-md">Alt</kbd> + <kbd class="rounded-corner-xs bg-surface-container-highest px-1 type-label-md">G</kbd> to move the focus to a snackbar that has an action.
|
||||
</p>
|
||||
</div>
|
||||
BLADE,
|
||||
'Plain tooltips' => <<<'BLADE'
|
||||
<x-button icon="content_copy" tooltip="Copy link" />
|
||||
<x-button icon="qr_code_2" tooltip-bottom="Show QR code" />
|
||||
|
||||
@@ -21,6 +21,18 @@ it('draws no state icon, and reaches 48px from its 40px controls', function () {
|
||||
->toContain('state-layer focus-ring touch-target inline-flex size-10');
|
||||
});
|
||||
|
||||
it('grows to M3\'s two-line height, and wraps the action below on a compact window', function () {
|
||||
$html = (string) $this->blade('<x-toast />');
|
||||
|
||||
// SnackbarTokens: 48dp for one line, 68dp for two — a description is that second line.
|
||||
expect($html)
|
||||
->toContain('x-bind:class="current.description ? \'min-h-17\' : \'min-h-12\'"')
|
||||
->toContain('flex-wrap')
|
||||
// M3's "two lines with longer action": the action leaves the text's line below `medium`.
|
||||
->toContain('x-bind:class="current.description && current.action ? \'max-medium:basis-full\' : \'\'"')
|
||||
->toContain('touch-target ms-auto h-10');
|
||||
});
|
||||
|
||||
it('can sit at the start', function () {
|
||||
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user