From 25091ebf211f4985d0e72d2d1be1438ebb7d5cf4 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:31:56 +0200 Subject: [PATCH 1/7] Open a submenu from a menu item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 22, actions.md § Missing (Submenus): M3's Expressive vertical menu specifies submenus and the Left/Right keys that open and close them, and the library had neither. `` puts its slot in a second popover on the item's end, flipping to the start where there is no room. `materialSubmenu` reuses the menu button pattern one level in: the item is its own trigger, so the anchor move and the button lookup are overridden away; Right, Enter and Space open it, Left and Escape close it and return the focus, and a fine pointer resting on the item opens it after a moment. `items()` now stops at the popover it belongs to, so the arrows never walk between a menu and an open submenu. The menu publishes its container as custom properties so a vibrant menu's submenus are vibrant too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 13 +- resources/css/components/menu.css | 27 +- resources/js/menu.js | 494 +++++++++++------- .../views/components/menu-item.blade.php | 56 +- resources/views/components/menu.blade.php | 2 + .../views/showcase/sections/menus.blade.php | 27 +- tests/Feature/Components/MenuTest.php | 48 ++ 7 files changed, 462 insertions(+), 205 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 02d87a4f..00c3bc2b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -261,7 +261,18 @@ 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`. ``: `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. ### `` diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css index e03404d0..5226b775 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/menu.js b/resources/js/menu.js index f51379be..38836293 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -20,6 +20,12 @@ * 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"]' @@ -95,6 +101,12 @@ const menu = () => ({ } this.focusWasInside = false + + // 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() + } }) // A press outside closes the menu without pulling focus back to the trigger. @@ -174,6 +186,16 @@ const menu = () => ({ 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) { @@ -263,6 +285,106 @@ const menu = () => ({ } }, + /** + * ``: 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) diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index e86fbec6..ee31e313 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -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 `` 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
class('relative inline-flex') }}> @@ -68,16 +82,17 @@ {{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }} id="material-menu-{{ $key }}" popover="auto" - role="menu" + @unless ($filtering) role="menu" @endunless data-menu @if ($vibrant) data-vibrant @endif - @if ($label) aria-label="{{ $label }}" @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, @@ -88,6 +103,36 @@ 'origin-bottom [position-area:top_span-left]' => $position === 'top-end', ]) > - {{ $slot }} + @if ($filtering) +
+ + + +
+ +
"material-menu-{$key}-list", + 'role' => 'menu', + 'aria-label' => $label, + ], fn ($value): bool => filled($value))) }} class="p-1"> + {{ $slot }} + + +
+ @else + {{ $slot }} + @endif
diff --git a/resources/views/showcase/sections/menus.blade.php b/resources/views/showcase/sections/menus.blade.php index c8330721..c4e95cfe 100644 --- a/resources/views/showcase/sections/menus.blade.php +++ b/resources/views/showcase/sections/menus.blade.php @@ -91,6 +91,22 @@
BLADE, + 'A menu that filters as you type' => <<<'BLADE' + + + + + + + + + + + + + + + BLADE, 'Icons in their own colour' => <<<'BLADE' diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index 2f7b0da8..635e928f 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -176,6 +176,37 @@ it('marks a submenu item with a chevron instead of a tick', function () { ->not->toContain($chevron); }); +it('embeds a text field that filters the list, as a combobox over the menu', function () { + $html = (string) $this->blade(''); + + 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("
toMatch('/]*popover="auto"(?![^>]*role="menu")/'); +}); + +it('names a bare filter field, and leaves a plain menu alone', function () { + expect((string) $this->blade('')) + ->toContain('aria-label="Filter"') + ->and((string) $this->blade('')) + ->not->toContain('data-menu-filter') + ->not->toContain('x-ref="filter"') + ->toMatch('/]*popover="auto"[^>]*role="menu"/'); +}); + it('tells a vibrant menu apart, so the submenus inside it take the same container', function () { expect((string) $this->blade('')) ->toContain('data-menu') From 5d0e9bc12fdbdd48e48c50a4d1c141d26bbc5516 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:40:14 +0200 Subject: [PATCH 4/7] Square a button group and the choice drawn as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 22, actions.md § Missing (Square-by-default button groups): M3 lists "Default shape | Round, square" as a button-group configuration and neither `` nor `` had a `shape` prop, so a group of square buttons still drew round outer corners. `shape="square"` passes the square corner scale to every child through `data-shape` and two new variables in groups.css: `--group-outer` is what the two ends of a group round to (half the height when round, the corner its inner edges take when square — M3's square table, 4/8/8/16/20dp), and `--group-square`/`--group-square-pressed` give a standard group's buttons the scale `` draws. The selected rules keep `--group-full`, so a selected button in a square group rounds: M3 has a toggle inside a group swap shape on selection either way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 4 +- resources/css/components/groups.css | 43 ++++++++++++++++--- .../views/components/button-group.blade.php | 10 +++++ resources/views/components/group.blade.php | 13 +++++- .../views/showcase/sections/buttons.blade.php | 21 +++++++++ tests/Feature/Components/ButtonGroupTest.php | 16 +++++++ 6 files changed, 96 insertions(+), 11 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 145cc8e0..359d50a8 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -280,7 +280,7 @@ Clusters: `` draws M3's line, `` M3 Expres ### `` -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. ### `` @@ -294,7 +294,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. ### `` 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/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php index 8a670aa5..f8935067 100644 --- a/resources/views/components/button-group.blade.php +++ b/resources/views/components/button-group.blade.php @@ -11,6 +11,13 @@ 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. + `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 smaller `size` or fewer buttons, or two groups stacked. @@ -22,10 +29,12 @@ 'connected' => false, 'size' => 'sm', 'label' => null, + 'shape' => 'round', ]) @php $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; + $shape = $shape === 'square' ? 'square' : 'round'; @endphp
class([ 'inline-flex items-center', 'gap-0.5' => $connected, 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)