Merge branch 'worktree-agent-a6b15d4e17dad5970'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:17:26 +02:00
33 changed files with 603 additions and 165 deletions
@@ -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 `<html>` 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 `<html>` by `<x-toast>` while one shows and removed when it goes. `<x-button fab>` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `<x-fab>` does the same by wrapping it in `<div class="fixed end-4 bottom-[calc(1rem+var(--material-snackbar-height,0px))] large:end-6">`.
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (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 `<a>`, with `wire:navigate` unless external |
@@ -240,7 +242,7 @@ Label button, icon button, toggle and responsive FAB in one component.
### `<x-tooltip>`
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `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: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `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 `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
@@ -259,7 +261,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `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.
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
### `<x-button-group>`
@@ -291,7 +293,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
### `<x-fab>`
`<x-fab icon="add" tooltip="New share" />``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 (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `disabled`, `type`.
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
### `<x-fab-menu>`, `<x-fab-menu-item>`
@@ -304,7 +306,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
</div>
```
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
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. `<x-fab-menu>`: `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 `<x-menu>`. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`.
### `<x-loading>`
@@ -326,7 +328,7 @@ The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`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.
### `<x-progress>`
@@ -368,10 +371,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
### `<x-badge>`
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — 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.
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — 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: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. 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: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `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: `<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
### `<x-alert>`
@@ -385,7 +388,7 @@ A notice in the page, in the state's container colour with its icon:
</x-alert>
```
`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.
### `<x-rich-tooltip>`
@@ -397,7 +400,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
</x-rich-tooltip>
```
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`.
### `<x-stat>`
+86
View File
@@ -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;
}
+51 -7
View File
@@ -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 (`<x-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 (`<x-split-button>`): the same idea for two halves, on its own corner scale
* SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px
* under the finger, the opposite of a connected group, so `--split-inner*` is separate from
* `--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
* `<x-button>`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 {
+1
View File
@@ -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';
+15 -4
View File
@@ -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)
}
}
},
+29
View File
@@ -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)) {
+41 -6
View File
@@ -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 <html> 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)
},
}))
})
+36 -14
View File
@@ -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()) {
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())
},
}))
+12 -5
View File
@@ -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
<div
role="{{ in_array($color, ['error', 'warning'], true) ? 'alert' : 'status' }}"
role="{{ $assertive ? 'alert' : 'status' }}"
@if ($dismissible) x-data="{ shown: true }" x-show="shown" x-transition.opacity @endif
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
>
@@ -58,8 +65,8 @@
</div>
@if ($dismissible)
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-livewire-material::icon name="close" class="size-5" />
<button type="button" class="state-layer focus-ring touch-target -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-livewire-material::icon name="close" optical="20" class="size-5" />
</button>
@endif
</div>
+10 -7
View File
@@ -4,8 +4,11 @@
- `<x-badge />` the small badge, a 6px dot: something new, no number.
- `<x-badge value="3" />` 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:
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
@@ -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: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
The value is `value`, or the slot, which renders as HTML an icon beside the word:
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. 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,
@@ -11,6 +11,10 @@
the size of the buttons inside, so the spacing and corners match. For a choice bound to a
property, `<x-group>` draws a connected group of radios.
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),
+27 -14
View File
@@ -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)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-livewire-material::loading :class="$iconSize" :label="false" />
<x-livewire-material::loading :class="$iconSize.' text-current'" :label="false" />
</span>
@endif
@if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
<x-livewire-material::icon :name="$icon" :optical="$optical" :filled="$selected === true || ($iconOnly && $selected === null)" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
</span>
@endif
@@ -221,7 +234,7 @@
@endunless
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
@endif
@if ($tip !== null)
+31 -14
View File
@@ -1,6 +1,6 @@
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
<div class="fixed end-4 bottom-4">
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
@@ -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 `<x-menu>`. `label` names the FAB
for screen readers. Give the items the same `color`. Like `<x-menu>`'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 `<x-menu>`'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 @@
>
<button
type="button"
@if ($label) aria-label="{{ $label }}" @endif
aria-label="{{ $label }}"
@class([
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
'state-transition-default aria-expanded:rounded-corner-full',
$colours,
])
>
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
<x-livewire-material::icon :name="$icon" filled class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" filled optical="20" class="hidden size-5 group-aria-expanded/fab:block" />
</button>
</span>
@@ -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 }}
+15 -5
View File
@@ -9,6 +9,17 @@
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
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)
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
@endif
@if ($extended)
+14 -5
View File
@@ -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)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'min-w-0 flex-1' => ! $inline,
'state-transition-fast',
'flex-1' => ! $inline,
$small ? 'touch-target min-w-12' : 'min-w-0',
$segment,
$colours,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
@@ -91,7 +100,7 @@
/>
@if (filled(data_get($option, $optionIcon)))
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
@@ -3,7 +3,7 @@
@props(['label'])
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
{{ $slot }}
</div>
+18 -9
View File
@@ -4,11 +4,13 @@
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
Expressive's selected shape and tertiary-container, and a `check` at its end unless it has an
`icon-right` of its own M3 asks for a cue beyond the colour and the shape. `current` is for a menu of places rather
than choices a section picker and marks the page you are on: `aria-current="page"`, the
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
reach. `keep-open` leaves the menu open when it is activated for a choice the person may
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, reachable by the
keyboard but not selectable M3 keeps a disabled item focusable so a person can find out it
is there. `keep-open` leaves the menu open when it is activated for a choice the person may
want to change twice.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
@@ -17,8 +19,12 @@
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
disabled.
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
12px at the ends of the list. --}}
48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive
spacing), 4px corners that open to 12px at the ends of the list the corner on the spatial
spring and the colour on the effects one (`state-transition-fast`). The row is 48px rather
than SegmentedMenuTokens' 44px: M3's menu page publishes 48dp and asks for ≥48×48 targets
inside an item's slots, and the library's own select and choices menus are 48px, so the more
binding source wins over the more specific one. --}}
@props([
'label' => null,
@@ -43,9 +49,9 @@
$attributes = $attributes
->class([
'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none',
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'state-transition-fast',
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
'py-2' => filled($description),
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
@@ -84,7 +90,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
@@ -103,6 +109,9 @@
@endif
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
@elseif ($selected === true)
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
@@ -1,3 +1,4 @@
{{-- A line between groups of items in an `<x-menu>`. --}}
{{-- A line between groups of items in an `<x-menu>`: 1px, with M3's 8px above and below it, inset
to the 16px the items keep either side. --}}
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
<hr role="separator" {{ $attributes->class('mx-4 my-2 h-px border-0 bg-outline-variant') }} />
+14 -6
View File
@@ -32,6 +32,14 @@
of the loop iteration around it, which would give every child component after the menu the
same key.
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow
keys, Home, End and typeahead bring the item they move to into view, and a disabled item is
among them: M3 keeps one reachable so a person can find out that it exists.
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.
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). --}}
@@ -67,15 +75,15 @@
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 overflow-visible 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 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'popover-transition',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
'[position-area:bottom_span-right]' => $position === 'bottom-start',
'[position-area:bottom_span-left]' => $position === 'bottom-end',
'[position-area:top_span-right]' => $position === 'top-start',
'[position-area:top_span-left]' => $position === 'top-end',
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
])
>
{{ $slot }}
@@ -15,6 +15,12 @@
action's `wire:click` too and resources/js/rich-tooltip.js keeps showing and hiding the
element on the page rather than one the morph took away.
resources/js/rich-tooltip.js points the trigger at the bubble with `aria-describedby`, and a
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded`: the explanation is
the whole point of a rich tooltip, and without the association a screen reader reads only the
trigger's own label. The trigger arrives in a slot, so only script can find the element in it
that takes the focus.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. --}}
@@ -13,7 +13,11 @@
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
wide there. The corners are resources/css/components/groups.css. --}}
wide there. resources/css/components/groups.css carries the rest of the spec, which is all
geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
connected group's do), the trailing icon's own size (22/22/26/38/50px, which the icon-button
table has no entry for), the 1/1/2/3/6px nudge that sits the chevron towards the leading half,
and the standard motion scheme M3 asks for while it turns over. --}}
@props([
'label' => null,
+1 -1
View File
@@ -17,7 +17,7 @@
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
@if ($icon)
<x-livewire-material::icon :name="$icon" class="size-5" />
<x-livewire-material::icon :name="$icon" optical="20" class="size-5" />
@endif
<span>{{ $title }}</span>
</div>
+29 -27
View File
@@ -7,32 +7,50 @@
dispatches from a Livewire component and for `window.materialToast(title, options)` from
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 410s),
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
name) on `window`; both may be given.
paused while the pointer or focus is on it. A toast with an `action` has no timeout at all,
as M3 requires it waits to be read and acted on unless the caller writes a `timeout` out.
A toast with an action or no timeout gets a close button. Pressing the action closes the
snackbar, calls `handler` and dispatches `event` (a name) on `window`; both may be given.
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
The live region is the host itself, not the snackbar: a region must be in the page before its
contents change for a screen reader to announce them reliably, and the snackbar comes and
goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar (never
assertive); a `type` picks the region's role `alert` for an error or a warning, `status`
otherwise and the explicit `aria-live` keeps even those polite.
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
action button.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives.
There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon
feels necessary"), and both lines of the message are plain inverse-on-surface — M3 gives the
supporting text no fourth colour and tells the two lines apart by position. The 40px action
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
Escape dismisses a snackbar that holds the focus.
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
48px for one line. A type draws its state icon in the inverse state colour. `position`:
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar;
from `medium` it hugs its line length instead, as M3 asks. --}}
48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
above a bottom bar through `--material-bottom-bar`, and publishes its own height as
`--material-snackbar-height` so a FAB can lift clear of it M3: a snackbar appears above a
FAB, never in front of or behind one. A compact window (below `medium`, 600px) gets the
full-width snackbar; from `medium` it hugs its line length instead, as M3 asks. --}}
@props(['position' => 'bottom'])
@persist('material-toast')
<div
x-data="materialSnackbar"
x-bind:role="current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'"
role="status"
aria-live="polite"
aria-atomic="true"
{{ $attributes->class([
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
'justify-center' => $position !== 'bottom-start',
@@ -43,40 +61,24 @@
<div
x-bind:key="current.id"
data-toast
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
aria-live="polite"
x-on:mouseenter="pause()"
x-on:mouseleave="resume()"
x-on:focusin="pause()"
x-on:focusout="resume()"
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86"
>
<template x-if="icon(current.type)">
<span class="flex shrink-0" x-bind:class="{
'text-inverse-success': current.type === 'success',
'text-inverse-error': current.type === 'error',
'text-inverse-warning': current.type === 'warning',
'text-inverse-info': current.type === 'info',
}">
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
</span>
</template>
<div class="min-w-0 flex-1 py-1.5">
<p class="type-body-md" x-text="current.title"></p>
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p>
<p class="type-body-md" x-show="current.description" x-text="current.description"></p>
</div>
<template x-if="current.action">
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
<button type="button" data-toast-action class="state-layer focus-ring touch-target h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
</template>
<template x-if="current.action || ! current.timeout">
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-livewire-material::icon name="close" class="size-5" />
<button type="button" class="state-layer focus-ring touch-target inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-livewire-material::icon name="close" optical="20" class="size-5" />
</button>
</template>
</div>
+3 -2
View File
@@ -5,8 +5,9 @@
<x-tooltip text="Copy link"><button ></button></x-tooltip>
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and
hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and hides
at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one
tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its
words. The bubble is a `popover="manual"` in the top layer never clipped by an
`overflow-hidden` parent, never widening a scroll container placed by CSS anchor
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
@@ -12,7 +12,7 @@
<x-badge value="Draft" color="neutral" tonal />
<x-badge value="Archived" color="neutral" outline />
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled optical="20" class="size-3" /> Pro</x-badge>
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
BLADE,
'Snackbars' => <<<'BLADE'
+1
View File
@@ -39,6 +39,7 @@ class RichTooltipMorphProbe extends Component
}
}
// The live region is the host itself, in the page before any message is (actions.md ACT-02).
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"][aria-live]')";
const TOAST = "document.querySelector('[data-toast]')";
+5 -3
View File
@@ -4,17 +4,18 @@ it('states a notice in its state\'s container, with the state\'s icon', function
$html = (string) $this->blade('<x-alert title="Storage almost full" description="3.8 of 4 GB" color="warning" />');
expect($html)
->toContain('role="alert"')
->toContain('role="status"')
->toContain('bg-warning-container text-on-warning-container')
->toContain('Storage almost full')
->toContain('3.8 of 4 GB')
->toContain('<svg');
});
it('is a status unless it is an error or a warning', function () {
it('is a status whatever its colour, and interrupts only when asked to', function () {
expect((string) $this->blade('<x-alert description="Saved." tone="success" />'))->toContain('role="status"')->toContain('bg-success-container')
->and((string) $this->blade('<x-alert description="Heads up." />'))->toContain('role="status"')->toContain('bg-info-container')
->and((string) $this->blade('<x-alert description="Broken." color="error" />'))->toContain('role="alert"');
->and((string) $this->blade('<x-alert description="Broken." color="error" />'))->toContain('role="status"')
->and((string) $this->blade('<x-alert description="Broken." color="error" assertive />'))->toContain('role="alert"');
});
it('takes actions, drops its icon on request, and can be dismissed', function () {
@@ -30,5 +31,6 @@ it('takes actions, drops its icon on request, and can be dismissed', function ()
->toContain('Try again')
->toContain('x-data="{ shown: true }"')
->toContain('aria-label="Dismiss"')
->toContain('touch-target')
->and(substr_count($html, '<svg'))->toBe(1);
});
+5 -5
View File
@@ -14,9 +14,9 @@ it('is M3\'s large badge with a count, capped by max', function () {
});
it('pins itself to the corner of an icon when floating', function () {
expect((string) $this->blade('<x-badge floating />'))->toContain('absolute top-0.5 end-0.5')
expect((string) $this->blade('<x-badge floating />'))->toContain('absolute top-0 end-0')
->and((string) $this->blade('<x-badge value="3" floating label="3 new messages" />'))
->toContain('absolute -top-1')
->toContain('absolute -top-0.5')
->toContain('aria-label="3 new messages"')
->not->toContain('aria-hidden');
});
@@ -27,7 +27,7 @@ it('draws a status label in a container or an outline', function () {
->toContain('bg-success-container text-on-success-container')
->not->toContain('aria-hidden')
->and((string) $this->blade('<x-badge value="Pro" outline />'))
->toContain('border border-outline-variant text-on-surface-variant')
->toContain('border border-outline text-on-surface-variant')
->not->toContain('bg-error');
});
@@ -48,7 +48,7 @@ it('draws neutral ink on every variant', function () {
expect((string) $this->blade('<x-badge color="neutral" />'))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge value="7" color="neutral" />'))->toContain('tabular-nums bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge value="Draft" tone="neutral" tonal />'))->toContain('type-label-md bg-surface-container-high text-on-surface-variant')
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline-variant text-on-surface-variant');
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline text-on-surface-variant');
});
it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) {
@@ -73,7 +73,7 @@ it('keeps its default colours, and falls back to error on a colour it does not k
->and((string) $this->blade('<x-badge value="Active" tonal />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md bg-error-container text-on-error-container"')
->and((string) $this->blade('<x-badge value="Pro" outline color="success" />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md border border-outline-variant text-on-surface-variant"')
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md border border-outline text-on-surface-variant"')
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
});
+15 -1
View File
@@ -17,7 +17,11 @@ it('groups buttons with the spacing of their size', function () {
it('connects buttons 2px apart', function () {
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
->toContain('data-button-group="connected"')
->toContain('gap-0.5')
->toContain('gap-0.5');
});
it('never wraps a group onto a second line', function () {
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->not->toContain('flex-wrap');
});
@@ -46,6 +50,16 @@ it('draws a choice as connected radios', function () {
->and(substr_count($html, '<svg'))->toBe(1);
});
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
$options = [['id' => 'a', 'name' => 'A']];
expect((string) $this->blade('<x-group size="sm" name="x" :$options />', ['options' => $options]))
->toContain('touch-target min-w-12')
->and((string) $this->blade('<x-group size="md" name="x" :$options />', ['options' => $options]))
->not->toContain('touch-target')
->toContain('min-w-0');
});
it('draws several choices as checkboxes', function () {
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
->toContain('type="checkbox"')
+27 -6
View File
@@ -49,7 +49,7 @@ it('sizes a label button on Expressive\'s scale', function (string $size, string
expect($html)->toContain($classes)->toContain($icon);
})->with([
'xs' => ['xs', 'h-8 gap-2 px-3 type-label-lg', 'size-5'],
'xs' => ['xs', 'h-8 gap-2 px-4 type-label-lg', 'size-5'],
'sm' => ['sm', 'h-10 gap-2 px-4 type-label-lg', 'size-5'],
'md' => ['md', 'h-14 gap-2 px-6 type-title-md', 'size-6'],
'lg' => ['lg', 'h-24 gap-3 px-12 type-headline-sm', 'size-8'],
@@ -63,8 +63,25 @@ it('rounds by default, squares on request, and squares off further while pressed
});
it('reaches a 48px touch target below the medium size', function () {
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('after:min-h-12')
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('after:min-h-12');
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('touch-target')
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('touch-target');
});
it('fills a default icon button\'s glyph, and draws a 20px one from the 20px cut', function () {
$geometry = function (string $file): string {
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/'.$file.'.svg'));
return substr($svg, (int) strpos($svg, '><') + 1);
};
// A default icon button is filled, a toggle still reads outlined unselected, filled selected.
expect((string) $this->blade('<x-button icon="favorite" aria-label="Keep" />'))->toContain($geometry('filled/favorite'))
->and((string) $this->blade('<x-button icon="favorite" aria-label="Keep" :selected="false" />'))->toContain($geometry('outlined/favorite'))
->and((string) $this->blade('<x-button icon="favorite" aria-label="Keep" :selected="true" />'))->toContain($geometry('filled/favorite'));
// 20px glyphs come from the 20px cut; 24px ones stay on the default.
expect((string) $this->blade('<x-button label="Keep" icon="favorite" />'))->toContain($geometry('outlined-20/favorite'))
->and((string) $this->blade('<x-button label="Keep" icon="favorite" size="md" />'))->toContain($geometry('outlined/favorite'));
});
it('is an icon button named by its tooltip when it has no label', function () {
@@ -120,10 +137,12 @@ it('disables a button, and a link as far as a link can be', function () {
->assertSee('tabindex="-1"', false);
});
it('shows the loading indicator while its own action runs', function () {
it('shows the loading indicator while its own action runs, in the button\'s own ink', function () {
$this->blade('<x-button label="Save" wire:click="save" spinner />')
->assertSee('wire:loading.attr="disabled"', false)
->assertSee('wire:target="save"', false);
->assertSee('wire:target="save"', false)
->assertSee('size-5 text-current', false)
->assertDontSee('text-primary"', false);
$this->blade('<x-button label="Save" wire:click="save" spinner="upload" />')
->assertSee('wire:target="upload"', false);
@@ -149,7 +168,9 @@ it('is a FAB on a compact window and a filled button from medium, in one element
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
expect(substr_count($html, '<button'))->toBe(1)
->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary');
->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary')
// M3 puts a snackbar above a FAB, never over one: the host publishes its height.
->toContain('max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)]');
});
it('submits a form when asked', function () {
+38 -2
View File
@@ -17,14 +17,42 @@ it('sizes a FAB at 56, 80 and 96px', function (string $size, string $classes, st
'lg' => ['lg', 'size-24 rounded-corner-xl', 'size-8'],
]);
it('fills the FAB\'s glyph, as M3 asks twice over', function () {
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/filled/add.svg'));
$filled = substr($svg, (int) strpos($svg, '><') + 1);
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain($filled)
->and((string) $this->blade('<x-fab-menu label="New" />'))->toContain($filled);
});
it('extends with a label', function () {
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
->toContain('h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg')
->toContain('h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg')
->toContain('bg-tertiary text-on-tertiary')
->toContain('<span>Upload</span>')
->not->toContain('aria-label');
});
it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (string $size, string $classes) {
expect((string) $this->blade("<x-fab icon=\"add\" label=\"New\" size=\"{$size}\" />"))->toContain($classes);
})->with([
'sm' => ['sm', 'h-14 min-w-20 gap-2'],
'md' => ['md', 'h-20 min-w-20 gap-4'],
'lg' => ['lg', 'h-24 min-w-24 gap-5'],
]);
it('marks its root for the places that draw a nested FAB their own way', function () {
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
});
it('cannot be disabled, because M3 says to remove a FAB instead', function () {
$html = (string) $this->blade('<x-fab icon="add" aria-label="New" disabled />');
// `disabled` is no longer a prop, so it falls through as a plain attribute rather than
// painting M3's disabled treatment.
expect($html)->not->toContain('disabled:bg-on-surface/10')->not->toContain('disabled:shadow-none');
});
it('opens a FAB menu of end-aligned actions above it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-fab-menu label="New" color="secondary">
@@ -42,5 +70,13 @@ it('opens a FAB menu of end-aligned actions above it', function () {
->toContain('role="menuitem"')
->toContain('wire:click="upload"')
->toContain('h-14')
->toContain('Upload files');
->toContain('Upload files')
->toContain('max-h-[calc(100dvh-8rem)] overflow-y-auto')
->toContain('data-fab-menu');
});
it('names the FAB menu even when the caller forgets to', function () {
expect((string) $this->blade('<x-fab-menu />'))
->toContain('aria-label="Toggle menu"')
->and(substr_count((string) $this->blade('<x-fab-menu />'), 'aria-label="Toggle menu"'))->toBe(2);
});
+21 -2
View File
@@ -23,6 +23,12 @@ it('opens a popover menu from its trigger', function () {
->toContain('[position-area:bottom_span-right]');
});
it('scrolls a menu too long for the window instead of running off it', function () {
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto')
->not->toContain('overflow-visible');
});
it('opens at the position asked for, in the vibrant colours on request', function () {
$html = (string) $this->blade('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');
@@ -43,14 +49,27 @@ it('draws an item as a menuitem button', function () {
->toContain('size-5 text-on-surface-variant');
});
it('makes a selectable item a menuitemcheckbox', function () {
it('makes a selectable item a menuitemcheckbox, ticked at its end', function () {
$check = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/check.svg'));
$check = substr($check, (int) strpos($check, '><') + 1);
expect((string) $this->blade('<x-menu-item label="Newest" :selected="true" keep-open />'))
->toContain('role="menuitemcheckbox"')
->toContain('aria-checked="true"')
->toContain('bg-tertiary-container')
->toContain('data-keep-open')
->toContain($check)
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
->toContain('aria-checked="false"');
->toContain('aria-checked="false"')
->not->toContain($check)
->and((string) $this->blade('<x-menu-item label="Newest" :selected="true" icon-right="chevron_right" />'))
->not->toContain($check);
});
it('gives an item M3\'s 48px row and 16px sides, and the separator its 8px', function () {
expect((string) $this->blade('<x-menu-item label="Copy" />'))->toContain('min-h-12')->toContain('px-4')
->and((string) $this->blade('<x-menu-separator />'))->toContain('mx-4 my-2')
->and((string) $this->blade('<x-menu-group label="Sort by" />'))->toContain('px-4 pt-2 pb-1');
});
it('marks the page an item leads to, and carries a badge', function () {
+22 -1
View File
@@ -10,6 +10,17 @@ it('hosts the snackbar queue, kept across wire:navigate', function () {
->toContain('justify-center');
});
it('draws no state icon, and reaches 48px from its 40px controls', function () {
$html = (string) $this->blade('<x-toast />');
// M3 tells you to avoid an icon in a snackbar; the type is left to pick the announcement role.
expect(substr_count($html, '<svg'))->toBe(1)
->and($html)->not->toContain('text-inverse-success')
->not->toContain('opacity-80')
->toContain('data-toast-action class="state-layer focus-ring touch-target')
->toContain('state-layer focus-ring touch-target inline-flex size-10');
});
it('can sit at the start', function () {
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
});
@@ -18,6 +29,16 @@ it('marks the snackbar and its action for tests and styling', function () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toMatch('/<div\s[^>]*\bdata-toast\b[^>]*aria-live="polite"/')
->toMatch('/<div\s[^>]*\bdata-toast\b/')
->toMatch('/<button\s[^>]*\bdata-toast-action\b[^>]*x-on:click="act\(\)"/');
});
it('keeps the live region in the page, polite, around the snackbar that comes and goes', function () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toMatch('/<div\s[^>]*x-data="materialSnackbar"[^>]*aria-live="polite"/')
->toContain('aria-atomic="true"')
->toContain("x-bind:role=\"current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'\"")
->and(substr_count($html, 'aria-live'))->toBe(1);
});