Merge branch 'worktree-agent-aadcbb716875e9282'
This commit is contained in:
@@ -44,6 +44,8 @@ import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
- 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).
|
||||
- Breakpoints are M3's window size classes, and only those: `medium:` 600px, `expanded:` 840px, `large:` 1200px, `extra-large:` 1600px, with `max-medium:` … for "below" (compact is below `medium`). Tailwind's `sm:`…`2xl:` are cleared — a `sm:` compiles to nothing — because 640px means nothing in M3. Scripts ask `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) so a stylesheet and a script never disagree at the boundary pixel; a component's *own* width is a container query (`@md:`), which is a different thing.
|
||||
- `dark:` follows the page's theme (`data-theme`), not the operating system.
|
||||
- `x-figure` on an element holding one number counts it up on first appearance and on change.
|
||||
|
||||
@@ -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.
|
||||
|
||||
`--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
|
||||
|
||||
@@ -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 |
|
||||
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
|
||||
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there |
|
||||
|
||||
```blade
|
||||
<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 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>`
|
||||
|
||||
```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-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<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>
|
||||
```
|
||||
|
||||
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>`
|
||||
|
||||
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>`
|
||||
|
||||
@@ -600,7 +601,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
@@ -650,7 +651,7 @@ Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options
|
||||
|
||||
### `<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
|
||||
<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>`
|
||||
|
||||
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` 600–839 | collapsed rail (96px) in the layout, no bar; its menu button opens it expanded over a scrim | 24px |
|
||||
| Expanded | `expanded` 840–1199 | 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
|
||||
<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:top>
|
||||
{{-- 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>
|
||||
|
||||
{{ $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`).
|
||||
- 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`).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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`).
|
||||
- `--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)`.
|
||||
- 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>`
|
||||
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
|
||||
|
||||
```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-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') }}" />
|
||||
@@ -735,11 +747,11 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
</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.
|
||||
- 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.
|
||||
- `$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>`
|
||||
|
||||
@@ -779,7 +791,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
||||
|
||||
### `<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>`
|
||||
|
||||
@@ -872,6 +884,6 @@ It also fails on every value the theme cleared, and names the replacement on the
|
||||
## 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.
|
||||
- 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`.
|
||||
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
|
||||
|
||||
@@ -30,9 +30,17 @@
|
||||
*
|
||||
* `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
|
||||
* <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
|
||||
* (`<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 {
|
||||
@@ -48,13 +56,24 @@
|
||||
@slot;
|
||||
}
|
||||
|
||||
@media (width < 64rem) {
|
||||
@media (width < 52.5rem) {
|
||||
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 64rem) {
|
||||
/* Expanded (840–1199): 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'] *) {
|
||||
@slot;
|
||||
}
|
||||
@@ -176,18 +195,18 @@
|
||||
}
|
||||
|
||||
/* 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'] {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
@media (width < 64rem) {
|
||||
@media (width < 52.5rem) {
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 40rem) {
|
||||
@media (width < 37.5rem) {
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 0;
|
||||
}
|
||||
@@ -230,9 +249,9 @@
|
||||
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. */
|
||||
@media (width < 40rem) {
|
||||
@media (width < 37.5rem) {
|
||||
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
|
||||
@@ -24,10 +24,18 @@
|
||||
* 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.
|
||||
*
|
||||
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does
|
||||
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px
|
||||
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks
|
||||
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands.
|
||||
* In a landscape window with no room for the upright dial the dial variant lies on its side, as
|
||||
* Compose's HorizontalTimePicker does: the display with the period selector (216×38, 16px under it)
|
||||
* on the start side, the dial 36px after it, the actions under both; and the dial shrinks to 238 or
|
||||
* 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
|
||||
* handle round and moves the clip that inks the number under it, on the default spatial spring. The
|
||||
@@ -400,7 +408,7 @@
|
||||
|
||||
/* ---- Landscape ---------------------------------------------------------------------------- */
|
||||
|
||||
@media (orientation: landscape) and (min-width: 37rem) {
|
||||
@media (orientation: landscape) and (height < 35rem) {
|
||||
[data-timepicker-surface][data-mode="dial"] {
|
||||
grid-template-columns: 13.5rem auto;
|
||||
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] {
|
||||
--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] {
|
||||
--dial: 12.5rem;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* it is open.
|
||||
*
|
||||
@@ -20,7 +21,7 @@
|
||||
* 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.
|
||||
*/
|
||||
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`.
|
||||
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
|
||||
query.addEventListener('change', (event) => (this.compact = event.matches))
|
||||
|
||||
+49
-15
@@ -5,6 +5,9 @@
|
||||
* 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
|
||||
* `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 (840–1199), 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
|
||||
* 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
|
||||
* 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
|
||||
@@ -48,6 +51,10 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
window.Alpine.store('rail', {
|
||||
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,
|
||||
|
||||
toggle() {
|
||||
@@ -64,7 +71,9 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
set(collapsed) {
|
||||
this.collapsed = collapsed
|
||||
this.auto = false
|
||||
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
|
||||
root.removeAttribute('data-rail-auto')
|
||||
|
||||
try {
|
||||
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())
|
||||
|
||||
window.Alpine.data('materialNavigationRail', (mode) => ({
|
||||
wide: mode === 'adaptive' ? window.matchMedia(WIDE).matches : false,
|
||||
query: null,
|
||||
onWidth: null,
|
||||
wide: mode === 'adaptive' ? from('expanded').matches : false,
|
||||
roomy: mode === 'adaptive' ? from('large').matches : false,
|
||||
queries: [],
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
if (mode !== 'adaptive') {
|
||||
return
|
||||
}
|
||||
|
||||
// From lg the adaptive rail is a standard, collapsible rail: a modal left open while
|
||||
// the window widens is shut, or its focus trap would hold a page that has no scrim.
|
||||
this.query = window.matchMedia(WIDE)
|
||||
this.onWidth = (event) => {
|
||||
this.wide = event.matches
|
||||
// From `expanded` (840px) the adaptive rail is a standard, collapsible rail — what M3
|
||||
// asks for at expanded and above. A modal left open while the window widens is shut,
|
||||
// or its focus trap would hold a page that has no scrim.
|
||||
this.watch(from('expanded'), (matches) => {
|
||||
this.wide = matches
|
||||
|
||||
if (event.matches) {
|
||||
if (matches) {
|
||||
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() {
|
||||
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. */
|
||||
@@ -126,7 +148,17 @@ document.addEventListener('alpine:init', () => {
|
||||
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. */
|
||||
@@ -134,7 +166,9 @@ document.addEventListener('alpine:init', () => {
|
||||
if (this.modal) {
|
||||
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
|
||||
} 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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* 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
|
||||
* 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
|
||||
* behind does not scroll. The results themselves are the caller's, rendered by Livewire into the
|
||||
* view as the query changes.
|
||||
* compact window (below `medium`, 600px) the view is full screen and modal: focus stays in it and
|
||||
* the page behind does not scroll — M3 docks the view from medium upwards. The results themselves
|
||||
* 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"])'
|
||||
|
||||
@@ -26,7 +26,7 @@ document.addEventListener('alpine:init', () => {
|
||||
closedAt: -Infinity,
|
||||
|
||||
init() {
|
||||
const query = window.matchMedia(COMPACT)
|
||||
const query = upTo('medium')
|
||||
|
||||
this.compact = query.matches
|
||||
query.addEventListener('change', (event) => (this.compact = event.matches))
|
||||
|
||||
@@ -11,13 +11,31 @@
|
||||
…the page…
|
||||
</x-app-shell>
|
||||
|
||||
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
|
||||
Everything else is in the modal rail, which slides in when something calls
|
||||
`$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>`.
|
||||
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
|
||||
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
|
||||
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
||||
The navigation is M3's per window size class (docs/reference/m3/foundations.md § Layout and
|
||||
foundations-supplement.md § Breakpoints), and only those four numbers:
|
||||
|
||||
- **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`,
|
||||
pinned to the bottom. Everything else is in the modal rail, which slides in when something
|
||||
calls `$store.rail.show()` — put a menu button in the app bar for it, hidden from `medium`:
|
||||
`<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
|
||||
- **Medium** (600–839): 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** (840–1199): 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
|
||||
`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 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
|
||||
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
|
||||
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
|
||||
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
|
||||
the props and slots. --}}
|
||||
@@ -84,8 +102,8 @@
|
||||
<div
|
||||
data-app-shell
|
||||
@class([
|
||||
'min-h-dvh bg-surface text-on-surface sm:flex',
|
||||
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
|
||||
'min-h-dvh bg-surface text-on-surface [--material-margin:1rem] medium:flex medium:[--material-margin:1.5rem]',
|
||||
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
|
||||
])
|
||||
>
|
||||
<a
|
||||
@@ -133,13 +151,13 @@
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{ $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 }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@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">
|
||||
@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']" />
|
||||
|
||||
@@ -24,9 +24,11 @@
|
||||
Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external`
|
||||
or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows
|
||||
the loading indicator while the button's own `wire:click` runs (or the action named by a
|
||||
string); `responsive` hides the label below `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
|
||||
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
|
||||
`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:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
|
||||
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
|
||||
// The FAB, below sm 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,
|
||||
// The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
|
||||
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
|
||||
];
|
||||
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
@@ -210,12 +212,12 @@
|
||||
|
||||
@if ($icon)
|
||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-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>
|
||||
@endif
|
||||
|
||||
@unless ($iconOnly)
|
||||
<span @class(['max-lg:hidden' => $responsive])>{{ $label ?? $slot }}</span>
|
||||
<span @class(['max-expanded:hidden' => $responsive])>{{ $label ?? $slot }}</span>
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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,
|
||||
elevation 1; `side` `end` (the default) or `start`; `width` from `sm` (a caller's `w-*` would
|
||||
race the sheet's own).
|
||||
elevation 1; `side` `end` (the default) or `start`; `width` from `medium` (a caller's `w-*`
|
||||
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
|
||||
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
|
||||
As a pane (`pane`, from `expanded`) nothing is covered: the page renders the drawer after its
|
||||
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
|
||||
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`
|
||||
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:`),
|
||||
@@ -35,7 +39,7 @@
|
||||
'withoutBackdropClose' => false,
|
||||
'width' => '25rem',
|
||||
'pane' => false,
|
||||
'paneWidth' => null,
|
||||
'paneWidth' => '22.5rem',
|
||||
'paneCloseOnEscape' => false,
|
||||
])
|
||||
|
||||
@@ -52,7 +56,7 @@
|
||||
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
||||
@if ($pane)
|
||||
init() {
|
||||
const query = window.matchMedia('(min-width: 80rem)');
|
||||
const query = window.matchMedia('(width >= 52.5rem)');
|
||||
this.wide = query.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
|
||||
data-sheet="{{ $id }}"
|
||||
@if ($pane)
|
||||
x-bind:class="! open && 'xl:hidden'"
|
||||
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
|
||||
x-bind:class="! open && 'expanded:hidden'"
|
||||
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
|
||||
@endif
|
||||
>
|
||||
@@ -71,7 +75,7 @@
|
||||
x-show="open"
|
||||
x-transition.opacity.duration.200ms
|
||||
@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"
|
||||
></div>
|
||||
|
||||
@@ -80,24 +84,24 @@
|
||||
x-show="open"
|
||||
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-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: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-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 }}"
|
||||
x-bind:role="wide ? 'region' : 'dialog'"
|
||||
x-bind:aria-modal="wide ? null : 'true'"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@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([
|
||||
'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,
|
||||
'start-0 sm:rounded-e-corner-lg' => $start,
|
||||
'sm:w-(--sheet-width) sm: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,
|
||||
'end-0 medium:rounded-s-corner-lg' => ! $start,
|
||||
'start-0 medium:rounded-e-corner-lg' => $start,
|
||||
'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]',
|
||||
'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'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
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
|
||||
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
|
||||
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',
|
||||
'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',
|
||||
'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'),
|
||||
])
|
||||
>
|
||||
<div @class([
|
||||
'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,
|
||||
])>
|
||||
@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()" />
|
||||
|
||||
@if (filled($title))
|
||||
@@ -77,20 +78,20 @@
|
||||
</div>
|
||||
@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)
|
||||
{{-- 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)
|
||||
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
@endif
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@if ($separator)
|
||||
@@ -105,7 +106,7 @@
|
||||
@isset($actions)
|
||||
<div @class([
|
||||
'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 }}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
|
||||
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-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
||||
<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).
|
||||
|
||||
<div class="flex min-h-dvh">
|
||||
@@ -29,8 +29,12 @@
|
||||
- `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
|
||||
leaving the page closes it (Compose's ModalWideNavigationRail).
|
||||
- `adaptive` — what `<x-app-shell>` uses: below `sm` nothing until `$store.rail.show()` slides
|
||||
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
|
||||
- `adaptive` — what `<x-app-shell>` uses, one rail per M3 window size class: on a compact
|
||||
window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
|
||||
at `medium` (600–839) collapsed in the layout, opening as a modal; at `expanded` (840–1199)
|
||||
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
|
||||
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
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
|
||||
`['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).
|
||||
Errors are read from the bag under the `wire:model` name
|
||||
(resources/css/components/selection.css). --}}
|
||||
@@ -36,7 +37,7 @@
|
||||
<legend class="mb-3 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
@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)
|
||||
@php($optionHintText = data_get($option, $optionHint))
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
@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)
|
||||
<label
|
||||
data-scheme-option="{{ $profile }}"
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
single destination in the app's own navigation.
|
||||
|
||||
`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
|
||||
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them
|
||||
(an item is current when `active` is true, or when its `url` is the page's). From `medium`
|
||||
(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
|
||||
marked `aria-current="page"` and each with its badge. The same list is
|
||||
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
|
||||
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
|
||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
`no-wire-navigate`. --}}
|
||||
@@ -29,15 +32,15 @@
|
||||
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||
|
||||
$layout = match (true) {
|
||||
count($items) < 5 => 'sm:flex',
|
||||
count($items) < 7 => 'sm:grid sm:grid-cols-3 xl:flex',
|
||||
default => 'sm:grid sm:grid-cols-4 xl:flex',
|
||||
count($items) < 5 => 'medium:flex',
|
||||
count($items) < 7 => 'medium:grid medium:grid-cols-3 large:flex',
|
||||
default => 'medium:grid medium:grid-cols-4 large:flex',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
||||
@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-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">
|
||||
@@ -56,7 +59,7 @@
|
||||
</div>
|
||||
@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 }}">
|
||||
@foreach ($items as $item)
|
||||
@php($on = $isCurrent($item))
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
(`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 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 (840–1199) 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
|
||||
<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 choice = settings.default;
|
||||
var rail = settings.rail.default;
|
||||
var railChosen = false;
|
||||
|
||||
try {
|
||||
var storedRail = localStorage.getItem(settings.rail.key);
|
||||
|
||||
if (storedRail === 'collapsed' || storedRail === 'expanded') {
|
||||
rail = storedRail;
|
||||
railChosen = true;
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
@@ -114,6 +119,12 @@
|
||||
root.setAttribute('data-theme-choice', choice);
|
||||
root.setAttribute('data-rail-key', settings.rail.key);
|
||||
root.setAttribute('data-rail', rail);
|
||||
|
||||
if (railChosen) {
|
||||
root.removeAttribute('data-rail-auto');
|
||||
} else {
|
||||
root.setAttribute('data-rail-auto', '');
|
||||
}
|
||||
apply();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
@@ -152,7 +163,7 @@
|
||||
@endif
|
||||
|
||||
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)];
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
|
||||
48px for one line. A type draws its state icon in the inverse state colour. `position`:
|
||||
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
|
||||
`--material-bottom-bar`. --}}
|
||||
`--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'])
|
||||
|
||||
@@ -35,7 +36,7 @@
|
||||
{{ $attributes->class([
|
||||
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
|
||||
'justify-center' => $position !== 'bottom-start',
|
||||
'justify-start sm:start-6' => $position === 'bottom-start',
|
||||
'justify-start medium:start-6' => $position === 'bottom-start',
|
||||
]) }}
|
||||
>
|
||||
<template x-if="current">
|
||||
@@ -48,7 +49,7 @@
|
||||
x-on:mouseleave="resume()"
|
||||
x-on:focusin="pause()"
|
||||
x-on:focusout="resume()"
|
||||
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 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)">
|
||||
<span class="flex shrink-0" x-bind:class="{
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</head>
|
||||
<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">
|
||||
<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">
|
||||
<x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
<p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p>
|
||||
</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')
|
||||
@yield('headline')
|
||||
@else
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
@if ($paginator->hasPages())
|
||||
<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">
|
||||
<span class="sm: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="medium:hidden">{{ __('Page :page of :pages', ['page' => $paginator->currentPage(), 'pages' => $paginator->lastPage()]) }}</span>
|
||||
<span class="max-medium:hidden">{{ __(':first–:last of :total', ['first' => $paginator->firstItem(), 'last' => $paginator->lastItem(), 'total' => $paginator->total()]) }}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -29,15 +29,15 @@
|
||||
|
||||
@foreach ($elements as $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
|
||||
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@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
|
||||
<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
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
@if ($paginator->hasPages())
|
||||
<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">
|
||||
<span class="sm: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="medium:hidden">{{ __('Page :page of :pages', ['page' => $paginator->currentPage(), 'pages' => $paginator->lastPage()]) }}</span>
|
||||
<span class="max-medium:hidden">{{ __(':first–:last of :total', ['first' => $paginator->firstItem(), 'last' => $paginator->lastItem(), 'total' => $paginator->total()]) }}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -45,12 +45,12 @@
|
||||
|
||||
@foreach ($elements as $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
|
||||
|
||||
@if (is_array($element))
|
||||
@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())
|
||||
<span aria-current="page" class="{{ $step }} bg-secondary-container text-on-secondary-container">{{ $page }}</span>
|
||||
@else
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('livewire-material::showcase.layout')
|
||||
|
||||
@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">
|
||||
<h1 class="type-headline-lg">Livewire Material</h1>
|
||||
<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) }}">
|
||||
<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)
|
||||
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
|
||||
<div class="flex items-start gap-4">
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<x-slot:top>
|
||||
<x-livewire-material::app-bar variant="search">
|
||||
<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>
|
||||
|
||||
{{-- The search looks through every section, example and component: the index is
|
||||
@@ -145,8 +145,8 @@
|
||||
</x-livewire-material::menu>
|
||||
@endif
|
||||
|
||||
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||
<span class="ms-2 me-3 max-expanded:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||
<span class="expanded:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||
</x-slot:actions>
|
||||
</x-livewire-material::app-bar>
|
||||
</x-slot:top>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@endphp
|
||||
|
||||
@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">
|
||||
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
|
||||
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</x-button-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="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-4 expanded:grid-cols-2">
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<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>
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="space-y-2">
|
||||
<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)
|
||||
<div class="flex items-center gap-2">
|
||||
<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" />
|
||||
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="Files" value="8,317" icon="description" />
|
||||
<x-stat title="Stored" value="3.2 GB" icon="hard_drive" description="of 4 GB" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@php
|
||||
$examples = [
|
||||
'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">
|
||||
The default card.
|
||||
<x-slot:actions><x-button label="Open" /></x-slot:actions>
|
||||
@@ -27,7 +27,7 @@
|
||||
</x-card>
|
||||
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-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" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
Five shadow levels as <code>shadow-elevation-*</code>, for what floats over content.
|
||||
</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)
|
||||
<div @class(['flex h-24 items-center justify-center rounded-corner-md bg-surface-container-high', $shadow])>
|
||||
<code class="type-label-md">{{ $shadow }}</code>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@php
|
||||
$examples = [
|
||||
'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">
|
||||
<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" />
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
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">
|
||||
<x-input label="Email" required />
|
||||
<x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']">
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
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">
|
||||
<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]]" />
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
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">
|
||||
<x-checkbox label="Notify me on download" checked />
|
||||
<x-checkbox label="Select all files" indeterminate />
|
||||
@@ -75,13 +75,13 @@
|
||||
</div>
|
||||
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="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>
|
||||
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">
|
||||
<x-toggle label="Email notifications" checked />
|
||||
<x-toggle label="Weekly summary" />
|
||||
@@ -99,7 +99,7 @@
|
||||
</div>
|
||||
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">
|
||||
<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']]" />
|
||||
@@ -112,7 +112,7 @@
|
||||
</div>
|
||||
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: '' }">
|
||||
<x-search x-model="query" placeholder="Search shares">
|
||||
<x-list>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@php
|
||||
$examples = [
|
||||
'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">
|
||||
<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>
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
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">
|
||||
<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>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
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">
|
||||
<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>
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
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">
|
||||
<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>
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
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">
|
||||
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
|
||||
<x-datepicker label="Required" required />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
The corner scale as <code>rounded-corner-*</code>, and M3 Expressive's shapes as <code><x-shape name="…" /></code>.
|
||||
</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)
|
||||
<div class="space-y-2">
|
||||
<div @class(['h-20 bg-primary-container', $corner])></div>
|
||||
@@ -14,7 +14,7 @@
|
||||
@endforeach
|
||||
</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)
|
||||
<div class="space-y-2 text-center">
|
||||
<x-livewire-material::shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@php
|
||||
$examples = [
|
||||
'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="Quality" value="6" :max="10" ticks />
|
||||
<x-slider label="Locked" value="70" disabled />
|
||||
@@ -17,20 +17,20 @@
|
||||
</div>
|
||||
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="Balance" value="15" :min="-50" :max="50" centered />
|
||||
</div>
|
||||
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="Tertiary, no label" value="35" color="tertiary" value-label="never" size="sm" />
|
||||
<x-slider label="Error" :value="[30, 70]" color="error" range size="sm" />
|
||||
</div>
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@php
|
||||
$examples = [
|
||||
'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">
|
||||
<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 />
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
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">
|
||||
<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" />
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<div class="divide-y divide-divider rounded-corner-lg bg-surface-container">
|
||||
@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>
|
||||
<p @class([$regular, 'truncate'])>Share files, safely</p>
|
||||
<p @class([$emphasized, 'truncate'])>Share files, safely</p>
|
||||
|
||||
@@ -59,14 +59,14 @@
|
||||
</x-slot:actions>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
||||
<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="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 medium:px-0">{{ $current['title'] }}</h1>
|
||||
<x-livewire-material::button icon="search" tooltip="Search" />
|
||||
</header>
|
||||
</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>
|
||||
|
||||
<x-livewire-material::list segmented>
|
||||
|
||||
@@ -16,9 +16,11 @@ function navigationReady(mixed $page): 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
|
||||
// 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))
|
||||
->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}";
|
||||
}
|
||||
|
||||
it('shows the navigation bar on a phone and marks the current destination', function () {
|
||||
shellPage(400, 860)
|
||||
it('shows the navigation bar on a compact window and marks the current destination', function () {
|
||||
shellPage(599, 860)
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'")
|
||||
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
|
||||
->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();
|
||||
});
|
||||
|
||||
it('collapses the rail on a medium window, icon over label', function () {
|
||||
shellPage(800)
|
||||
it('collapses the rail across the medium class, icon over label, from its first pixel', function () {
|
||||
shellPage(600)
|
||||
->assertScript(railWidth(96))
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
shellPage(839)
|
||||
->assertScript(railWidth(96))
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
|
||||
->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');
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
$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("window.eval('window.firstPaint.width') === '256px'")
|
||||
->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.width') === '96px'")
|
||||
->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 () {
|
||||
$page = shellPage(800);
|
||||
it('expands the rail by default at extra-large', function () {
|
||||
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.
|
||||
$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");
|
||||
});
|
||||
|
||||
it('slides the modal rail in on a phone from the app bar\'s menu button', function () {
|
||||
$page = shellPage(400, 860);
|
||||
it('slides the modal rail in on a compact window from the app bar\'s menu button', function () {
|
||||
$page = shellPage(599, 860);
|
||||
|
||||
$page->click('@shell-menu')
|
||||
->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 () {
|
||||
$page = shellPage(1280);
|
||||
$page = shellPage(1200);
|
||||
|
||||
$page->click('[data-navigation-rail-menu]')
|
||||
->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))";
|
||||
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
|
||||
|
||||
$page = shellPage(400, 860);
|
||||
$page = shellPage(599, 860);
|
||||
$snackbar = (int) $page->script($snackbarBottom);
|
||||
$content = (int) $page->script($contentPadding);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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-bar')
|
||||
->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('x-data="materialSnackbar"')
|
||||
->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 () {
|
||||
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]]]))
|
||||
->not->toContain('data-app-shell-bar')
|
||||
->not->toContain('--material-bottom-bar:');
|
||||
|
||||
@@ -129,9 +129,9 @@ it('shows the loading indicator while its own action runs', function () {
|
||||
->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 />')
|
||||
->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 () {
|
||||
@@ -145,11 +145,11 @@ it('anchors its tooltip to itself', function () {
|
||||
->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 />');
|
||||
|
||||
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 () {
|
||||
|
||||
@@ -33,7 +33,7 @@ it('draws Laravel\'s paginators in M3', function () {
|
||||
->toContain('data-pagination')
|
||||
->toContain('Page 3 of 10')
|
||||
->toContain('21–30 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('aria-label="Go to page 4"')
|
||||
->not->toContain('text-gray');
|
||||
|
||||
@@ -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-on:cancel.prevent=""')
|
||||
->not->toContain('x-on:click.self')
|
||||
->toContain('max-sm:h-dvh')
|
||||
->toContain('max-medium:h-dvh')
|
||||
->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>');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/')
|
||||
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>')
|
||||
->not->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-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-medium:mt-0">Scan the code</p>')
|
||||
->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-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>');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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>'))
|
||||
->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('role="dialog"')
|
||||
->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>'))
|
||||
->toContain('data-pane')
|
||||
->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 () {
|
||||
|
||||
@@ -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\']]" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('sm:flex')
|
||||
->toContain('medium:flex')
|
||||
->and(substr_count($html, 'name="theme"'))->toBe(2)
|
||||
->and($html)->toMatch('/value="dark"\s+checked/')
|
||||
->not->toMatch('/value="light"\s+checked/');
|
||||
|
||||
@@ -64,7 +64,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
||||
->toContain('aria-label="Settings"')
|
||||
->toContain('data-section-picker')
|
||||
->toContain('data-variant="secondary"')
|
||||
->toContain('sm:flex')
|
||||
->toContain('medium:flex')
|
||||
->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/')
|
||||
// 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();
|
||||
|
||||
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/')
|
||||
->not->toContain('wire:navigate');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user