Merge branch 'worktree-agent-a6b15d4e17dad5970'
This commit is contained in:
@@ -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.
|
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.
|
`--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
|
## 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 |
|
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||||||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||||||
| `shape` | `round` | or `square`; both square off further while pressed |
|
| `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` |
|
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||||||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
| `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 |
|
| `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>`
|
### `<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>`
|
### `<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>
|
||||||
```
|
```
|
||||||
|
|
||||||
`<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>`
|
### `<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>`
|
||||||
|
|
||||||
`<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>`
|
### `<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>
|
</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>`
|
### `<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() } })
|
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.
|
- `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:
|
- `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())
|
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.
|
- 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>`
|
### `<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>`
|
||||||
|
|
||||||
- `<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 />` — 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`.
|
- `<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-variant edge with on-surface-variant text.
|
- `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.
|
- `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>`
|
### `<x-alert>`
|
||||||
|
|
||||||
@@ -385,7 +388,7 @@ A notice in the page, in the state's container colour with its icon:
|
|||||||
</x-alert>
|
</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>`
|
### `<x-rich-tooltip>`
|
||||||
|
|
||||||
@@ -397,7 +400,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
|||||||
</x-rich-tooltip>
|
</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>`
|
### `<x-stat>`
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,8 +13,15 @@
|
|||||||
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
|
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
|
||||||
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
|
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
|
||||||
*
|
*
|
||||||
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
* Split button (`<x-split-button>`): the same idea for two halves, on its own corner scale —
|
||||||
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
|
* 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
|
* "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
|
* 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;
|
--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='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='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
|
||||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
|
[data-button-group][data-size='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));
|
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
|
* 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.
|
* or selecting changes one variable and the rounded outer corners stay put.
|
||||||
*/
|
*/
|
||||||
[data-button-group='connected'] > *,
|
[data-button-group='connected'] > *,
|
||||||
[data-split] {
|
[data-split] {
|
||||||
--group-corner: var(--group-inner);
|
|
||||||
border-start-start-radius: var(--group-corner);
|
border-start-start-radius: var(--group-corner);
|
||||||
border-end-start-radius: var(--group-corner);
|
border-end-start-radius: var(--group-corner);
|
||||||
border-start-end-radius: var(--group-corner);
|
border-start-end-radius: var(--group-corner);
|
||||||
border-end-end-radius: var(--group-corner);
|
border-end-end-radius: var(--group-corner);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-button-group='connected'] > :active,
|
[data-button-group='connected'] > * {
|
||||||
[data-split]:active {
|
--group-corner: var(--group-inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-button-group='connected'] > :active {
|
||||||
--group-corner: var(--group-inner-pressed);
|
--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-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||||
[data-split='trailing'][aria-expanded='true'] {
|
[data-split='trailing'][aria-expanded='true'] {
|
||||||
--group-corner: var(--group-full);
|
--group-corner: var(--group-full);
|
||||||
@@ -82,8 +117,17 @@
|
|||||||
border-end-end-radius: var(--group-full);
|
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 {
|
[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 {
|
[data-split='trailing'][aria-expanded='true'] svg {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
@import './tokens/font.css';
|
@import './tokens/font.css';
|
||||||
@import './tokens/theme.css';
|
@import './tokens/theme.css';
|
||||||
@import './tokens/state.css';
|
@import './tokens/state.css';
|
||||||
|
@import './components/actions.css';
|
||||||
@import './components/groups.css';
|
@import './components/groups.css';
|
||||||
@import './components/list.css';
|
@import './components/list.css';
|
||||||
@import './components/field.css';
|
@import './components/field.css';
|
||||||
|
|||||||
+15
-4
@@ -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() {
|
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) {
|
focusItem(which) {
|
||||||
const items = this.items()
|
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) {
|
navigate(event) {
|
||||||
@@ -195,7 +206,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const move = (index) => {
|
const move = (index) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
items[(index + items.length) % items.length]?.focus()
|
this.reach(items[(index + items.length) % items.length])
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
@@ -226,7 +237,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
match.focus()
|
this.reach(match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
* Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover,
|
* 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
|
* 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.
|
* 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 HOVER_DELAY_MS = 500
|
||||||
const LEAVE_GRACE_MS = 200
|
const LEAVE_GRACE_MS = 200
|
||||||
@@ -17,6 +24,28 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const wrapper = this.$el
|
const wrapper = this.$el
|
||||||
const open = () => bubble.matches(':popover-open')
|
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) {
|
if (persistent) {
|
||||||
wrapper.addEventListener('click', (event) => {
|
wrapper.addEventListener('click', (event) => {
|
||||||
if (bubble.contains(event.target)) {
|
if (bubble.contains(event.target)) {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*
|
*
|
||||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
* 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
|
* 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
|
* 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
|
* 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,
|
timer: null,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
|
escape: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
host = this
|
host = this
|
||||||
pending.splice(0).forEach((detail) => this.add(detail))
|
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() {
|
destroy() {
|
||||||
|
document.removeEventListener('keydown', this.escape)
|
||||||
|
document.documentElement.style.removeProperty('--material-snackbar-height')
|
||||||
|
|
||||||
if (host === this) {
|
if (host === this) {
|
||||||
host = null
|
host = null
|
||||||
}
|
}
|
||||||
@@ -52,12 +67,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||||
const sticky = toast.sticky === true
|
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 = {
|
const entry = {
|
||||||
id: ++sequence,
|
id: ++sequence,
|
||||||
type: toast.type ?? null,
|
type: toast.type ?? null,
|
||||||
title: toast.title ?? '',
|
title: toast.title ?? '',
|
||||||
description: toast.description ?? null,
|
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,
|
action: toast.action ?? null,
|
||||||
sticky,
|
sticky,
|
||||||
}
|
}
|
||||||
@@ -87,6 +107,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.timer = null
|
this.timer = null
|
||||||
this.current = this.queue.shift() ?? this.sticky
|
this.current = this.queue.shift() ?? this.sticky
|
||||||
|
this.measure()
|
||||||
|
|
||||||
if (this.current?.timeout) {
|
if (this.current?.timeout) {
|
||||||
this.remaining = 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() {
|
pause() {
|
||||||
if (!this.current?.timeout || !this.timer) {
|
if (!this.current?.timeout || !this.timer) {
|
||||||
return
|
return
|
||||||
@@ -136,9 +175,5 @@ document.addEventListener('alpine:init', () => {
|
|||||||
window.dispatchEvent(new CustomEvent(action.event))
|
window.dispatchEvent(new CustomEvent(action.event))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
icon(type) {
|
|
||||||
return ['success', 'error', 'warning', 'info'].includes(type)
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
+37
-15
@@ -3,10 +3,15 @@
|
|||||||
*
|
*
|
||||||
* The trigger is the popover's parent element (the button, or the standalone wrapper). Hover
|
* 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
|
* 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
|
* toolbar does not flash every label; keyboard focus shows it at once. A press and Escape hide it
|
||||||
* 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 HOVER_DELAY_MS = 500
|
||||||
|
const LEAVE_DELAY_MS = 1500
|
||||||
|
|
||||||
|
let visible = null
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
window.Alpine.data('materialTooltip', () => ({
|
window.Alpine.data('materialTooltip', () => ({
|
||||||
@@ -22,25 +27,37 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const show = (delay) => {
|
const show = (delay) => {
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
if (tip.isConnected && !open()) {
|
if (!tip.isConnected || open()) {
|
||||||
tip.showPopover()
|
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)
|
}, 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, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||||
this.listen(trigger, 'pointerleave', hide)
|
this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS))
|
||||||
this.listen(trigger, 'pointerdown', hide)
|
this.listen(trigger, 'pointerdown', () => hide())
|
||||||
this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
|
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())
|
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -51,6 +68,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
|
|
||||||
|
if (visible === this.$el) {
|
||||||
|
visible = null
|
||||||
|
}
|
||||||
|
|
||||||
this.listeners.forEach((remove) => remove())
|
this.listeners.forEach((remove) => remove())
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -5,9 +5,15 @@
|
|||||||
text from `description` or the slot, and an `actions` slot for one or two text buttons.
|
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`,
|
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
|
||||||
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
|
`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([
|
@props([
|
||||||
'title' => null,
|
'title' => null,
|
||||||
@@ -16,6 +22,7 @@
|
|||||||
'tone' => null,
|
'tone' => null,
|
||||||
'icon' => null,
|
'icon' => null,
|
||||||
'dismissible' => false,
|
'dismissible' => false,
|
||||||
|
'assertive' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -35,7 +42,7 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<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
|
@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]) }}
|
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
|
||||||
>
|
>
|
||||||
@@ -58,8 +65,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($dismissible)
|
@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">
|
<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" class="size-5" />
|
<x-livewire-material::icon name="close" optical="20" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
- `<x-badge />` — the small badge, a 6px dot: something new, no number.
|
- `<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
|
- `<x-badge value="3" />` — the large badge, 16px tall, label-small: a count. `max` caps what is
|
||||||
shown ("99+").
|
shown ("99+").
|
||||||
Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a
|
Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
|
||||||
`relative` parent — an icon or an icon button:
|
`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>
|
<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:
|
and two without a hue of their own:
|
||||||
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
|
- `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
|
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
|
- `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" />`.
|
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:
|
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
|
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. --}}
|
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
$color === 'plain' => '',
|
$color === 'plain' => '',
|
||||||
! $status, $solid => $filled[$color],
|
! $status, $solid => $filled[$color],
|
||||||
$tonal => $container[$color],
|
$tonal => $container[$color],
|
||||||
default => 'border-outline-variant text-on-surface-variant',
|
default => 'border-outline text-on-surface-variant',
|
||||||
};
|
};
|
||||||
|
|
||||||
$attributes = $attributes
|
$attributes = $attributes
|
||||||
@@ -76,8 +79,8 @@
|
|||||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||||
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||||
$paint => $paint !== '',
|
$paint => $paint !== '',
|
||||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
'absolute top-0 end-0' => $floating && $dot,
|
||||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||||
])
|
])
|
||||||
->merge(array_filter([
|
->merge(array_filter([
|
||||||
'aria-label' => $label,
|
'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
|
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.
|
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
|
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
|
||||||
shapes are resources/css/components/groups.css. --}}
|
shapes are resources/css/components/groups.css. --}}
|
||||||
|
|
||||||
@@ -32,7 +36,6 @@
|
|||||||
{{ $attributes->class([
|
{{ $attributes->class([
|
||||||
'inline-flex items-center',
|
'inline-flex items-center',
|
||||||
'gap-0.5' => $connected,
|
'gap-0.5' => $connected,
|
||||||
'flex-wrap' => ! $connected,
|
|
||||||
'gap-[18px]' => ! $connected && $size === 'xs',
|
'gap-[18px]' => ! $connected && $size === 'xs',
|
||||||
'gap-3' => ! $connected && $size === 'sm',
|
'gap-3' => ! $connected && $size === 'sm',
|
||||||
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
||||||
|
|||||||
@@ -9,13 +9,18 @@
|
|||||||
`size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px,
|
`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
|
`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
|
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`,
|
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
|
`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`,
|
readers. Its glyph is filled, as M3 asks of a default icon button. `selected` makes it a
|
||||||
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
|
toggle: `true` or `false` sets `aria-pressed` (not on a `link`, which is no toggle — give it
|
||||||
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
`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,
|
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
|
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`
|
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
|
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
|
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`
|
`tooltip`, `tooltip-left`, `tooltip-right`
|
||||||
and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
|
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
|
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
|
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. --}}
|
`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'],
|
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
|
||||||
][$size][$width]
|
][$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',
|
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
'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-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];
|
: ['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];
|
$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);
|
$contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true);
|
||||||
|
|
||||||
$classes = [
|
$classes = [
|
||||||
'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
'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,
|
$dimensions,
|
||||||
// A composite component (a split button's halves) passes the corners its place needs.
|
// A composite component (a split button's halves) passes the corners its place needs.
|
||||||
$corners ?? $corner.' '.$pressed[$size],
|
$corners ?? $corner.' '.$pressed[$size],
|
||||||
@@ -170,13 +183,13 @@
|
|||||||
$outline => $variant === 'outlined',
|
$outline => $variant === 'outlined',
|
||||||
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
|
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
|
||||||
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
|
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
|
||||||
// Below 48px the touch target reaches past the button, as M3 requires.
|
// Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css).
|
||||||
'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),
|
'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: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: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,
|
'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.
|
// 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';
|
$tag = $isLink ? 'a' : 'button';
|
||||||
@@ -206,13 +219,13 @@
|
|||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($spinnerTarget)
|
@if ($spinnerTarget)
|
||||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
<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>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
<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>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -221,7 +234,7 @@
|
|||||||
@endunless
|
@endunless
|
||||||
|
|
||||||
@if ($iconRight)
|
@if ($iconRight)
|
||||||
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
|
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($tip !== null)
|
@if ($tip !== null)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
|
{{-- 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 label="New">
|
||||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
<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" />
|
<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
|
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
|
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
|
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
|
and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button
|
||||||
Livewire, so it stays open through a render of the component around it.
|
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
|
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([
|
@props([
|
||||||
'icon' => 'add',
|
'icon' => 'add',
|
||||||
@@ -24,6 +31,10 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@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';
|
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
||||||
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||||
$anchor = "--material-fab-menu-{$key}";
|
$anchor = "--material-fab-menu-{$key}";
|
||||||
@@ -43,15 +54,15 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@if ($label) aria-label="{{ $label }}" @endif
|
aria-label="{{ $label }}"
|
||||||
@class([
|
@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',
|
'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,
|
$colours,
|
||||||
])
|
])
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
<x-livewire-material::icon :name="$icon" filled 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="close" filled optical="20" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -61,17 +72,23 @@
|
|||||||
id="material-fab-menu-{{ $key }}"
|
id="material-fab-menu-{{ $key }}"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
role="menu"
|
role="menu"
|
||||||
@if ($label) aria-label="{{ $label }}" @endif
|
data-fab-menu
|
||||||
|
aria-label="{{ $label }}"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
style="position-anchor: {{ $anchor }}"
|
style="position-anchor: {{ $anchor }}"
|
||||||
x-on:keydown="navigate($event)"
|
x-on:keydown="navigate($event)"
|
||||||
x-on:click="activate($event)"
|
x-on:click="activate($event)"
|
||||||
@class([
|
@class([
|
||||||
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]',
|
'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]',
|
||||||
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end',
|
// A landscape phone has no room for six 56px items: the list scrolls, the FAB below it
|
||||||
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start',
|
// does not. The 8px padding is the gap M3 leaves above the FAB and the room the items'
|
||||||
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
|
// elevation needs inside the scroll box; the negative margins put it back where it was.
|
||||||
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
|
'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 }}
|
{{ $slot }}
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
|
`<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>`.
|
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
|
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
|
||||||
(androidx Compose Material 3, Apache-2.0). --}}
|
(androidx Compose Material 3, Apache-2.0). --}}
|
||||||
|
|
||||||
@@ -21,7 +32,6 @@
|
|||||||
'link' => null,
|
'link' => null,
|
||||||
'external' => false,
|
'external' => false,
|
||||||
'tooltip' => null,
|
'tooltip' => null,
|
||||||
'disabled' => false,
|
|
||||||
'type' => 'button',
|
'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];
|
: ['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
|
$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];
|
: ['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];
|
$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',
|
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
|
||||||
$dimensions,
|
$dimensions,
|
||||||
$colours,
|
$colours,
|
||||||
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
|
|
||||||
])
|
])
|
||||||
->merge(array_filter([
|
->merge(array_filter([
|
||||||
'href' => $isLink ? $link : null,
|
'href' => $isLink ? $link : null,
|
||||||
@@ -57,7 +66,8 @@
|
|||||||
'rel' => $isLink && $external ? 'noopener' : null,
|
'rel' => $isLink && $external ? 'noopener' : null,
|
||||||
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
|
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
|
||||||
'type' => $isLink ? null : $type,
|
'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,
|
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
|
||||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||||
], fn ($value): bool => $value !== null));
|
], fn ($value): bool => $value !== null));
|
||||||
@@ -65,7 +75,7 @@
|
|||||||
|
|
||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
|
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($extended)
|
@if ($extended)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
bind as on any input, the arrow keys move the choice, and a screen reader announces a group.
|
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
|
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
|
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
|
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`,
|
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);
|
$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)) : [];
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
$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 = [
|
$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',
|
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||||
@@ -57,6 +63,8 @@
|
|||||||
: 'mt-1 type-body-sm text-on-surface-variant';
|
: '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];
|
$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) {
|
$colours = match ($variant) {
|
||||||
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
|
'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)
|
@foreach ($options as $option)
|
||||||
<label @class([
|
<label @class([
|
||||||
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
'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',
|
'state-transition-fast',
|
||||||
'min-w-0 flex-1' => ! $inline,
|
'flex-1' => ! $inline,
|
||||||
|
$small ? 'touch-target min-w-12' : 'min-w-0',
|
||||||
$segment,
|
$segment,
|
||||||
$colours,
|
$colours,
|
||||||
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
|
'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)))
|
@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
|
@endif
|
||||||
|
|
||||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@props(['label'])
|
@props(['label'])
|
||||||
|
|
||||||
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
|
<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 }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
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`
|
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
|
(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
|
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`
|
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
|
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
|
||||||
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
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.
|
want to change twice.
|
||||||
|
|
||||||
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
`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
|
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
|
||||||
disabled.
|
disabled.
|
||||||
|
|
||||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive
|
||||||
12px at the ends of the list. --}}
|
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([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
@@ -43,9 +49,9 @@
|
|||||||
|
|
||||||
$attributes = $attributes
|
$attributes = $attributes
|
||||||
->class([
|
->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',
|
'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',
|
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||||
'py-2' => filled($description),
|
'py-2' => filled($description),
|
||||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
||||||
@@ -84,7 +90,7 @@
|
|||||||
|
|
||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($icon)
|
@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
|
@endif
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
@@ -103,6 +109,9 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($iconRight)
|
@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
|
@endif
|
||||||
</{{ $tag }}>
|
</{{ $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') }} />
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
of the loop iteration around it, which would give every child component after the menu the
|
of the loop iteration around it, which would give every child component after the menu the
|
||||||
same key.
|
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
|
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||||
androidx Compose Material 3 (Apache-2.0). --}}
|
androidx Compose Material 3 (Apache-2.0). --}}
|
||||||
@@ -67,15 +75,15 @@
|
|||||||
x-on:keydown="navigate($event)"
|
x-on:keydown="navigate($event)"
|
||||||
x-on:click="activate($event)"
|
x-on:click="activate($event)"
|
||||||
@class([
|
@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]',
|
'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-surface-container-low text-on-surface' => ! $vibrant,
|
||||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||||
'[position-area:bottom_span-right]' => $position === 'bottom-start',
|
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||||
'[position-area:bottom_span-left]' => $position === 'bottom-end',
|
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||||
'[position-area:top_span-right]' => $position === 'top-start',
|
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
|
||||||
'[position-area:top_span-left]' => $position === 'top-end',
|
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
|
||||||
])
|
])
|
||||||
>
|
>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
|
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.
|
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
|
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||||
text in on-surface-variant, label-large actions in primary. --}}
|
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
|
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
|
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([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
|
<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">
|
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" class="size-5" />
|
<x-livewire-material::icon :name="$icon" optical="20" class="size-5" />
|
||||||
@endif
|
@endif
|
||||||
<span>{{ $title }}</span>
|
<span>{{ $title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,32 +7,50 @@
|
|||||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||||
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
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 4–10s),
|
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||||
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
|
paused while the pointer or focus is on it. A toast with an `action` has no timeout at all,
|
||||||
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
|
as M3 requires — it waits to be read and acted on — unless the caller writes a `timeout` out.
|
||||||
name) on `window`; both may be given.
|
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
|
`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
|
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.
|
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
|
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
|
||||||
action button.
|
action button.
|
||||||
|
|
||||||
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||||
still on screen when the next page arrives.
|
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,
|
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,
|
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`:
|
48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
|
||||||
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
|
above a bottom bar through `--material-bottom-bar`, and publishes its own height as
|
||||||
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar;
|
`--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a
|
||||||
from `medium` it hugs its line length instead, as M3 asks. --}}
|
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'])
|
@props(['position' => 'bottom'])
|
||||||
|
|
||||||
@persist('material-toast')
|
@persist('material-toast')
|
||||||
<div
|
<div
|
||||||
x-data="materialSnackbar"
|
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([
|
{{ $attributes->class([
|
||||||
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
|
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
|
||||||
'justify-center' => $position !== 'bottom-start',
|
'justify-center' => $position !== 'bottom-start',
|
||||||
@@ -43,40 +61,24 @@
|
|||||||
<div
|
<div
|
||||||
x-bind:key="current.id"
|
x-bind:key="current.id"
|
||||||
data-toast
|
data-toast
|
||||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
|
||||||
aria-live="polite"
|
|
||||||
x-on:mouseenter="pause()"
|
x-on:mouseenter="pause()"
|
||||||
x-on:mouseleave="resume()"
|
x-on:mouseleave="resume()"
|
||||||
x-on:focusin="pause()"
|
x-on:focusin="pause()"
|
||||||
x-on:focusout="resume()"
|
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"
|
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">
|
<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" 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>
|
</div>
|
||||||
|
|
||||||
<template x-if="current.action">
|
<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>
|
||||||
|
|
||||||
<template x-if="current.action || ! current.timeout">
|
<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()">
|
<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" class="size-5" />
|
<x-livewire-material::icon name="close" optical="20" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
<x-tooltip text="Copy link"><button …>…</button></x-tooltip>
|
<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
|
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and hides
|
||||||
hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its
|
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
|
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
|
`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.
|
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="Draft" color="neutral" tonal />
|
||||||
<x-badge value="Archived" color="neutral" outline />
|
<x-badge value="Archived" color="neutral" outline />
|
||||||
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
|
<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" />
|
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
|
||||||
BLADE,
|
BLADE,
|
||||||
'Snackbars' => <<<'BLADE'
|
'Snackbars' => <<<'BLADE'
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class RichTooltipMorphProbe extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
// 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]')";
|
const TOAST = "document.querySelector('[data-toast]')";
|
||||||
|
|
||||||
|
|||||||
@@ -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" />');
|
$html = (string) $this->blade('<x-alert title="Storage almost full" description="3.8 of 4 GB" color="warning" />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('role="alert"')
|
->toContain('role="status"')
|
||||||
->toContain('bg-warning-container text-on-warning-container')
|
->toContain('bg-warning-container text-on-warning-container')
|
||||||
->toContain('Storage almost full')
|
->toContain('Storage almost full')
|
||||||
->toContain('3.8 of 4 GB')
|
->toContain('3.8 of 4 GB')
|
||||||
->toContain('<svg');
|
->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')
|
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="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 () {
|
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('Try again')
|
||||||
->toContain('x-data="{ shown: true }"')
|
->toContain('x-data="{ shown: true }"')
|
||||||
->toContain('aria-label="Dismiss"')
|
->toContain('aria-label="Dismiss"')
|
||||||
|
->toContain('touch-target')
|
||||||
->and(substr_count($html, '<svg'))->toBe(1);
|
->and(substr_count($html, '<svg'))->toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () {
|
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" />'))
|
->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"')
|
->toContain('aria-label="3 new messages"')
|
||||||
->not->toContain('aria-hidden');
|
->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')
|
->toContain('bg-success-container text-on-success-container')
|
||||||
->not->toContain('aria-hidden')
|
->not->toContain('aria-hidden')
|
||||||
->and((string) $this->blade('<x-badge value="Pro" outline />'))
|
->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');
|
->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')
|
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="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" 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) {
|
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 />'))
|
->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"')
|
->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" />'))
|
->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');
|
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ it('groups buttons with the spacing of their size', function () {
|
|||||||
it('connects buttons 2px apart', function () {
|
it('connects buttons 2px apart', function () {
|
||||||
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
||||||
->toContain('data-button-group="connected"')
|
->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');
|
->not->toContain('flex-wrap');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +50,16 @@ it('draws a choice as connected radios', function () {
|
|||||||
->and(substr_count($html, '<svg'))->toBe(1);
|
->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 () {
|
it('draws several choices as checkboxes', function () {
|
||||||
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
||||||
->toContain('type="checkbox"')
|
->toContain('type="checkbox"')
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ it('sizes a label button on Expressive\'s scale', function (string $size, string
|
|||||||
|
|
||||||
expect($html)->toContain($classes)->toContain($icon);
|
expect($html)->toContain($classes)->toContain($icon);
|
||||||
})->with([
|
})->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'],
|
'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'],
|
'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'],
|
'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 () {
|
it('reaches a 48px touch target below the medium size', function () {
|
||||||
expect(buttonClasses('<x-button label="Go" size="sm" />'))->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('after:min-h-12');
|
->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 () {
|
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);
|
->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 />')
|
$this->blade('<x-button label="Save" wire:click="save" spinner />')
|
||||||
->assertSee('wire:loading.attr="disabled"', false)
|
->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" />')
|
$this->blade('<x-button label="Save" wire:click="save" spinner="upload" />')
|
||||||
->assertSee('wire:target="upload"', false);
|
->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 />');
|
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
|
||||||
|
|
||||||
expect(substr_count($html, '<button'))->toBe(1)
|
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 () {
|
it('submits a form when asked', function () {
|
||||||
|
|||||||
@@ -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'],
|
'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 () {
|
it('extends with a label', function () {
|
||||||
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
|
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('bg-tertiary text-on-tertiary')
|
||||||
->toContain('<span>Upload</span>')
|
->toContain('<span>Upload</span>')
|
||||||
->not->toContain('aria-label');
|
->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 () {
|
it('opens a FAB menu of end-aligned actions above it', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-fab-menu label="New" color="secondary">
|
<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('role="menuitem"')
|
||||||
->toContain('wire:click="upload"')
|
->toContain('wire:click="upload"')
|
||||||
->toContain('h-14')
|
->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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ it('opens a popover menu from its trigger', function () {
|
|||||||
->toContain('[position-area:bottom_span-right]');
|
->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 () {
|
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>');
|
$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');
|
->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 />'))
|
expect((string) $this->blade('<x-menu-item label="Newest" :selected="true" keep-open />'))
|
||||||
->toContain('role="menuitemcheckbox"')
|
->toContain('role="menuitemcheckbox"')
|
||||||
->toContain('aria-checked="true"')
|
->toContain('aria-checked="true"')
|
||||||
->toContain('bg-tertiary-container')
|
->toContain('bg-tertiary-container')
|
||||||
->toContain('data-keep-open')
|
->toContain('data-keep-open')
|
||||||
|
->toContain($check)
|
||||||
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
|
->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 () {
|
it('marks the page an item leads to, and carries a badge', function () {
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ it('hosts the snackbar queue, kept across wire:navigate', function () {
|
|||||||
->toContain('justify-center');
|
->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 () {
|
it('can sit at the start', function () {
|
||||||
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
|
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 />');
|
$html = (string) $this->blade('<x-toast />');
|
||||||
|
|
||||||
expect($html)
|
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\(\)"/');
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user