Merge branch 'worktree-agent-aadcbb716875e9282'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:37:13 +02:00
45 changed files with 463 additions and 207 deletions
+2
View File
@@ -44,6 +44,8 @@ import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
Do not install Alpine separately; Livewire provides it. Do not install Alpine separately; Livewire provides it.
Importing the package's CSS replaces parts of Tailwind's theme with M3's, so some default utilities no longer compile. Breakpoints are the most visible: they are M3's window size classes and only those — `medium:` 600px, `expanded:` 840px, `large:` 1200px and `extra-large:` 1600px, with `max-medium:` and friends for "below", compact being everything under `medium`. Tailwind's `sm:``2xl:` are cleared, so an `sm:grid-cols-2` left over from another project compiles to nothing; rewrite it as `medium:grid-cols-2`. The same goes for the default radius, shadow, text-size, weight, leading, tracking and easing scales, which the M3 sets (`rounded-corner-*`, `shadow-elevation-*`, `type-*`, `ease-spatial-*`) replace. Scripts that need a window size class import `from()` / `upTo()` from the package's `resources/js/breakpoints.js` rather than writing their own media query.
### Layout ### Layout
The theme script goes in `<head>`, before `@vite`, so the page paints in the visitor's theme: The theme script goes in `<head>`, before `@vite`, so the page paints in the visitor's theme:
@@ -86,6 +86,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone). - 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, 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 `<html data-motion>` and which swaps the three spatial springs; a component names a spring, never a scheme. Reduced motion zeroes every duration in both schemes. - 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 `<html data-motion>` 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). - 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. - `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. - `x-figure` on an element holding one number counts it up on first appearance and on change.
@@ -99,7 +100,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
`--material-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)))`, below `sm` only. `--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only.
## Toasts ## Toasts
@@ -218,7 +219,7 @@ Label button, icon button, toggle and responsive FAB in one component.
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external | | `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action | | `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 | | `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 ```blade
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner /> <x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
@@ -279,12 +280,12 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
### `<x-fab>` ### `<x-fab>`
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `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. `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-menu>`, `<x-fab-menu-item>` ### `<x-fab-menu>`, `<x-fab-menu-item>`
```blade ```blade
<div class="fixed end-4 bottom-4"> <div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
<x-fab-menu label="New"> <x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" /> <x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" /> <x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
@@ -461,11 +462,11 @@ An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; cl
</x-modal> </x-modal>
``` ```
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen below `sm`, for forms), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead. Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
### `<x-drawer>` ### `<x-drawer>`
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`. An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `<div class="expanded:flex expanded:items-start expanded:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class.
### `<x-bottom-sheet>` ### `<x-bottom-sheet>`
@@ -600,7 +601,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
| Prop | Default | | | Prop | Default | |
|---|---|---| |---|---|---|
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing | | `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog on a compact window (below `medium`), as M3 asks; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field | | `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside | | `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's | | `label`, `hint`, `icon`, `variant`, `size` | | the field's |
@@ -650,7 +651,7 @@ Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options
### `<x-search>` ### `<x-search>`
M3 search bar that opens into a search view: docked under the bar from `sm`, full screen with a back arrow below (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons). M3 search bar that opens into a search view: docked under the bar from `medium` (600px), full screen with a back arrow on a compact window (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
```blade ```blade
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares"> <x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
@@ -665,7 +666,16 @@ The docked view overlaps what is under it; never place a search inside an elemen
### `<x-app-shell>` ### `<x-app-shell>`
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`. The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
| Window size class | Width | Navigation | Margin |
| --- | --- | --- | --- |
| Compact | below `medium` (600px) | navigation bar, pinned to the bottom; the rest in the modal rail, opened by `$store.rail.show()` | 16px |
| Medium | `medium` 600839 | collapsed rail (96px) in the layout, no bar; its menu button opens it expanded over a scrim | 24px |
| Expanded | `expanded` 8401199 | standard rail in the layout, collapsed; the menu button expands it in place, no scrim | 24px |
| Large, extra-large | `large` from 1200 | the same standard rail, expanded to begin with | 24px |
A visitor who has pressed the menu button keeps that choice in both standard bands (`$store.rail`, remembered and applied before the first paint).
```blade ```blade
<x-app-shell :destinations="[ <x-app-shell :destinations="[
@@ -683,7 +693,7 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
</x-slot:rail-footer> </x-slot:rail-footer>
<x-slot:top> <x-slot:top>
{{-- the page's app bar; its menu button opens the modal rail on a phone --}} {{-- the page's app bar; its menu button opens the modal rail on a phone --}}
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span> <span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
</x-slot:top> </x-slot:top>
{{ $slot }} {{ $slot }}
@@ -692,16 +702,18 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). - `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`). - Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`). - The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas. - `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside. - Two panes side by side are M3's from `expanded`: `<x-drawer pane>` is the second one, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
- On a compact window the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
- The content region is `max-expanded:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
### `<x-navigation-bar>`, `<x-navigation-bar-item>` ### `<x-navigation-bar>`, `<x-navigation-bar-item>`
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does): M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
```blade ```blade
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden"> <div class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-navigation-bar> <x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" /> <x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" /> <x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
@@ -735,11 +747,11 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
</div> </div>
``` ```
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`). - `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`).
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. - Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. - Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way. - `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php``rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`). - `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php``rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
### `<x-app-bar>` ### `<x-app-bar>`
@@ -779,7 +791,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
### `<x-section-nav>` ### `<x-section-nav>`
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`. Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px; wrapping onto a grid rather than a single row below `large`, rather than scrolling), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
### `<x-account-menu>` ### `<x-account-menu>`
@@ -872,6 +884,6 @@ It also fails on every value the theme cleared, and names the replacement on the
## Livewire traps ## Livewire traps
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings, or put the Alpine on a plain element inside the slot. - Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings, or put the Alpine on a plain element inside the slot.
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-sm:hidden`) is safe. - Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-medium:hidden`) is safe.
- `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`. - `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`.
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it. - End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
+27 -8
View File
@@ -30,9 +30,17 @@
* *
* `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed — whatever * `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed — whatever
* made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by * made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by
* <x-theme-script>), or a window under `lg` for the adaptive rail. A rail item is written once * <x-theme-script>), or a window below `expanded` (840px) for the adaptive rail. A rail item is written once
* and takes both shapes from it; so can anything an application puts in a rail * and takes both shapes from it; so can anything an application puts in a rail
* (`<span class="rail-collapsed:hidden">`). * (`<span class="rail-collapsed:hidden">`).
*
* The two widths the adaptive rail turns on are M3's window size classes, not Tailwind's screens
* (docs/reference/m3/foundations.md § Layout, foundations-supplement.md § Breakpoints): 37.5rem
* (600px) is the compact/medium boundary — below it the navigation bar carries the destinations and
* the rail takes no room in the layout; 52.5rem (840px) is `expanded`, where M3 asks for a standard
* rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
* rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
* the same 37.5rem, so a bar in a narrow column lays out by its own width.
*/ */
@custom-variant rail-collapsed { @custom-variant rail-collapsed {
@@ -48,13 +56,24 @@
@slot; @slot;
} }
@media (width < 64rem) { @media (width < 52.5rem) {
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) { &:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot; @slot;
} }
} }
@media (width >= 64rem) { /* Expanded (8401199): a standard rail, collapsed unless the visitor expanded it. `data-rail`
alone cannot say that — it carries `rail.default` for a visitor who never chose — so
`data-rail-auto`, which <x-theme-script> sets while nothing is stored, stands for "no
choice yet" and the class's own default applies. */
@media (52.5rem <= width < 75rem) {
&:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive'], :is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive'] *) {
@slot;
}
}
/* Large and extra-large (from 1200): expanded to begin with, which is what M3 prefers there. */
@media (width >= 75rem) {
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) { &:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
@slot; @slot;
} }
@@ -176,18 +195,18 @@
} }
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's /* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
ModalWideNavigationRail does; the adaptive rail does below lg, and takes no room below sm. */ ModalWideNavigationRail does; the adaptive rail does below `expanded`, and takes no room on a compact window. */
[data-navigation-rail='modal'] { [data-navigation-rail='modal'] {
width: 6rem; width: 6rem;
} }
@media (width < 64rem) { @media (width < 52.5rem) {
[data-navigation-rail='adaptive'] { [data-navigation-rail='adaptive'] {
width: 6rem; width: 6rem;
} }
} }
@media (width < 40rem) { @media (width < 37.5rem) {
[data-navigation-rail='adaptive'] { [data-navigation-rail='adaptive'] {
width: 0; width: 0;
} }
@@ -230,9 +249,9 @@
box-shadow: var(--md-sys-elevation-2); box-shadow: var(--md-sys-elevation-2);
} }
/* Below sm there is no collapsed rail to grow out of: the open rail slides in from the edge, /* On a compact window there is no collapsed rail to grow out of: the open rail slides in from the edge,
on emphasized decelerate rather than a spring, which would overshoot and open a gap. */ on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
@media (width < 40rem) { @media (width < 37.5rem) {
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] { [data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
position: fixed; position: fixed;
inset-block: 0; inset-block: 0;
+15 -7
View File
@@ -24,10 +24,18 @@
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px * on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
* corner and primary-container with a bold label when on, not the outlined pair of the tokens. * corner and primary-container with a bold label when on, not the outlined pair of the tokens.
* *
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does * In a landscape window with no room for the upright dial the dial variant lies on its side, as
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px * Compose's HorizontalTimePicker does: the display with the period selector (216×38, 16px under it)
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks * on the start side, the dial 36px after it, the actions under both; and the dial shrinks to 238 or
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands. * 200px when the window is shorter still (ClockFaceSizeModifier). The input variant always stands.
*
* The switch is orientation and viewport *height*, never a width breakpoint: M3 says the picker
* "swaps orientation/variant based on device orientation and viewport height"
* (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour), and no
* window size class applies. 35rem is what the upright dial needs — 33rem of surface (3 padding,
* 2.25 title, 7.25 display, 17.5 dial, 3 actions) inside the dialog's `100dvh - 2rem` — so a
* landscape phone lies the dial down and a landscape tablet or desktop, which has the height,
* leaves it standing.
* *
* The selector's angle is a registered property, so a single transition turns the line, carries the * The selector's angle is a registered property, so a single transition turns the line, carries the
* handle round and moves the clip that inks the number under it, on the default spatial spring. The * handle round and moves the clip that inks the number under it, on the default spatial spring. The
@@ -400,7 +408,7 @@
/* ---- Landscape ---------------------------------------------------------------------------- */ /* ---- Landscape ---------------------------------------------------------------------------- */
@media (orientation: landscape) and (min-width: 37rem) { @media (orientation: landscape) and (height < 35rem) {
[data-timepicker-surface][data-mode="dial"] { [data-timepicker-surface][data-mode="dial"] {
grid-template-columns: 13.5rem auto; grid-template-columns: 13.5rem auto;
grid-template-areas: grid-template-areas:
@@ -446,13 +454,13 @@
} }
} }
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 22.75rem) { @media (orientation: landscape) and (max-height: 22.75rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] { [data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 14.875rem; --dial: 14.875rem;
} }
} }
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 21.625rem) { @media (orientation: landscape) and (max-height: 21.625rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] { [data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 12.5rem; --dial: 12.5rem;
} }
+4 -3
View File
@@ -4,7 +4,8 @@
* *
* The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS * The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS
* anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact * anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact
* window) it is opened with `showModal()`. Everything a person sees in it is drawn by Alpine from * window — below `medium`, 600px, where M3 puts the picker in a dialog rather than under the
* field) it is opened with `showModal()`. Everything a person sees in it is drawn by Alpine from
* the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while * the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while
* it is open. * it is open.
* *
@@ -20,7 +21,7 @@
* Enter selects and confirms, Escape closes and hands focus back to the field. Focus never leaves * Enter selects and confirms, Escape closes and hands focus back to the field. Focus never leaves
* the `min``max` span, so a disabled day cannot be chosen from the keyboard either. * the `min``max` span, so a disabled day cannot be chosen from the keyboard either.
*/ */
const COMPACT = '(max-width: 39.99rem)' import { upTo } from './breakpoints.js'
// CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`. // CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`.
const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ') const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ')
@@ -222,7 +223,7 @@ document.addEventListener('alpine:init', () => {
} }
}) })
const query = window.matchMedia(COMPACT) const query = upTo('medium')
this.compact = query.matches this.compact = query.matches
query.addEventListener('change', (event) => (this.compact = event.matches)) query.addEventListener('change', (event) => (this.compact = event.matches))
+49 -15
View File
@@ -5,6 +5,9 @@
* localStorage. <x-theme-script> has already applied it before the first paint as * localStorage. <x-theme-script> has already applied it before the first paint as
* <html data-rail="expanded|collapsed">, which is what the stylesheet keys on (the * <html data-rail="expanded|collapsed">, which is what the stylesheet keys on (the
* `rail-collapsed:` variant); the store starts from that attribute and writes it back. * `rail-collapsed:` variant); the store starts from that attribute and writes it back.
* `$store.rail.auto` is true while nothing is stored — the value is `rail.default`, not a choice —
* and the adaptive rail then takes its window size class's default instead: collapsed in the
* expanded class (8401199), expanded from `large` (1200), as M3 asks. The first `set()` drops it.
* *
* `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu * `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu
* button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it * button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
@@ -13,7 +16,7 @@
* `materialNavigationRail` is one rail's view of the store for its `mode` — see * `materialNavigationRail` is one rail's view of the store for its `mode` — see
* resources/views/components/navigation-rail.blade.php. * resources/views/components/navigation-rail.blade.php.
*/ */
const WIDE = '(min-width: 64rem)' import { from } from './breakpoints.js'
/* /*
* The active indicator grows out of its centre when a page arrives through wire:navigate. The * The active indicator grows out of its centre when a page arrives through wire:navigate. The
@@ -48,6 +51,10 @@ document.addEventListener('alpine:init', () => {
window.Alpine.store('rail', { window.Alpine.store('rail', {
collapsed: root.dataset.rail === 'collapsed', collapsed: root.dataset.rail === 'collapsed',
// Nothing stored yet: `collapsed` is only `rail.default`, so an adaptive rail may still
// take its window size class's own default. The first choice made here clears it.
auto: root.hasAttribute('data-rail-auto'),
open: false, open: false,
toggle() { toggle() {
@@ -64,7 +71,9 @@ document.addEventListener('alpine:init', () => {
set(collapsed) { set(collapsed) {
this.collapsed = collapsed this.collapsed = collapsed
this.auto = false
root.dataset.rail = collapsed ? 'collapsed' : 'expanded' root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
root.removeAttribute('data-rail-auto')
try { try {
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail) localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
@@ -86,30 +95,43 @@ document.addEventListener('alpine:init', () => {
document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide()) document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide())
window.Alpine.data('materialNavigationRail', (mode) => ({ window.Alpine.data('materialNavigationRail', (mode) => ({
wide: mode === 'adaptive' ? window.matchMedia(WIDE).matches : false, wide: mode === 'adaptive' ? from('expanded').matches : false,
query: null, roomy: mode === 'adaptive' ? from('large').matches : false,
onWidth: null, queries: [],
listeners: [],
init() { init() {
if (mode !== 'adaptive') { if (mode !== 'adaptive') {
return return
} }
// From lg the adaptive rail is a standard, collapsible rail: a modal left open while // From `expanded` (840px) the adaptive rail is a standard, collapsible rail — what M3
// the window widens is shut, or its focus trap would hold a page that has no scrim. // asks for at expanded and above. A modal left open while the window widens is shut,
this.query = window.matchMedia(WIDE) // or its focus trap would hold a page that has no scrim.
this.onWidth = (event) => { this.watch(from('expanded'), (matches) => {
this.wide = event.matches this.wide = matches
if (event.matches) { if (matches) {
this.$store.rail.hide() this.$store.rail.hide()
} }
} })
this.query.addEventListener('change', this.onWidth)
// From `large` (1200px) it starts expanded rather than collapsed, until someone
// chooses otherwise; below that the expanded class starts it collapsed. Both mirror
// the `rail-collapsed:` variant in resources/css/components/navigation.css.
this.watch(from('large'), (matches) => (this.roomy = matches))
},
watch(query, onChange) {
const listener = (event) => onChange(event.matches)
query.addEventListener('change', listener)
this.queries.push(query)
this.listeners.push(listener)
}, },
destroy() { destroy() {
this.query?.removeEventListener('change', this.onWidth) this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
}, },
/** Whether this rail expands over a scrim rather than in the layout. */ /** Whether this rail expands over a scrim rather than in the layout. */
@@ -126,7 +148,17 @@ document.addEventListener('alpine:init', () => {
return true return true
} }
return (mode === 'collapsible' || (mode === 'adaptive' && this.wide)) && !this.$store.rail.collapsed if (this.$store.rail.collapsed) {
return false
}
if (mode === 'adaptive') {
// A standard rail from `expanded`; with no choice stored it is the window size
// class that decides, and only `large` and above start it expanded.
return this.wide && (this.roomy || !this.$store.rail.auto)
}
return mode === 'collapsible'
}, },
/** The rail's own menu button: open or close the modal, or collapse and expand in place. */ /** The rail's own menu button: open or close the modal, or collapse and expand in place. */
@@ -134,7 +166,9 @@ document.addEventListener('alpine:init', () => {
if (this.modal) { if (this.modal) {
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show() this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
} else { } else {
this.$store.rail.toggle() // `set`, not `toggle`: with nothing stored the store's `collapsed` is only
// `rail.default`, so the button has to flip what is actually drawn.
this.$store.rail.set(this.expanded)
} }
}, },
})) }))
+5 -5
View File
@@ -4,11 +4,11 @@
* Focus or a press on the bar opens the view; Escape, the back arrow, a press outside, focus * Focus or a press on the bar opens the view; Escape, the back arrow, a press outside, focus
* leaving the search, or choosing a result closes it. ArrowDown from the input moves into the * leaving the search, or choosing a result closes it. ArrowDown from the input moves into the
* results and the arrow keys walk them; ArrowUp past the first result returns to the input. On a * results and the arrow keys walk them; ArrowUp past the first result returns to the input. On a
* compact window (below `sm`) the view is full screen and modal: focus stays in it and the page * compact window (below `medium`, 600px) the view is full screen and modal: focus stays in it and
* behind does not scroll. The results themselves are the caller's, rendered by Livewire into the * the page behind does not scroll — M3 docks the view from medium upwards. The results themselves
* view as the query changes. * are the caller's, rendered by Livewire into the view as the query changes.
*/ */
const COMPACT = '(max-width: 39.99rem)' import { upTo } from './breakpoints.js'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
@@ -26,7 +26,7 @@ document.addEventListener('alpine:init', () => {
closedAt: -Infinity, closedAt: -Infinity,
init() { init() {
const query = window.matchMedia(COMPACT) const query = upTo('medium')
this.compact = query.matches this.compact = query.matches
query.addEventListener('change', (event) => (this.compact = event.matches)) query.addEventListener('change', (event) => (this.compact = event.matches))
+32 -14
View File
@@ -11,13 +11,31 @@
…the page… …the page…
</x-app-shell> </x-app-shell>
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom. The navigation is M3's per window size class (docs/reference/m3/foundations.md § Layout and
Everything else is in the modal rail, which slides in when something calls foundations-supplement.md § Breakpoints), and only those four numbers:
`$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`. - **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`,
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal. pinned to the bottom. Everything else is in the modal rail, which slides in when something
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is calls `$store.rail.show()` put a menu button in the app bar for it, hidden from `medium`:
remembered and applied before the first paint (`$store.rail`, <x-theme-script>). `<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
- **Medium** (600839): the collapsed rail in the layout, 96px, and no bar; its menu button
opens it expanded over a scrim, since 256px beside the page would leave the page too little.
- **Expanded** (8401199): a standard rail in the layout, nothing covered collapsed until
the menu button expands it in place.
- **Large and extra-large** (from 1200): the same standard rail, expanded to begin with
(`rail.default`), which is what M3 prefers once there is room.
From `expanded` the choice the menu button makes is remembered and applied before the first
paint (`$store.rail`, <x-theme-script>), so the rail never paints one width and snaps to the
other. A visitor who has chosen keeps that choice in both bands; one who never has gets the
class's own default.
`--material-margin` is M3's window margin 16px on a compact window, 24px from `medium` and
the content region is padded with it, so a page inside the shell writes no gutters of its own.
Something meant to reach the window's edges opts out with `-mx-(--material-margin)`.
Two panes side by side are M3's from `expanded`: `<x-drawer pane>` is the second one, 360dp
wide, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally `destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the page's; during a Livewire update request, the page the `active` (by default: the URL is the page's; during a Livewire update request, the page the
@@ -37,15 +55,15 @@
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell; the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`: on a compact window it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on `--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar). top of the bar).
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays `max-expanded:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll `clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
`lg` only, so a wide window never clips what overhangs on purpose. `expanded` only, so a wide window never clips what overhangs on purpose.
Nothing application-specific belongs in here: an app's destinations and chrome come in through Nothing application-specific belongs in here: an app's destinations and chrome come in through
the props and slots. --}} the props and slots. --}}
@@ -84,8 +102,8 @@
<div <div
data-app-shell data-app-shell
@class([ @class([
'min-h-dvh bg-surface text-on-surface sm:flex', 'min-h-dvh bg-surface text-on-surface [--material-margin:1rem] medium:flex medium:[--material-margin:1.5rem]',
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(), 'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
]) ])
> >
<a <a
@@ -133,13 +151,13 @@
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }} {{ $top ?? '' }}
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)"> <main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
{{ $slot }} {{ $slot }}
</main> </main>
</div> </div>
@if ($barItems->isNotEmpty()) @if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden"> <div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-livewire-material::navigation-bar :label="$label"> <x-livewire-material::navigation-bar :label="$label">
@foreach ($barItems as $item) @foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" /> <x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
+8 -6
View File
@@ -24,9 +24,11 @@
Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external` Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external`
or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows
the loading indicator while the button's own `wire:click` runs (or the action named by a the loading indicator while the button's own `wire:click` runs (or the action named by a
string); `responsive` hides the label below `lg`; `tooltip`, `tooltip-left`, `tooltip-right` string); `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 and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
pinned in the thumb zone below `sm`, a filled button above it one element either way. pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from
there one element either way.
Never pass `hidden`, a display or a position class: the button is `inline-flex` and Never pass `hidden`, a display or a position class: the button is `inline-flex` and
`relative`, and whichever Tailwind emits last wins. Wrap it instead. --}} `relative`, and whichever Tailwind emits last wins. Wrap it instead. --}}
@@ -173,8 +175,8 @@
'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none', 'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none',
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained, 'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained, 'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
// The FAB, below sm only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares. // The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
'max-sm:fixed max-sm:end-4 max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30 max-sm:h-14 max-sm:gap-2 max-sm:px-4 max-sm:rounded-corner-lg max-sm:type-title-md max-sm:bg-primary-container max-sm:text-on-primary-container max-sm:shadow-elevation-3' => $fab, '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,
]; ];
$tag = $isLink ? 'a' : 'button'; $tag = $isLink ? 'a' : 'button';
@@ -210,12 +212,12 @@
@if ($icon) @if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif> <span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" /> <x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
</span> </span>
@endif @endif
@unless ($iconOnly) @unless ($iconOnly)
<span @class(['max-lg:hidden' => $responsive])>{{ $label ?? $slot }}</span> <span @class(['max-expanded:hidden' => $responsive])>{{ $label ?? $slot }}</span>
@endunless @endunless
@if ($iconRight) @if ($iconRight)
+22 -18
View File
@@ -1,5 +1,8 @@
{{-- An M3 side sheet: detail or controls that slide in from the edge over a scrim; on a phone it is {{-- An M3 side sheet: detail or controls that slide in from the edge over a scrim; on a phone it is
the whole screen. With `pane`, from `xl` it is a list-detail pane beside the list instead. the whole screen. With `pane`, from `expanded` (840px) it is a second pane beside the list
instead M3 shows two panes for list-detail from expanded upwards, and puts a supporting pane
on the side of the focus pane at expanded, 360dp wide
(docs/reference/m3/foundations-supplement.md § Canonical layout examples).
The open state is the Livewire property in `wire:model` (entangled live, so a detail held in the The open state is the Livewire property in `wire:model` (entangled live, so a detail held in the
URL follows a close) a flag or an id and closing writes back `false` or `null`. Without URL follows a close) a flag or an id and closing writes back `false` or `null`. Without
@@ -9,13 +12,14 @@
As a sheet it is modal: the page inert and still (`x-trap.inert.noscroll`), and it enters on As a sheet it is modal: the page inert and still (`x-trap.inert.noscroll`), and it enters on
emphasized decelerate rather than a spring a sheet anchored to the edge that overshot would emphasized decelerate rather than a spring a sheet anchored to the edge that overshot would
open a gap. M3's modal side sheet: surface-container-low, a large corner on its inner edge, open a gap. M3's modal side sheet: surface-container-low, a large corner on its inner edge,
elevation 1; `side` `end` (the default) or `start`; `width` from `sm` (a caller's `w-*` would elevation 1; `side` `end` (the default) or `start`; `width` from `medium` (a caller's `w-*`
race the sheet's own). would race the sheet's own — a compact window gets the full-bleed sheet).
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in As a pane (`pane`, from `expanded`) nothing is covered: the page renders the drawer after its
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the
top of the viewport, the
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). Escape closes closed it takes no room. `pane-width` sizes the pane (`22.5rem`, M3's 360dp fixed pane). Escape closes
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape` the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
closes the pane on Escape too, for a pane that is a transient detail. The body is closes the pane on Escape too, for a pane that is a transient detail. The body is
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`), a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
@@ -35,7 +39,7 @@
'withoutBackdropClose' => false, 'withoutBackdropClose' => false,
'width' => '25rem', 'width' => '25rem',
'pane' => false, 'pane' => false,
'paneWidth' => null, 'paneWidth' => '22.5rem',
'paneCloseOnEscape' => false, 'paneCloseOnEscape' => false,
]) ])
@@ -52,7 +56,7 @@
close() { this.open = typeof this.open === 'boolean' ? false : null; }, close() { this.open = typeof this.open === 'boolean' ? false : null; },
@if ($pane) @if ($pane)
init() { init() {
const query = window.matchMedia('(min-width: 80rem)'); const query = window.matchMedia('(width >= 52.5rem)');
this.wide = query.matches; this.wide = query.matches;
query.addEventListener('change', (event) => this.wide = event.matches); query.addEventListener('change', (event) => this.wide = event.matches);
}, },
@@ -61,8 +65,8 @@
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif @if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
data-sheet="{{ $id }}" data-sheet="{{ $id }}"
@if ($pane) @if ($pane)
x-bind:class="! open && 'xl:hidden'" x-bind:class="! open && 'expanded:hidden'"
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start" class="expanded:sticky expanded:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] expanded:shrink-0 expanded:self-start"
data-pane data-pane
@endif @endif
> >
@@ -71,7 +75,7 @@
x-show="open" x-show="open"
x-transition.opacity.duration.200ms x-transition.opacity.duration.200ms
@if (! $withoutBackdropClose) x-on:click="close()" @endif @if (! $withoutBackdropClose) x-on:click="close()" @endif
@class(['fixed inset-0 z-40 bg-scrim/32', 'xl:hidden' => $pane]) @class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $pane])
aria-hidden="true" aria-hidden="true"
></div> ></div>
@@ -80,24 +84,24 @@
x-show="open" x-show="open"
x-trap.inert.noscroll="open && ! wide" x-trap.inert.noscroll="open && ! wide"
x-transition:enter="transition-[translate,opacity] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate" x-transition:enter="transition-[translate,opacity] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate"
x-transition:enter-start="{{ $pane ? ($start ? 'max-xl:-translate-x-full xl:opacity-0' : 'max-xl:translate-x-full xl:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}" x-transition:enter-start="{{ $pane ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
x-transition:enter-end="translate-x-0 opacity-100" x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transition-[translate,opacity] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate" x-transition:leave="transition-[translate,opacity] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
x-transition:leave-start="translate-x-0 opacity-100" x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="{{ $pane ? ($start ? 'max-xl:-translate-x-full xl:opacity-0' : 'max-xl:translate-x-full xl:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}" x-transition:leave-end="{{ $pane ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
id="{{ $id }}" id="{{ $id }}"
x-bind:role="wide ? 'region' : 'dialog'" x-bind:role="wide ? 'region' : 'dialog'"
x-bind:aria-modal="wide ? null : 'true'" x-bind:aria-modal="wide ? null : 'true'"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}" style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([ {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1', 'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'end-0 sm:rounded-s-corner-lg' => ! $start, 'end-0 medium:rounded-s-corner-lg' => ! $start,
'start-0 sm:rounded-e-corner-lg' => $start, 'start-0 medium:rounded-e-corner-lg' => $start,
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]', 'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]',
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane, 'expanded:relative expanded:top-0 expanded:z-auto expanded:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] expanded:w-(--pane-width) expanded:max-w-none expanded:rounded-corner-lg expanded:bg-surface-container expanded:shadow-none' => $pane,
$attributes->get('class'), $attributes->get('class'),
]) }} ]) }}
> >
+10 -9
View File
@@ -19,7 +19,8 @@
high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that
holds a form take the whole screen below `sm`, with a close button and the title in a top bar holds a form take the whole screen on a compact window (below `medium`, 600px M3 uses
full-screen dialogs "only in compact breakpoints"), with a close button and the title in a top bar
clear of the notch. `persistent` ignores Escape and the scrim, for a dialog that must be clear of the notch. `persistent` ignores Escape and the scrim, for a dialog that must be
answered. It opens on the fast spatial spring and closes at once, as M3's do. --}} answered. It opens on the fast spatial spring and closes at once, as M3's do. --}}
@@ -58,17 +59,17 @@
'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface', 'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface',
'backdrop:bg-scrim/32', 'backdrop:bg-scrim/32',
'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', 'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'max-sm:m-0 max-sm:h-dvh max-sm:max-h-none max-sm:w-full max-sm:max-w-none max-sm:min-w-0' => $fullscreen, 'max-medium:m-0 max-medium:h-dvh max-medium:max-h-none max-medium:w-full max-medium:max-w-none max-medium:min-w-0' => $fullscreen,
$attributes->get('class'), $attributes->get('class'),
]) ])
> >
<div @class([ <div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3', 'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen, 'max-medium:h-full max-medium:rounded-none max-medium:p-0 max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass, $boxClass,
])> ])>
@if ($fullscreen) @if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden"> <div class="flex h-16 shrink-0 items-center gap-1 px-1 medium:hidden">
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" /> <x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title)) @if (filled($title))
@@ -77,20 +78,20 @@
</div> </div>
@endif @endif
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])> <div @class(['min-h-0 flex-1', 'max-medium:overflow-y-auto max-medium:px-6 max-medium:pb-6' => $fullscreen])>
@if (filled($title) || filled($subtitle) || $icon) @if (filled($title) || filled($subtitle) || $icon)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])> <div @class(['mb-4', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon) @if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" /> <x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif @endif
@if (filled($title)) @if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2> <h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif @endif
@if (filled($subtitle)) @if (filled($subtitle))
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p> <p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif @endif
@if ($separator) @if ($separator)
@@ -105,7 +106,7 @@
@isset($actions) @isset($actions)
<div @class([ <div @class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6', 'flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6',
'max-sm:border-t max-sm:border-outline-variant max-sm:px-6 max-sm:py-4' => $fullscreen, 'max-medium:border-t max-medium:border-outline-variant max-medium:px-6 max-medium:py-4' => $fullscreen,
])> ])>
{{ $actions }} {{ $actions }}
</div> </div>
@@ -1,7 +1,7 @@
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a {{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
compact window. compact window.
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden"> <div class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-navigation-bar> <x-navigation-bar>
<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" /> <x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-bar-item label="Starred" icon="star" link="/starred" /> <x-navigation-bar-item label="Starred" icon="star" link="/starred" />
@@ -1,4 +1,4 @@
{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider {{-- M3 Expressive's navigation rail: destinations down the start edge of a `medium` or wider
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill). window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
<div class="flex min-h-dvh"> <div class="flex min-h-dvh">
@@ -29,8 +29,12 @@
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere) - `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail). leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` — what `<x-app-shell>` uses: below `sm` nothing until `$store.rail.show()` slides - `adaptive` — what `<x-app-shell>` uses, one rail per M3 window size class: on a compact
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible. window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
at `medium` (600839) collapsed in the layout, opening as a modal; at `expanded` (8401199)
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
that choice in both standard bands.
Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
+3 -2
View File
@@ -3,7 +3,8 @@
A `<fieldset>` whose legend is the question, so a screen reader reads the group's name with each A `<fieldset>` whose legend is the question, so a screen reader reads the group's name with each
option, and native radios sharing a name, so the arrow keys move between them. `options` as option, and native radios sharing a name, so the arrow keys move between them. `options` as
`['id' => …, 'name' => …, 'hint' => …]` (`'disabled' => true` greys one out), `option-value`, `['id' => …, 'name' => …, 'hint' => …]` (`'disabled' => true` greys one out), `option-value`,
`option-label` and `option-hint` to read other keys, `inline` to lay them in a row from `sm`, and `option-label` and `option-hint` to read other keys, `inline` to lay them in a row from `medium`
(600px, M3's compact/medium boundary — compact always stacks), and
`value` for the option chosen when the page loads (with `wire:model`, Livewire sets it). `value` for the option chosen when the page loads (with `wire:model`, Livewire sets it).
Errors are read from the bag under the `wire:model` name Errors are read from the bag under the `wire:model` name
(resources/css/components/selection.css). --}} (resources/css/components/selection.css). --}}
@@ -36,7 +37,7 @@
<legend class="mb-3 type-label-lg text-on-surface-variant">{{ $label }}</legend> <legend class="mb-3 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif @endif
<div @class(['grid gap-4', 'sm:flex sm:flex-wrap sm:gap-x-6' => $inline])> <div @class(['grid gap-4', 'medium:flex medium:flex-wrap medium:gap-x-6' => $inline])>
@foreach ($options as $option) @foreach ($options as $option)
@php($optionHintText = data_get($option, $optionHint)) @php($optionHintText = data_get($option, $optionHint))
@@ -34,7 +34,7 @@
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend> <legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif @endif
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-2 medium:grid-cols-4">
@foreach ($profiles as $profile => $scheme) @foreach ($profiles as $profile => $scheme)
<label <label
data-scheme-option="{{ $profile }}" data-scheme-option="{{ $profile }}"
@@ -2,8 +2,10 @@
single destination in the app's own navigation. single destination in the app's own navigation.
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge` `items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
(an item is current when `active` is true, or when its `url` is the page's). From `sm` they (an item is current when `active` is true, or when its `url` is the page's). From `medium`
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them (600px) they
are M3's secondary tabs as links, the current one underlined; on a compact window, where a row
of them
never fits, a button naming the current section opens a menu of all of them, the current one never fits, a button naming the current section opens a menu of all of them, the current one
marked `aria-current="page"` and each with its badge. The same list is marked `aria-current="page"` and each with its badge. The same list is
rendered for both, and CSS shows one. rendered for both, and CSS shows one.
@@ -11,7 +13,8 @@
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
request is Livewire's update endpoint, and comparing with it left no section lit. request is Livewire's update endpoint, and comparing with it left no section lit.
A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six A row too long for its column wraps onto a grid rather than scrolling: below `large` (1200px)
five or six
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
`no-wire-navigate`. --}} `no-wire-navigate`. --}}
@@ -29,15 +32,15 @@
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null); $current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
$layout = match (true) { $layout = match (true) {
count($items) < 5 => 'sm:flex', count($items) < 5 => 'medium:flex',
count($items) < 7 => 'sm:grid sm:grid-cols-3 xl:flex', count($items) < 7 => 'medium:grid medium:grid-cols-3 large:flex',
default => 'sm:grid sm:grid-cols-4 xl:flex', default => 'medium:grid medium:grid-cols-4 large:flex',
}; };
@endphp @endphp
<div {{ $attributes->class(['min-w-0']) }} data-section-nav> <div {{ $attributes->class(['min-w-0']) }} data-section-nav>
@if ($current) @if ($current)
<div class="sm:hidden" data-section-picker> <div class="medium:hidden" data-section-picker>
<x-livewire-material::menu :$label position="bottom-start"> <x-livewire-material::menu :$label position="bottom-start">
<x-slot:trigger> <x-slot:trigger>
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface"> <button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
@@ -56,7 +59,7 @@
</div> </div>
@endif @endif
<nav aria-label="{{ $label }}" class="max-sm:hidden"> <nav aria-label="{{ $label }}" class="max-medium:hidden">
<ul data-tabs-bar data-variant="secondary" class="{{ $layout }}"> <ul data-tabs-bar data-variant="secondary" class="{{ $layout }}">
@foreach ($items as $item) @foreach ($items as $item)
@php($on = $isCurrent($item)) @php($on = $isCurrent($item))
@@ -19,7 +19,10 @@
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible (`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js) rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it. changes it. <html data-rail-auto> rides with it and says nothing was stored — the value is only
`rail.default`, not a choice — so `<x-app-shell>`'s adaptive rail can start collapsed in the
expanded class (8401199) and expanded from large, as M3 asks, while still obeying a visitor
who has chosen. `$store.rail` drops it the first time they do.
With `theme.meta` on, the browser's own chrome follows too: the `content` of every With `theme.meta` on, the browser's own chrome follows too: the `content` of every
<meta name="theme-color"> without a `media` attribute — one is added to <head> when there is <meta name="theme-color"> without a `media` attribute — one is added to <head> when there is
@@ -72,12 +75,14 @@
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; }; var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var choice = settings.default; var choice = settings.default;
var rail = settings.rail.default; var rail = settings.rail.default;
var railChosen = false;
try { try {
var storedRail = localStorage.getItem(settings.rail.key); var storedRail = localStorage.getItem(settings.rail.key);
if (storedRail === 'collapsed' || storedRail === 'expanded') { if (storedRail === 'collapsed' || storedRail === 'expanded') {
rail = storedRail; rail = storedRail;
railChosen = true;
} }
var stored = localStorage.getItem(settings.key); var stored = localStorage.getItem(settings.key);
@@ -114,6 +119,12 @@
root.setAttribute('data-theme-choice', choice); root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-rail-key', settings.rail.key); root.setAttribute('data-rail-key', settings.rail.key);
root.setAttribute('data-rail', rail); root.setAttribute('data-rail', rail);
if (railChosen) {
root.removeAttribute('data-rail-auto');
} else {
root.setAttribute('data-rail-auto', '');
}
apply(); apply();
media.addEventListener('change', apply); media.addEventListener('change', apply);
@@ -152,7 +163,7 @@
@endif @endif
document.addEventListener('livewire:navigating', function (event) { document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) { var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-auto', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)]; return [name, root.getAttribute(name)];
}); });
+4 -3
View File
@@ -25,7 +25,8 @@
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3, body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
48px for one line. A type draws its state icon in the inverse state colour. `position`: 48px for one line. 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 `bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
`--material-bottom-bar`. --}} `--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. --}}
@props(['position' => 'bottom']) @props(['position' => 'bottom'])
@@ -35,7 +36,7 @@
{{ $attributes->class([ {{ $attributes->class([
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]', 'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
'justify-center' => $position !== 'bottom-start', 'justify-center' => $position !== 'bottom-start',
'justify-start sm:start-6' => $position === 'bottom-start', 'justify-start medium:start-6' => $position === 'bottom-start',
]) }} ]) }}
> >
<template x-if="current"> <template x-if="current">
@@ -48,7 +49,7 @@
x-on:mouseleave="resume()" x-on:mouseleave="resume()"
x-on:focusin="pause()" x-on:focusin="pause()"
x-on:focusout="resume()" x-on:focusout="resume()"
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 sm:w-auto sm:min-w-86" class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86"
> >
<template x-if="icon(current.type)"> <template x-if="icon(current.type)">
<span class="flex shrink-0" x-bind:class="{ <span class="flex shrink-0" x-bind:class="{
@@ -47,7 +47,7 @@
</head> </head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased"> <body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
<main data-error-page class="mx-auto flex min-h-dvh max-w-xl flex-col items-center justify-center px-6 py-12 text-center"> <main data-error-page class="mx-auto flex min-h-dvh max-w-xl flex-col items-center justify-center px-6 py-12 text-center">
<div data-error-art class="relative grid size-48 shrink-0 place-items-center sm:size-60"> <div data-error-art class="relative grid size-48 shrink-0 place-items-center medium:size-60">
<div data-error-shape class="absolute inset-0"> <div data-error-shape class="absolute inset-0">
<x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" /> <x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
</div> </div>
@@ -55,7 +55,7 @@
<p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p> <p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p>
</div> </div>
<h1 data-error-headline class="mt-10 type-headline-md text-balance sm:type-headline-lg"> <h1 data-error-headline class="mt-10 type-headline-md text-balance medium:type-headline-lg">
@hasSection('headline') @hasSection('headline')
@yield('headline') @yield('headline')
@else @else
@@ -12,8 +12,8 @@
@if ($paginator->hasPages()) @if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4"> <nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
<p class="type-body-sm text-on-surface-variant"> <p class="type-body-sm text-on-surface-variant">
<span class="sm:hidden">{{ __('Page :page of :pages', ['page' => $paginator->currentPage(), 'pages' => $paginator->lastPage()]) }}</span> <span class="medium:hidden">{{ __('Page :page of :pages', ['page' => $paginator->currentPage(), 'pages' => $paginator->lastPage()]) }}</span>
<span class="max-sm:hidden">{{ __(':first:last of :total', ['first' => $paginator->firstItem(), 'last' => $paginator->lastItem(), 'total' => $paginator->total()]) }}</span> <span class="max-medium:hidden">{{ __(':first:last of :total', ['first' => $paginator->firstItem(), 'last' => $paginator->lastItem(), 'total' => $paginator->total()]) }}</span>
</p> </p>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -29,15 +29,15 @@
@foreach ($elements as $element) @foreach ($elements as $element)
@if (is_string($element)) @if (is_string($element))
<span class="{{ $dead }} max-sm:hidden" aria-disabled="true">{{ $element }}</span> <span class="{{ $dead }} max-medium:hidden" aria-disabled="true">{{ $element }}</span>
@endif @endif
@if (is_array($element)) @if (is_array($element))
@foreach ($element as $page => $url) @foreach ($element as $page => $url)
@if ($page == $paginator->currentPage()) @if ($page == $paginator->currentPage())
<span aria-current="page" class="{{ $step }} bg-secondary-container text-on-secondary-container max-sm:hidden">{{ $page }}</span> <span aria-current="page" class="{{ $step }} bg-secondary-container text-on-secondary-container max-medium:hidden">{{ $page }}</span>
@else @else
<a href="{{ $url }}" class="{{ $live }} max-sm:hidden" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a> <a href="{{ $url }}" class="{{ $live }} max-medium:hidden" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
@endif @endif
@endforeach @endforeach
@endif @endif
@@ -28,8 +28,8 @@
@if ($paginator->hasPages()) @if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4"> <nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
<p class="type-body-sm text-on-surface-variant"> <p class="type-body-sm text-on-surface-variant">
<span class="sm:hidden">{{ __('Page :page of :pages', ['page' => $paginator->currentPage(), 'pages' => $paginator->lastPage()]) }}</span> <span class="medium:hidden">{{ __('Page :page of :pages', ['page' => $paginator->currentPage(), 'pages' => $paginator->lastPage()]) }}</span>
<span class="max-sm:hidden">{{ __(':first:last of :total', ['first' => $paginator->firstItem(), 'last' => $paginator->lastItem(), 'total' => $paginator->total()]) }}</span> <span class="max-medium:hidden">{{ __(':first:last of :total', ['first' => $paginator->firstItem(), 'last' => $paginator->lastItem(), 'total' => $paginator->total()]) }}</span>
</p> </p>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -45,12 +45,12 @@
@foreach ($elements as $element) @foreach ($elements as $element)
@if (is_string($element)) @if (is_string($element))
<span class="{{ $dead }} max-sm:hidden" aria-disabled="true">{{ $element }}</span> <span class="{{ $dead }} max-medium:hidden" aria-disabled="true">{{ $element }}</span>
@endif @endif
@if (is_array($element)) @if (is_array($element))
@foreach ($element as $page => $url) @foreach ($element as $page => $url)
<span wire:key="paginator-{{ $paginator->getPageName() }}-page{{ $page }}" class="max-sm:hidden"> <span wire:key="paginator-{{ $paginator->getPageName() }}-page{{ $page }}" class="max-medium:hidden">
@if ($page == $paginator->currentPage()) @if ($page == $paginator->currentPage())
<span aria-current="page" class="{{ $step }} bg-secondary-container text-on-secondary-container">{{ $page }}</span> <span aria-current="page" class="{{ $step }} bg-secondary-container text-on-secondary-container">{{ $page }}</span>
@else @else
+2 -2
View File
@@ -1,7 +1,7 @@
@extends('livewire-material::showcase.layout') @extends('livewire-material::showcase.layout')
@section('content') @section('content')
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6"> <div class="mx-auto w-full max-w-6xl space-y-12 pt-4 pb-16">
<div class="max-w-3xl space-y-3"> <div class="max-w-3xl space-y-3">
<h1 class="type-headline-lg">Livewire Material</h1> <h1 class="type-headline-lg">Livewire Material</h1>
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p> <p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
@@ -15,7 +15,7 @@
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}"> <section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2> <h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-3 medium:grid-cols-2 expanded:grid-cols-3">
@foreach ($entries as $key => $entry) @foreach ($entries as $key => $entry)
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}"> <x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
+3 -3
View File
@@ -50,7 +50,7 @@
<x-slot:top> <x-slot:top>
<x-livewire-material::app-bar variant="search"> <x-livewire-material::app-bar variant="search">
<x-slot:navigation> <x-slot:navigation>
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span> <span class="medium:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
</x-slot:navigation> </x-slot:navigation>
{{-- The search looks through every section, example and component: the index is {{-- The search looks through every section, example and component: the index is
@@ -145,8 +145,8 @@
</x-livewire-material::menu> </x-livewire-material::menu>
@endif @endif
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span> <span class="ms-2 me-3 max-expanded:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span> <span class="expanded:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
</x-slot:actions> </x-slot:actions>
</x-livewire-material::app-bar> </x-livewire-material::app-bar>
</x-slot:top> </x-slot:top>
+1 -1
View File
@@ -11,7 +11,7 @@
@endphp @endphp
@section('content') @section('content')
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6"> <div class="mx-auto w-full max-w-6xl pt-4 pb-16">
<header class="mb-6 space-y-1"> <header class="mb-6 space-y-1">
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p> <p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1> <h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
@@ -77,7 +77,7 @@
</x-button-group> </x-button-group>
BLADE, BLADE,
'A choice as a connected group' => <<<'BLADE' 'A choice as a connected group' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 md:grid-cols-2"> <div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 medium:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[ <x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'], ['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'], ['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
@@ -38,7 +38,7 @@
<x-showcase::example :$title :$code /> <x-showcase::example :$title :$code />
@endforeach @endforeach
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 expanded:grid-cols-2">
@foreach (['light', 'dark'] as $theme) @foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface"> <div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
<h3 class="type-title-md capitalize">{{ $theme }}</h3> <h3 class="type-title-md capitalize">{{ $theme }}</h3>
@@ -47,7 +47,7 @@
<div class="space-y-2"> <div class="space-y-2">
<h4 class="type-label-lg text-on-surface-variant">{{ $group }}</h4> <h4 class="type-label-lg text-on-surface-variant">{{ $group }}</h4>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div class="grid grid-cols-2 gap-2 medium:grid-cols-3">
@foreach ($roles as $role) @foreach ($roles as $role)
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span @class(['size-8 shrink-0 rounded-corner-sm border border-outline-variant', $role])></span> <span @class(['size-8 shrink-0 rounded-corner-sm border border-outline-variant', $role])></span>
@@ -53,7 +53,7 @@
<x-alert description="Settings saved." color="success" class="w-full" /> <x-alert description="Settings saved." color="success" class="w-full" />
BLADE, BLADE,
'Stats' => <<<'BLADE' 'Stats' => <<<'BLADE'
<div class="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="grid w-full gap-4 medium:grid-cols-2 expanded:grid-cols-4">
<x-stat title="Shares" value="1,204" icon="link" description="12 this week" /> <x-stat title="Shares" value="1,204" icon="link" description="12 this week" />
<x-stat title="Files" value="8,317" icon="description" /> <x-stat title="Files" value="8,317" icon="description" />
<x-stat title="Stored" value="3.2 GB" icon="hard_drive" description="of 4 GB" /> <x-stat title="Stored" value="3.2 GB" icon="hard_drive" description="of 4 GB" />
@@ -1,7 +1,7 @@
@php @php
$examples = [ $examples = [
'Cards' => <<<'BLADE' 'Cards' => <<<'BLADE'
<div class="grid w-full gap-4 md:grid-cols-3"> <div class="grid w-full gap-4 expanded:grid-cols-3">
<x-card title="Filled" subtitle="surface-container-highest"> <x-card title="Filled" subtitle="surface-container-highest">
The default card. The default card.
<x-slot:actions><x-button label="Open" /></x-slot:actions> <x-slot:actions><x-button label="Open" /></x-slot:actions>
@@ -27,7 +27,7 @@
</x-card> </x-card>
BLADE, BLADE,
'Lists' => <<<'BLADE' 'Lists' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<x-list dividers label="Files"> <x-list dividers label="Files">
<x-list-item title="holiday-photos.zip" description="248 MB" icon="folder_zip" trailing="3 days" /> <x-list-item title="holiday-photos.zip" description="248 MB" icon="folder_zip" trailing="3 days" />
<x-list-item title="contract.pdf" overline="Password protected" description="1.2 MB · downloaded twice" icon="picture_as_pdf" trailing="1 hour" /> <x-list-item title="contract.pdf" overline="Password protected" description="1.2 MB · downloaded twice" icon="picture_as_pdf" trailing="1 hour" />
@@ -5,7 +5,7 @@
Five shadow levels as <code>shadow-elevation-*</code>, for what floats over content. Five shadow levels as <code>shadow-elevation-*</code>, for what floats over content.
</p> </p>
<div class="grid grid-cols-2 gap-6 rounded-corner-lg bg-surface-container-low p-6 sm:grid-cols-5"> <div class="grid grid-cols-2 gap-6 rounded-corner-lg bg-surface-container-low p-6 medium:grid-cols-5">
@foreach (['shadow-elevation-1', 'shadow-elevation-2', 'shadow-elevation-3', 'shadow-elevation-4', 'shadow-elevation-5'] as $shadow) @foreach (['shadow-elevation-1', 'shadow-elevation-2', 'shadow-elevation-3', 'shadow-elevation-4', 'shadow-elevation-5'] as $shadow)
<div @class(['flex h-24 items-center justify-center rounded-corner-md bg-surface-container-high', $shadow])> <div @class(['flex h-24 items-center justify-center rounded-corner-md bg-surface-container-high', $shadow])>
<code class="type-label-md">{{ $shadow }}</code> <code class="type-label-md">{{ $shadow }}</code>
@@ -1,7 +1,7 @@
@php @php
$examples = [ $examples = [
'Outlined and filled text fields' => <<<'BLADE' 'Outlined and filled text fields' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-input label="Share name" hint="Recipients see this name" /> <x-input label="Share name" hint="Recipients see this name" />
<x-input label="Recipient" icon="mail" type="email" placeholder="name@example.com" clearable value="anna@example.com" /> <x-input label="Recipient" icon="mail" type="email" placeholder="name@example.com" clearable value="anna@example.com" />
@@ -22,7 +22,7 @@
</div> </div>
BLADE, BLADE,
'Errors, required and sizes' => <<<'BLADE' 'Errors, required and sizes' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-input label="Email" required /> <x-input label="Email" required />
<x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']"> <x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']">
@@ -42,7 +42,7 @@
</div> </div>
BLADE, BLADE,
'Textarea and select' => <<<'BLADE' 'Textarea and select' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-textarea label="Message" hint="Grows as you type, up to six lines" rows="2" max-rows="6" /> <x-textarea label="Message" hint="Grows as you type, up to six lines" rows="2" max-rows="6" />
<x-select label="Expires after" :options="[['id' => 1, 'name' => '1 hour'], ['id' => 24, 'name' => '1 day'], ['id' => 168, 'name' => '7 days'], ['id' => 720, 'name' => '30 days', 'disabled' => true]]" /> <x-select label="Expires after" :options="[['id' => 1, 'name' => '1 hour'], ['id' => 24, 'name' => '1 day'], ['id' => 168, 'name' => '7 days'], ['id' => 720, 'name' => '30 days', 'disabled' => true]]" />
@@ -57,7 +57,7 @@
</div> </div>
BLADE, BLADE,
'Checkboxes' => <<<'BLADE' 'Checkboxes' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-3"> <div class="grid w-full gap-6 expanded:grid-cols-3">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-checkbox label="Notify me on download" checked /> <x-checkbox label="Notify me on download" checked />
<x-checkbox label="Select all files" indeterminate /> <x-checkbox label="Select all files" indeterminate />
@@ -75,13 +75,13 @@
</div> </div>
BLADE, BLADE,
'Radio buttons' => <<<'BLADE' 'Radio buttons' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<x-radio label="Who can open the link" name="showcase-audience" value="password" :options="[['id' => 'anyone', 'name' => 'Anyone with the link'], ['id' => 'password', 'name' => 'Anyone with the password', 'hint' => 'Share the password separately'], ['id' => 'team', 'name' => 'My team only', 'disabled' => true]]" /> <x-radio label="Who can open the link" name="showcase-audience" value="password" :options="[['id' => 'anyone', 'name' => 'Anyone with the link'], ['id' => 'password', 'name' => 'Anyone with the password', 'hint' => 'Share the password separately'], ['id' => 'team', 'name' => 'My team only', 'disabled' => true]]" />
<x-radio label="Theme" name="showcase-radio-theme" value="system" inline :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark'], ['id' => 'system', 'name' => 'System']]" hint="Inline from sm" /> <x-radio label="Theme" name="showcase-radio-theme" value="system" inline :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark'], ['id' => 'system', 'name' => 'System']]" hint="Inline from sm" />
</div> </div>
BLADE, BLADE,
'Switches' => <<<'BLADE' 'Switches' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-3"> <div class="grid w-full gap-6 expanded:grid-cols-3">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-toggle label="Email notifications" checked /> <x-toggle label="Email notifications" checked />
<x-toggle label="Weekly summary" /> <x-toggle label="Weekly summary" />
@@ -99,7 +99,7 @@
</div> </div>
BLADE, BLADE,
'Choices' => <<<'BLADE' 'Choices' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-6"> <div class="grid content-start gap-6">
<x-choices label="Days you are free" hint="Filter chips: any number" :value="[1, 3]" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed'], ['id' => 4, 'name' => 'Thu'], ['id' => 5, 'name' => 'Fri'], ['id' => 6, 'name' => 'Sat', 'disabled' => true]]" /> <x-choices label="Days you are free" hint="Filter chips: any number" :value="[1, 3]" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed'], ['id' => 4, 'name' => 'Thu'], ['id' => 5, 'name' => 'Fri'], ['id' => 6, 'name' => 'Sat', 'disabled' => true]]" />
<x-choices label="Expires after" single :value="24" :options="[['id' => 1, 'name' => '1 hour'], ['id' => 24, 'name' => '1 day'], ['id' => 168, 'name' => '7 days']]" /> <x-choices label="Expires after" single :value="24" :options="[['id' => 1, 'name' => '1 hour'], ['id' => 24, 'name' => '1 day'], ['id' => 168, 'name' => '7 days']]" />
@@ -112,7 +112,7 @@
</div> </div>
BLADE, BLADE,
'Search' => <<<'BLADE' 'Search' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div x-data="{ query: '' }"> <div x-data="{ query: '' }">
<x-search x-model="query" placeholder="Search shares"> <x-search x-model="query" placeholder="Search shares">
<x-list> <x-list>
@@ -1,7 +1,7 @@
@php @php
$examples = [ $examples = [
'Docked' => <<<'BLADE' 'Docked' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }"> <div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" /> <x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p> <p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
@@ -14,7 +14,7 @@
</div> </div>
BLADE, BLADE,
'Modal and modal input' => <<<'BLADE' 'Modal and modal input' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }"> <div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" /> <x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(birthday)"></code></p> <p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(birthday)"></code></p>
@@ -27,7 +27,7 @@
</div> </div>
BLADE, BLADE,
'Range' => <<<'BLADE' 'Range' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }"> <div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Trip" range x-model="trip" /> <x-datepicker label="Trip" range x-model="trip" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(trip)"></code></p> <p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(trip)"></code></p>
@@ -40,7 +40,7 @@
</div> </div>
BLADE, BLADE,
'First day of the week and format' => <<<'BLADE' 'First day of the week and format' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ race: '{{ now()->addMonth()->format('Y-m-d') }}', block: { start: null, end: null } }"> <div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ race: '{{ now()->addMonth()->format('Y-m-d') }}', block: { start: null, end: null } }">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Race day" week-start="0" format="yyyy-MM-dd" x-model="race" hint="Weeks start on Sunday; typed year first" /> <x-datepicker label="Race day" week-start="0" format="yyyy-MM-dd" x-model="race" hint="Weeks start on Sunday; typed year first" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(race)"></code></p> <p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(race)"></code></p>
@@ -53,7 +53,7 @@
</div> </div>
BLADE, BLADE,
'Limits, errors and states' => <<<'BLADE' 'Limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" /> <x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
<x-datepicker label="Required" required /> <x-datepicker label="Required" required />
@@ -5,7 +5,7 @@
The corner scale as <code>rounded-corner-*</code>, and M3 Expressive's shapes as <code>&lt;x-shape name="" /&gt;</code>. The corner scale as <code>rounded-corner-*</code>, and M3 Expressive's shapes as <code>&lt;x-shape name="" /&gt;</code>.
</p> </p>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5"> <div class="grid grid-cols-2 gap-4 medium:grid-cols-5">
@foreach (['rounded-corner-none', 'rounded-corner-xs', 'rounded-corner-sm', 'rounded-corner-md', 'rounded-corner-lg', 'rounded-corner-lg-increased', 'rounded-corner-xl', 'rounded-corner-xl-increased', 'rounded-corner-xxl', 'rounded-corner-full'] as $corner) @foreach (['rounded-corner-none', 'rounded-corner-xs', 'rounded-corner-sm', 'rounded-corner-md', 'rounded-corner-lg', 'rounded-corner-lg-increased', 'rounded-corner-xl', 'rounded-corner-xl-increased', 'rounded-corner-xxl', 'rounded-corner-full'] as $corner)
<div class="space-y-2"> <div class="space-y-2">
<div @class(['h-20 bg-primary-container', $corner])></div> <div @class(['h-20 bg-primary-container', $corner])></div>
@@ -14,7 +14,7 @@
@endforeach @endforeach
</div> </div>
<div class="grid grid-cols-3 gap-4 sm:grid-cols-5 lg:grid-cols-7"> <div class="grid grid-cols-3 gap-4 medium:grid-cols-5 expanded:grid-cols-7">
@foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape) @foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape)
<div class="space-y-2 text-center"> <div class="space-y-2 text-center">
<x-livewire-material::shape :name="$shape" class="mx-auto size-20 text-secondary-container" /> <x-livewire-material::shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
@@ -1,7 +1,7 @@
@php @php
$examples = [ $examples = [
'Standard, discrete and disabled' => <<<'BLADE' 'Standard, discrete and disabled' => <<<'BLADE'
<div class="grid w-full gap-8 md:grid-cols-3"> <div class="grid w-full gap-8 expanded:grid-cols-3">
<x-slider label="Volume" value="40" hint="Drag the handle or use the arrow keys" /> <x-slider label="Volume" value="40" hint="Drag the handle or use the arrow keys" />
<x-slider label="Quality" value="6" :max="10" ticks /> <x-slider label="Quality" value="6" :max="10" ticks />
<x-slider label="Locked" value="70" disabled /> <x-slider label="Locked" value="70" disabled />
@@ -17,20 +17,20 @@
</div> </div>
BLADE, BLADE,
'Range and centred' => <<<'BLADE' 'Range and centred' => <<<'BLADE'
<div class="grid w-full gap-8 md:grid-cols-2"> <div class="grid w-full gap-8 medium:grid-cols-2">
<x-slider label="Price" :value="[20, 60]" range ticks :step="10" hint="Handles never cross" /> <x-slider label="Price" :value="[20, 60]" range ticks :step="10" hint="Handles never cross" />
<x-slider label="Balance" value="15" :min="-50" :max="50" centered /> <x-slider label="Balance" value="15" :min="-50" :max="50" centered />
</div> </div>
BLADE, BLADE,
'Colours and value labels' => <<<'BLADE' 'Colours and value labels' => <<<'BLADE'
<div class="grid w-full gap-8 md:grid-cols-3"> <div class="grid w-full gap-8 expanded:grid-cols-3">
<x-slider label="Always labelled" value="55" color="secondary" value-label="always" size="sm" /> <x-slider label="Always labelled" value="55" color="secondary" value-label="always" size="sm" />
<x-slider label="Tertiary, no label" value="35" color="tertiary" value-label="never" size="sm" /> <x-slider label="Tertiary, no label" value="35" color="tertiary" value-label="never" size="sm" />
<x-slider label="Error" :value="[30, 70]" color="error" range size="sm" /> <x-slider label="Error" :value="[30, 70]" color="error" range size="sm" />
</div> </div>
BLADE, BLADE,
'Bound with x-model' => <<<'BLADE' 'Bound with x-model' => <<<'BLADE'
<div x-data="{ volume: 25, price: [100, 300] }" class="grid w-full gap-8 md:grid-cols-2"> <div x-data="{ volume: 25, price: [100, 300] }" class="grid w-full gap-8 medium:grid-cols-2">
<div class="space-y-2"> <div class="space-y-2">
<x-slider label="Bound volume" x-model="volume" /> <x-slider label="Bound volume" x-model="volume" />
<p class="type-body-md text-on-surface-variant">volume: <span data-bound="volume" x-text="volume"></span> <x-button label="Set 80" x-on:click="volume = 80" /></p> <p class="type-body-md text-on-surface-variant">volume: <span data-bound="volume" x-text="volume"></span> <x-button label="Set 80" x-on:click="volume = 80" /></p>
@@ -1,7 +1,7 @@
@php @php
$examples = [ $examples = [
'12 and 24 hours, outlined and filled' => <<<'BLADE' '12 and 24 hours, outlined and filled' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" /> <x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
<x-timepicker label="Pick-up" value="17:15" format="12" icon="local_shipping" clearable /> <x-timepicker label="Pick-up" value="17:15" format="12" icon="local_shipping" clearable />
@@ -16,7 +16,7 @@
</div> </div>
BLADE, BLADE,
'Steps and limits' => <<<'BLADE' 'Steps and limits' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2"> <div class="grid w-full gap-6 medium:grid-cols-2">
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" /> <x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
<x-timepicker label="Night shift starts" value="23:00" format="24" min="22:00" max="06:00" hint="From 22:00, across midnight, to 06:00" /> <x-timepicker label="Night shift starts" value="23:00" format="24" min="22:00" max="06:00" hint="From 22:00, across midnight, to 06:00" />
@@ -27,7 +27,7 @@
<div class="divide-y divide-divider rounded-corner-lg bg-surface-container"> <div class="divide-y divide-divider rounded-corner-lg bg-surface-container">
@foreach ($styles as [$regular, $emphasized]) @foreach ($styles as [$regular, $emphasized])
<div class="grid gap-2 p-4 md:grid-cols-[12rem_1fr_1fr] md:items-baseline"> <div class="grid gap-2 p-4 expanded:grid-cols-[12rem_1fr_1fr] expanded:items-baseline">
<code class="type-label-md text-on-surface-variant">{{ $regular }}</code> <code class="type-label-md text-on-surface-variant">{{ $regular }}</code>
<p @class([$regular, 'truncate'])>Share files, safely</p> <p @class([$regular, 'truncate'])>Share files, safely</p>
<p @class([$emphasized, 'truncate'])>Share files, safely</p> <p @class([$emphasized, 'truncate'])>Share files, safely</p>
+4 -4
View File
@@ -59,14 +59,14 @@
</x-slot:actions> </x-slot:actions>
<x-slot:top> <x-slot:top>
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] sm:px-4"> <header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] medium:px-4">
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span> <span class="medium:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1> <h1 class="min-w-0 flex-1 truncate px-3 type-title-lg medium:px-0">{{ $current['title'] }}</h1>
<x-livewire-material::button icon="search" tooltip="Search" /> <x-livewire-material::button icon="search" tooltip="Search" />
</header> </header>
</x-slot:top> </x-slot:top>
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6"> <div class="mx-auto w-full max-w-5xl space-y-4 pb-6">
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p> <p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
<x-livewire-material::list segmented> <x-livewire-material::list segmented>
+64 -14
View File
@@ -16,9 +16,11 @@ function navigationReady(mixed $page): mixed
function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
{ {
// The window is resized after the page starts loading, and an adaptive rail hears of the new // The window is resized after the page starts loading, and an adaptive rail hears of the new
// width from a media query's change event, which can arrive after the first key press. // width from a media query's change event, which can arrive after the first key press. Both
// of its queries are M3 window size classes: `expanded` (840px) says the rail is standard
// rather than modal, `large` (1200px) that it starts expanded rather than collapsed.
return navigationReady(visit($path)->resize($width, $height)) return navigationReady(visit($path)->resize($width, $height))
->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); return ! rail || rail.dataset.navigationRail !== 'adaptive' || Alpine.\$data(rail).wide === window.matchMedia('(min-width: 64rem)').matches; })()\")"); ->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); if (! rail || rail.dataset.navigationRail !== 'adaptive') return true; const state = Alpine.\$data(rail); return state.wide === window.matchMedia('(width >= 840px)').matches && state.roomy === window.matchMedia('(width >= 1200px)').matches; })()\")");
} }
/** /**
@@ -61,8 +63,8 @@ function railWidth(int $pixels): string
return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}"; return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}";
} }
it('shows the navigation bar on a phone and marks the current destination', function () { it('shows the navigation bar on a compact window and marks the current destination', function () {
shellPage(400, 860) shellPage(599, 860)
->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'") ->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'") ->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript(BOTTOM_BAR.".querySelectorAll('[data-navigation-bar-item]').length === 4") ->assertScript(BOTTOM_BAR.".querySelectorAll('[data-navigation-bar-item]').length === 4")
@@ -72,8 +74,13 @@ it('shows the navigation bar on a phone and marks the current destination', func
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
}); });
it('collapses the rail on a medium window, icon over label', function () { it('collapses the rail across the medium class, icon over label, from its first pixel', function () {
shellPage(800) shellPage(600)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertNoJavaScriptErrors();
shellPage(839)
->assertScript(railWidth(96)) ->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-item]')).flexDirection === 'column'") ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-item]')).flexDirection === 'column'")
@@ -81,10 +88,42 @@ it('collapses the rail on a medium window, icon over label', function () {
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false'); ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false');
}); });
it('expands the rail from lg, and keeps it collapsed across a reload from the first paint', function () { it('starts the rail collapsed across the expanded class and expands it in the layout', function () {
// 840-1199 is M3's expanded class: a standard rail, collapsed until someone says otherwise,
// whose menu button widens it in place — no scrim, no focus trap, the page beside it live.
shellPage(1199)
->assertScript(railWidth(96))
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
->assertScript("localStorage.getItem('material-rail') === null")
->assertNoJavaScriptErrors();
$page = shellPage(840)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false');
$page->click('[data-navigation-rail-menu]')
->assertScript(railWidth(256))
->assertScript('! '.RAIL.".hasAttribute('data-open')")
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-scrim]')).display === 'none'")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.documentElement.getAttribute('data-rail') === 'expanded'")
->assertScript("! document.documentElement.hasAttribute('data-rail-auto')")
->assertScript("localStorage.getItem('material-rail') === 'expanded'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true')
->assertNoJavaScriptErrors();
// The choice outlives the reload, and holds to the top of the class.
navigationReady($page->refresh())->assertScript(railWidth(256));
});
it('expands the rail from large, and keeps it collapsed across a reload from the first paint', function () {
firstPaintProbe(); firstPaintProbe();
$page = shellPage(1280, 900, '/rail-probe') // 1200 is the first pixel of M3's large class, where the rail starts expanded. The first-paint
// probe runs before the resize, so the harness's own window must be in the same class — it is:
// Playwright opens 1280 wide.
$page = shellPage(1200, 900, '/rail-probe')
->assertScript(railWidth(256)) ->assertScript(railWidth(256))
->assertScript("window.eval('window.firstPaint.width') === '256px'") ->assertScript("window.eval('window.firstPaint.width') === '256px'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true'); ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
@@ -100,10 +139,21 @@ it('expands the rail from lg, and keeps it collapsed across a reload from the fi
->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'") ->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'")
->assertScript("window.eval('window.firstPaint.width') === '96px'") ->assertScript("window.eval('window.firstPaint.width') === '96px'")
->assertScript(railWidth(96)); ->assertScript(railWidth(96));
// Extra-large is the same band, and the collapse just chosen still holds there.
shellPage(1600, 900)->assertScript(railWidth(96));
}); });
it('opens the modal rail from its menu button below lg, holding focus until Escape or the scrim', function () { it('expands the rail by default at extra-large', function () {
$page = shellPage(800); shellPage(1600, 900)
->assertScript(railWidth(256))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true')
->assertNoJavaScriptErrors();
});
it('opens the modal rail from its menu button below expanded, holding focus until Escape or the scrim', function () {
$page = shellPage(839);
// From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it. // From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it.
$page->script("document.querySelector('[data-navigation-rail-menu]').focus()"); $page->script("document.querySelector('[data-navigation-rail-menu]').focus()");
@@ -129,8 +179,8 @@ it('opens the modal rail from its menu button below lg, holding focus until Esca
->assertScript("localStorage.getItem('material-rail') === null"); ->assertScript("localStorage.getItem('material-rail') === null");
}); });
it('slides the modal rail in on a phone from the app bar\'s menu button', function () { it('slides the modal rail in on a compact window from the app bar\'s menu button', function () {
$page = shellPage(400, 860); $page = shellPage(599, 860);
$page->click('@shell-menu') $page->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-open')") ->assertScript(RAIL.".hasAttribute('data-open')")
@@ -144,7 +194,7 @@ it('slides the modal rail in on a phone from the app bar\'s menu button', functi
}); });
it('keeps the rail and the theme through wire:navigate and moves aria-current', function () { it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
$page = shellPage(1280); $page = shellPage(1200);
$page->click('[data-navigation-rail-menu]') $page->click('[data-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'"); ->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'");
@@ -193,7 +243,7 @@ it('lifts the snackbar and the page above something docked on the phone\'s bar',
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))"; $snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))"; $contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
$page = shellPage(400, 860); $page = shellPage(599, 860);
$snackbar = (int) $page->script($snackbarBottom); $snackbar = (int) $page->script($snackbarBottom);
$content = (int) $page->script($contentPadding); $content = (int) $page->script($contentPadding);
+85
View File
@@ -0,0 +1,85 @@
<?php
use Illuminate\Support\Facades\File;
/**
* Breakpoints are M3's window size classes and only those: compact below `medium`, then medium 600,
* expanded 840, large 1200 and extra-large 1600 (docs/reference/m3/foundations.md § Layout,
* foundations-supplement.md § Breakpoints). Tailwind's own screens are cleared in
* resources/css/tokens/theme.css `--breakpoint-*: initial` so an `sm:` left behind compiles to
* nothing at all and the rule it carried silently disappears. These two scans are the guard: one
* for the utilities, one for the media queries a stylesheet or a script writes by hand.
*
* @return list<SplFileInfo>
*/
function breakpointSources(): array
{
return collect(['views', 'css', 'js'])
->flatMap(fn (string $directory): array => File::allFiles(__DIR__.'/../../resources/'.$directory))
->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'css', 'js'], true))
->values()
->all();
}
/**
* Every line of a file that matches, as `path:line: the line`.
*
* @return list<string>
*/
function breakpointOffenders(SplFileInfo $file, string $pattern): array
{
$lines = preg_split('/\R/', $file->getContents());
return collect($lines)
->filter(fn (string $line): bool => preg_match($pattern, $line) === 1)
->map(fn (string $line, int $index): string => $file->getRelativePathname().':'.($index + 1).': '.trim($line))
->values()
->all();
}
it('writes no Tailwind breakpoint prefix in any view, stylesheet or script', function () {
// A prefix, not a name: `--radius-corner-sm:` and slider.js's `sm: { corner: 8 }` are excluded
// by the character before and the one after, and `@md:` is a container query, a different
// thing — a component sized by the room it has, not by the window.
$pattern = '/(?<![\w@-])(?:max-)?(?:sm|md|lg|xl|2xl):[a-z\[(-]/';
$offenders = collect(breakpointSources())
->flatMap(fn (SplFileInfo $file): array => breakpointOffenders($file, $pattern))
->values();
expect($offenders->all())->toBe([]);
});
it('writes no media query at a Tailwind screen width', function () {
// 40rem/640px, 48rem/768px, 64rem/1024px, 80rem/1280px and the 39.99rem that stood for "below
// 640" — none of them is an M3 boundary. The M3 ones are 37.5, 52.5, 75 and 100rem, and a
// script asks resources/js/breakpoints.js for them instead of writing its own string.
$widths = '/(?<![\d.])(?:40|48|64|80|39\.99)rem|(?<![\d.])(?:640|768|1024|1280)px/';
$offenders = collect(breakpointSources())
->flatMap(function (SplFileInfo $file) use ($widths): array {
// Only the condition of a query, so `max-width: min(40rem, 70dvh)` — a real width, not
// a breakpoint — and a 640px cap on a bottom sheet are left alone.
preg_match_all('/@(?:media|container)\b[^{]*|matchMedia\(\s*[\'"`][^\'"`]*/', $file->getContents(), $matches);
return collect($matches[0])
->filter(fn (string $condition): bool => preg_match($widths, $condition) === 1)
->map(fn (string $condition): string => $file->getRelativePathname().': '.trim($condition))
->values()
->all();
})
->values();
expect($offenders->all())->toBe([]);
});
it('scans the showcase example heredocs too, where the package teaches its own markup', function () {
$sections = collect(breakpointSources())
->filter(fn (SplFileInfo $file): bool => str_contains($file->getRelativePathname(), 'showcase'.DIRECTORY_SEPARATOR.'sections'))
->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), "<<<'BLADE'"));
// The scan reads whole files, so nothing above can be hiding inside a heredoc; this only holds
// the premise up, in case the examples ever move out of them.
expect($sections)->not->toBeEmpty()
->and($sections->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), 'medium:'))->isNotEmpty())->toBeTrue();
});
+2 -2
View File
@@ -22,7 +22,7 @@ it('draws the adaptive rail, the bar, the content region and the snackbar host',
->toContain('data-navigation-rail="adaptive"') ->toContain('data-navigation-rail="adaptive"')
->toContain('data-navigation-bar') ->toContain('data-navigation-bar')
->toContain('<main id="content" tabindex="-1" wire:transition.navigate') ->toContain('<main id="content" tabindex="-1" wire:transition.navigate')
->toContain('max-lg:overflow-x-clip') ->toContain('max-expanded:overflow-x-clip')
->toContain('The page') ->toContain('The page')
->toContain('x-data="materialSnackbar"') ->toContain('x-data="materialSnackbar"')
->toMatch('/<a\s+href="#content"\s+data-skip-link/') ->toMatch('/<a\s+href="#content"\s+data-skip-link/')
@@ -91,7 +91,7 @@ it('keeps the destination at the page\'s URL current while a Livewire component
it('lifts the snackbar above the bar only when there is a bar', function () { it('lifts the snackbar above the bar only when there is a bar', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()])) expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]') ->toContain('max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) ->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
->not->toContain('data-app-shell-bar') ->not->toContain('data-app-shell-bar')
->not->toContain('--material-bottom-bar:'); ->not->toContain('--material-bottom-bar:');
+4 -4
View File
@@ -129,9 +129,9 @@ it('shows the loading indicator while its own action runs', function () {
->assertSee('wire:target="upload"', false); ->assertSee('wire:target="upload"', false);
}); });
it('hides a responsive label below lg', function () { it('hides a responsive label below expanded', function () {
$this->blade('<x-button label="New share" icon="add" responsive />') $this->blade('<x-button label="New share" icon="add" responsive />')
->assertSee('<span class="max-lg:hidden">New share</span>', false); ->assertSee('<span class="max-expanded:hidden">New share</span>', false);
}); });
it('anchors its tooltip to itself', function () { it('anchors its tooltip to itself', function () {
@@ -145,11 +145,11 @@ it('anchors its tooltip to itself', function () {
->not->toContain('aria-label="Saves the draft"'); ->not->toContain('aria-label="Saves the draft"');
}); });
it('is a FAB below sm and a filled button above it, in one element', function () { it('is a FAB on a compact window and a filled button from medium, in one element', function () {
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />'); $html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
expect(substr_count($html, '<button'))->toBe(1) expect(substr_count($html, '<button'))->toBe(1)
->and($html)->toContain('max-sm:fixed')->toContain('max-sm:bg-primary-container')->toContain('bg-primary text-on-primary'); ->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary');
}); });
it('submits a form when asked', function () { it('submits a form when asked', function () {
+1 -1
View File
@@ -33,7 +33,7 @@ it('draws Laravel\'s paginators in M3', function () {
->toContain('data-pagination') ->toContain('data-pagination')
->toContain('Page 3 of 10') ->toContain('Page 3 of 10')
->toContain('2130 of 95') ->toContain('2130 of 95')
->toContain('<span aria-current="page" class="grid size-10 place-items-center rounded-corner-full type-label-lg tabular-nums bg-secondary-container text-on-secondary-container max-sm:hidden">3</span>') ->toContain('<span aria-current="page" class="grid size-10 place-items-center rounded-corner-full type-label-lg tabular-nums bg-secondary-container text-on-secondary-container max-medium:hidden">3</span>')
->toContain('href="/shares?page=2" rel="prev"') ->toContain('href="/shares?page=2" rel="prev"')
->toContain('aria-label="Go to page 4"') ->toContain('aria-label="Go to page 4"')
->not->toContain('text-gray'); ->not->toContain('text-gray');
+9 -9
View File
@@ -40,7 +40,7 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
->toContain('x-data="{ close() { this.open = false } }"') ->toContain('x-data="{ close() { this.open = false } }"')
->toContain('x-on:cancel.prevent=""') ->toContain('x-on:cancel.prevent=""')
->not->toContain('x-on:click.self') ->not->toContain('x-on:click.self')
->toContain('max-sm:h-dvh') ->toContain('max-medium:h-dvh')
->toContain('aria-label="Close"'); ->toContain('aria-label="Close"');
}); });
@@ -48,10 +48,10 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>'); $html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
expect($html) expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/') ->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>') ->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code</p>')
->not->toContain('<div class="mb-4 max-sm:hidden">') ->not->toContain('<div class="mb-4 max-medium:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-sm:hidden">') ->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-medium:hidden">')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>'); ->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
}); });
@@ -64,18 +64,18 @@ it('leaves a pane open on Escape unless it is asked to close then too', function
->not->toContain('keydown.window.escape'); ->not->toContain('keydown.window.escape');
}); });
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () { it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>')) expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"') ->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('end-0 sm:rounded-s-corner-lg') ->toContain('end-0 medium:rounded-s-corner-lg')
->toContain('bg-surface-container-low') ->toContain('bg-surface-container-low')
->toContain('role="dialog"') ->toContain('role="dialog"')
->toContain('aria-label="Close"') ->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 sm:rounded-e-corner-lg') ->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 medium:rounded-e-corner-lg')
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>')) ->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
->toContain('data-pane') ->toContain('data-pane')
->toContain('--pane-width: 28rem') ->toContain('--pane-width: 28rem')
->toContain("matchMedia('(min-width: 80rem)')"); ->toContain("matchMedia('(width >= 52.5rem)')");
}); });
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () { it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
+1 -1
View File
@@ -59,7 +59,7 @@ it('names unbound radios after their group and checks the given value', function
$html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />'); $html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />');
expect($html) expect($html)
->toContain('sm:flex') ->toContain('medium:flex')
->and(substr_count($html, 'name="theme"'))->toBe(2) ->and(substr_count($html, 'name="theme"'))->toBe(2)
->and($html)->toMatch('/value="dark"\s+checked/') ->and($html)->toMatch('/value="dark"\s+checked/')
->not->toMatch('/value="light"\s+checked/'); ->not->toMatch('/value="light"\s+checked/');
+2 -2
View File
@@ -64,7 +64,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toContain('aria-label="Settings"') ->toContain('aria-label="Settings"')
->toContain('data-section-picker') ->toContain('data-section-picker')
->toContain('data-variant="secondary"') ->toContain('data-variant="secondary"')
->toContain('sm:flex') ->toContain('medium:flex')
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/') ->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/') ->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
// The picker is a menu of places: the current one is the page, not a checked choice, // The picker is a menu of places: the current one is the page, not a checked choice,
@@ -80,7 +80,7 @@ it('marks the section whose url is the request\'s, and wraps many sections onto
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all(); $items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items])) expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
->toContain('sm:grid sm:grid-cols-4 xl:flex') ->toContain('medium:grid medium:grid-cols-4 large:flex')
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/') ->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
->not->toContain('wire:navigate'); ->not->toContain('wire:navigate');
}); });
+1 -1
View File
@@ -42,7 +42,7 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
it('puts its attributes back on <html> when wire:navigate swaps the page', function () { it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
$this->blade('<x-theme-script />') $this->blade('<x-theme-script />')
->assertSee("document.addEventListener('livewire:navigating'", false) ->assertSee("document.addEventListener('livewire:navigating'", false)
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false) ->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-auto', 'data-rail-key']", false)
->assertSee('event.detail.onSwap(', false); ->assertSee('event.detail.onSwap(', false);
}); });