Merge branch 'worktree-agent-a0d44b9ad5ca7814d'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 10:38:56 +02:00
22 changed files with 1332 additions and 239 deletions
@@ -261,11 +261,37 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`submenu` makes an item a menu of its own — the slot holds the nested `<x-menu-item>`s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:
```blade
<x-menu-item label="Export as" icon="download" submenu>
<x-menu-item label="ZIP" icon="folder_zip" wire:click="exportZip" />
<x-menu-item label="PDF" icon="picture_as_pdf" wire:click="exportPdf" />
</x-menu-item>
```
The item says so with `aria-haspopup="menu"`, `aria-expanded` and a chevron; Right, Enter or Space open it on its first item, Left or Escape close it and come back, and on a fine pointer resting on the item opens it. Choosing anything inside closes the whole menu. Arrows stay inside the list they are in. M3 calls submenus a large-screen pattern — on a phone give the menu `sheet-at-compact`, or keep the list flat.
`filter` puts a text field at the top of the list (M3's menu as a filtering surface) and narrows the items to those whose label holds what has been typed — in the browser, over the items already rendered, so nothing is fetched and every `wire:click` stays where it was. `filter="Find a person"` names the field; bare `filter` calls it "Filter". The field keeps the focus while the arrow keys, Home and End move a highlighted row and Enter chooses it (`aria-activedescendant`, as `<x-choices searchable>`); a query that leaves nothing says "Nothing matches". Reach for it once a menu is long enough to hunt through; for a value bound to a property, `<x-choices searchable>` is the field, not the menu.
Clusters: `<x-menu-separator />` draws M3's line, `<x-menu-group gap>` M3 Expressive's grouped layout — no line, the cluster set 8px off its neighbours with its items 2px apart and its ends rounded. Reach for the divider first (M3: "on web, use dividers to separate items", and it is the only one a scrolling menu may use); reach for the gap for one or two clusters in a menu short enough not to scroll, and never vary the gap. `<x-menu-group>` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was.
### `<x-button-group>`
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. `shape="square"` is M3's square group and covers every button in it, so do not write `shape` on each one: a connected group's ends square to the corner its inner edges take, a standard group's buttons take the square corner scale, and a selected button still rounds — M3 has the toggle morph the other way.
`selection` is M3's third configuration — `single`, `multi`, and either with `required` ("selection-required"). The group then owns `aria-pressed`:
```blade
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" variant="tonal" :selected="$view === 'day'" />
<x-button label="Week" value="week" variant="tonal" :selected="$view === 'week'" />
</x-button-group>
```
Pressing a button writes its `value` (an array with `multi`) to `wire:model` or `x-model`, deselects the others in `single`, and with `required` refuses the press that would leave nothing selected; without a model it reads the buttons' own `aria-pressed` once and goes on from there. A button with no `value` is known by its label. The group manages state and shape, not colour — each button draws its selected colours from its own `:selected`, so bind both from one property as above. **Reach for `<x-group>` first**: it is the component for a choice whose options are data (real radios or checkboxes, a plain form post, the browser's keyboard, segments that paint themselves). `<x-button-group selection>` is for buttons you write yourself — icons, tooltips, mixed content — and never becomes a form control.
### `<x-group>`
@@ -279,7 +305,7 @@ A choice between a few options as a connected button group of native radios (che
]" hint="Recipients lose access after that" />
```
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `shape` (`round`, `square`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
### `<x-split-button>`
@@ -295,6 +321,8 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
`collapse-on-scroll` on an extended FAB with an `icon` (`<x-fab icon="edit" label="Compose" collapse-on-scroll />`) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane.
### `<x-fab-menu>`, `<x-fab-menu-item>`
```blade
@@ -338,7 +366,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>`
+63 -1
View File
@@ -1,6 +1,6 @@
/*
* The transitions an action runs between its states — buttons, connected segments, menu items,
* the FAB menu's trigger.
* the FAB menu's trigger, and an extended FAB collapsing to a FAB on scroll.
*
* M3 has two spring styles and they are not interchangeable: spatial moves things (position,
* size, rotation, rounded corners) and overshoots by 9% on the way, effects changes how things
@@ -84,3 +84,65 @@
translate: 0 0.5rem;
opacity: 0;
}
/*
* An extended FAB that collapses to a FAB while the page scrolls down (`<x-fab
* collapse-on-scroll>`, resources/js/fab.js sets `data-collapsed`). M3's extended FAB page: it
* "can collapse to a FAB on scroll-down and re-expand to extended on scroll-up — when switching
* between FAB↔extended FAB, shape changes, the icon moves left, and the text label fades in/out".
*
* `width: auto` cannot transition, so the label sits in a one-column grid whose track closes to
* zero; that track is what animates, and the button lays itself out around it every frame. The
* gap and the minimum width go with it, all on the default spatial spring — the size of morph
* `state-transition-default` is for — while the label fades on the effects spring. The collapsed
* sizes are the FAB's own (FabBaseline/Medium/LargeTokens: 56/80/96px). The extended FAB's
* padding already centres the glyph at `sm` (16px + 24px + 16px) and `md` (26 + 28 + 26); at
* `lg` its 28px is 4px short of the 32px that centres a 32px glyph in 96px, so that one moves.
* Corners do not change: each size's FAB and extended FAB share one.
*
* The transition list repeats the FAB's own shadow and colour transitions, which this rule
* replaces. Under reduced motion tokens/motion.css takes every duration to zero, so it swaps.
*/
[data-fab-collapsible] {
--fab-collapsed-size: 3.5rem;
transition-property: min-inline-size, padding-inline, gap, box-shadow, background-color, color;
transition-duration:
var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-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-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
}
[data-fab-collapsible][data-fab-size='md'] { --fab-collapsed-size: 5rem; }
[data-fab-collapsible][data-fab-size='lg'] { --fab-collapsed-size: 6rem; }
[data-fab-label] {
display: grid;
grid-template-columns: 1fr;
transition-property: grid-template-columns, opacity;
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default);
}
[data-fab-label] > span {
overflow: hidden;
white-space: nowrap;
}
[data-fab-collapsible][data-collapsed] {
min-inline-size: var(--fab-collapsed-size);
gap: 0;
}
[data-fab-collapsible][data-fab-size='lg'][data-collapsed] {
padding-inline: 2rem;
}
/* The label stays in the accessibility tree — clipped and transparent, never `display: none` — so
the collapsed FAB keeps the name the extended one had. */
[data-fab-collapsible][data-collapsed] [data-fab-label] {
grid-template-columns: 0fr;
opacity: 0;
}
+36 -7
View File
@@ -13,6 +13,16 @@
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
*
* Shape (`shape="square"`, M3's "Default shape | Round, square" configuration): `--group-outer`
* is what the two ends of a group round to half the height for a round group, and for a square
* one the same corner its inner edges take, which is the square table M3 publishes for connected
* groups (4/8/8/16/20dp by size). A square *standard* group has no inner edges, so it hands its
* buttons the square corner scale `<x-button shape="square">` draws (`--group-square`, and
* `--group-square-pressed` under the finger) instead of asking the caller to write it on each
* one. Selection morphs the other way in both: M3 has a toggle inside a group "swap shape
* square/round on selection", so a selected button in a square group turns round, which is why
* the selected rules keep `--group-full` rather than `--group-outer`.
*
* Split button (`<x-split-button>`): the same idea for two halves, on its own corner scale
* SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px
* under the finger, the opposite of a connected group, so `--split-inner*` is separate from
@@ -34,13 +44,32 @@
--group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs);
--group-full: 1.25rem;
--group-square: var(--md-sys-shape-corner-md);
--group-square-pressed: var(--md-sys-shape-corner-sm);
--group-outer: var(--group-full);
}
[data-button-group][data-size='xs'] { --group-pad: 1rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; --group-square: var(--md-sys-shape-corner-lg); --group-square-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); }
/* A square group's ends take the corner its inner edges take — M3's square table for connected
groups and a square standard group hands its buttons the button's own square scale. */
[data-button-group][data-shape='square'] { --group-outer: var(--group-inner); }
[data-button-group='standard'][data-shape='square'] > * {
border-radius: var(--group-square);
}
[data-button-group='standard'][data-shape='square'] > :active:not(:disabled, [aria-disabled='true']) {
border-radius: var(--group-square-pressed);
}
[data-button-group='standard'][data-shape='square'] > [aria-pressed='true'] {
border-radius: var(--group-full);
}
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow));
@@ -107,14 +136,14 @@
[data-button-group='connected'] > :first-child,
[data-split='leading'] {
border-start-start-radius: var(--group-full);
border-end-start-radius: var(--group-full);
border-start-start-radius: var(--group-outer);
border-end-start-radius: var(--group-outer);
}
[data-button-group='connected'] > :last-child,
[data-split='trailing'] {
border-start-end-radius: var(--group-full);
border-end-end-radius: var(--group-full);
border-start-end-radius: var(--group-outer);
border-end-end-radius: var(--group-outer);
}
/*
+72 -2
View File
@@ -1,7 +1,7 @@
/*
* The exposed dropdown menu the list a field drops open.
* The menus: the exposed dropdown a field drops open, and what `<x-menu>` cannot say in utilities.
*
* Two lists wear it, so a form reads as one family:
* Two lists wear the dropdown, so a form reads as one family:
*
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while the
@@ -20,6 +20,76 @@
* paints option colours into its native list would otherwise show a half-painted one.
*/
/*
* `<x-menu>` and the submenus inside it.
*
* A submenu is a popover of its own nested in the menu's, so it cannot take the container colour
* from a class the item knows nothing about: the menu declares the pair as custom properties,
* which a nested popover inherits down the DOM whatever the top layer does with its painting. The
* fallbacks are the standard menu's, for a submenu in some other list (a FAB menu's).
*/
[data-menu] {
--material-menu-surface: var(--md-sys-color-surface-container-low);
--material-menu-ink: var(--md-sys-color-on-surface);
}
[data-menu][data-vibrant] {
--material-menu-surface: var(--md-sys-color-tertiary-container);
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
}
[data-submenu] {
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
}
/*
* `<x-menu filter>`: M3's menu as a filtering surface. The field stays put while the list scrolls
* under it and takes the menu's own container, so a vibrant menu's field is vibrant too. It is a
* plain <input> with no field chrome M3's menus embed a text field, not a text field component.
*/
[data-menu-filter] {
position: sticky;
inset-block-start: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-inline: 1rem;
border-block-end: 1px solid var(--md-sys-color-outline-variant);
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
[data-menu-filter] input {
flex: 1;
min-inline-size: 0;
border: 0;
background: transparent;
padding: 0;
color: inherit;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
outline: none;
}
[data-menu-filter] input::placeholder {
color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 70%, transparent);
}
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
`display: flex` utility would otherwise beat the user agent's `[hidden] { display: none }`. */
[data-menu] [hidden] {
display: none;
}
/* `data-active` is the row the arrow keys are on while the focus stays in the field the state
layer the roving focus would have drawn, without taking the focus off the field. A chosen or
current row keeps its own colour. */
[data-menu] [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
background: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 8%, transparent);
}
.field-menu {
max-block-size: 18rem;
overflow-y: auto;
+74
View File
@@ -0,0 +1,74 @@
/**
* `materialFab`: `<x-fab collapse-on-scroll>` M3's extended FAB that collapses to a FAB while the
* page scrolls down and extends again on the way back up, or once the page is at the top.
*
* Only the flag lives here. The morph itself is CSS (resources/css/components/actions.css), which
* is how it stays on the spatial spring and how reduced motion makes it instant without a second
* path through this file.
*
* It watches the window, which is what a FAB pinned to the corner of the page scrolls against. A
* FAB inside a scrolling pane of its own is not this.
*/
// Scrolling is noisy — a wheel's own wobble, a rubber-band bounce at either end — and a FAB that
// flipped on every pixel would never be still. A move has to be worth this much to count as a
// direction, and one worth less is kept and added to the next.
const STEP_PX = 8
// Within this much of the top the FAB is extended whichever way the page was last going: M3
// re-expands it "at the bottom of the view", which on a web page is where the page begins.
const TOP_PX = 24
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialFab', () => ({
collapsed: false,
lastY: 0,
ticking: false,
listeners: [],
init() {
this.lastY = Math.max(window.scrollY, 0)
this.listen(window, 'scroll', () => this.queue(), { passive: true })
},
/** One reading a frame: `scroll` fires far more often than anything can be drawn. */
queue() {
if (this.ticking) {
return
}
this.ticking = true
requestAnimationFrame(() => {
this.ticking = false
this.measure()
})
},
measure() {
const y = Math.max(window.scrollY, 0)
const moved = y - this.lastY
if (y <= TOP_PX) {
this.collapsed = false
} else if (moved > STEP_PX) {
this.collapsed = true
} else if (moved < -STEP_PX) {
this.collapsed = false
}
if (Math.abs(moved) > STEP_PX || y <= TOP_PX) {
this.lastY = y
}
},
listen(target, type, handler, options) {
target.addEventListener(type, handler, options)
this.listeners.push(() => target.removeEventListener(type, handler, options))
},
destroy() {
this.listeners.forEach((remove) => remove())
},
}))
})
+1
View File
@@ -11,6 +11,7 @@ import './theme.js'
import './figure.js'
import './tooltip.js'
import './menu.js'
import './fab.js'
import './snackbar.js'
import './rich-tooltip.js'
import './progress.js'
+416 -198
View File
@@ -1,5 +1,6 @@
/**
* `materialMenu`: the behaviour of `<x-menu>` WAI-ARIA's menu button pattern on a popover.
* `materialSubmenu`: the same pattern one level in, for `<x-menu-item submenu>`.
*
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
* script, which a Livewire morph removes along with anything else the server did not render,
@@ -12,6 +13,19 @@
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
* which puts the server's attributes, and a fresh name, back.
*
* A submenu's popover sits inside its parent's, so the browser keeps the two open together a
* nested `popover="auto"` light-dismisses only down to its DOM ancestor and closes the inner one
* when the outer goes. Its trigger *is* the item, which the server names itself, so the two pieces
* that exist only for a wrapper (moving the anchor name, and finding the button inside the trigger
* slot) are overridden away. `items()` stops at the popover it belongs to, so the arrow keys in a
* menu never walk into an open submenu's rows, nor a submenu's back out into its parent's.
*
* `<x-menu filter>` adds a text field at the top of the same list. The field keeps the focus while
* the arrow keys move a highlight APG's combobox, which is what a text field inside a popup
* asks for so `refine()`, `visible()`, `mark()` and `search()` work on `aria-activedescendant`
* and the `hidden` attribute rather than on the roving focus the rest of this file uses. They do
* nothing at all in a menu with no field: `$refs.filter` is what turns them on.
*/
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
@@ -21,244 +35,448 @@ const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradi
// click.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity,
anchored: null,
returnFocus: true,
focusWasInside: false,
listeners: [],
// A submenu opens after the pointer has rested on its item for a moment, and closes a moment after
// it leaves the pair — long enough to cross the gap between them. APG's menu pattern asks for both
// delays. A coarse pointer has no hover to speak of, so there it opens on the press instead.
const HOVER_OPEN_MS = 180
const HOVER_CLOSE_MS = 320
init() {
const menu = this.$refs.menu
const menu = () => ({
closedAt: -Infinity,
anchored: null,
returnFocus: true,
focusWasInside: false,
listeners: [],
this.label()
init() {
const menu = this.$refs.menu
this.label()
this.anchor()
// A morph rewrites the wrapper's style with this render's name and the button's without
// it, takes the button's ARIA attributes away and gives the popover a new id; the
// observer runs before the next frame is drawn, so an open menu never moves and its
// button never shows it shut.
const observer = new MutationObserver(() => {
this.anchor()
this.label()
})
// A morph rewrites the wrapper's style with this render's name and the button's without
// it, takes the button's ARIA attributes away and gives the popover a new id; the
// observer runs before the next frame is drawn, so an open menu never moves and its
// button never shows it shut.
const observer = new MutationObserver(() => {
this.anchor()
this.label()
})
observer.observe(this.$refs.trigger, {
attributes: true,
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
childList: true,
subtree: true,
})
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
this.listeners.push(() => observer.disconnect())
observer.observe(this.$refs.trigger, {
attributes: true,
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
childList: true,
subtree: true,
})
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
this.listeners.push(() => observer.disconnect())
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut.
// Whether focus was in the menu is read before it closes: once closed, a browser may
// already have handed focus to what had it before the menu opened (WebKit does, when
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut.
// Whether focus was in the menu is read before it closes: once closed, a browser may
// already have handed focus to what had it before the menu opened (WebKit does, when
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
if (event.newState === 'closed') {
this.closedAt = performance.now()
}
})
if (event.newState === 'closed') {
this.closedAt = performance.now()
}
})
this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open'
this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open'
this.control()?.setAttribute('aria-expanded', String(opened))
this.control()?.setAttribute('aria-expanded', String(opened))
if (opened) {
return
}
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus()
}
this.focusWasInside = false
})
// A press outside closes the menu without pulling focus back to the trigger.
this.listen(document, 'pointerdown', (event) => {
if (!menu.contains(event.target) && !this.$refs.trigger.contains(event.target)) {
this.returnFocus = false
}
})
},
control() {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
},
/**
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
* name only as rendered this render's, which the popover's `position-anchor` matches so
* it is read there.
*/
anchor() {
const trigger = this.$refs.trigger
const control = this.control()
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
const name = rendered.startsWith('--') ? rendered : this.anchored
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
if (!control || !name) {
if (opened) {
return
}
const names = control.style
.getPropertyValue('anchor-name')
.split(',')
.map((each) => each.trim())
.filter((each) => each.startsWith('--'))
if (!names.includes(name)) {
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus()
}
this.anchored = name
this.focusWasInside = false
if (rendered !== '') {
trigger.style.removeProperty('anchor-name')
// A filtered menu opens on the whole list again: the query belonged to that visit.
if (this.$refs.filter) {
this.$refs.filter.value = ''
this.refine()
}
},
})
/** Writes only what differs: the observer that calls this watches these same attributes. */
label() {
const control = this.control()
if (!control) {
return
// A press outside closes the menu without pulling focus back to the trigger.
this.listen(document, 'pointerdown', (event) => {
if (!menu.contains(event.target) && !this.$refs.trigger.contains(event.target)) {
this.returnFocus = false
}
})
},
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
control() {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
},
for (const [name, value] of Object.entries(attributes)) {
if (control.getAttribute(name) !== value) {
control.setAttribute(name, value)
}
/**
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
* name only as rendered this render's, which the popover's `position-anchor` matches so
* it is read there.
*/
anchor() {
const trigger = this.$refs.trigger
const control = this.control()
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
const name = rendered.startsWith('--') ? rendered : this.anchored
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
if (!control || !name) {
return
}
const names = control.style
.getPropertyValue('anchor-name')
.split(',')
.map((each) => each.trim())
.filter((each) => each.startsWith('--'))
if (!names.includes(name)) {
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
}
this.anchored = name
if (rendered !== '') {
trigger.style.removeProperty('anchor-name')
}
},
/** Writes only what differs: the observer that calls this watches these same attributes. */
label() {
const control = this.control()
if (!control) {
return
}
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
for (const [name, value] of Object.entries(attributes)) {
if (control.getAttribute(name) !== value) {
control.setAttribute(name, value)
}
},
}
},
isOpen() {
return this.$refs.menu.matches(':popover-open')
},
isOpen() {
return this.$refs.menu.matches(':popover-open')
},
open(focus = 'first') {
this.label()
this.anchor()
open(focus = 'first') {
this.label()
this.anchor()
if (!this.isOpen()) {
this.$refs.menu.showPopover()
this.returnFocus = true
}
if (!this.isOpen()) {
this.$refs.menu.showPopover()
this.returnFocus = true
}
this.control()?.setAttribute('aria-expanded', 'true')
this.control()?.setAttribute('aria-expanded', 'true')
// A filtering menu hands the focus to its field, not to a row: the field is where the
// typing goes, and `aria-activedescendant` says which row the arrows are on meanwhile.
if (this.$refs.filter) {
this.$refs.filter.focus()
this.$refs.filter.select()
this.mark(this.visible()[0] ?? null)
return
}
// `false` opens without taking the focus: a submenu the pointer rested on belongs to the
// pointer, and taking the focus out from under the keyboard would be the wrong answer.
if (focus !== false) {
this.focusItem(focus)
},
}
},
close() {
if (this.isOpen()) {
this.$refs.menu.hidePopover()
}
close() {
if (this.isOpen()) {
this.$refs.menu.hidePopover()
}
this.control()?.setAttribute('aria-expanded', 'false')
},
this.control()?.setAttribute('aria-expanded', 'false')
},
toggle(focus = 'first') {
if (this.isOpen()) {
toggle(focus = 'first') {
if (this.isOpen()) {
this.close()
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
this.open(focus)
}
},
/**
* Every item of *this* menu, disabled ones included: M3 keeps a disabled item focusable
* ("disabled items can still receive focus, just aren't selectable") so a person reading the
* menu with the keyboard learns that it exists. activate() is where the refusal lives.
*
* An open submenu is a popover of its own nested in this one, and its rows are its: the
* nearest popover around a row says which menu the arrow keys should find it in.
*/
items() {
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover]') === this.$refs.menu)
},
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
focusItem(which) {
const items = this.items()
this.reach(which === 'last' ? items.at(-1) : items[0])
},
reach(item) {
item?.focus()
item?.scrollIntoView({ block: 'nearest' })
},
navigate(event) {
const items = this.items()
const current = items.indexOf(document.activeElement)
const move = (index) => {
event.preventDefault()
this.reach(items[(index + items.length) % items.length])
}
switch (event.key) {
case 'ArrowDown':
return move(current + 1)
case 'ArrowUp':
return move(current < 0 ? items.length - 1 : current - 1)
case 'Home':
return move(0)
case 'End':
return move(items.length - 1)
case 'Escape':
this.returnFocus = true
return
case 'Tab':
this.returnFocus = false
this.close()
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
this.open(focus)
}
},
/**
* Every item, disabled ones included: M3 keeps a disabled item focusable ("disabled items
* can still receive focus, just aren't selectable") so a person reading the menu with the
* keyboard learns that it exists. activate() is where the refusal lives.
*/
items() {
return [...this.$refs.menu.querySelectorAll(ITEMS)]
},
return
}
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
focusItem(which) {
const items = this.items()
// Typeahead: a printable letter moves to the next item whose label starts with it.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const letter = event.key.toLowerCase()
const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)]
const match = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(letter))
this.reach(which === 'last' ? items.at(-1) : items[0])
},
reach(item) {
item?.focus()
item?.scrollIntoView({ block: 'nearest' })
},
navigate(event) {
const items = this.items()
const current = items.indexOf(document.activeElement)
const move = (index) => {
if (match) {
event.preventDefault()
this.reach(items[(index + items.length) % items.length])
this.reach(match)
}
}
},
switch (event.key) {
case 'ArrowDown':
return move(current + 1)
case 'ArrowUp':
return move(current < 0 ? items.length - 1 : current - 1)
case 'Home':
return move(0)
case 'End':
return move(items.length - 1)
case 'Escape':
/**
* `<x-menu filter>`: M3's menu as a filtering surface. The rows are already rendered, so this
* only hides the ones the query leaves out with the `hidden` attribute, which menu.css turns
* into `display: none` over the row's own `display: flex`. A divider means nothing between two
* filtered clusters, and a group whose every row has gone is a heading over nothing.
*/
refine() {
const query = this.$refs.filter.value.trim().toLowerCase()
for (const item of this.items()) {
item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query)
}
for (const rule of this.$refs.menu.querySelectorAll('[role="separator"]')) {
rule.hidden = query !== ''
}
for (const group of this.$refs.menu.querySelectorAll('[role="group"]')) {
group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden)
}
const left = this.visible()
this.$refs.empty.hidden = left.length > 0
this.mark(left[0] ?? null)
},
/** The rows a query has left, in the order they are read. */
visible() {
return this.items().filter((item) => !item.hidden && item.closest('[hidden]') === null)
},
/**
* Moves the highlight the arrow keys carry while the focus stays in the field. The row needs
* an id for `aria-activedescendant` to name it, and gets one if the caller wrote none.
*/
mark(item) {
for (const each of this.items()) {
if (each !== item) {
each.removeAttribute('data-active')
}
}
if (!item) {
this.$refs.filter.removeAttribute('aria-activedescendant')
return
}
item.id ||= `${this.$refs.menu.id}-item-${this.items().indexOf(item)}`
item.setAttribute('data-active', '')
this.$refs.filter.setAttribute('aria-activedescendant', item.id)
if (this.isOpen()) {
item.scrollIntoView({ block: 'nearest' })
}
},
/** The APG combobox keyboard, on the field: the list moves under it and Enter takes a row. */
search(event) {
const left = this.visible()
const current = left.findIndex((item) => item.hasAttribute('data-active'))
const move = (index) => {
event.preventDefault()
if (left.length > 0) {
this.mark(left[(index + left.length) % left.length])
}
}
switch (event.key) {
case 'ArrowDown':
return move(current + 1)
case 'ArrowUp':
return move(current < 0 ? left.length - 1 : current - 1)
case 'Home':
return move(0)
case 'End':
return move(left.length - 1)
case 'Enter':
event.preventDefault()
if (left[current] && left[current].getAttribute('aria-disabled') !== 'true') {
left[current].click()
}
return
case 'Escape':
// The browser's own light dismiss closes the popover; this only says where the
// focus goes after it.
this.returnFocus = true
return
case 'Tab':
this.returnFocus = false
this.close()
}
},
activate(event) {
const item = event.target.closest(ITEMS)
if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) {
return
}
this.close()
},
listen(target, type, handler) {
target.addEventListener(type, handler)
this.listeners.push(() => target.removeEventListener(type, handler))
},
destroy() {
this.listeners.forEach((remove) => remove())
},
})
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', menu)
window.Alpine.data('materialSubmenu', () => {
const base = menu()
return {
...base,
hoverTimer: null,
/** The item is the menu button, and the server named it: nothing has to be moved. */
control() {
return this.$refs.trigger
},
anchor() {},
navigate(event) {
// APG: Left closes a submenu and puts the focus back on the item that opened it.
// Escape does the same through the browser's own light dismiss, which the `toggle`
// listener follows with the focus.
if (event.key === 'ArrowLeft') {
event.preventDefault()
this.returnFocus = true
this.close()
this.control()?.focus()
return
case 'Tab':
}
base.navigate.call(this, event)
},
/** Hover opens a submenu only where hovering means something, and never on a first tap. */
fine(event) {
return (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches
},
hover(event) {
if (!this.fine(event)) {
return
}
clearTimeout(this.hoverTimer)
this.hoverTimer = setTimeout(() => this.open(false), HOVER_OPEN_MS)
},
/**
* The submenu is a DOM child of the item's wrapper, so crossing into it is not a leave;
* a pointer that really left closes it, unless the keyboard has since taken it over.
*/
unhover() {
clearTimeout(this.hoverTimer)
if (!this.fine()) {
return
}
this.hoverTimer = setTimeout(() => {
if (this.$el.contains(document.activeElement)) {
return
}
this.returnFocus = false
this.close()
}, HOVER_CLOSE_MS)
},
return
}
// Typeahead: a printable letter moves to the next item whose label starts with it.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const letter = event.key.toLowerCase()
const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)]
const match = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(letter))
if (match) {
event.preventDefault()
this.reach(match)
}
}
},
activate(event) {
const item = event.target.closest(ITEMS)
if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) {
return
}
this.close()
},
listen(target, type, handler) {
target.addEventListener(type, handler)
this.listeners.push(() => target.removeEventListener(type, handler))
},
destroy() {
this.listeners.forEach((remove) => remove())
},
}))
destroy() {
clearTimeout(this.hoverTimer)
base.destroy.call(this)
},
}
})
})
+26 -6
View File
@@ -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) {
@@ -8,8 +8,37 @@
Standard (the default): the buttons stand apart, and pressing a label button widens it while
its neighbours give way. `connected`: 2px apart with small inner corners, the shape that
replaced M3's segmented button; a selected (aria-pressed) button rounds fully. Give `size`
the size of the buttons inside, so the spacing and corners match. For a choice bound to a
property, `<x-group>` draws a connected group of radios.
the size of the buttons inside, so the spacing and corners match.
`selection` is M3's third button-group configuration `single`, `multi`, and either of them
with `required` ("selection-required"):
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" variant="tonal" :selected="$view === 'day'" />
<x-button label="Week" value="week" variant="tonal" :selected="$view === 'week'" />
</x-button-group>
The group owns `aria-pressed` from then on: pressing a button writes the pressed one's `value`
(an array with `multi`) to `wire:model` or `x-model`, deselects the others in `single`, and
with `required` refuses the press that would leave nothing selected. Without a model it reads
the buttons' own `aria-pressed` once and takes it from there. A button with no `value` is
known by its label.
The group manages state and shape, not colour: each `<x-button>` draws its own selected
colours from its own `:selected`, which is why the example binds both from one property. This
is where `<x-group>` and `<x-button-group selection>` part, and neither absorbs the other
`<x-group>` is for a choice whose options are *data*: it renders real radios or checkboxes
from an `options` array, so it posts in a plain form, takes the browser's own keyboard, and
paints its own segments. `<x-button-group selection>` is for buttons you write yourself
icons, tooltips, mixed content, a `wire:click` of their own and never becomes a form
control. Reach for `<x-group>` first.
`shape` is M3's "Default shape | Round, square" configuration, and covers every button in the
group so it need not be written on each one: `square` squares a connected group's two ends to
the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size) and gives a
standard group's buttons the square corner scale `<x-button shape="square">` draws. Selection
still morphs the other way M3 has a toggle inside a group "swap shape square/round on
selection" — so a selected button in a square group rounds.
A group never wraps to a second line M3's rule, and the press expansion only reaches a
neighbour on the same line anyway. Where a row is too long for its window the answer is a
@@ -22,10 +51,21 @@
'connected' => false,
'size' => 'sm',
'label' => null,
'shape' => 'round',
'selection' => null,
'required' => false,
])
@php
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$shape = $shape === 'square' ? 'square' : 'round';
$selection = in_array($selection, ['single', 'multi'], true) ? $selection : null;
$multiple = $selection === 'multi';
$wire = $attributes->wire('model');
$model = $selection !== null && $wire->value() !== false;
// `wire:model` is entangled into the Alpine state below, so it must not also reach the div,
// where Livewire would find no input to bind. `x-model` stays: `x-modelable` pairs with it.
$attributes = $model ? $attributes->whereDoesntStartWith('wire:model') : $attributes;
@endphp
<div
@@ -33,6 +73,62 @@
@if ($label) aria-label="{{ $label }}" @endif
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}"
data-shape="{{ $shape }}"
@if ($selection !== null)
data-selection="{{ $selection }}"
@if ($required) data-selection-required @endif
x-data="{
multiple: {{ $multiple ? 'true' : 'false' }},
required: {{ $required ? 'true' : 'false' }},
@if ($model) value: @entangle($wire), @else value: {{ $multiple ? '[]' : 'null' }}, @endif
init() {
this.adopt();
this.$watch('value', () => this.paint());
},
segments() {
return [...this.$el.children].filter((child) => child.matches('button, a'));
},
name(segment) {
return segment.getAttribute('value') ?? segment.textContent.trim();
},
chosen() {
return (this.multiple ? this.value ?? [] : [this.value]).filter((each) => each !== null && each !== undefined && each !== '');
},
adopt() {
const carried = this.multiple ? (this.value ?? []).length > 0 : this.value !== null && this.value !== undefined;
if (! carried) {
const pressed = this.segments().filter((segment) => segment.getAttribute('aria-pressed') === 'true').map((segment) => this.name(segment));
this.value = this.multiple ? pressed : (pressed[0] ?? null);
}
this.paint();
},
press(event) {
const segment = event.target.closest('button, a');
if (! segment || ! this.segments().includes(segment) || segment.disabled || segment.getAttribute('aria-disabled') === 'true') return;
const name = this.name(segment);
const chosen = this.chosen();
const on = chosen.includes(name);
if (on && this.required && (! this.multiple || chosen.length === 1)) return;
this.value = this.multiple
? (on ? chosen.filter((each) => each !== name) : [...chosen, name])
: (on ? null : name);
},
paint() {
const chosen = this.chosen();
this.segments().forEach((segment) => segment.setAttribute('aria-pressed', String(chosen.includes(this.name(segment)))));
},
}"
x-on:click="press($event)"
@unless ($model) x-modelable="value" @endunless
@endif
{{ $attributes->class([
'inline-flex items-center',
'gap-0.5' => $connected,
+17 -1
View File
@@ -20,6 +20,14 @@
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
rail flattens a nested FAB to elevation 0.
`collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and
re-expand to extended on scroll-up": while the window scrolls down it shrinks to the FAB of
its size, and it extends again when the page scrolls back up or reaches the top. The label
closes and fades while the width follows it on the spatial spring (resources/css/components/
actions.css); under reduced motion the two swap outright. The label stays in the page,
clipped rather than removed, so the collapsed FAB keeps its accessible name. It needs an
`icon` a FAB with no glyph is no FAB and does nothing on a plain FAB.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
@@ -33,12 +41,14 @@
'external' => false,
'tooltip' => null,
'type' => 'button',
'collapseOnScroll' => false,
])
@php
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$extended = filled($label) || $slot->isNotEmpty();
$collapsing = $collapseOnScroll && $extended && filled($icon);
$isLink = filled($link);
$colours = $variant === 'filled'
@@ -68,6 +78,10 @@
'type' => $isLink ? null : $type,
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-fab' => true,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
'data-fab-collapsible' => $collapsing ? true : null,
'data-fab-size' => $collapsing ? $size : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@@ -78,7 +92,9 @@
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
@endif
@if ($extended)
@if ($collapsing)
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
@elseif ($extended)
<span>{{ $label ?? $slot }}</span>
@endif
+11 -2
View File
@@ -15,9 +15,14 @@
you never to reduce. Corners move on the spatial spring and colours on the effects one
(`state-transition-fast`).
`shape` is M3's "Default shape | Round, square" configuration: `square` squares the two ends
of the group to the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size),
and the chosen segment still rounds fully, so selection reads the same either way.
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
`variant`, `shape`, `multiple`, `inline`. A validation message for the bound property replaces
the hint.
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
@@ -35,6 +40,7 @@
'optionIcon' => 'icon',
'size' => 'sm',
'variant' => 'tonal',
'shape' => 'round',
'multiple' => false,
'inline' => false,
])
@@ -46,6 +52,9 @@
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
// M3's "Default shape | Round, square": a square group's ends take the corner its inner edges
// take; the chosen segment still rounds fully, which is the cue selection has always carried.
$shape = $shape === 'square' ? 'square' : 'round';
// M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever
// its 32px/40px container measures. From md the segment is wider than that on its own.
$small = in_array($size, ['xs', 'sm'], true);
@@ -78,7 +87,7 @@
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif
<div data-button-group="connected" data-size="{{ $size }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
<div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
@@ -1,9 +1,48 @@
{{-- A labelled group of items in an `<x-menu>`: "Sort by", "Share with". --}}
{{-- A group of items in an `<x-menu>`, labelled ("Sort by", "Share with"), set apart by a gap, or
both.
@props(['label'])
M3 Expressive gives a menu two ways to break its items into clusters, and they are not
interchangeable:
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
- `<x-menu-separator />`, a line. M3: "dividers are more subtle and are the right choice for
scrollable menus or text-field dropdowns", and "on web, use dividers to separate items".
The default answer, and the only one for a menu long enough to scroll.
- `<x-menu-group gap>`, the Expressive "Grouped" layout: no line, a gap. M3: "gaps are the
more expressive way to separate item clusters" — but "limit to one or two gaps per menu",
"don't vary gap size", and "never use gaps in a scrollable menu" (unsupported).
{{ $slot }}
A gapped cluster sets its items 2px apart (SegmentedMenuTokens.SegmentedGap = 2dp) and holds
them in a box of their own, so the two ends of the cluster round the way the ends of a whole
list round and each cluster reads as one block. Clusters stand 8px apart, the same 8px a
separator keeps above and below its line, so a menu is the same height whichever it uses.
SegmentedMenuTokens' own GroupShape is 8dp where the library's list ends are 12dp; the
library's shape wins, so a cluster's ends and a list's ends match. --}}
@props([
'label' => null,
'gap' => false,
])
@php
$attributes = $attributes
->class([
'py-1 first:pt-0 last:pb-0' => ! $gap,
'not-first:mt-2' => $gap,
])
->merge(array_filter([
'role' => 'group',
'aria-label' => $label,
], fn ($value): bool => filled($value)));
@endphp
<div {{ $attributes }}>
@if (filled($label))
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
@endif
@if ($gap)
<div class="space-y-0.5">{{ $slot }}</div>
@else
{{ $slot }}
@endif
</div>
+54 -2
View File
@@ -13,6 +13,14 @@
is there. `keep-open` leaves the menu open when it is activated for a choice the person may
want to change twice.
`submenu` turns the item into a menu of its own: the slot holds `<x-menu-item>`s instead of a
label, and they open in a second popover beside this one, on the item's end, flipping to its
start where the window has no room. The item says so `aria-haspopup="menu"`,
`aria-expanded`, and a chevron at its end and keeps the APG menu keyboard: Right, Enter or
Space open it on its first item, Left or Escape close it and come back here, and on a fine
pointer resting on the item opens it. Choosing anything inside closes the whole menu, as it
would from the outer list.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
item's too: the icon's own colour then carries no specificity, because which of two colour
@@ -41,12 +49,18 @@
'badge' => null,
'disabled' => false,
'keepOpen' => false,
'submenu' => false,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
// A submenu's own popover, named like the menu's: a new id and a new anchor name with every
// render, matched through a morph by the key rather than by either of them.
$key = $submenu ? \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$anchor = $submenu ? "--material-submenu-{$key}" : null;
$attributes = $attributes
->class([
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
@@ -64,12 +78,19 @@
'aria-current' => $current ? 'page' : null,
'aria-disabled' => $disabled ? 'true' : null,
'tabindex' => '-1',
'x-ref' => $submenu ? 'trigger' : null,
'style' => $submenu ? "anchor-name: {$anchor}" : null,
'aria-haspopup' => $submenu ? 'menu' : null,
'aria-expanded' => $submenu ? 'false' : null,
'aria-controls' => $submenu ? "material-submenu-{$key}" : null,
'x-on:click' => $submenu ? "toggle('first')" : null,
'type' => $isLink ? null : 'button',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'data-keep-open' => $keepOpen ? true : null,
// Opening a submenu is not choosing anything: the outer menu stays where it was.
'data-keep-open' => $keepOpen || $submenu ? true : null,
], fn ($value): bool => $value !== null));
$iconInk = match (true) {
@@ -88,13 +109,21 @@
};
@endphp
@if ($submenu)
<div x-data="materialSubmenu"
x-on:keydown.right.prevent.stop="open('first')"
x-on:pointerenter="hover($event)"
x-on:pointerleave="unhover()"
>
@endif
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
<span class="block truncate type-body-lg">{{ $label ?? $slot }}</span>
<span class="block truncate type-body-lg">{{ $submenu ? $label : ($label ?? $slot) }}</span>
@if ($description)
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
@@ -110,8 +139,31 @@
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
@elseif ($submenu)
{{-- M3's submenu marker: it points the way the list opens, and turns over in an RTL page. --}}
<x-livewire-material::icon name="chevron_right" optical="20" :class="'size-5 rtl:-scale-x-100 '.$iconInk" />
@elseif ($selected === true)
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
@if ($submenu)
<div
x-ref="menu"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-submenu-'.substr(md5((string) $label), 0, 10)]) }}
id="material-submenu-{{ $key }}"
popover="auto"
role="menu"
data-submenu
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown.stop="navigate($event)"
x-on:click="activate($event)"
class="m-0 mx-1 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] origin-top overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 popover-transition [inset:auto] [position-area:inline-end_span-block-end] [position-try-fallbacks:flip-inline]"
>
{{ $slot }}
</div>
</div>
@endif
+51 -4
View File
@@ -40,6 +40,16 @@
It opens by growing out of the corner nearest its trigger and fades as it goes
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
`filter` is M3's menu as a filtering surface ("autocomplete"): a text field at the top of the
list, which stays put while the list scrolls under it, narrowing the items to those whose
label holds what has been typed in the browser, over the items already rendered, so nothing
is fetched and a `wire:click` stays where it was. `filter="Find a person"` names the field;
bare `filter` calls it "Filter". The field, not the list, holds the focus, so a person can
type and steer at once: the arrow keys, Home and End move a highlighted row and say which one
through `aria-activedescendant`, and Enter chooses it the APG combobox keyboard, the same
one `<x-choices searchable>` uses. The list around it stays a `role="menu"` of its own inside
the popover, because a text field is not a thing a menu may contain.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0). --}}
@@ -48,12 +58,16 @@
'label' => null,
'position' => 'bottom-start',
'vibrant' => false,
'filter' => false,
])
@php
$position = in_array($position, ['bottom-start', 'bottom-end', 'top-start', 'top-end'], true) ? $position : 'bottom-start';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-menu-{$key}";
$filtering = $filter !== false && $filter !== null && $filter !== '';
$filterLabel = is_string($filter) && filled($filter) ? $filter : __('Filter');
@endphp
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
@@ -68,14 +82,17 @@
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@if ($label) aria-label="{{ $label }}" @endif
@unless ($filtering) role="menu" @endunless
data-menu
@if ($vibrant) data-vibrant @endif
@if ($label && ! $filtering) aria-label="{{ $label }}" @endif
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'p-1' => ! $filtering,
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'popover-transition',
'bg-surface-container-low text-on-surface' => ! $vibrant,
@@ -86,6 +103,36 @@
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
])
>
{{ $slot }}
@if ($filtering)
<div data-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
<input
x-ref="filter"
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="material-menu-{{ $key }}-list"
aria-label="{{ $filterLabel }}"
placeholder="{{ $filterLabel }}"
x-on:input="refine()"
x-on:keydown.stop="search($event)"
/>
</div>
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
'id' => "material-menu-{$key}-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="p-1">
{{ $slot }}
<p x-ref="empty" hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
</div>
@else
{{ $slot }}
@endif
</div>
</div>
+14 -4
View File
@@ -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">
@@ -76,6 +76,47 @@
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
BLADE,
'A connected group that owns its selection' => <<<'BLADE'
<div x-data="{ view: 'week', marks: ['bold'] }" class="w-full space-y-6">
<x-button-group connected selection="single" required label="View" x-model="view">
<x-button label="Day" value="day" variant="tonal" :selected="false" />
<x-button label="Week" value="week" variant="tonal" :selected="true" />
<x-button label="Month" value="month" variant="tonal" :selected="false" />
</x-button-group>
<x-button-group connected selection="multi" label="Formatting" x-model="marks">
<x-button icon="format_bold" aria-label="Bold" value="bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" value="italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" value="underline" variant="tonal" :selected="false" />
</x-button-group>
<p class="type-body-md text-on-surface-variant">
View: <code x-text="view"></code> · marks: <code x-text="marks.join(', ') || 'none'"></code>.
The shape follows at once; the colours come from each button's own <code>:selected</code>, which a Livewire render brings back.
</p>
</div>
BLADE,
'Square groups (hold a button down)' => <<<'BLADE'
<x-button-group label="Square standard" size="md" shape="square">
<x-button label="Day" variant="tonal" size="md" />
<x-button label="Week" variant="tonal" size="md" />
<x-button label="Month" variant="tonal" size="md" />
</x-button-group>
<x-button-group label="Square formatting" connected shape="square">
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
<div x-data="{ density: 'cosy' }" class="w-full">
<x-group label="Density" name="showcase-density" shape="square" x-model="density" inline :options="[
['id' => 'compact', 'name' => 'Compact'],
['id' => 'cosy', 'name' => 'Cosy'],
['id' => 'roomy', 'name' => 'Roomy'],
]" />
</div>
BLADE,
'A choice as a connected group' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 medium:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
@@ -114,6 +155,11 @@
<x-fab icon="add" label="New share" />
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
BLADE,
'An extended FAB that collapses on scroll (scroll the page)' => <<<'BLADE'
<x-fab icon="edit" label="Compose" collapse-on-scroll />
<x-fab icon="upload" label="Upload" size="md" color="secondary" collapse-on-scroll />
<x-fab icon="add" label="New share" size="lg" color="tertiary" collapse-on-scroll />
BLADE,
'FAB menu' => <<<'BLADE'
<div class="flex h-72 w-full items-end justify-end">
<x-fab-menu label="New">
@@ -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>&nbsp;+&nbsp;<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" />
@@ -36,6 +36,77 @@
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
</x-menu>
BLADE,
'Submenus' => <<<'BLADE'
<x-menu label="Share actions">
<x-slot:trigger>
<x-button label="Share" icon="share" variant="tonal" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Send to" icon="send" submenu>
<x-menu-item label="A person" icon="person" />
<x-menu-item label="A team" icon="group" />
<x-menu-item label="Somewhere else" icon="more_horiz" submenu>
<x-menu-item label="Slack" icon="chat" />
<x-menu-item label="Email" icon="mail" />
</x-menu-item>
</x-menu-item>
<x-menu-item label="Export as" icon="download" submenu>
<x-menu-item label="ZIP" icon="folder_zip" />
<x-menu-item label="PDF" icon="picture_as_pdf" />
<x-menu-item label="CSV" icon="table" disabled />
</x-menu-item>
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'Clusters: a gap, or a divider' => <<<'BLADE'
<x-menu label="Grouped by a gap">
<x-slot:trigger>
<x-button label="A gap" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-group gap>
<x-menu-item label="Cut" icon="content_cut" shortcut="⌘X" />
<x-menu-item label="Copy" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Paste" icon="content_paste" shortcut="⌘V" />
</x-menu-group>
<x-menu-group label="Then" gap>
<x-menu-item label="Rename" icon="edit" />
<x-menu-item label="Delete" icon="delete" />
</x-menu-group>
</x-menu>
<x-menu label="Separated by a divider">
<x-slot:trigger>
<x-button label="A divider" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-item label="Cut" icon="content_cut" shortcut="⌘X" />
<x-menu-item label="Copy" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Paste" icon="content_paste" shortcut="⌘V" />
<x-menu-separator />
<x-menu-item label="Rename" icon="edit" />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'A menu that filters as you type' => <<<'BLADE'
<x-menu label="Assign to" filter="Find a person">
<x-slot:trigger>
<x-button label="Assign to" icon="person_add" variant="tonal" />
</x-slot:trigger>
<x-menu-item label="Ada Lovelace" icon="person" description="Engineering" />
<x-menu-item label="Grace Hopper" icon="person" description="Engineering" />
<x-menu-item label="Katherine Johnson" icon="person" description="Research" />
<x-menu-item label="Mary Jackson" icon="person" description="Research" />
<x-menu-item label="Radia Perlman" icon="person" description="Networks" />
<x-menu-item label="Barbara Liskov" icon="person" description="Networks" />
<x-menu-separator />
<x-menu-item label="Nobody, for now" icon="person_off" />
</x-menu>
BLADE,
'Icons in their own colour' => <<<'BLADE'
<x-menu label="New plan">
<x-slot:trigger>
@@ -56,7 +127,8 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-menu&gt;</code> with <code>&lt;x-menu-item&gt;</code>, <code>&lt;x-menu-group&gt;</code> and <code>&lt;x-menu-separator&gt;</code>.
Open one with the keyboard too: arrows, Home, End, a letter, Escape.
Open one with the keyboard too: arrows, Home, End, a letter, Escape. A <code>submenu</code> item opens a second list beside it
&mdash; Right to enter it, Left to come back.
</p>
@foreach ($examples as $title => $code)
@@ -20,6 +20,69 @@ it('connects buttons 2px apart', function () {
->toContain('gap-0.5');
});
it('squares a group, and a choice drawn as one', function () {
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
// every button in the group, so it need not be written on each one.
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group connected shape="square"><x-button label="Day" /></x-button-group>'))
->toContain('data-button-group="connected"')
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="round"')
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="round"');
});
it('takes over aria-pressed for a group that carries a selection', function () {
$html = (string) $this->blade('<x-button-group connected selection="single" required label="View"><x-button label="Day" value="day" :selected="true" /></x-button-group>');
expect($html)
->toContain('data-selection="single"')
->toContain('data-selection-required')
->toContain('multiple: false')
->toContain('required: true')
->toContain('value: null')
->toContain('x-on:click="press($event)"')
// No model: the group takes the state from the buttons' own aria-pressed and goes on.
->toContain('x-modelable="value"')
->and((string) $this->blade('<x-button-group connected selection="multi"><x-button label="Bold" value="bold" :selected="false" /></x-button-group>'))
->toContain('multiple: true')
->toContain('required: false')
->toContain('value: []')
->and((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
->not->toContain('data-selection')
->not->toContain('press($event)');
});
it('entangles a selection group with the property it binds, and keeps wire:model off the div', function () {
$component = new class extends Component
{
public string $view = 'week';
public function render(): string
{
return <<<'BLADE'
<div>
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" :selected="$view === 'day'" />
<x-button label="Week" value="week" :selected="$view === 'week'" />
</x-button-group>
</div>
BLADE;
}
};
$html = Livewire::test($component)->assertSet('view', 'week')->html();
expect($html)
->toContain('data-selection="single"')
->toMatch('/value: window\.Livewire\.find\(/')
->and(substr_count($html, 'wire:model.live="view"'))->toBe(0);
});
it('never wraps a group onto a second line', function () {
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->not->toContain('flex-wrap');
+17
View File
@@ -41,6 +41,23 @@ it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (str
'lg' => ['lg', 'h-24 min-w-24 gap-5'],
]);
it('collapses an extended FAB to a FAB on scroll when asked', function () {
$html = (string) $this->blade('<x-fab icon="add" label="New share" size="lg" collapse-on-scroll />');
expect($html)
->toContain('x-data="materialFab"')
->toContain('x-bind:data-collapsed="collapsed ? &#039;&#039; : null"')
->toContain('data-fab-collapsible')
->toContain('data-fab-size="lg"')
// The label is clipped, never removed, so the collapsed FAB keeps its name.
->toContain('<span data-fab-label><span>New share</span></span>');
// A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing.
expect((string) $this->blade('<x-fab icon="add" aria-label="New" collapse-on-scroll />'))->not->toContain('materialFab')
->and((string) $this->blade('<x-fab label="New share" collapse-on-scroll />'))->not->toContain('materialFab')
->and((string) $this->blade('<x-fab icon="add" label="New share" />'))->not->toContain('materialFab')->toContain('<span>New share</span>');
});
it('marks its root for the places that draw a nested FAB their own way', function () {
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
});
+98
View File
@@ -103,6 +103,25 @@ it('separates and labels groups', function () {
->assertSee('role="group" aria-label="Sort by"', false);
});
it('separates clusters by a gap instead of a divider when asked', function () {
$html = (string) $this->blade('<x-menu-group gap><x-menu-item label="Cut" /></x-menu-group>');
// M3 Expressive's grouped layout: 2px between the items of a cluster, 8px between clusters —
// the same 8px the divider keeps above and below its line.
expect($html)
->toContain('role="group"')
->toContain('not-first:mt-2')
->toContain('space-y-0.5')
->not->toContain('aria-label')
->not->toContain('py-1 first:pt-0')
->and((string) $this->blade('<x-menu-group label="Then" gap><x-menu-item label="Cut" /></x-menu-group>'))
->toContain('aria-label="Then"')
->toContain('space-y-0.5')
->and((string) $this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>'))
->toContain('py-1 first:pt-0 last:pb-0')
->not->toContain('space-y-0.5');
});
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
@@ -117,3 +136,82 @@ it('adds icon-class to the leading icon, over its own colour but not over disabl
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
->not->toContain('text-sport-run');
});
it('opens a submenu beside the item that holds it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu-item label="Send to" icon="send" submenu>
<x-menu-item label="A person" icon="person" />
</x-menu-item>
BLADE);
preg_match('/anchor-name: (--material-submenu-([a-z0-9]+))/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialSubmenu"')
->toContain('x-on:keydown.right.prevent.stop="open(\'first\')"')
->toContain('x-on:pointerenter="hover($event)"')
->toContain('aria-haspopup="menu"')
->toContain('aria-expanded="false"')
->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"")
->toContain("id=\"material-submenu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}")
->toContain('data-submenu')
->toContain('aria-label="Send to"')
->toContain('[position-area:inline-end_span-block-end]')
->toContain('[position-try-fallbacks:flip-inline]')
// Opening a submenu chooses nothing, so the menu around it stays where it was.
->toContain('data-keep-open')
->toContain('A person');
});
it('marks a submenu item with a chevron instead of a tick', function () {
$chevron = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/chevron_right.svg'));
$chevron = substr($chevron, (int) strpos($chevron, '><') + 1);
expect((string) $this->blade('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->toContain($chevron)
->toContain('rtl:-scale-x-100')
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->not->toContain($chevron);
});
it('embeds a text field that filters the list, as a combobox over the menu', function () {
$html = (string) $this->blade('<x-menu label="Assign to" filter="Find a person"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Ada" /></x-menu>');
preg_match('/id="(material-menu-[a-z0-9]+)"/', $html, $id);
expect($id)->not->toBeEmpty()
->and($html)
->toContain('data-menu-filter')
->toContain('x-ref="filter"')
->toContain('role="combobox"')
->toContain('aria-autocomplete="list"')
->toContain("aria-controls=\"{$id[1]}-list\"")
->toContain('aria-label="Find a person"')
->toContain('placeholder="Find a person"')
->toContain('x-on:input="refine()"')
->toContain('x-on:keydown.stop="search($event)"')
->toContain('Nothing matches')
// A text field is not something a `role="menu"` may hold, so the list moves inside it.
->toContain("<div id=\"{$id[1]}-list\" role=\"menu\" aria-label=\"Assign to\"")
->toMatch('/<div\s[^>]*popover="auto"(?![^>]*role="menu")/');
});
it('names a bare filter field, and leaves a plain menu alone', function () {
expect((string) $this->blade('<x-menu filter><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('aria-label="Filter"')
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->not->toContain('data-menu-filter')
->not->toContain('x-ref="filter"')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
});
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->toContain('data-vibrant')
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->not->toContain('data-vibrant');
});
+12
View File
@@ -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');
});