From a88a052d9a96eb69d1cb9419f37aa0cfb333e690 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 16:11:55 +0200 Subject: [PATCH] Move onto Livewire Material 2.0.0 and leave Tailwind behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Livewire Material 2.0.0 aligns every component with Material 3 Expressive and carries no Tailwind anywhere, so SealShare drops tailwindcss and its Vite plugin and writes its views in the package's vocabulary: layout components (, , , , ) with M3's spacing tokens, the md-type-*/md-ink-* text classes, and --md-sys-* tokens in its own small stylesheet. - resources/css/app.css opens with the package's layer order, imports foundation.css and the stylesheet of each component the views render, then the scheme, regenerated with the 2025 colour rules at M3's three contrast levels. The app's own rules follow, one section per view, on tokens and on M3's breakpoints (600/840/1200/1600px) only. - Every view is rewritten in that vocabulary while SealShare keeps the shape it had: the admin table, the admin settings, the recovery codes, the share options and the download page are cards, and their fields fill them rather than stopping at the 40rem bound a card already bounds. The user settings pages became cards too, to match the admin's, each with the sections that stand apart from its one subject — deleting the account, the recovery codes — in a card beside it. Material 3 decides how a component behaves, not whether a container survives: buttons keep their label's width, a form's actions end it, and each heading level keeps one type role. - The layouts clear the floating toolbar by the --material-bottom-toolbar the package publishes, and the snackbar clears it by itself. - The two-factor setup QR code comes from QrCodeService, so it keeps a white field and quiet zone in the dark theme and still scans. - Browse Files is a real button that opens the file input, reachable and visibly focused from the keyboard. - Tests: the design test scans views, JS, CSS and app/ and checks the CSS entry both ways (no missing, no unused import). New Chromium suites cover the frame, settings and admin, the share flow, and every page at M3's breakpoint edges (599/600, 839/840, 1199/1200, 1600px). The package's renamed data-md-* hooks replace the 1.x ones. - Boost's update brings the material-3 guideline and skill and drops the Tailwind skill. composer.json requires nonameweb/livewire-material ^2.0 from the Gitea repository, resolved at the 2.0.0 tag. The CHANGELOG records the move as 2.1.0. Co-Authored-By: Claude Opus 5 (1M context) --- .../livewire-material-development/SKILL.md | 529 ++- .claude/skills/material-3-design/SKILL.md | 299 ++ .../skills/tailwindcss-development/SKILL.md | 96 - CHANGELOG.md | 11 +- CLAUDE.md | 69 + boost.json | 4 +- composer.json | 2 +- composer.lock | 11 +- package-lock.json | 634 ---- package.json | 3 - resources/css/app.css | 401 ++- resources/css/material-scheme.css | 3100 +++++++++++++++-- resources/css/material-scheme.json | 3015 ++++++++++++++-- .../views/components/auth-header.blade.php | 8 +- resources/views/layouts/app.blade.php | 6 +- resources/views/layouts/auth.blade.php | 12 +- .../livewire/admin/admin-dashboard.blade.php | 99 +- .../livewire/admin/admin-settings.blade.php | 224 +- .../views/livewire/file-uploader.blade.php | 406 ++- .../views/livewire/setup-wizard.blade.php | 12 +- .../views/livewire/share-created.blade.php | 142 +- .../views/livewire/share-download.blade.php | 124 +- .../livewire/system-password-prompt.blade.php | 12 +- .../pages/auth/confirm-password.blade.php | 8 +- .../pages/auth/forgot-password.blade.php | 12 +- resources/views/pages/auth/login.blade.php | 20 +- .../views/pages/auth/reset-password.blade.php | 8 +- .../pages/auth/two-factor-challenge.blade.php | 22 +- .../views/pages/auth/verify-email.blade.php | 18 +- .../views/pages/settings/layout.blade.php | 20 +- .../two-factor/⚡recovery-codes.blade.php | 59 +- .../pages/settings/⚡appearance.blade.php | 4 +- .../settings/⚡delete-user-form.blade.php | 29 +- .../views/pages/settings/⚡password.blade.php | 16 +- .../views/pages/settings/⚡profile.blade.php | 30 +- .../pages/settings/⚡two-factor.blade.php | 88 +- .../views/partials/settings-heading.blade.php | 8 +- resources/views/partials/toolbar.blade.php | 20 +- tests/Browser/BreakpointsTest.php | 271 ++ tests/Browser/FrameTest.php | 152 + tests/Browser/SealShareTest.php | 21 +- tests/Browser/SettingsAndAdminTest.php | 138 + tests/Browser/ShareFlowTest.php | 192 + tests/Feature/AppToolbarTest.php | 8 +- tests/Feature/DesignLanguageTest.php | 7 +- tests/Feature/ShareCreatedTest.php | 7 +- tests/Pest.php | 10 + vite.config.js | 2 - 48 files changed, 8311 insertions(+), 2078 deletions(-) create mode 100644 .claude/skills/material-3-design/SKILL.md delete mode 100644 .claude/skills/tailwindcss-development/SKILL.md create mode 100644 tests/Browser/BreakpointsTest.php create mode 100644 tests/Browser/FrameTest.php create mode 100644 tests/Browser/SettingsAndAdminTest.php create mode 100644 tests/Browser/ShareFlowTest.php diff --git a/.claude/skills/livewire-material-development/SKILL.md b/.claude/skills/livewire-material-development/SKILL.md index a294a87..13676b0 100644 --- a/.claude/skills/livewire-material-development/SKILL.md +++ b/.claude/skills/livewire-material-development/SKILL.md @@ -15,13 +15,15 @@ Composer packages must be installed before the Vite build (in Dockerfiles and CI ```css /* resources/css/app.css */ -@import 'tailwindcss'; -@import '../../vendor/nonameweb/livewire-material/resources/css/material.css'; +@import '../../vendor/nonameweb/livewire-material/resources/css/foundation.css'; +@import '../../vendor/nonameweb/livewire-material/resources/css/layout/scaffold.css'; +@import '../../vendor/nonameweb/livewire-material/resources/css/components/button.css'; +@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css'; @import './material-scheme.css'; -@source '../../vendor/nonameweb/livewire-material/resources/views'; -@source '../../vendor/nonameweb/livewire-material/src'; ``` +`foundation.css` is required and first; then one stylesheet per component the views render, under `resources/css/components/` or `resources/css/layout/`, named like the component (`` is drawn by `tabs.css`, and `` needs none). Each imports the stylesheets of the components it draws (a split button's button and menu), and Vite keeps a file several of them import once. `resources/css/all.css` stands for the foundation and every component at once. A component rendered without its stylesheet is unstyled, so add the import with the tag. + ```js // resources/js/app.js import '../../vendor/nonameweb/livewire-material/resources/js/material.js' @@ -44,7 +46,18 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari php artisan material:scheme "#4f46e5" --variant=tonal-spot ``` -Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet. +Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--harmonize` pulls those three towards the seed (off by default: a state has to stay recognisable). The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet. + +`success`, `warning` and `info` are built exactly as M3 builds `error` — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (`bg-success`, `text-on-warning-container`), never as a hex. + +### Contrast levels + +Every scheme is generated at M3's three levels: standard, medium (3:1) and high (7:1), for light and dark and for every profile. `--contrast` (and a profile's `contrast`) moves the **standard** level only and must stay below 0.5; medium and high are Google's fixed levels and are always written, under `[data-contrast='medium']` and `[data-contrast='high']`. + +- `` is the level on screen, written by `` before the first paint; standard writes no attribute, because the plain blocks are already standard. No stylesheet ever asks `prefers-contrast` — the head script does, once. +- `theme.contrast.default` is `system` (follow the operating system), `standard`, `medium` or `high`, kept in localStorage under `theme.contrast.storage_key`. +- In Alpine: `$store.theme.contrast` (the choice), `$store.theme.resolvedContrast` (the level showing) and `$store.theme.setContrast('high')`. `` is the ready-made row of three. +- Nothing in a template names a level: a role's value changes underneath it. Never set `data-contrast` on an element to make a corner of the page higher-contrast — the level is the visitor's, page-wide. ### Colour profiles @@ -62,6 +75,7 @@ An installation that switches between several schemes lists them in `config/live php artisan material:scheme ``` +- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0, below 0.5), `harmonize`, `spec`, `success`, `warning`, `info` (for these five, without the key the command's `--harmonize`, `--spec`, `--success`, `--warning`, `--info` or their defaults apply). Every profile is generated at all three contrast levels, keyed on `[data-scheme='x'][data-contrast='high']` and so on. - Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet. - The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default: @@ -71,26 +85,41 @@ use NoNameWeb\LivewireMaterial\Support\Scheme; Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile')); ``` -- `` writes the active profile to `` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`. +- `` writes the active profile to `` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`. Both take a contrast level as their last argument (`Scheme::load($path, $profile, 'high')`, `Scheme::profiles(contrast: 'medium')`); without one they answer with the standard level, which is what a mail wears. - Choose with `` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on ``. ## Tokens -Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing. +2.0.0's vocabulary is plain CSS, without Tailwind. `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token (spacing included: `--md-sys-measurement-space25` … `space900`, 2–72px), and `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`. Text on plain elements takes the fixed text classes of `resources/css/text.css` and nothing else: `md-type-{display|headline|title|body|label}-{lg|md|sm}` and `md-type-emphasized-…`; `md-ink` (on-surface), `md-ink-variant`, `md-ink-quiet`, `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`; `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in ``, `` or ``, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token. Every component is plain CSS now; the Tailwind names below stay for the showcase and an application's own views, until Tailwind itself leaves the package. + +Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing. The rules behind the names below — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen. - Colour roles (`bg-*`, `text-*`, `border-*`, …): `primary`, `on-primary`, `primary-container`, `on-primary-container`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline`, `outline-variant`, `scrim`, `shadow`; plus `white` and `black`. - Ink and lines by meaning: `text-body` (body copy), `text-meta` (metadata), `text-quiet` (decoration only), `border-structure`, `border-chrome`, `border-divider` / `divide-divider`. -- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…`. Never assemble `text-*`, `leading-*` and `tracking-*` by hand. The font is Google Sans Flex (`font-sans`). +- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking. Never assemble `text-*`, `leading-*` and `tracking-*` by hand; a utility carries size, line height and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular utility inside an emphasized one goes back to plain. The font is Google Sans Flex (`font-sans`). - Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`. - Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone). -- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`. Reduced motion zeroes the durations. +- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity, which never overshoot). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast` — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. `motion.scheme` in the config picks `expressive` (the default, with the bounce) or `standard` (minimal bounce), which the head script writes to `` and which swaps the three spatial springs; a component names a spring, never a scheme. Reduced motion zeroes every duration in both schemes. - States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text). +- Breakpoints are M3's window size classes, and only those: `medium:` 600px, `expanded:` 840px, `large:` 1200px, `extra-large:` 1600px, with `max-medium:` … for "below" (compact is below `medium`). Tailwind's `sm:`…`2xl:` are cleared — a `sm:` compiles to nothing — because 640px means nothing in M3. Scripts ask `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) so a stylesheet and a script never disagree at the boundary pixel; a component's *own* width is a container query (`@md:`), which is a different thing. - `dark:` follows the page's theme (`data-theme`), not the operating system. - `x-figure` on an element holding one number counts it up on first appearance and on change. ## Theme -`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything. +`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`, `theme.contrast` (`default` and `storage_key`, see Contrast levels) and `motion.scheme` (`expressive`, the default, or `standard` — M3's restrained springs, written to ``). In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. It also holds `contrast`, `resolvedContrast` and `setContrast()` for the contrast level. With colour profiles it holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything. + +`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `` without a `media` attribute to the resolved theme's `surface` — of the profile in ``, at the level in `` — before the first paint, adding one to `` when there is none. It follows every later change of `data-theme`, `data-scheme` or `data-contrast` (`$store.theme.set()`/`toggle()`, `setContrast()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on. + +## Safe areas + +Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. + +`--material-snackbar-height` is the height of the snackbar on screen, written on `` by `` while one shows and removed when it goes. `` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; so does an `` in ``'s `fab` slot, which places it. + +`--material-bottom-toolbar` is the distance from the window's bottom edge to the top of a toolbar at `place="bottom"`, unset while there is none: the snackbar clears it, and a page without `` pads its end with it (see ``). + +`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `` (an offline banner): the scaffold adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button, the scaffold's FAB 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 @@ -117,7 +146,7 @@ The methods are protected. They dispatch a `toast` browser event (`assertDispatc Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins. -- The pages load `config('livewire-material.showcase.vite')` and ``, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`. +- The pages load `config('livewire-material.showcase.vite')` and ``, so they use the app's scheme, font and theme, and inline the error layout's own stylesheet beside them. The application imports nothing for them: no `error-page.css` in the CSS entry (the design guard's `missingStylesheets()` does not ask for it, and an entry that imports it through `all.css` does no harm). While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`. - `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`. - To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `` name) and optionally `actions`: @@ -170,15 +199,20 @@ Your export is ready. ### `` -A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws. +A Material Symbol (Rounded, weight 400, grade 0), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws. | Prop | Default | | |---|---|---| | `name` | required | `calendar_month`, `cloud_upload`, `content_copy` | | `filled` | `false` | the filled symbol — M3 uses it for active or selected | +| `size` | `24` | the drawn size in px, a whole number from 8 to 256 (`16`, `18`, `20`, `32`, `40`, `48` …), written as `--md-icon-size` | +| `optical` | from `size` | the cut the glyph is drawn from, `24` or `20`; anything else falls back to 24 | | `label` | `null` | names the icon for screen readers when it carries the meaning alone; otherwise it is `aria-hidden` | +| `mirror-rtl` | `false` | flips a directional symbol (an arrow, a chevron) in a right-to-left document | -24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: ``. +Colour follows the text: ``. + +M3's optical size axis redraws a symbol so its strokes look equally heavy at every size, so **an icon drawn at 20px or smaller takes the 20 cut**; scaling the 24 cut down thins its strokes by about a sixth. `size="20"` and below pick it for you. Pass `optical="20"` yourself only when the icon's size comes from somewhere else (your own CSS setting its width and height, which outranks the package's layer). ### `` @@ -186,7 +220,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size ### `` -The theme decided before the first paint. Exactly once per layout, in ``, before `@vite`. No props; configured in `config/livewire-material.php`. +The theme decided before the first paint. Exactly once per layout, in ``, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `` (see Theme); a layout's own theme-color meta goes before it. ### `` @@ -200,13 +234,13 @@ Label button, icon button, toggle and responsive FAB in one component. | `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning | | `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px | | `shape` | `round` | or `square`; both square off further while pressed | -| `icon`, `icon-right` | | Material Symbol names | +| `icon`, `icon-right` | | Material Symbol names; drawn from M3's 20px cut where the glyph is 20px, and filled on a default icon button | | `width` | `default` | icon buttons only: `narrow`, `default`, `wide` | | `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) | | `link`, `external`, `no-wire-navigate` | | renders ``, with `wire:navigate` unless external | | `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action | | `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name | -| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above | +| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there | ```blade @@ -216,7 +250,7 @@ Label button, icon button, toggle and responsive FAB in one component. ### `` -M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead. +M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use ``, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead. ### ``, ``, ``, `` @@ -235,11 +269,55 @@ M3's plain tooltip, standalone around any trigger: ` ``` -``: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. ``: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab. +``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. ``: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls. + +`submenu` makes an item a menu of its own — the slot holds the nested ``s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room: + +```blade + + + + +``` + +The item says so with `aria-haspopup="menu"`, `aria-expanded` and a chevron; Right, Enter or Space open it on its first item, Left or Escape close it and come back, and on a fine pointer resting on the item opens it. Choosing anything inside closes the whole menu. Arrows stay inside the list they are in. M3 calls submenus a large-screen pattern — on a phone give the menu `sheet-at-compact`, or keep the list flat. + +`filter` puts a text field at the top of the list (M3's menu as a filtering surface) and narrows the items to those whose label holds what has been typed — in the browser, over the items already rendered, so nothing is fetched and every `wire:click` stays where it was. `filter="Find a person"` names the field; bare `filter` calls it "Filter". The field keeps the focus while the arrow keys, Home and End move a highlighted row and Enter chooses it (`aria-activedescendant`, as ``); a query that leaves nothing says "Nothing matches". Reach for it once a menu is long enough to hunt through; for a value bound to a property, `` is the field, not the menu. + +`sheet-at-compact` is M3's adaptive menu ("at compact breakpoints, consider swapping a menu for a bottom sheet"): below `medium` (600px) the trigger opens the same items in a modal ``, and from `medium` up it opens the popover. Write the items once — the slot is drawn in both: + +```blade + + + + + + + + + + + +``` + +On a compact window the trigger says `aria-haspopup="dialog"` (and `menu` from `medium`), with `aria-expanded` in both. In the sheet the items keep their `menuitem` roles and the menu keyboard (arrows, Home, End, a letter; Escape or Tab close it and focus returns to the trigger); choosing an item closes it, as do the scrim and a swipe down; a `submenu` opens in place under its item rather than beside it; a `filter` field sits at the top; a `keep-open` item's render keeps the sheet open. Resizing the window across 600px while it is open closes it. The sheet is teleported to the end of ``, so it covers the window from inside a sticky app bar or a toolbar, and is surface-container-low even for a `vibrant` menu. Because the items are rendered twice, do not give them an `id` or nest a Livewire component in a `sheet-at-compact` menu. + +Clusters: `` draws M3's line, `` M3 Expressive's grouped layout — no line, the cluster set 8px off its neighbours with its items 2px apart and its ends rounded. Reach for the divider first (M3: "on web, use dividers to separate items", and it is the only one a scrolling menu may use); reach for the gap for one or two clusters in a menu short enough not to scroll, and never vary the gap. `` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was. ### `` -A row of ``s: ``. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. +A row of ``s: ``. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. `shape="square"` is M3's square group and covers every button in it, so do not write `shape` on each one: a connected group's ends square to the corner its inner edges take, a standard group's buttons take the square corner scale, and a selected button still rounds — M3 has the toggle morph the other way. + +`selection` is M3's third configuration — `single`, `multi`, and either with `required` ("selection-required"). The group then owns `aria-pressed`: + +```blade + + + + +``` + +Pressing a button writes its `value` (an array with `multi`) to `wire:model` or `x-model`, deselects the others in `single`, and with `required` refuses the press that would leave nothing selected; without a model it reads the buttons' own `aria-pressed` once and goes on from there. A button with no `value` is known by its label. The group manages state and shape, not colour — each button draws its selected colours from its own `:selected`, so bind both from one property as above. **Reach for `` first**: it is the component for a choice whose options are data (real radios or checkboxes, a plain form post, the browser's keyboard, segments that paint themselves). `` is for buttons you write yourself — icons, tooltips, mixed content — and never becomes a form control. ### `` @@ -253,7 +331,7 @@ A choice between a few options as a connected button group of native radios (che ]" hint="Recipients lose access after that" /> ``` -Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint. +Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `shape` (`round`, `square`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint. ### `` @@ -267,12 +345,14 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d ### `` -`` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`
`). `link`, `external`, `disabled`, `type`. +`` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself: put the page's FAB in ``'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `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). + +`collapse-on-scroll` on an extended FAB with an `icon` (``) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane. ### ``, `` ```blade -
+
@@ -280,7 +360,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
``` -Two to six items open above the FAB, which turns into a close button. ``: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as ``. +Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. ``: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as ``. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`. ### `` @@ -302,7 +382,19 @@ The snackbar host. Once per layout, near the end of ``: `` (`po materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } }) ``` -`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. +`type` (`success`, `error`, `warning`, `info`) picks the announcement role and draws no icon (M3 tells you to avoid one in a snackbar); `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out. + +- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON. +- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news: + +```js +window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } })) +window.addEventListener('app:update', () => location.reload()) +``` + +- `description` is M3's second line: the container goes from 48px to 68px (`SnackbarTokens.TwoLinesContainerHeight`), and below `medium` a two-line snackbar with an action wraps the action under the text. +- Escape dismisses a snackbar that holds the focus, and **Alt+G** moves the focus to a snackbar that carries an action from wherever the page had it — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus itself. Say so where your users read about keyboard shortcuts. +- Hooks: `data-md-toast` on the host, `data-md-toast-snackbar` on the snackbar on screen (absent while nothing shows), `data-md-toast-action` on its action button and `data-md-toast-dismiss` on its close button. Target these in tests, not classes. ### `` @@ -334,7 +426,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl ### `` - `` — M3's small badge, a dot. `` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: ``. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread"). -- ``, ``, `` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`. +- ``, ``, ``, `` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral `outline` edge (the role that has to be seen, not the decorative `outline-variant` dividers use). `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`. +- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline edge with on-surface-variant text. +- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: ``. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour. +- The value is `value` or the slot; the slot renders as HTML: ` Pro`. `value` is escaped. A slot that holds only whitespace or comments is still a dot. ### `` @@ -348,7 +443,7 @@ A notice in the page, in the state's container colour with its icon: ``` -`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`. +`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Every alert is `role="status"`, whatever its colour: it is usually on the page as it renders, and an assertive region talks over the page title on load. Pass `assertive` for one put on screen in answer to something the person just did. ### `` @@ -360,7 +455,7 @@ A few lines of context around a trigger, with an optional `title` and `actions` ``` -Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`. +Shows on hover and keyboard focus and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `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`. ### `` @@ -370,22 +465,39 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u "Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing. -### `` - -`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`. - -A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `
` or use a stretched link. +The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon. ```blade - - {{ $share->name }} + + + + +``` + +### `` + +`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Do not pass `bg-*`; use `variant`. + +A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move. + +```blade + + {{ $share->name }} ``` +`data-md-dragged` on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires. + +That is M3's *non-actionable card with actionable elements*: Tab walks the controls inside. For M3's *directly actionable card*, where Tab lands on the card and then moves to the next card, add `actionable` (and `role="link"` where it goes somewhere) and put `tabindex="-1"` on the opener — the card is then the tab stop, named by its `title`, and Enter or Space on it reaches the opener while the card's other actions follow it. + ### ``, `` -``: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). ``: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content. +``: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). ``: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `video` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content, and a three-line item top-aligns as M3 asks. Its icons are 24px, 20px in a `segmented` list. + +`video` is M3's leading media in landscape: a poster URL, or `` for a `