diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 80faddb2..02fa5376 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -261,11 +261,37 @@ M3's plain tooltip, standalone around any trigger: ` ``` -``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` 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`. ``: `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. +``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` 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`. ``: `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 ``s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room: + +```blade + + + + +``` + +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 ``); 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, `` is the field, not the menu. + +Clusters: `` draws M3's line, `` 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. `` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was. ### `` -A row of ``s: ``. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. +A row of ``s: ``. `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 + + + + +``` + +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 `` 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). `` is for buttons you write yourself — icons, tooltips, mixed content — and never becomes a form control. ### `` @@ -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. ### `` @@ -295,6 +321,8 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d `` — `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 (`
` — 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` (``) 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. + ### ``, `` ```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. ### `` diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index 4018d2d3..0f96f924 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.css @@ -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 (``, 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; +} diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css index 6058a39a..de21ac79 100644 --- a/resources/css/components/groups.css +++ b/resources/css/components/groups.css @@ -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 `` 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 (``): 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); } /* diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css index e03404d0..ac785bf5 100644 --- a/resources/css/components/menu.css +++ b/resources/css/components/menu.css @@ -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 `` 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: * * - ``'s: the native 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; diff --git a/resources/js/fab.js b/resources/js/fab.js new file mode 100644 index 00000000..b4578d45 --- /dev/null +++ b/resources/js/fab.js @@ -0,0 +1,74 @@ +/** + * `materialFab`: `` — 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()) + }, + })) +}) diff --git a/resources/js/material.js b/resources/js/material.js index aa6a83f5..2b780e2a 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -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' diff --git a/resources/js/menu.js b/resources/js/menu.js index 9462d03b..38836293 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -1,5 +1,6 @@ /** * `materialMenu`: the behaviour of `` — WAI-ARIA's menu button pattern on a popover. + * `materialSubmenu`: the same pattern one level in, for ``. * * 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. + * + * `` 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': + /** + * ``: 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) + }, + } + }) }) diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index ffca2a70..2fff8626 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -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) { diff --git a/resources/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php index 8a670aa5..3f5b5a4e 100644 --- a/resources/views/components/button-group.blade.php +++ b/resources/views/components/button-group.blade.php @@ -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, `` 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"): + + + + + + + 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 `` draws its own selected + colours from its own `:selected`, which is why the example binds both from one property. This + is where `` and `` part, and neither absorbs the other — + `` 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. `` is for buttons you write yourself — + icons, tooltips, mixed content, a `wire:click` of their own — and never becomes a form + control. Reach for `` 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 `` 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
class([ 'inline-flex items-center', 'gap-0.5' => $connected, diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php index 647336f1..c9aefaea 100644 --- a/resources/views/components/fab.blade.php +++ b/resources/views/components/fab.blade.php @@ -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 @@ @endif - @if ($extended) + @if ($collapsing) + {{ $label ?? $slot }} + @elseif ($extended) {{ $label ?? $slot }} @endif diff --git a/resources/views/components/group.blade.php b/resources/views/components/group.blade.php index d5bbef1e..c64e453e 100644 --- a/resources/views/components/group.blade.php +++ b/resources/views/components/group.blade.php @@ -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 ``: 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 @@ {{ $label }} @endif -
! $inline, 'w-fit' => $inline])> +
! $inline, 'w-fit' => $inline])> @foreach ($options as $option)