Merge branch 'worktree-agent-a0d44b9ad5ca7814d'
This commit is contained in:
@@ -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>`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> + <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><x-menu></code> with <code><x-menu-item></code>, <code><x-menu-group></code> and <code><x-menu-separator></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
|
||||
— 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');
|
||||
|
||||
@@ -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 ? '' : 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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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