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.
|
||||
|
||||
`--material-snackbar-height` is the height of the snackbar on screen, written on `<html>` by `<x-toast>` while one shows and removed when it goes. `<x-button fab>` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `<x-fab>` does the same by wrapping it in `<div class="fixed end-4 bottom-[calc(1rem+var(--material-snackbar-height,0px))] large:end-6">`.
|
||||
|
||||
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only.
|
||||
|
||||
## Toasts
|
||||
@@ -224,7 +226,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||||
| `shape` | `round` | or `square`; both square off further while pressed |
|
||||
| `icon`, `icon-right` | | Material Symbol names |
|
||||
| `icon`, `icon-right` | | Material Symbol names; drawn from M3's 20px cut where the glyph is 20px, and filled on a default icon button |
|
||||
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
||||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||||
@@ -240,7 +242,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
|
||||
|
||||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||||
|
||||
@@ -259,7 +261,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
@@ -291,7 +293,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `disabled`, `type`.
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
|
||||
|
||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||
|
||||
@@ -304,7 +306,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
|
||||
Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. `<x-fab-menu>`: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
@@ -326,7 +328,7 @@ The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`po
|
||||
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
|
||||
```
|
||||
|
||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||
`type` (`success`, `error`, `warning`, `info`) picks the announcement role and draws no icon (M3 tells you to avoid one in a snackbar); `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out.
|
||||
|
||||
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
|
||||
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
|
||||
@@ -336,6 +338,7 @@ window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: '
|
||||
window.addEventListener('app:update', () => location.reload())
|
||||
```
|
||||
|
||||
- Escape dismisses a snackbar that holds the focus.
|
||||
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
|
||||
|
||||
### `<x-progress>`
|
||||
@@ -368,10 +371,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||||
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral `outline` edge (the role that has to be seen, not the decorative `outline-variant` dividers use). `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||||
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline edge with on-surface-variant text.
|
||||
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
|
||||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||
|
||||
### `<x-alert>`
|
||||
|
||||
@@ -385,7 +388,7 @@ A notice in the page, in the state's container colour with its icon:
|
||||
</x-alert>
|
||||
```
|
||||
|
||||
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`.
|
||||
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Every alert is `role="status"`, whatever its colour: it is usually on the page as it renders, and an assertive region talks over the page title on load. Pass `assertive` for one put on screen in answer to something the person just did.
|
||||
|
||||
### `<x-rich-tooltip>`
|
||||
|
||||
@@ -397,7 +400,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
|
||||
@@ -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
|
||||
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
|
||||
*
|
||||
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
||||
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
|
||||
* Split button (`<x-split-button>`): the same idea for two halves, on its own corner scale —
|
||||
* SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px
|
||||
* under the finger, the opposite of a connected group, so `--split-inner*` is separate from
|
||||
* `--group-inner*`. The trailing half turns round and its chevron turns over while its menu is
|
||||
* open, on M3's standard scheme rather than the expressive springs (the site: "the trailing
|
||||
* button uses the standard motion scheme, not expressive, when rotating"), and the chevron sits
|
||||
* a little towards the leading half so the pair reads as one control (`--split-nudge`:
|
||||
* 1/1/2/3/6px). `--split-icon` is the trailing icon's own size, 22/22/26/38/50px, which the
|
||||
* icon-button table does not know about.
|
||||
*
|
||||
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
|
||||
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
|
||||
@@ -29,7 +36,7 @@
|
||||
--group-full: 1.25rem;
|
||||
}
|
||||
|
||||
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
|
||||
[data-button-group][data-size='xs'] { --group-pad: 1rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
|
||||
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
|
||||
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
|
||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
|
||||
@@ -47,24 +54,52 @@
|
||||
padding-inline-start: calc(var(--group-pad) - var(--group-grow));
|
||||
}
|
||||
|
||||
[data-button-group='split'] { --split-inner: var(--md-sys-shape-corner-xs); --split-inner-pressed: var(--md-sys-shape-corner-md); --split-icon: 22px; --split-nudge: 1px; }
|
||||
[data-button-group='split'][data-size='xs'] { --split-inner-pressed: var(--md-sys-shape-corner-sm); }
|
||||
[data-button-group='split'][data-size='md'] { --split-icon: 26px; --split-nudge: 2px; }
|
||||
[data-button-group='split'][data-size='lg'] { --split-inner: var(--md-sys-shape-corner-sm); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 38px; --split-nudge: 3px; }
|
||||
[data-button-group='split'][data-size='xl'] { --split-inner: var(--md-sys-shape-corner-md); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 50px; --split-nudge: 6px; }
|
||||
|
||||
/*
|
||||
* A connected segment at the two smallest sizes is 32px or 40px tall, and an icon-only one is as
|
||||
* narrow as it is tall. M3 names both numbers and tells you not to reduce them: "XS and S
|
||||
* connected button groups have a 48dp target area and a 48dp minimum width". The target is the
|
||||
* `touch-target` utility on the segment itself; the width is here, where a group can reach the
|
||||
* `<x-button>`s a caller put inside it.
|
||||
*/
|
||||
[data-button-group='connected'][data-size='xs'] > *,
|
||||
[data-button-group='connected'][data-size='sm'] > * {
|
||||
min-inline-size: 3rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Connected segments and split halves take their inner corner from `--group-corner`, so pressing
|
||||
* or selecting changes one variable and the rounded outer corners stay put.
|
||||
*/
|
||||
[data-button-group='connected'] > *,
|
||||
[data-split] {
|
||||
--group-corner: var(--group-inner);
|
||||
border-start-start-radius: var(--group-corner);
|
||||
border-end-start-radius: var(--group-corner);
|
||||
border-start-end-radius: var(--group-corner);
|
||||
border-end-end-radius: var(--group-corner);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :active,
|
||||
[data-split]:active {
|
||||
[data-button-group='connected'] > * {
|
||||
--group-corner: var(--group-inner);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :active {
|
||||
--group-corner: var(--group-inner-pressed);
|
||||
}
|
||||
|
||||
[data-split] {
|
||||
--group-corner: var(--split-inner);
|
||||
}
|
||||
|
||||
[data-split]:active {
|
||||
--group-corner: var(--split-inner-pressed);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||
[data-split='trailing'][aria-expanded='true'] {
|
||||
--group-corner: var(--group-full);
|
||||
@@ -82,8 +117,17 @@
|
||||
border-end-end-radius: var(--group-full);
|
||||
}
|
||||
|
||||
/*
|
||||
* The trailing chevron: its own size, and `--split-nudge` off centre towards the leading half —
|
||||
* equal and opposite margins move it by exactly that much inside a centred flex row, in whichever
|
||||
* direction the writing mode runs.
|
||||
*/
|
||||
[data-split='trailing'] svg {
|
||||
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
|
||||
width: var(--split-icon);
|
||||
height: var(--split-icon);
|
||||
margin-inline-start: calc(-1 * var(--split-nudge));
|
||||
margin-inline-end: var(--split-nudge);
|
||||
transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard);
|
||||
}
|
||||
|
||||
[data-split='trailing'][aria-expanded='true'] svg {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
@import './tokens/font.css';
|
||||
@import './tokens/theme.css';
|
||||
@import './tokens/state.css';
|
||||
@import './components/actions.css';
|
||||
@import './components/groups.css';
|
||||
@import './components/list.css';
|
||||
@import './components/field.css';
|
||||
|
||||
+15
-4
@@ -179,14 +179,25 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Every item, disabled ones included: M3 keeps a disabled item focusable ("disabled items
|
||||
* can still receive focus, just aren't selectable") so a person reading the menu with the
|
||||
* keyboard learns that it exists. activate() is where the refusal lives.
|
||||
*/
|
||||
items() {
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true')
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)]
|
||||
},
|
||||
|
||||
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
|
||||
focusItem(which) {
|
||||
const items = this.items()
|
||||
|
||||
;(which === 'last' ? items.at(-1) : items[0])?.focus()
|
||||
this.reach(which === 'last' ? items.at(-1) : items[0])
|
||||
},
|
||||
|
||||
reach(item) {
|
||||
item?.focus()
|
||||
item?.scrollIntoView({ block: 'nearest' })
|
||||
},
|
||||
|
||||
navigate(event) {
|
||||
@@ -195,7 +206,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
const move = (index) => {
|
||||
event.preventDefault()
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
this.reach(items[(index + items.length) % items.length])
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
@@ -226,7 +237,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (match) {
|
||||
event.preventDefault()
|
||||
match.focus()
|
||||
this.reach(match)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover,
|
||||
* at once on keyboard focus, and it stays while the pointer moves onto the bubble to reach its
|
||||
* actions. Persistent: a press on the trigger opens it as a light-dismiss popover.
|
||||
*
|
||||
* The trigger is pointed at the bubble with `aria-describedby`, so the explanation — the whole
|
||||
* point of a rich tooltip — is read out with the control rather than never at all; a persistent
|
||||
* one also says `aria-haspopup="dialog"` and whether it is open. The server cannot write any of
|
||||
* it: the trigger comes from a slot, and only script knows which element in it takes the focus.
|
||||
* A Livewire morph strips attributes the server did not render and gives the bubble a new id, so
|
||||
* they are written again whenever the trigger is reached.
|
||||
*/
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_GRACE_MS = 200
|
||||
@@ -17,6 +24,28 @@ document.addEventListener('alpine:init', () => {
|
||||
const wrapper = this.$el
|
||||
const open = () => bubble.matches(':popover-open')
|
||||
|
||||
const control = () => [...wrapper.querySelectorAll('button, a[href], [tabindex]')].find((element) => !bubble.contains(element))
|
||||
|
||||
const describe = () => {
|
||||
const trigger = control()
|
||||
|
||||
if (!trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
trigger.setAttribute('aria-describedby', bubble.id)
|
||||
|
||||
if (persistent) {
|
||||
trigger.setAttribute('aria-haspopup', 'dialog')
|
||||
trigger.setAttribute('aria-expanded', String(open()))
|
||||
}
|
||||
}
|
||||
|
||||
describe()
|
||||
bubble.addEventListener('toggle', describe)
|
||||
wrapper.addEventListener('focusin', describe)
|
||||
wrapper.addEventListener('pointerenter', describe)
|
||||
|
||||
if (persistent) {
|
||||
wrapper.addEventListener('click', (event) => {
|
||||
if (bubble.contains(event.target)) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
||||
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
||||
* action) and is replaced by the next.
|
||||
* action) and is replaced by the next. A snackbar carrying an action has no timeout at all —
|
||||
* M3's accessibility page forbids one — unless the caller writes a timeout out.
|
||||
*
|
||||
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
|
||||
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||
@@ -34,13 +35,27 @@ document.addEventListener('alpine:init', () => {
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
startedAt: 0,
|
||||
escape: null,
|
||||
|
||||
init() {
|
||||
host = this
|
||||
pending.splice(0).forEach((detail) => this.add(detail))
|
||||
|
||||
// M3: Esc dismisses the focused snackbar. Only the focused one — a key pressed
|
||||
// anywhere else on the page belongs to whatever has the focus there.
|
||||
this.escape = (event) => {
|
||||
if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) {
|
||||
this.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.escape)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.removeEventListener('keydown', this.escape)
|
||||
document.documentElement.style.removeProperty('--material-snackbar-height')
|
||||
|
||||
if (host === this) {
|
||||
host = null
|
||||
}
|
||||
@@ -52,12 +67,17 @@ document.addEventListener('alpine:init', () => {
|
||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||
const sticky = toast.sticky === true
|
||||
|
||||
// M3 forbids a snackbar with an action from auto-dismissing: it has to wait for the
|
||||
// person to read it and reach it. A timeout written out still wins, for a caller who
|
||||
// means it; a missing one no longer falls back to the default.
|
||||
const untimed = sticky || toast.timeout === 0 || toast.timeout === null || (toast.action != null && toast.timeout === undefined)
|
||||
|
||||
const entry = {
|
||||
id: ++sequence,
|
||||
type: toast.type ?? null,
|
||||
title: toast.title ?? '',
|
||||
description: toast.description ?? null,
|
||||
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
timeout: untimed ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
action: toast.action ?? null,
|
||||
sticky,
|
||||
}
|
||||
@@ -87,6 +107,7 @@ document.addEventListener('alpine:init', () => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
this.current = this.queue.shift() ?? this.sticky
|
||||
this.measure()
|
||||
|
||||
if (this.current?.timeout) {
|
||||
this.remaining = this.current.timeout
|
||||
@@ -94,6 +115,24 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Publishes the snackbar's height on <html> as `--material-snackbar-height`, and clears it
|
||||
* when nothing shows. M3 puts a snackbar above a FAB, never in front of or behind one, and
|
||||
* the FAB is somewhere else in the page: a variable is the only thing the two share.
|
||||
*/
|
||||
measure() {
|
||||
this.$nextTick(() => {
|
||||
const snackbar = this.$el.querySelector('[data-toast]')
|
||||
const root = document.documentElement.style
|
||||
|
||||
if (snackbar) {
|
||||
root.setProperty('--material-snackbar-height', `${Math.round(snackbar.getBoundingClientRect().height)}px`)
|
||||
} else {
|
||||
root.removeProperty('--material-snackbar-height')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
pause() {
|
||||
if (!this.current?.timeout || !this.timer) {
|
||||
return
|
||||
@@ -136,9 +175,5 @@ document.addEventListener('alpine:init', () => {
|
||||
window.dispatchEvent(new CustomEvent(action.event))
|
||||
}
|
||||
},
|
||||
|
||||
icon(type) {
|
||||
return ['success', 'error', 'warning', 'info'].includes(type)
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+37
-15
@@ -3,10 +3,15 @@
|
||||
*
|
||||
* The trigger is the popover's parent element (the button, or the standalone wrapper). Hover
|
||||
* counts only for a pointer that can hover, after a short delay so a pointer passing over a
|
||||
* toolbar does not flash every label; keyboard focus shows it at once. A press, leaving, blur
|
||||
* and Escape hide it.
|
||||
* toolbar does not flash every label; keyboard focus shows it at once. A press and Escape hide it
|
||||
* at once; leaving and blur let it stand for M3's 1.5s, so the words can still be read while the
|
||||
* pointer moves on. Only one tooltip is up at a time: showing one takes the last one down, which
|
||||
* the browser will not do for us because these are `popover="manual"`.
|
||||
*/
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_DELAY_MS = 1500
|
||||
|
||||
let visible = null
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialTooltip', () => ({
|
||||
@@ -22,25 +27,37 @@ document.addEventListener('alpine:init', () => {
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (tip.isConnected && !open()) {
|
||||
tip.showPopover()
|
||||
if (!tip.isConnected || open()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (visible && visible !== tip && visible.matches(':popover-open')) {
|
||||
visible.hidePopover()
|
||||
}
|
||||
|
||||
visible = tip
|
||||
tip.showPopover()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = (delay = 0) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
|
||||
if (visible === tip) {
|
||||
visible = null
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(this.timer)
|
||||
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
}
|
||||
|
||||
this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
this.listen(trigger, 'pointerleave', hide)
|
||||
this.listen(trigger, 'pointerdown', hide)
|
||||
this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(trigger, 'pointerdown', () => hide())
|
||||
this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
|
||||
this.listen(trigger, 'focusout', hide)
|
||||
this.listen(trigger, 'focusout', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
|
||||
},
|
||||
|
||||
@@ -51,6 +68,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
|
||||
if (visible === this.$el) {
|
||||
visible = null
|
||||
}
|
||||
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
text from `description` or the slot, and an `actions` slot for one or two text buttons.
|
||||
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
|
||||
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
|
||||
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser.
|
||||
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser,
|
||||
40px drawn with the 48px target M3 asks for.
|
||||
|
||||
Errors and warnings are `role="alert"` and announced at once; the rest are `role="status"`. --}}
|
||||
It is a `role="status"`, whatever its colour: an alert is usually part of the page as it
|
||||
renders, and `role="alert"` is an assertive live region that some screen readers announce over
|
||||
the page title on load — and that a Livewire morph re-announces. `assertive` opts into
|
||||
`role="alert"` for the case the prop is named after: a notice put on screen in answer to
|
||||
something the person just did. M3 publishes no banner; the nearest thing it does publish, the
|
||||
snackbar, says polite and never assertive. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
@@ -16,6 +22,7 @@
|
||||
'tone' => null,
|
||||
'icon' => null,
|
||||
'dismissible' => false,
|
||||
'assertive' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -35,7 +42,7 @@
|
||||
@endphp
|
||||
|
||||
<div
|
||||
role="{{ in_array($color, ['error', 'warning'], true) ? 'alert' : 'status' }}"
|
||||
role="{{ $assertive ? 'alert' : 'status' }}"
|
||||
@if ($dismissible) x-data="{ shown: true }" x-show="shown" x-transition.opacity @endif
|
||||
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
|
||||
>
|
||||
@@ -58,8 +65,8 @@
|
||||
</div>
|
||||
|
||||
@if ($dismissible)
|
||||
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
<button type="button" class="state-layer focus-ring touch-target -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
|
||||
<x-livewire-material::icon name="close" optical="20" class="size-5" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
- `<x-badge />` — the small badge, a 6px dot: something new, no number.
|
||||
- `<x-badge value="3" />` — the large badge, 16px tall, label-small: a count. `max` caps what is
|
||||
shown ("99+").
|
||||
Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a
|
||||
`relative` parent — an icon or an icon button:
|
||||
Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
|
||||
`outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and
|
||||
`outline-variant` is the decorative role dividers use. `floating` pins one to the top-end
|
||||
corner of a `relative` parent, on M3's anchor geometry — the dot flush in the corner, the
|
||||
count 2px above it — an icon or an icon button:
|
||||
|
||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||
|
||||
@@ -16,12 +19,12 @@
|
||||
and two without a hue of their own:
|
||||
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
|
||||
count (the ink an outline badge already writes in), surface-container-high with
|
||||
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
|
||||
on-surface-variant text when `tonal`, the outline edge when `outline`.
|
||||
- `plain` — no background, text or border colour at all, only shape, size and type, so the
|
||||
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
|
||||
|
||||
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
|
||||
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
|
||||
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
||||
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||
@@ -65,7 +68,7 @@
|
||||
$color === 'plain' => '',
|
||||
! $status, $solid => $filled[$color],
|
||||
$tonal => $container[$color],
|
||||
default => 'border-outline-variant text-on-surface-variant',
|
||||
default => 'border-outline text-on-surface-variant',
|
||||
};
|
||||
|
||||
$attributes = $attributes
|
||||
@@ -76,8 +79,8 @@
|
||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||
$paint => $paint !== '',
|
||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
'absolute top-0 end-0' => $floating && $dot,
|
||||
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'aria-label' => $label,
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
the size of the buttons inside, so the spacing and corners match. For a choice bound to a
|
||||
property, `<x-group>` draws a connected group of radios.
|
||||
|
||||
A group never wraps to a second line — M3's rule, and the press expansion only reaches a
|
||||
neighbour on the same line anyway. Where a row is too long for its window the answer is a
|
||||
smaller `size` or fewer buttons, or two groups stacked.
|
||||
|
||||
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
|
||||
shapes are resources/css/components/groups.css. --}}
|
||||
|
||||
@@ -32,7 +36,6 @@
|
||||
{{ $attributes->class([
|
||||
'inline-flex items-center',
|
||||
'gap-0.5' => $connected,
|
||||
'flex-wrap' => ! $connected,
|
||||
'gap-[18px]' => ! $connected && $size === 'xs',
|
||||
'gap-3' => ! $connected && $size === 'sm',
|
||||
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
||||
|
||||
@@ -9,13 +9,18 @@
|
||||
`size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px,
|
||||
`xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the
|
||||
default) or `square`; either one squares off towards a smaller corner while pressed, on the
|
||||
fast spatial spring.
|
||||
fast spatial spring, while the colours cross on the fast effects spring beside it
|
||||
(`state-transition-fast`, resources/css/components/actions.css: a colour must never
|
||||
overshoot).
|
||||
|
||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
||||
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
|
||||
which is no toggle — give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
|
||||
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
||||
readers. Its glyph is filled, as M3 asks of a default icon button. `selected` makes it a
|
||||
toggle: `true` or `false` sets `aria-pressed` (not on a `link`, which is no toggle — give it
|
||||
`aria-current` instead) and M3's selected colours, and a selected round button turns square
|
||||
(a selected square icon button turns round); a toggle reads unselected outlined and selected
|
||||
filled, which is the rest of the same rule. Text buttons are not toggles in M3; a selected
|
||||
one takes the tonal container.
|
||||
|
||||
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
||||
Apache-2.0); the text button's label is primary, as Compose draws it, not the token's
|
||||
@@ -24,11 +29,15 @@
|
||||
Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external`
|
||||
or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows
|
||||
the loading indicator while the button's own `wire:click` runs (or the action named by a
|
||||
string); `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition);
|
||||
string), drawn in the button's own ink — M3 asks an indicator embedded in another component
|
||||
to take that component's label colour, so it stays visible on a filled or tonal container;
|
||||
`responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition);
|
||||
`tooltip`, `tooltip-left`, `tooltip-right`
|
||||
and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
|
||||
pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from
|
||||
there — one element either way.
|
||||
there — one element either way. It lifts clear of a bottom bar and of a snackbar on screen
|
||||
(`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a
|
||||
FAB and never over one.
|
||||
|
||||
Never pass `hidden`, a display or a position class: the button is `inline-flex` and
|
||||
`relative`, and whichever Tailwind emits last wins. Wrap it instead. --}}
|
||||
@@ -146,7 +155,7 @@
|
||||
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
|
||||
][$size][$width]
|
||||
: [
|
||||
'xs' => 'h-8 gap-2 px-3 type-label-lg',
|
||||
'xs' => 'h-8 gap-2 px-4 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
@@ -157,12 +166,16 @@
|
||||
? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]
|
||||
: ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
// M3 draws a Material Symbol from a 20px cut below 24px: heavier strokes, wider counters, so a
|
||||
// small glyph does not thin out. The cut follows the size the icon is drawn at, never the button's.
|
||||
$optical = $iconSize === 'size-5' ? '20' : '24';
|
||||
|
||||
$outline = ['xs' => 'border', 'sm' => 'border', 'md' => 'border', 'lg' => 'border-2', 'xl' => 'border-3'][$size];
|
||||
$contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true);
|
||||
|
||||
$classes = [
|
||||
'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'state-transition-fast',
|
||||
$dimensions,
|
||||
// A composite component (a split button's halves) passes the corners its place needs.
|
||||
$corners ?? $corner.' '.$pressed[$size],
|
||||
@@ -170,13 +183,13 @@
|
||||
$outline => $variant === 'outlined',
|
||||
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
|
||||
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
|
||||
// Below 48px the touch target reaches past the button, as M3 requires.
|
||||
'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true),
|
||||
// Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css).
|
||||
'touch-target' => in_array($size, ['xs', 'sm'], true),
|
||||
'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none',
|
||||
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
|
||||
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
|
||||
// The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
|
||||
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
|
||||
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
|
||||
];
|
||||
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
@@ -206,13 +219,13 @@
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($spinnerTarget)
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
||||
<x-livewire-material::loading :class="$iconSize" :label="false" />
|
||||
<x-livewire-material::loading :class="$iconSize.' text-current'" :label="false" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($icon)
|
||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
|
||||
<x-livewire-material::icon :name="$icon" :optical="$optical" :filled="$selected === true || ($iconOnly && $selected === null)" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@@ -221,7 +234,7 @@
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($tip !== null)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
|
||||
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
|
||||
@@ -10,11 +10,18 @@
|
||||
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
|
||||
round close button in the colour itself while the list is open above it, end-aligned; the
|
||||
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
|
||||
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
|
||||
Livewire, so it stays open through a render of the component around it.
|
||||
and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button
|
||||
are aria-hidden, so without it the trigger has no name at all. Give the items the same
|
||||
`color`. Like `<x-menu>`'s, the list is keyed for Livewire, so it stays open through a render
|
||||
of the component around it.
|
||||
|
||||
It does not place itself. M3's margin from the edge of the window is 16dp, and 24dp from the
|
||||
`large` class up: `fixed end-4 bottom-4 large:end-6 large:bottom-6`. A window too short for
|
||||
the list scrolls it; the items rise into place as it opens and sink back as it closes.
|
||||
|
||||
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||
above the close button. --}}
|
||||
above the close button. The FAB's corner and colours morph on the two default springs at once
|
||||
(`state-transition-default`, resources/css/components/actions.css). --}}
|
||||
|
||||
@props([
|
||||
'icon' => 'add',
|
||||
@@ -24,6 +31,10 @@
|
||||
])
|
||||
|
||||
@php
|
||||
// The trigger's only content is two icons, which are aria-hidden, so without a name it is an
|
||||
// unnamed button. M3 asks the FAB's label to say what menu it opens; "Toggle menu" is its own
|
||||
// fallback wording for the close button.
|
||||
$label = filled($label) ? $label : __('Toggle menu');
|
||||
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
||||
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||
$anchor = "--material-fab-menu-{$key}";
|
||||
@@ -43,15 +54,15 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
aria-label="{{ $label }}"
|
||||
@class([
|
||||
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
|
||||
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
|
||||
'state-transition-default aria-expanded:rounded-corner-full',
|
||||
$colours,
|
||||
])
|
||||
>
|
||||
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
<x-livewire-material::icon :name="$icon" filled class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-livewire-material::icon name="close" filled optical="20" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -61,17 +72,23 @@
|
||||
id="material-fab-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
data-fab-menu
|
||||
aria-label="{{ $label }}"
|
||||
tabindex="-1"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
x-on:keydown="navigate($event)"
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]',
|
||||
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end',
|
||||
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start',
|
||||
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]',
|
||||
// A landscape phone has no room for six 56px items: the list scrolls, the FAB below it
|
||||
// does not. The 8px padding is the gap M3 leaves above the FAB and the room the items'
|
||||
// elevation needs inside the scroll box; the negative margins put it back where it was.
|
||||
'max-h-[calc(100dvh-8rem)] overflow-y-auto',
|
||||
'transition-[display,overlay] transition-discrete duration-(--md-sys-motion-spatial-fast-duration)',
|
||||
'-mx-2 -mt-2 items-end [position-area:top_span-left]' => $position === 'top-end',
|
||||
'-mx-2 -mt-2 items-start [position-area:top_span-right]' => $position === 'top-start',
|
||||
'-mx-2 -mb-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'-mx-2 -mb-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
|
||||
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
|
||||
|
||||
The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its
|
||||
guidelines ("icon should be filled (not outlined) and unambiguous").
|
||||
|
||||
There is no `disabled`: M3 says never to disable a FAB — "if its action is unavailable,
|
||||
remove the FAB entirely instead" — so hide it (`@if`), or leave it out of the render, rather
|
||||
than greying it out. A form-submit FAB that must not be pressed twice takes the caller's own
|
||||
`wire:loading.attr="disabled"`.
|
||||
|
||||
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
|
||||
rail flattens a nested FAB to elevation 0.
|
||||
|
||||
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
|
||||
(androidx Compose Material 3, Apache-2.0). --}}
|
||||
|
||||
@@ -21,7 +32,6 @@
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'tooltip' => null,
|
||||
'disabled' => false,
|
||||
'type' => 'button',
|
||||
])
|
||||
|
||||
@@ -36,7 +46,7 @@
|
||||
: ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color];
|
||||
|
||||
$dimensions = $extended
|
||||
? ['sm' => 'h-14 min-w-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size]
|
||||
? ['sm' => 'h-14 min-w-20 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-5 px-7 rounded-corner-xl type-headline-sm'][$size]
|
||||
: ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size];
|
||||
|
||||
$iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size];
|
||||
@@ -49,7 +59,6 @@
|
||||
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
|
||||
$dimensions,
|
||||
$colours,
|
||||
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
|
||||
])
|
||||
->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
@@ -57,7 +66,8 @@
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
|
||||
'data-fab' => true,
|
||||
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
|
||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@@ -65,7 +75,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($extended)
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
bind as on any input, the arrow keys move the choice, and a screen reader announces a group.
|
||||
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the
|
||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||
`inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a
|
||||
segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells
|
||||
you never to reduce. Corners move on the spatial spring and colours on the effects one
|
||||
(`state-transition-fast`).
|
||||
|
||||
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||
@@ -43,9 +46,12 @@
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
// M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever
|
||||
// its 32px/40px container measures. From md the segment is wider than that on its own.
|
||||
$small = in_array($size, ['xs', 'sm'], true);
|
||||
|
||||
$segment = [
|
||||
'xs' => 'h-8 gap-2 px-3 type-label-lg',
|
||||
'xs' => 'h-8 gap-2 px-4 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
@@ -57,6 +63,8 @@
|
||||
: 'mt-1 type-body-sm text-on-surface-variant';
|
||||
|
||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
// A 20px glyph comes from M3's 20px cut, which is drawn for that size rather than scaled to it.
|
||||
$optical = $iconSize === 'size-5' ? '20' : '24';
|
||||
|
||||
$colours = match ($variant) {
|
||||
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
|
||||
@@ -74,8 +82,9 @@
|
||||
@foreach ($options as $option)
|
||||
<label @class([
|
||||
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'min-w-0 flex-1' => ! $inline,
|
||||
'state-transition-fast',
|
||||
'flex-1' => ! $inline,
|
||||
$small ? 'touch-target min-w-12' : 'min-w-0',
|
||||
$segment,
|
||||
$colours,
|
||||
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
|
||||
@@ -91,7 +100,7 @@
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@props(['label'])
|
||||
|
||||
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
|
||||
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
|
||||
Expressive's selected shape and tertiary-container, and a `check` at its end unless it has an
|
||||
`icon-right` of its own — M3 asks for a cue beyond the colour and the shape. `current` is for a menu of places rather
|
||||
than choices — a section picker — and marks the page you are on: `aria-current="page"`, the
|
||||
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
|
||||
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
|
||||
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, reachable by the
|
||||
keyboard but not selectable — M3 keeps a disabled item focusable so a person can find out it
|
||||
is there. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||
want to change twice.
|
||||
|
||||
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
||||
@@ -17,8 +19,12 @@
|
||||
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
|
||||
disabled.
|
||||
|
||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||
12px at the ends of the list. --}}
|
||||
48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive
|
||||
spacing), 4px corners that open to 12px at the ends of the list — the corner on the spatial
|
||||
spring and the colour on the effects one (`state-transition-fast`). The row is 48px rather
|
||||
than SegmentedMenuTokens' 44px: M3's menu page publishes 48dp and asks for ≥48×48 targets
|
||||
inside an item's slots, and the library's own select and choices menus are 48px, so the more
|
||||
binding source wins over the more specific one. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -43,9 +49,9 @@
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none',
|
||||
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
|
||||
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
|
||||
'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'state-transition-fast',
|
||||
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||
'py-2' => filled($description),
|
||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
||||
@@ -84,7 +90,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -103,6 +109,9 @@
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@elseif ($selected === true)
|
||||
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
|
||||
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{-- A line between groups of items in an `<x-menu>`. --}}
|
||||
{{-- A line between groups of items in an `<x-menu>`: 1px, with M3's 8px above and below it, inset
|
||||
to the 16px the items keep either side. --}}
|
||||
|
||||
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
|
||||
<hr role="separator" {{ $attributes->class('mx-4 my-2 h-px border-0 bg-outline-variant') }} />
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
of the loop iteration around it, which would give every child component after the menu the
|
||||
same key.
|
||||
|
||||
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
|
||||
top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow
|
||||
keys, Home, End and typeahead bring the item they move to into view, and a disabled item is
|
||||
among them: M3 keeps one reachable so a person can find out that it exists.
|
||||
|
||||
It opens by growing out of the corner nearest its trigger and fades as it goes
|
||||
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
|
||||
|
||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||
androidx Compose Material 3 (Apache-2.0). --}}
|
||||
@@ -67,15 +75,15 @@
|
||||
x-on:keydown="navigate($event)"
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
|
||||
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||
'popover-transition',
|
||||
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||
'[position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'[position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'[position-area:top_span-right]' => $position === 'top-start',
|
||||
'[position-area:top_span-left]' => $position === 'top-end',
|
||||
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
|
||||
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
|
||||
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
|
||||
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
|
||||
element on the page rather than one the morph took away.
|
||||
|
||||
resources/js/rich-tooltip.js points the trigger at the bubble with `aria-describedby`, and a
|
||||
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded`: the explanation is
|
||||
the whole point of a rich tooltip, and without the association a screen reader reads only the
|
||||
trigger's own label. The trigger arrives in a slot, so only script can find the element in it
|
||||
that takes the focus.
|
||||
|
||||
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||
text in on-surface-variant, label-large actions in primary. --}}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
|
||||
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
|
||||
wide there. The corners are resources/css/components/groups.css. --}}
|
||||
wide there. resources/css/components/groups.css carries the rest of the spec, which is all
|
||||
geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
|
||||
connected group's do), the trailing icon's own size (22/22/26/38/50px, which the icon-button
|
||||
table has no entry for), the 1/1/2/3/6px nudge that sits the chevron towards the leading half,
|
||||
and the standard motion scheme M3 asks for while it turns over. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
|
||||
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" class="size-5" />
|
||||
<x-livewire-material::icon :name="$icon" optical="20" class="size-5" />
|
||||
@endif
|
||||
<span>{{ $title }}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,32 +7,50 @@
|
||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
||||
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
|
||||
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
|
||||
name) on `window`; both may be given.
|
||||
paused while the pointer or focus is on it. A toast with an `action` has no timeout at all,
|
||||
as M3 requires — it waits to be read and acted on — unless the caller writes a `timeout` out.
|
||||
A toast with an action or no timeout gets a close button. Pressing the action closes the
|
||||
snackbar, calls `handler` and dispatches `event` (a name) on `window`; both may be given.
|
||||
|
||||
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
|
||||
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
|
||||
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
|
||||
|
||||
The live region is the host itself, not the snackbar: a region must be in the page before its
|
||||
contents change for a screen reader to announce them reliably, and the snackbar comes and
|
||||
goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar (never
|
||||
assertive); a `type` picks the region's role — `alert` for an error or a warning, `status`
|
||||
otherwise — and the explicit `aria-live` keeps even those polite.
|
||||
|
||||
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
|
||||
action button.
|
||||
|
||||
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||
still on screen when the next page arrives.
|
||||
|
||||
There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon
|
||||
feels necessary"), and both lines of the message are plain inverse-on-surface — M3 gives the
|
||||
supporting text no fourth colour and tells the two lines apart by position. The 40px action
|
||||
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
|
||||
Escape dismisses a snackbar that holds the focus.
|
||||
|
||||
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
|
||||
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
|
||||
48px for one line. A type draws its state icon in the inverse state colour. `position`:
|
||||
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
|
||||
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar;
|
||||
from `medium` it hugs its line length instead, as M3 asks. --}}
|
||||
48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
|
||||
above a bottom bar through `--material-bottom-bar`, and publishes its own height as
|
||||
`--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a
|
||||
FAB, never in front of or behind one. A compact window (below `medium`, 600px) gets the
|
||||
full-width snackbar; from `medium` it hugs its line length instead, as M3 asks. --}}
|
||||
|
||||
@props(['position' => 'bottom'])
|
||||
|
||||
@persist('material-toast')
|
||||
<div
|
||||
x-data="materialSnackbar"
|
||||
x-bind:role="current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
{{ $attributes->class([
|
||||
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
|
||||
'justify-center' => $position !== 'bottom-start',
|
||||
@@ -43,40 +61,24 @@
|
||||
<div
|
||||
x-bind:key="current.id"
|
||||
data-toast
|
||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
||||
aria-live="polite"
|
||||
x-on:mouseenter="pause()"
|
||||
x-on:mouseleave="resume()"
|
||||
x-on:focusin="pause()"
|
||||
x-on:focusout="resume()"
|
||||
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86"
|
||||
>
|
||||
<template x-if="icon(current.type)">
|
||||
<span class="flex shrink-0" x-bind:class="{
|
||||
'text-inverse-success': current.type === 'success',
|
||||
'text-inverse-error': current.type === 'error',
|
||||
'text-inverse-warning': current.type === 'warning',
|
||||
'text-inverse-info': current.type === 'info',
|
||||
}">
|
||||
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="min-w-0 flex-1 py-1.5">
|
||||
<p class="type-body-md" x-text="current.title"></p>
|
||||
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p>
|
||||
<p class="type-body-md" x-show="current.description" x-text="current.description"></p>
|
||||
</div>
|
||||
|
||||
<template x-if="current.action">
|
||||
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
<button type="button" data-toast-action class="state-layer focus-ring touch-target h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
</template>
|
||||
|
||||
<template x-if="current.action || ! current.timeout">
|
||||
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
<button type="button" class="state-layer focus-ring touch-target inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
|
||||
<x-livewire-material::icon name="close" optical="20" class="size-5" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
<x-tooltip text="Copy link"><button …>…</button></x-tooltip>
|
||||
|
||||
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and
|
||||
hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its
|
||||
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and hides
|
||||
at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one
|
||||
tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its
|
||||
words. The bubble is a `popover="manual"` in the top layer — never clipped by an
|
||||
`overflow-hidden` parent, never widening a scroll container — placed by CSS anchor
|
||||
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<x-badge value="Draft" color="neutral" tonal />
|
||||
<x-badge value="Archived" color="neutral" outline />
|
||||
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
|
||||
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
|
||||
<x-badge tonal color="tertiary"><x-icon name="bolt" filled optical="20" class="size-3" /> Pro</x-badge>
|
||||
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
|
||||
BLADE,
|
||||
'Snackbars' => <<<'BLADE'
|
||||
|
||||
Reference in New Issue
Block a user