diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 30784477..02d87a4f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -111,6 +111,8 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. +`--material-snackbar-height` is the height of the snackbar on screen, written on `` by `` while one shows and removed when it goes. `` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `` does the same by wrapping it in `
`. + `--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only. ## Toasts @@ -224,7 +226,7 @@ Label button, icon button, toggle and responsive FAB in one component. | `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning | | `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px | | `shape` | `round` | or `square`; both square off further while pressed | -| `icon`, `icon-right` | | Material Symbol names | +| `icon`, `icon-right` | | Material Symbol names; drawn from M3's 20px cut where the glyph is 20px, and filled on a default icon button | | `width` | `default` | icon buttons only: `narrow`, `default`, `wide` | | `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) | | `link`, `external`, `no-wire-navigate` | | renders ``, with `wire:navigate` unless external | @@ -240,7 +242,7 @@ Label button, icon button, toggle and responsive FAB in one component. ### `` -M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead. +M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use ``, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead. ### ``, ``, ``, `` @@ -259,7 +261,7 @@ 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`), `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. +``: `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. ### `` @@ -291,7 +293,7 @@ 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. `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`, `disabled`, `type`. +`` — `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). ### ``, `` @@ -304,7 +306,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
``` -Two to six items open above the FAB, which turns into a close button. ``: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as ``. +Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. ``: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as ``. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`. ### `` @@ -326,7 +328,7 @@ The snackbar host. Once per layout, near the end of ``: `` (`po materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } }) ``` -`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. +`type` (`success`, `error`, `warning`, `info`) picks the announcement role and draws no icon (M3 tells you to avoid one in a snackbar); `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out. - `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON. - `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news: @@ -336,6 +338,7 @@ window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: ' window.addEventListener('app:update', () => location.reload()) ``` +- Escape dismisses a snackbar that holds the focus. - 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. ### `` @@ -368,10 +371,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl ### `` - `` — M3's small badge, a dot. `` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: ``. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread"). -- ``, ``, ``, `` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`. -- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text. +- ``, ``, ``, `` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral `outline` edge (the role that has to be seen, not the decorative `outline-variant` dividers use). `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`. +- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline edge with on-surface-variant text. - `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: ``. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour. -- The value is `value` or the slot; the slot renders as HTML: ` Pro`. `value` is escaped. A slot that holds only whitespace or comments is still a dot. +- The value is `value` or the slot; the slot renders as HTML: ` Pro`. `value` is escaped. A slot that holds only whitespace or comments is still a dot. ### `` @@ -385,7 +388,7 @@ A notice in the page, in the state's container colour with its icon: ``` -`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`. +`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Every alert is `role="status"`, whatever its colour: it is usually on the page as it renders, and an assertive region talks over the page title on load. Pass `assertive` for one put on screen in answer to something the person just did. ### `` @@ -397,7 +400,7 @@ A few lines of context around a trigger, with an optional `title` and `actions` ``` -Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`. +Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`. ### `` diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css new file mode 100644 index 00000000..4018d2d3 --- /dev/null +++ b/resources/css/components/actions.css @@ -0,0 +1,86 @@ +/* + * The transitions an action runs between its states — buttons, connected segments, menu items, + * the FAB menu's trigger. + * + * 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 + * look (colour, opacity, elevation) and is critically damped, because a colour must never + * overshoot — tokens/motion.css says so in the library's own words. An element whose corners and + * colours both change on a press therefore needs two springs at once, which Tailwind cannot write + * (`duration-*` and `ease-*` each emit one value); CSS can, by giving `transition-property` a + * list and matching it position for position. + * + * `state-transition-fast` is the press/hover/select transition of a control the finger lands on: + * shape, size and the place it sits on the fast spatial spring (a `fab` button nudges up when a + * snackbar arrives under it), colour and elevation on the fast effects spring. + * `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB + * turning into a close button. + * + * `popover-transition` is the enter and exit of a menu: M3 asks for a transition that ties the + * list to its trigger, so it scales up from its `transform-origin` on the spatial spring while it + * fades in on the effects one, and `allow-discrete` keeps `display` and `overlay` alive long + * enough for the exit to be seen. + * + * These are `@utility` declarations, so they compile into Tailwind's utilities layer like any + * other class: a `transition-*` utility written beside one on the call site will fight it, and + * whichever Tailwind emits last wins. Pass neither. + */ + +@utility state-transition-fast { + transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow; + transition-duration: + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-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-fast), var(--md-sys-motion-spatial-fast), + var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), + var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast); +} + +@utility state-transition-default { + transition-property: border-radius, padding, margin, background-color, color, box-shadow; + 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-default-duration), var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-effects-default-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-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default); +} + +@utility popover-transition { + opacity: 0; + scale: 0.95; + transition-property: opacity, scale, display, overlay; + transition-duration: + var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration), + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: + var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear; + transition-behavior: allow-discrete; + + &:popover-open { + opacity: 1; + scale: 1; + } + + @starting-style { + &:popover-open { + opacity: 0; + scale: 0.95; + } + } +} + +/* + * A FAB menu's items rise into place as the list opens and sink back as it closes. The entry is + * `@starting-style` on the item itself; the exit needs the state to be readable while the popover + * is on its way out, which is what `:not(:popover-open)` is — the popover keeps `display: flex` + * for the length of its own discrete transition, so the items have somewhere to animate to. + * + * Unlayered (a plain rule, not an `@utility`), so it beats the item's own utilities. + */ +[data-fab-menu]:not(:popover-open) > * { + translate: 0 0.5rem; + opacity: 0; +} diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css index ce05d2be..6058a39a 100644 --- a/resources/css/components/groups.css +++ b/resources/css/components/groups.css @@ -13,8 +13,15 @@ * full, the inner corners small, smaller still while pressed, and a selected segment fully round * (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec). * - * Split button (``): the same idea for two halves; the trailing half turns - * round and its chevron turns over while its menu is open (SplitButton*Tokens). + * 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 + * `--group-inner*`. The trailing half turns round and its chevron turns over while its menu is + * open, on M3's standard scheme rather than the expressive springs (the site: "the trailing + * button uses the standard motion scheme, not expressive, when rotating"), and the chevron sits + * a little towards the leading half so the pair reads as one control (`--split-nudge`: + * 1/1/2/3/6px). `--split-icon` is the trailing icon's own size, 22/22/26/38/50px, which the + * icon-button table does not know about. * * "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s * 9999px. One element mixes full outer corners with small inner ones, and when a box's radii @@ -29,7 +36,7 @@ --group-full: 1.25rem; } -[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; } +[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; } @@ -47,24 +54,52 @@ padding-inline-start: calc(var(--group-pad) - var(--group-grow)); } +[data-button-group='split'] { --split-inner: var(--md-sys-shape-corner-xs); --split-inner-pressed: var(--md-sys-shape-corner-md); --split-icon: 22px; --split-nudge: 1px; } +[data-button-group='split'][data-size='xs'] { --split-inner-pressed: var(--md-sys-shape-corner-sm); } +[data-button-group='split'][data-size='md'] { --split-icon: 26px; --split-nudge: 2px; } +[data-button-group='split'][data-size='lg'] { --split-inner: var(--md-sys-shape-corner-sm); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 38px; --split-nudge: 3px; } +[data-button-group='split'][data-size='xl'] { --split-inner: var(--md-sys-shape-corner-md); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 50px; --split-nudge: 6px; } + +/* + * A connected segment at the two smallest sizes is 32px or 40px tall, and an icon-only one is as + * narrow as it is tall. M3 names both numbers and tells you not to reduce them: "XS and S + * connected button groups have a 48dp target area and a 48dp minimum width". The target is the + * `touch-target` utility on the segment itself; the width is here, where a group can reach the + * ``s a caller put inside it. + */ +[data-button-group='connected'][data-size='xs'] > *, +[data-button-group='connected'][data-size='sm'] > * { + min-inline-size: 3rem; +} + /* * Connected segments and split halves take their inner corner from `--group-corner`, so pressing * or selecting changes one variable and the rounded outer corners stay put. */ [data-button-group='connected'] > *, [data-split] { - --group-corner: var(--group-inner); border-start-start-radius: var(--group-corner); border-end-start-radius: var(--group-corner); border-start-end-radius: var(--group-corner); border-end-end-radius: var(--group-corner); } -[data-button-group='connected'] > :active, -[data-split]:active { +[data-button-group='connected'] > * { + --group-corner: var(--group-inner); +} + +[data-button-group='connected'] > :active { --group-corner: var(--group-inner-pressed); } +[data-split] { + --group-corner: var(--split-inner); +} + +[data-split]:active { + --group-corner: var(--split-inner-pressed); +} + [data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)), [data-split='trailing'][aria-expanded='true'] { --group-corner: var(--group-full); @@ -82,8 +117,17 @@ border-end-end-radius: var(--group-full); } +/* + * The trailing chevron: its own size, and `--split-nudge` off centre towards the leading half — + * equal and opposite margins move it by exactly that much inside a centred flex row, in whichever + * direction the writing mode runs. + */ [data-split='trailing'] svg { - transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast); + width: var(--split-icon); + height: var(--split-icon); + margin-inline-start: calc(-1 * var(--split-nudge)); + margin-inline-end: var(--split-nudge); + transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } [data-split='trailing'][aria-expanded='true'] svg { diff --git a/resources/css/material.css b/resources/css/material.css index 4ffefa6c..f997c77a 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -20,6 +20,7 @@ @import './tokens/font.css'; @import './tokens/theme.css'; @import './tokens/state.css'; +@import './components/actions.css'; @import './components/groups.css'; @import './components/list.css'; @import './components/field.css'; diff --git a/resources/js/menu.js b/resources/js/menu.js index 1140c641..9462d03b 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -179,14 +179,25 @@ document.addEventListener('alpine:init', () => { } }, + /** + * 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)].filter((item) => item.getAttribute('aria-disabled') !== 'true') + return [...this.$refs.menu.querySelectorAll(ITEMS)] }, + /** 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() - ;(which === 'last' ? items.at(-1) : items[0])?.focus() + this.reach(which === 'last' ? items.at(-1) : items[0]) + }, + + reach(item) { + item?.focus() + item?.scrollIntoView({ block: 'nearest' }) }, navigate(event) { @@ -195,7 +206,7 @@ document.addEventListener('alpine:init', () => { const move = (index) => { event.preventDefault() - items[(index + items.length) % items.length]?.focus() + this.reach(items[(index + items.length) % items.length]) } switch (event.key) { @@ -226,7 +237,7 @@ document.addEventListener('alpine:init', () => { if (match) { event.preventDefault() - match.focus() + this.reach(match) } } }, diff --git a/resources/js/rich-tooltip.js b/resources/js/rich-tooltip.js index 9d0cc473..6d0bc311 100644 --- a/resources/js/rich-tooltip.js +++ b/resources/js/rich-tooltip.js @@ -4,6 +4,13 @@ * Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover, * at once on keyboard focus, and it stays while the pointer moves onto the bubble to reach its * actions. Persistent: a press on the trigger opens it as a light-dismiss popover. + * + * The trigger is pointed at the bubble with `aria-describedby`, so the explanation — the whole + * point of a rich tooltip — is read out with the control rather than never at all; a persistent + * one also says `aria-haspopup="dialog"` and whether it is open. The server cannot write any of + * it: the trigger comes from a slot, and only script knows which element in it takes the focus. + * A Livewire morph strips attributes the server did not render and gives the bubble a new id, so + * they are written again whenever the trigger is reached. */ const HOVER_DELAY_MS = 500 const LEAVE_GRACE_MS = 200 @@ -17,6 +24,28 @@ document.addEventListener('alpine:init', () => { const wrapper = this.$el const open = () => bubble.matches(':popover-open') + const control = () => [...wrapper.querySelectorAll('button, a[href], [tabindex]')].find((element) => !bubble.contains(element)) + + const describe = () => { + const trigger = control() + + if (!trigger) { + return + } + + trigger.setAttribute('aria-describedby', bubble.id) + + if (persistent) { + trigger.setAttribute('aria-haspopup', 'dialog') + trigger.setAttribute('aria-expanded', String(open())) + } + } + + describe() + bubble.addEventListener('toggle', describe) + wrapper.addEventListener('focusin', describe) + wrapper.addEventListener('pointerenter', describe) + if (persistent) { wrapper.addEventListener('click', (event) => { if (bubble.contains(event.target)) { diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index 8d3cf59f..ffca2a70 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -3,7 +3,8 @@ * * One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused * while hovered or focused, so it is never pulled away from someone reading or reaching for its - * action) and is replaced by the next. + * action) and is replaced by the next. A snackbar carrying an action has no timeout at all — + * M3's accessibility page forbids one — unless the caller writes a timeout out. * * A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but * never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows @@ -34,13 +35,27 @@ document.addEventListener('alpine:init', () => { timer: null, remaining: 0, startedAt: 0, + escape: 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) => { + if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) { + this.dismiss() + } + } + + document.addEventListener('keydown', this.escape) }, destroy() { + document.removeEventListener('keydown', this.escape) + document.documentElement.style.removeProperty('--material-snackbar-height') + if (host === this) { host = null } @@ -52,12 +67,17 @@ document.addEventListener('alpine:init', () => { const toast = Array.isArray(detail) ? detail[0] : detail const sticky = toast.sticky === true + // M3 forbids a snackbar with an action from auto-dismissing: it has to wait for the + // person to read it and reach it. A timeout written out still wins, for a caller who + // means it; a missing one no longer falls back to the default. + const untimed = sticky || toast.timeout === 0 || toast.timeout === null || (toast.action != null && toast.timeout === undefined) + const entry = { id: ++sequence, type: toast.type ?? null, title: toast.title ?? '', description: toast.description ?? null, - timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), + timeout: untimed ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), action: toast.action ?? null, sticky, } @@ -87,6 +107,7 @@ document.addEventListener('alpine:init', () => { clearTimeout(this.timer) this.timer = null this.current = this.queue.shift() ?? this.sticky + this.measure() if (this.current?.timeout) { this.remaining = this.current.timeout @@ -94,6 +115,24 @@ document.addEventListener('alpine:init', () => { } }, + /** + * Publishes the snackbar's height on as `--material-snackbar-height`, and clears it + * when nothing shows. M3 puts a snackbar above a FAB, never in front of or behind one, and + * the FAB is somewhere else in the page: a variable is the only thing the two share. + */ + measure() { + this.$nextTick(() => { + const snackbar = this.$el.querySelector('[data-toast]') + const root = document.documentElement.style + + if (snackbar) { + root.setProperty('--material-snackbar-height', `${Math.round(snackbar.getBoundingClientRect().height)}px`) + } else { + root.removeProperty('--material-snackbar-height') + } + }) + }, + pause() { if (!this.current?.timeout || !this.timer) { return @@ -136,9 +175,5 @@ document.addEventListener('alpine:init', () => { window.dispatchEvent(new CustomEvent(action.event)) } }, - - icon(type) { - return ['success', 'error', 'warning', 'info'].includes(type) - }, })) }) diff --git a/resources/js/tooltip.js b/resources/js/tooltip.js index ce8d1fb6..3c65e2e5 100644 --- a/resources/js/tooltip.js +++ b/resources/js/tooltip.js @@ -3,10 +3,15 @@ * * The trigger is the popover's parent element (the button, or the standalone wrapper). Hover * counts only for a pointer that can hover, after a short delay so a pointer passing over a - * toolbar does not flash every label; keyboard focus shows it at once. A press, leaving, blur - * and Escape hide it. + * toolbar does not flash every label; keyboard focus shows it at once. A press and Escape hide it + * at once; leaving and blur let it stand for M3's 1.5s, so the words can still be read while the + * pointer moves on. Only one tooltip is up at a time: showing one takes the last one down, which + * the browser will not do for us because these are `popover="manual"`. */ const HOVER_DELAY_MS = 500 +const LEAVE_DELAY_MS = 1500 + +let visible = null document.addEventListener('alpine:init', () => { window.Alpine.data('materialTooltip', () => ({ @@ -22,25 +27,37 @@ document.addEventListener('alpine:init', () => { const show = (delay) => { clearTimeout(this.timer) this.timer = setTimeout(() => { - if (tip.isConnected && !open()) { - tip.showPopover() + if (!tip.isConnected || open()) { + return + } + + if (visible && visible !== tip && visible.matches(':popover-open')) { + visible.hidePopover() + } + + visible = tip + tip.showPopover() + }, delay) + } + + const hide = (delay = 0) => { + clearTimeout(this.timer) + this.timer = setTimeout(() => { + if (open()) { + tip.hidePopover() + } + + if (visible === tip) { + visible = null } }, delay) } - const hide = () => { - clearTimeout(this.timer) - - if (open()) { - tip.hidePopover() - } - } - this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS)) - this.listen(trigger, 'pointerleave', hide) - this.listen(trigger, 'pointerdown', hide) + this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS)) + this.listen(trigger, 'pointerdown', () => hide()) this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0)) - this.listen(trigger, 'focusout', hide) + this.listen(trigger, 'focusout', () => hide(LEAVE_DELAY_MS)) this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide()) }, @@ -51,6 +68,11 @@ document.addEventListener('alpine:init', () => { destroy() { clearTimeout(this.timer) + + if (visible === this.$el) { + visible = null + } + this.listeners.forEach((remove) => remove()) }, })) diff --git a/resources/views/components/alert.blade.php b/resources/views/components/alert.blade.php index dc177cb0..e16740f4 100644 --- a/resources/views/components/alert.blade.php +++ b/resources/views/components/alert.blade.php @@ -5,9 +5,15 @@ text from `description` or the slot, and an `actions` slot for one or two text buttons. `color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's - icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser. + icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser, + 40px drawn with the 48px target M3 asks for. - Errors and warnings are `role="alert"` and announced at once; the rest are `role="status"`. --}} + It is a `role="status"`, whatever its colour: an alert is usually part of the page as it + renders, and `role="alert"` is an assertive live region that some screen readers announce over + the page title on load — and that a Livewire morph re-announces. `assertive` opts into + `role="alert"` for the case the prop is named after: a notice put on screen in answer to + something the person just did. M3 publishes no banner; the nearest thing it does publish, the + snackbar, says polite and never assertive. --}} @props([ 'title' => null, @@ -16,6 +22,7 @@ 'tone' => null, 'icon' => null, 'dismissible' => false, + 'assertive' => false, ]) @php @@ -35,7 +42,7 @@ @endphp
class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }} > @@ -58,8 +65,8 @@
@if ($dismissible) - @endif
diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php index 181642b2..2f1a04ef 100644 --- a/resources/views/components/badge.blade.php +++ b/resources/views/components/badge.blade.php @@ -4,8 +4,11 @@ - `` — the small badge, a 6px dot: something new, no number. - `` — the large badge, 16px tall, label-small: a count. `max` caps what is shown ("99+"). - Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a - `relative` parent — an icon or an icon button: + Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in + `outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and + `outline-variant` is the decorative role dividers use. `floating` pins one to the top-end + corner of a `relative` parent, on M3's anchor geometry — the dot flush in the corner, the + count 2px above it — an icon or an icon button: @@ -16,12 +19,12 @@ and two without a hue of their own: - `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or count (the ink an outline badge already writes in), surface-container-high with - on-surface-variant text when `tonal`, the outline-variant edge when `outline`. + on-surface-variant text when `tonal`, the outline edge when `outline`. - `plain` — no background, text or border colour at all, only shape, size and type, so the caller's classes paint it: ``. The value is `value`, or the slot, which renders as HTML — an icon beside the word: - ` Pro`. With neither it is a dot. + ` Pro`. With neither it is a dot. A count or dot says nothing to a screen reader on its own: give the icon's control a label that includes it ("Notifications, 4 new"), or pass `label` here. --}} @@ -65,7 +68,7 @@ $color === 'plain' => '', ! $status, $solid => $filled[$color], $tonal => $container[$color], - default => 'border-outline-variant text-on-surface-variant', + default => 'border-outline text-on-surface-variant', }; $attributes = $attributes @@ -76,8 +79,8 @@ 'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status, 'border' => $outline && ! $tonal && ! $solid && ! $dot, $paint => $paint !== '', - 'absolute top-0.5 end-0.5' => $floating && $dot, - 'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot, + 'absolute top-0 end-0' => $floating && $dot, + 'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot, ]) ->merge(array_filter([ 'aria-label' => $label, diff --git a/resources/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php index a38bed0b..8a670aa5 100644 --- a/resources/views/components/button-group.blade.php +++ b/resources/views/components/button-group.blade.php @@ -11,6 +11,10 @@ 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. + 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. + Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The shapes are resources/css/components/groups.css. --}} @@ -32,7 +36,6 @@ {{ $attributes->class([ 'inline-flex items-center', 'gap-0.5' => $connected, - 'flex-wrap' => ! $connected, 'gap-[18px]' => ! $connected && $size === 'xs', 'gap-3' => ! $connected && $size === 'sm', 'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true), diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index dd8d75c5..4e8fab32 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -9,13 +9,18 @@ `size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px, `xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the default) or `square`; either one squares off towards a smaller corner while pressed, on the - fast spatial spring. + fast spatial spring, while the colours cross on the fast effects spring beside it + (`state-transition-fast`, resources/css/components/actions.css: a colour must never + overshoot). With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`, `variant="text"` is M3's standard icon button, and the tooltip or label names it for screen - readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`, - which is no toggle — give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button - turns round). Text buttons are not toggles in M3; a selected one takes the tonal container. + readers. Its glyph is filled, as M3 asks of a default icon button. `selected` makes it a + toggle: `true` or `false` sets `aria-pressed` (not on a `link`, which is no toggle — give it + `aria-current` instead) and M3's selected colours, and a selected round button turns square + (a selected square icon button turns round); a toggle reads unselected outlined and selected + filled, which is the rest of the same rule. Text buttons are not toggles in M3; a selected + one takes the tonal container. Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens, Apache-2.0); the text button's label is primary, as Compose draws it, not the token's @@ -24,11 +29,15 @@ Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows the loading indicator while the button's own `wire:click` runs (or the action named by a - string); `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition); + string), drawn in the button's own ink — M3 asks an indicator embedded in another component + to take that component's label colour, so it stays visible on a filled or tonal container; + `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition); `tooltip`, `tooltip-left`, `tooltip-right` and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from - there — one element either way. + there — one element either way. It lifts clear of a bottom bar and of a snackbar on screen + (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a + FAB and never over one. Never pass `hidden`, a display or a position class: the button is `inline-flex` and `relative`, and whichever Tailwind emits last wins. Wrap it instead. --}} @@ -146,7 +155,7 @@ 'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'], ][$size][$width] : [ - 'xs' => 'h-8 gap-2 px-3 type-label-lg', + 'xs' => 'h-8 gap-2 px-4 type-label-lg', 'sm' => 'h-10 gap-2 px-4 type-label-lg', 'md' => 'h-14 gap-2 px-6 type-title-md', 'lg' => 'h-24 gap-3 px-12 type-headline-sm', @@ -157,12 +166,16 @@ ? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size] : ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]; + // M3 draws a Material Symbol from a 20px cut below 24px: heavier strokes, wider counters, so a + // small glyph does not thin out. The cut follows the size the icon is drawn at, never the button's. + $optical = $iconSize === 'size-5' ? '20' : '24'; + $outline = ['xs' => 'border', 'sm' => 'border', 'md' => 'border', 'lg' => 'border-2', 'xl' => 'border-3'][$size]; $contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true); $classes = [ 'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap', - 'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', + 'state-transition-fast', $dimensions, // A composite component (a split button's halves) passes the corners its place needs. $corners ?? $corner.' '.$pressed[$size], @@ -170,13 +183,13 @@ $outline => $variant === 'outlined', 'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true), 'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated', - // Below 48px the touch target reaches past the button, as M3 requires. - 'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true), + // Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css). + 'touch-target' => in_array($size, ['xs', 'sm'], true), 'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none', 'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained, 'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained, // The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares. - 'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab, + 'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab, ]; $tag = $isLink ? 'a' : 'button'; @@ -206,13 +219,13 @@ <{{ $tag }} {{ $attributes }}> @if ($spinnerTarget) - + @endif @if ($icon) - + @endif @@ -221,7 +234,7 @@ @endunless @if ($iconRight) - + @endif @if ($tip !== null) diff --git a/resources/views/components/fab-menu.blade.php b/resources/views/components/fab-menu.blade.php index d80f5ee3..69798c96 100644 --- a/resources/views/components/fab-menu.blade.php +++ b/resources/views/components/fab-menu.blade.php @@ -1,6 +1,6 @@ {{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions. -
+
@@ -10,11 +10,18 @@ Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a round close button in the colour itself while the list is open above it, end-aligned; the list is a `popover="auto"` menu with the menu keyboard of ``. `label` names the FAB - for screen readers. Give the items the same `color`. Like ``'s, the list is keyed for - Livewire, so it stays open through a render of the component around it. + and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button + are aria-hidden, so without it the trigger has no name at all. Give the items the same + `color`. Like ``'s, the list is keyed for Livewire, so it stays open through a render + of the component around it. + + It does not place itself. M3's margin from the edge of the window is 16dp, and 24dp from the + `large` class up: `fixed end-4 bottom-4 large:end-6 large:bottom-6`. A window too short for + the list scrolls it; the items rise into place as it opens and sink back as it closes. FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px - above the close button. --}} + above the close button. The FAB's corner and colours morph on the two default springs at once + (`state-transition-default`, resources/css/components/actions.css). --}} @props([ 'icon' => 'add', @@ -24,6 +31,10 @@ ]) @php + // The trigger's only content is two icons, which are aria-hidden, so without a name it is an + // unnamed button. M3 asks the FAB's label to say what menu it opens; "Toggle menu" is its own + // fallback wording for the close button. + $label = filled($label) ? $label : __('Toggle menu'); $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; $key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); $anchor = "--material-fab-menu-{$key}"; @@ -43,15 +54,15 @@ > @@ -61,17 +72,23 @@ id="material-fab-menu-{{ $key }}" popover="auto" role="menu" - @if ($label) aria-label="{{ $label }}" @endif + data-fab-menu + aria-label="{{ $label }}" tabindex="-1" style="position-anchor: {{ $anchor }}" x-on:keydown="navigate($event)" x-on:click="activate($event)" @class([ - 'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]', - 'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end', - 'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start', - 'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end', - 'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start', + 'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]', + // A landscape phone has no room for six 56px items: the list scrolls, the FAB below it + // does not. The 8px padding is the gap M3 leaves above the FAB and the room the items' + // elevation needs inside the scroll box; the negative margins put it back where it was. + 'max-h-[calc(100dvh-8rem)] overflow-y-auto', + 'transition-[display,overlay] transition-discrete duration-(--md-sys-motion-spatial-fast-duration)', + '-mx-2 -mt-2 items-end [position-area:top_span-left]' => $position === 'top-end', + '-mx-2 -mt-2 items-start [position-area:top_span-right]' => $position === 'top-start', + '-mx-2 -mb-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end', + '-mx-2 -mb-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start', ]) > {{ $slot }} diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php index 91c02076..647336f1 100644 --- a/resources/views/components/fab.blade.php +++ b/resources/views/components/fab.blade.php @@ -9,6 +9,17 @@ `
`. For a create action that is a FAB on a phone and a header button above, use ``. + The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its + guidelines ("icon should be filled (not outlined) and unambiguous"). + + There is no `disabled`: M3 says never to disable a FAB — "if its action is unavailable, + remove the FAB entirely instead" — so hide it (`@if`), or leave it out of the render, rather + than greying it out. A form-submit FAB that must not be pressed twice takes the caller's own + `wire:loading.attr="disabled"`. + + `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. + Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0). --}} @@ -21,7 +32,6 @@ 'link' => null, 'external' => false, 'tooltip' => null, - 'disabled' => false, 'type' => 'button', ]) @@ -36,7 +46,7 @@ : ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color]; $dimensions = $extended - ? ['sm' => 'h-14 min-w-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size] + ? ['sm' => 'h-14 min-w-20 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-5 px-7 rounded-corner-xl type-headline-sm'][$size] : ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size]; $iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size]; @@ -49,7 +59,6 @@ 'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast', $dimensions, $colours, - 'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none', ]) ->merge(array_filter([ 'href' => $isLink ? $link : null, @@ -57,7 +66,8 @@ 'rel' => $isLink && $external ? 'noopener' : null, 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, 'type' => $isLink ? null : $type, - 'disabled' => ! $isLink && $disabled ? true : null, + // The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp. + 'data-fab' => true, 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, 'style' => $anchor ? "anchor-name: {$anchor}" : null, ], fn ($value): bool => $value !== null)); @@ -65,7 +75,7 @@ <{{ $tag }} {{ $attributes }}> @if ($icon) - + @endif @if ($extended) diff --git a/resources/views/components/group.blade.php b/resources/views/components/group.blade.php index d5a19826..d5bbef1e 100644 --- a/resources/views/components/group.blade.php +++ b/resources/views/components/group.blade.php @@ -10,7 +10,10 @@ bind as on any input, the arrow keys move the choice, and a screen reader announces a group. The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless - `inline`. An option with `'disabled' => true` greys its own segment. + `inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a + segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells + you never to reduce. Corners move on the spatial spring and colours on the effects one + (`state-transition-fast`). 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`, @@ -43,9 +46,12 @@ $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: 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); $segment = [ - 'xs' => 'h-8 gap-2 px-3 type-label-lg', + 'xs' => 'h-8 gap-2 px-4 type-label-lg', 'sm' => 'h-10 gap-2 px-4 type-label-lg', 'md' => 'h-14 gap-2 px-6 type-title-md', 'lg' => 'h-24 gap-3 px-12 type-headline-sm', @@ -57,6 +63,8 @@ : 'mt-1 type-body-sm text-on-surface-variant'; $iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]; + // A 20px glyph comes from M3's 20px cut, which is drawn for that size rather than scaled to it. + $optical = $iconSize === 'size-5' ? '20' : '24'; $colours = match ($variant) { 'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary', @@ -74,8 +82,9 @@ @foreach ($options as $option)