Merge branch 'worktree-agent-a4fc1072037346c95'
This commit is contained in:
@@ -538,7 +538,9 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d
|
||||
|
||||
### `<x-chip-set>`
|
||||
|
||||
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards). The set is one tab stop: the arrow keys move between the chips, Home and End go to the ends.
|
||||
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways). The set is one tab stop: the arrow keys move between the chips, Home and End go to the ends.
|
||||
|
||||
With `scroll`, M3's overflow affordance is drawn for you: the edge the row can still scroll towards fades, and where the pointer is fine (a mouse, no swipe to reach for) a small button sits over each fading edge and scrolls the row by most of its width. The buttons are pointer-only — not tab stops — because the arrow keys already walk every chip and scroll each one clear of both the fade and the buttons.
|
||||
|
||||
### `<x-form>`
|
||||
|
||||
@@ -559,9 +561,11 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `counter`, `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one.
|
||||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
|
||||
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A `max-w-*` class on the component narrows or widens it, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
|
||||
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
|
||||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
||||
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
||||
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have.
|
||||
@@ -570,7 +574,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
|
||||
|
||||
M3 selection controls on native inputs; the whole row is the label.
|
||||
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<div class="grid gap-4 expanded:grid-cols-2">` (or a card or side sheet) under a heading that names what the group asks.
|
||||
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads.
|
||||
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
|
||||
|
||||
@@ -602,10 +606,17 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
|
||||
| `ticks` | `false` | a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks |
|
||||
| `value-label` | `drag` | `drag` (while pressed, dragged or keyboard-focused), `always`, `never` |
|
||||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||||
| `orientation` | `horizontal` | `vertical` stands it up: the value grows upwards, the value label sits beside the handle, Up and Down move it. Ignored with `range` — M3 keeps range sliders horizontal |
|
||||
| `disabled` | `false` | |
|
||||
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||||
|
||||
A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so **give it a height** — `class="h-64"`, which the label and hint share. Without one it is 192px long.
|
||||
|
||||
```blade
|
||||
<x-slider label="Volume" orientation="vertical" wire:model.live="volume" class="h-64" />
|
||||
```
|
||||
|
||||
### `<x-datepicker>`
|
||||
|
||||
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
|
||||
@@ -620,7 +631,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 on a compact window (below `medium`), as M3 asks; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field. On a compact window (below `medium`) it opens as M3's full-screen range picker: an app bar with a close button and **Save**, the range as the headline, and the months in one scrolling list instead of stepped one at a time |
|
||||
| `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 |
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
@@ -669,7 +680,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 `medium` (600px) over a scrim, 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. The results are a list and a live region says how many there are. 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) over a scrim, 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. The results are a list and a live region says how many there are. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`, `trigger`; `trailing` slot (avatar, icon buttons) and `suggestions` slot.
|
||||
|
||||
```blade
|
||||
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||||
@@ -682,6 +693,22 @@ M3 search bar that opens into a search view: docked under the bar from `medium`
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`.
|
||||
|
||||
- `trigger="icon"` is M3's other entry point — search as a secondary action: one 48px search icon button that expands into the full-screen view at any width (so `docked` does not apply) and gives the button its focus back on close. Put it in a toolbar or an app bar row where a bar would not fit.
|
||||
- The `suggestions` slot is shown in the view until the first keystroke — recent or popular searches — and the results slot takes over once something is typed. The live region counts whichever list is on screen and names suggestions as such.
|
||||
|
||||
```blade
|
||||
<x-search trigger="icon" label="Search shares" wire:model.live.debounce.300ms="query">
|
||||
<x-slot:suggestions>
|
||||
@foreach ($this->recent as $term)
|
||||
<x-list-item :title="$term" icon="history" wire:click="$set('query', '{{ $term }}')" wire:key="recent-{{ $term }}" />
|
||||
@endforeach
|
||||
</x-slot:suggestions>
|
||||
@foreach ($this->results as $share)
|
||||
<x-list-item :title="$share->name" :link="route('shares.show', $share)" wire:key="result-{{ $share->id }}" />
|
||||
@endforeach
|
||||
</x-search>
|
||||
```
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
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>`.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/*
|
||||
* M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php).
|
||||
*
|
||||
* One `<dialog>` wears both: docked it is a popover hung under the field by CSS anchor positioning
|
||||
* (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's
|
||||
* header: a title, the chosen date as the headline, and the switch between calendar and typing).
|
||||
* Both are 360px of surface-container-high at elevation 3.
|
||||
* One `<dialog>` wears all three: docked it is a popover hung under the field by CSS anchor
|
||||
* positioning (large corner), modal it is opened with `showModal()` over a scrim (extra-large
|
||||
* corner, with M3's header: a title, the chosen date as the headline, and the switch between
|
||||
* calendar and typing), and full — a `range` picker on a compact window — is that modal dialog
|
||||
* grown to the whole screen, M3's full-screen range picker. The first two are 360px of
|
||||
* surface-container-high at elevation 3; the third keeps the colour and drops the corner.
|
||||
*
|
||||
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
|
||||
@@ -13,15 +15,19 @@
|
||||
*
|
||||
* [data-datepicker] the root: the field, its support line, the dialog
|
||||
* [data-datepicker-support] the hint, a server error, or what is wrong with the typed date
|
||||
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal"
|
||||
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal" | "full"
|
||||
* [data-datepicker-surface]
|
||||
* [data-datepicker-header] modal only: title, headline, switch
|
||||
* [data-datepicker-calendar]
|
||||
* [data-datepicker-full] full only: [data-datepicker-app-bar] (close, switch, Save)
|
||||
* and [data-datepicker-full-headline] (title, headline)
|
||||
* [data-datepicker-calendar] the scroller, full screen
|
||||
* [data-datepicker-nav] month and year: one menu button and arrows (modal),
|
||||
* or a month and a year stepper (docked, data-docked)
|
||||
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
|
||||
* data-today, data-selected, data-start, data-end,
|
||||
* data-between, data-outside, data-blank, aria-disabled
|
||||
* data-between, data-outside, data-blank, aria-disabled —
|
||||
* full screen, every month of the window under a
|
||||
* [data-datepicker-month-label] row
|
||||
* [data-datepicker-years] the modal year grid
|
||||
* [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s
|
||||
* [data-datepicker-entry] the text field(s) of the modal input
|
||||
@@ -128,6 +134,87 @@
|
||||
border-radius: var(--md-sys-shape-corner-xl);
|
||||
}
|
||||
|
||||
/* ---- The full-screen range picker (compact) ------------------------------------------------- */
|
||||
|
||||
/* M3 § Date Pickers: a range picker at a compact breakpoint is a full-screen dialog, "covering
|
||||
* the whole screen for readability and touch-target size", with a close (×) icon button and a
|
||||
* Save confirmation instead of Cancel and OK. Its anatomy is the modal picker's parts in a
|
||||
* different order: an app bar, the supporting text and the range as the headline, a divider, the
|
||||
* day-of-week labels, and a vertically scrolling list of months under them, each with its label.
|
||||
* Only the months scroll; everything above them stays where it is. */
|
||||
[data-datepicker-picker][data-presentation="full"] {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-datepicker-picker][data-presentation="full"] [data-datepicker-surface] {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
padding-block: var(--material-safe-top, env(safe-area-inset-top)) var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-datepicker-full] {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-datepicker-app-bar] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: 4rem;
|
||||
padding-inline: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
[data-datepicker-app-bar-actions] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
[data-datepicker-full-headline] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0 1.5rem 1rem;
|
||||
}
|
||||
|
||||
/* The calendar is the only part that scrolls, with the weekday header held at its top. */
|
||||
[data-datepicker-picker][data-presentation="full"] [data-datepicker-calendar] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
[data-datepicker-picker][data-presentation="full"] [data-datepicker-grid] thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
}
|
||||
|
||||
[data-datepicker-picker][data-presentation="full"] [data-datepicker-day] {
|
||||
scroll-margin-block: 3rem;
|
||||
}
|
||||
|
||||
[data-datepicker-month-label] {
|
||||
padding: 1.25rem 0 0.5rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-title-sm);
|
||||
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* ---- The modal header ---------------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-header] {
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
* error state's second indicator
|
||||
* .field-outline the fieldset and its legend (the filled field's indicator line)
|
||||
* .field-label the visible label
|
||||
* .field-support the hint, or the error in its place
|
||||
* .field-support-row the row under the field
|
||||
* .field-support the hint, or the error in its place
|
||||
* .field-counter M3's character counter, `n/max`, at the end of that row
|
||||
*
|
||||
* A select's open list is not part of the field: it is the dropdown menu in components/menu.css.
|
||||
*/
|
||||
@@ -40,6 +42,20 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width;
|
||||
* medium/expanded should bound it with flexible margins/other containers — never let it span the
|
||||
* full width of a large screen." The site names no number, so 40rem is this package's: it is
|
||||
* under half the `large` class's 1200px, a little over the `expanded` class's half, and holds
|
||||
* about the 70 characters body-large reads best at. It is a ceiling, not a width — a narrower
|
||||
* pane still gets a narrower field. A `max-w-*` class from the call site beats it, because
|
||||
* utilities come after this layer, and `full` takes it off for a field that really is the width
|
||||
* of its pane (a search-and-filter row, an editor). Below `medium` nothing is bounded. */
|
||||
@media (width >= 37.5rem) {
|
||||
.field:not([data-full]) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
|
||||
.field[data-variant="filled"] {
|
||||
--field-edge: var(--md-sys-color-on-surface-variant);
|
||||
@@ -450,7 +466,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The hint or the error, and — when the field has a maximum — M3's character counter at the far
|
||||
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
|
||||
two paddings between them already make). */
|
||||
.field-support-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.field-support {
|
||||
min-width: 0;
|
||||
padding: 0.25rem var(--field-pad) 0;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
@@ -461,6 +486,22 @@
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.field-counter {
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
padding-block-start: 0.25rem;
|
||||
padding-inline-end: var(--field-pad);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Past the maximum the count is an error, and says so in the error colour. */
|
||||
.field-counter[data-over] {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
/* ---- The filled text field ---------------------------------------------------------------- */
|
||||
|
||||
.field[data-variant="filled"] .field-box {
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
* `--search-width: 22.5rem` on a wrapper. The leading and trailing padding is 24px unfocused and
|
||||
* 16px focused, as the search specs table gives it.
|
||||
*
|
||||
* [data-search] the root; data-open, data-full-screen
|
||||
* [data-search-scrim] over the page while the view is docked
|
||||
* [data-search-bar] the pill, above the view
|
||||
* [data-search] the root; data-open, data-full-screen, data-trigger
|
||||
* [data-search-scrim] over the page while the view is docked
|
||||
* [data-search-trigger] data-trigger="icon" only: the icon button that expands the view
|
||||
* [data-search-bar] the pill, above the view
|
||||
* [data-search-leading], [data-search-field] (the combobox around [data-search-input]),
|
||||
* [data-search-clear], [data-search-trailing]
|
||||
* [data-search-view] the container behind the bar
|
||||
* [data-search-results]
|
||||
* [data-search-status] the polite live region that counts the results
|
||||
* [data-search-view] the container behind the bar
|
||||
* [data-search-suggestions] before the first keystroke
|
||||
* [data-search-results] once something is typed
|
||||
* [data-search-status] the polite live region that counts whichever list is on screen
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
@@ -204,19 +206,62 @@
|
||||
|
||||
/* M3: the docked container is at least 240px tall — once there is something in it to be tall
|
||||
about; a search with nothing to show stays the height of its bar. */
|
||||
[data-search-view]:has([data-search-results]) {
|
||||
[data-search-view]:has([data-search-results], [data-search-suggestions]) {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
/* No divider: that belongs to the divided style, which Expressive deprecates in favour of the
|
||||
contained one this file draws. */
|
||||
[data-search-results] {
|
||||
contained one this file draws. The suggestions stand in the same place, before the first
|
||||
keystroke; only one of the two is ever on screen. */
|
||||
[data-search-results],
|
||||
[data-search-suggestions] {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
/* M3's second entry point: search as a secondary action, one icon button that expands into the
|
||||
full-screen view. The root keeps the button's 48px whether the view is open or not, so a
|
||||
toolbar does not shift under it, and the bar is the view's header rather than a resting bar. */
|
||||
[data-search][data-trigger="icon"] {
|
||||
flex: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
[data-search-trigger] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-search-trigger]:hover {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
[data-search-trigger]:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
[data-search-trigger]:active {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
|
||||
}
|
||||
|
||||
[data-search][data-trigger="icon"]:not([data-open]) [data-search-bar] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Full screen, on a compact window. */
|
||||
[data-search][data-full-screen] [data-search-bar] {
|
||||
position: fixed;
|
||||
|
||||
+18
-5
@@ -7,8 +7,11 @@
|
||||
* after it (Delete), because a focused element that disappears leaves focus on the body.
|
||||
*
|
||||
* A scrolling set marks the edges it can still scroll towards (`data-scroll-start`,
|
||||
* `data-scroll-end`), and the set fades those edges. Its row carries `wire:ignore.self`, so a
|
||||
* Livewire morph keeps the marks.
|
||||
* `data-scroll-end`), and the set fades those edges and — where the pointer is fine, so there is no
|
||||
* swipe — puts a button over each of them, which is the visible affordance M3's chips accessibility
|
||||
* page asks a scrolling row for. Its row carries `wire:ignore.self`, so a Livewire morph keeps the
|
||||
* marks. Wrapping, the row is the element with `x-data`; scrolling, it is that element's `row` ref,
|
||||
* because the buttons stand outside the scroller.
|
||||
*
|
||||
* A set is also one tab stop with a roving tabindex, and the arrow keys walk its controls, which is
|
||||
* M3's chip keyboard table ("Arrows: moves focus between chips"; "only one chip can be in focus
|
||||
@@ -126,9 +129,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
window.Alpine.data('materialChipSet', () => ({
|
||||
observers: [],
|
||||
row: null,
|
||||
|
||||
init() {
|
||||
const row = this.$el
|
||||
// A scrolling set hangs its buttons outside the scroller, so the row is a ref there.
|
||||
const row = (this.row = this.$refs.row ?? this.$el)
|
||||
|
||||
const mark = () => {
|
||||
// scrollLeft runs negative towards the end in a right-to-left row.
|
||||
@@ -167,7 +172,15 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
/** Everything in the set the keyboard can reach, in the order it is written. */
|
||||
controls() {
|
||||
return [...this.$el.querySelectorAll(CONTROLS)]
|
||||
return [...this.row.querySelectorAll(CONTROLS)]
|
||||
},
|
||||
|
||||
/** A scroll button: most of a row's width towards one of its ends. */
|
||||
nudge(towards) {
|
||||
const rtl = getComputedStyle(this.row).direction === 'rtl'
|
||||
const step = Math.max(this.row.clientWidth * 0.8, 120)
|
||||
|
||||
this.row.scrollBy({ left: (towards === 'start' ? -1 : 1) * (rtl ? -1 : 1) * step, behavior: 'smooth' })
|
||||
},
|
||||
|
||||
/** One tab stop: the control focus is on, or the first one, is the only one Tab reaches. */
|
||||
@@ -203,7 +216,7 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const forwards = getComputedStyle(this.$el).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
|
||||
const forwards = getComputedStyle(this.row).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'
|
||||
|
||||
let next = null
|
||||
|
||||
+106
-7
@@ -2,12 +2,15 @@
|
||||
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
|
||||
* date pickers on one `<dialog>`.
|
||||
*
|
||||
* The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS
|
||||
* The dialog is shown three 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 — 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.
|
||||
* field) it is opened with `showModal()`; full, a `range` picker on a compact window, is that same
|
||||
* modal dialog grown to the whole screen — M3's full-screen range picker, with an app bar carrying
|
||||
* a close button and **Save**, the range as the headline, and a list of months scrolled through
|
||||
* rather than stepped (§ Date Pickers, Anatomy). 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.
|
||||
*
|
||||
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
|
||||
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
|
||||
@@ -33,6 +36,17 @@ const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.spli
|
||||
const YEARS_FROM = 1900
|
||||
const YEARS_TO = 2100
|
||||
|
||||
/**
|
||||
* The full-screen range picker scrolls through months rather than stepping between them, so it
|
||||
* draws a window of them: this many either side of the month it opens on, grown by the same number
|
||||
* whenever the scroll or the keyboard reaches an end. Compose's own list is lazy; this one is not,
|
||||
* so the window stays small enough for the DOM it costs.
|
||||
*/
|
||||
const MONTH_WINDOW = 6
|
||||
|
||||
/** How near an end of the month list a scroll has to come before the window grows. */
|
||||
const MONTH_EDGE = 400
|
||||
|
||||
function pad(number, length = 2) {
|
||||
return String(number).padStart(length, '0')
|
||||
}
|
||||
@@ -181,6 +195,9 @@ document.addEventListener('alpine:init', () => {
|
||||
entryError: '',
|
||||
today: localToday(),
|
||||
compact: false,
|
||||
// The first and last month the full-screen range picker draws, as `yyyy-MM-01`.
|
||||
monthsFrom: null,
|
||||
monthsTo: null,
|
||||
refocus: true,
|
||||
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
|
||||
// x-data scope, where every picker inside the same page scope would share the last one's.
|
||||
@@ -436,11 +453,21 @@ document.addEventListener('alpine:init', () => {
|
||||
const value = this.current()
|
||||
|
||||
this.today = localToday()
|
||||
this.presentation = config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
|
||||
// M3 gives a range picker its own full-screen dialog at compact; everything else is the
|
||||
// docked popover at medium and up, and the modal dialog otherwise.
|
||||
this.presentation = config.range && this.compact ? 'full' : config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
|
||||
this.typing = config.mode === 'input'
|
||||
this.view = 'days'
|
||||
this.draft = value
|
||||
this.monthsFrom = null
|
||||
this.monthsTo = null
|
||||
this.moveTo(this.start() ?? this.today, false)
|
||||
|
||||
if (this.presentation === 'full') {
|
||||
this.monthsFrom = this.clampMonth(addMonths(this.shown, -MONTH_WINDOW))
|
||||
this.monthsTo = this.clampMonth(addMonths(this.shown, MONTH_WINDOW))
|
||||
}
|
||||
|
||||
this.entryError = ''
|
||||
this.fillEntry()
|
||||
this.refocus = true
|
||||
@@ -566,12 +593,61 @@ document.addEventListener('alpine:init', () => {
|
||||
moveTo(value, focus = true) {
|
||||
this.focused = this.clamp(value)
|
||||
this.shown = this.focused.slice(0, 8) + '01'
|
||||
this.coverMonth(this.shown)
|
||||
|
||||
if (focus) {
|
||||
this.focusDay()
|
||||
}
|
||||
},
|
||||
|
||||
// ---- The full-screen range picker's list of months --------------------------------------
|
||||
|
||||
/** A month inside the years the picker allows, and inside `min` and `max`. */
|
||||
clampMonth(month) {
|
||||
const floor = this.min ? this.min.slice(0, 8) + '01' : `${pad(this.yearsFrom, 4)}-01-01`
|
||||
const ceiling = this.max ? this.max.slice(0, 8) + '01' : `${pad(this.yearsTo, 4)}-12-01`
|
||||
|
||||
return month < floor ? floor : month > ceiling ? ceiling : month
|
||||
},
|
||||
|
||||
/** Keeps a month the keyboard walked to inside the window the list draws. */
|
||||
coverMonth(month) {
|
||||
if (this.monthsFrom === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (month < this.monthsFrom) {
|
||||
this.monthsFrom = this.clampMonth(month)
|
||||
} else if (month > this.monthsTo) {
|
||||
this.monthsTo = this.clampMonth(month)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Grows the window when the scroll comes near either end. Adding months above moves
|
||||
* everything down, so the scroll is put back by however much the list grew.
|
||||
*/
|
||||
extendMonths(event) {
|
||||
if (this.presentation !== 'full' || this.monthsFrom === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const list = event.currentTarget
|
||||
|
||||
if (list.scrollTop < MONTH_EDGE) {
|
||||
const from = this.clampMonth(addMonths(this.monthsFrom, -MONTH_WINDOW))
|
||||
|
||||
if (from !== this.monthsFrom) {
|
||||
const before = list.scrollHeight
|
||||
|
||||
this.monthsFrom = from
|
||||
this.$nextTick(() => (list.scrollTop += list.scrollHeight - before))
|
||||
}
|
||||
} else if (list.scrollHeight - list.scrollTop - list.clientHeight < MONTH_EDGE) {
|
||||
this.monthsTo = this.clampMonth(addMonths(this.monthsTo, MONTH_WINDOW))
|
||||
}
|
||||
},
|
||||
|
||||
/** The single date, or a range's start. */
|
||||
start() {
|
||||
const value = this.current()
|
||||
@@ -805,8 +881,9 @@ document.addEventListener('alpine:init', () => {
|
||||
})
|
||||
},
|
||||
|
||||
get weeks() {
|
||||
const [year, month] = parts(this.shown)
|
||||
/** Six weeks of cells for one month, `yyyy-MM-01`. */
|
||||
weeksOf(shownMonth) {
|
||||
const [year, month] = parts(shownMonth)
|
||||
const first = utc(year, month, 1)
|
||||
const offset = (first.getUTCDay() - this.firstDay + 7) % 7
|
||||
const range = config.range ? (this.draft ?? { start: null, end: null }) : null
|
||||
@@ -836,6 +913,28 @@ document.addEventListener('alpine:init', () => {
|
||||
}))
|
||||
},
|
||||
|
||||
/**
|
||||
* The rows the grid draws: this month's six weeks, or — full screen — every month of the
|
||||
* window, each headed by its name, which is M3's vertically scrolling range picker. A
|
||||
* month's empty trailing weeks are dropped there, so one month follows the next.
|
||||
*/
|
||||
get rows() {
|
||||
const weeks = (month) => this.weeksOf(month).map((cells, row) => ({ key: `${month}-${row}`, cells }))
|
||||
|
||||
if (this.presentation !== 'full' || this.monthsFrom === null) {
|
||||
return weeks(this.shown)
|
||||
}
|
||||
|
||||
const rows = []
|
||||
|
||||
for (let month = this.monthsFrom; month <= this.monthsTo; month = addMonths(month, 1)) {
|
||||
rows.push({ key: `label-${month}`, label: this.formats.monthYear.format(utc(...parts(month))) })
|
||||
rows.push(...weeks(month).filter((row) => row.cells.some((cell) => !cell.blank)))
|
||||
}
|
||||
|
||||
return rows
|
||||
},
|
||||
|
||||
get years() {
|
||||
const shown = parts(this.shown)[0]
|
||||
const current = parts(this.today)[0]
|
||||
|
||||
+41
-12
@@ -11,7 +11,12 @@
|
||||
* Because the results arrive from the server, nothing in the page tells a screen reader they are
|
||||
* there; M3 asks that it be told. A MutationObserver counts the list items whenever the view's DOM
|
||||
* settles and writes "N results" into the polite live region the view renders, which is the one
|
||||
* announcement M3's search accessibility page names.
|
||||
* announcement M3's search accessibility page names. With a `suggestions` slot, whichever of the
|
||||
* two lists is on screen is the one counted, and the suggestions are named as such.
|
||||
*
|
||||
* `trigger` is M3's entry point: the bar itself, or `icon` — a single search icon button that
|
||||
* expands into the full-screen view wherever the window is wide enough to dock, because an icon
|
||||
* button has nowhere to dock under.
|
||||
*/
|
||||
import { upTo } from './breakpoints.js'
|
||||
|
||||
@@ -29,22 +34,25 @@ const RETURN_GUARD_MS = 250
|
||||
const SETTLE_MS = 120
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialSearch', (docked = false, announce = {}) => ({
|
||||
window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({
|
||||
open: false,
|
||||
compact: false,
|
||||
closedAt: -Infinity,
|
||||
announcement: '',
|
||||
// What is in the field, which is what tells suggestions from results.
|
||||
query: '',
|
||||
observer: null,
|
||||
settle: null,
|
||||
|
||||
init() {
|
||||
const query = upTo('medium')
|
||||
const media = upTo('medium')
|
||||
|
||||
this.compact = query.matches
|
||||
query.addEventListener('change', (event) => (this.compact = event.matches))
|
||||
this.compact = media.matches
|
||||
media.addEventListener('change', (event) => (this.compact = event.matches))
|
||||
|
||||
// Alpine registers x-ref as it walks the children, which is after this runs.
|
||||
this.$nextTick(() => {
|
||||
this.query = this.$refs.input?.value ?? ''
|
||||
this.observer = new MutationObserver(() => this.countLater())
|
||||
this.observer.observe(this.$refs.view, { childList: true, subtree: true, characterData: true })
|
||||
})
|
||||
@@ -69,25 +77,41 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const results = this.$refs.view.querySelector('[data-search-results]')
|
||||
const items = results ? results.querySelectorAll('[role="listitem"], li') : []
|
||||
const total = items.length || (results ? this.results().length : 0)
|
||||
// Suggestions and results never show together; count whichever one is on screen.
|
||||
const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-search-suggestions]')]
|
||||
.find((element) => element.getClientRects().length > 0)
|
||||
const items = list ? list.querySelectorAll('[role="listitem"], li') : []
|
||||
const total = items.length || (list ? this.results().length : 0)
|
||||
const suggesting = Boolean(list?.hasAttribute('data-search-suggestions'))
|
||||
|
||||
this.announcement = total === 0
|
||||
? (announce.none ?? '')
|
||||
: total === 1
|
||||
? (announce.one ?? '')
|
||||
: (announce.many ?? '').replace(':count', total)
|
||||
? ((suggesting ? announce.suggestionOne : announce.one) ?? '')
|
||||
: ((suggesting ? announce.suggestionMany : announce.many) ?? '').replace(':count', total)
|
||||
},
|
||||
|
||||
get fullScreen() {
|
||||
return this.open && this.compact && !docked
|
||||
// An icon button has nothing to dock under, so M3's icon entry point always expands.
|
||||
return this.open && (trigger === 'icon' || (this.compact && !docked))
|
||||
},
|
||||
|
||||
show() {
|
||||
this.open = true
|
||||
},
|
||||
|
||||
/** M3's search-icon entry point: the button opens the view and hands over focus. */
|
||||
expand() {
|
||||
this.show()
|
||||
this.$nextTick(() => requestAnimationFrame(() => this.$refs.input?.focus()))
|
||||
},
|
||||
|
||||
/** Every keystroke: the view opens, and the query decides suggestions or results. */
|
||||
typed(event) {
|
||||
this.query = event.target.value
|
||||
this.show()
|
||||
},
|
||||
|
||||
focused() {
|
||||
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
|
||||
this.show()
|
||||
@@ -99,7 +123,11 @@ document.addEventListener('alpine:init', () => {
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (refocus) {
|
||||
this.$refs.input.focus()
|
||||
// Back to whatever opened the view: the icon button, or the field itself. A tick
|
||||
// later, because the icon button is only on screen again once the view has closed.
|
||||
const back = this.$refs.trigger ?? this.$refs.input
|
||||
|
||||
this.$nextTick(() => back.focus())
|
||||
}
|
||||
},
|
||||
|
||||
@@ -107,6 +135,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const input = this.$refs.input
|
||||
|
||||
input.value = ''
|
||||
this.query = ''
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.focus()
|
||||
},
|
||||
|
||||
+41
-19
@@ -15,6 +15,9 @@
|
||||
* Alpine writes into an input (`input.value = …`, which fires no event) is caught on the input's
|
||||
* own `value` setter, and attribute changes by a MutationObserver.
|
||||
*
|
||||
* - Orientation: the geometry below is always the horizontal one. A vertical slider is that same
|
||||
* drawing turned a quarter by CSS, so only two things change here — the track's length is the
|
||||
* slider's height rather than its width, and a pointer is read along Y from the bottom edge up.
|
||||
* - Range: the handles never cross; an input that would pass the other is held at its value
|
||||
* before any listener (x-model, wire:model) reads it.
|
||||
* - PageUp and PageDown move by Compose's page: a tenth of the steps, at least one and at most
|
||||
@@ -224,6 +227,12 @@ function slider(root) {
|
||||
return { min, max: max > min ? max : min + 100, step: input.step === 'any' ? null : step > 0 ? step : 1 }
|
||||
}
|
||||
|
||||
/** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */
|
||||
const vertical = () => root.dataset.orientation === 'vertical'
|
||||
|
||||
/** The track's length: the slider's height while it stands up, its width while it lies down. */
|
||||
const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0)
|
||||
|
||||
const values = () => inputs.map((input) => Number.parseFloat(input.value))
|
||||
const thumbOf = (input) => drawing?.querySelector(`[data-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`)
|
||||
|
||||
@@ -247,7 +256,7 @@ function slider(root) {
|
||||
return
|
||||
}
|
||||
|
||||
const width = Math.max(root.clientWidth - HANDLE_WIDTH, 0)
|
||||
const width = trackLength()
|
||||
const size = SIZES[root.dataset.size] ?? SIZES.xs
|
||||
const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width)
|
||||
const part = (selector) => drawing.querySelector(selector)
|
||||
@@ -484,7 +493,7 @@ function slider(root) {
|
||||
|
||||
const mutations = new MutationObserver(schedule)
|
||||
inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] }))
|
||||
mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'dir'] })
|
||||
mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'data-orientation', 'dir'] })
|
||||
|
||||
const resizes = new ResizeObserver(schedule)
|
||||
resizes.observe(root)
|
||||
@@ -511,39 +520,52 @@ function slider(root) {
|
||||
event.preventDefault()
|
||||
|
||||
const box = root.getBoundingClientRect()
|
||||
const width = Math.max(box.width - HANDLE_WIDTH, 0)
|
||||
const upright = vertical()
|
||||
const width = Math.max((upright ? box.height : box.width) - HANDLE_WIDTH, 0)
|
||||
const rtl = getComputedStyle(root).direction === 'rtl'
|
||||
const { min, max } = bounds()
|
||||
const handles = geometry(width).handles
|
||||
|
||||
const offset = (clientX) => clamp(rtl ? box.right - HANDLE_WIDTH / 2 - clientX : clientX - box.left - HANDLE_WIDTH / 2, 0, width)
|
||||
const valueAt = (clientX) => min + (width > 0 ? offset(clientX) / width : 0) * (max - min)
|
||||
// Where a pointer is along the track, and how far into it that is from the low end —
|
||||
// the left edge lying down (the right one in a right-to-left page), the bottom standing up.
|
||||
const along = (pointer) => (upright ? pointer.clientY : pointer.clientX)
|
||||
const offset = (coordinate) =>
|
||||
clamp(
|
||||
upright
|
||||
? box.bottom - HANDLE_WIDTH / 2 - coordinate
|
||||
: rtl
|
||||
? box.right - HANDLE_WIDTH / 2 - coordinate
|
||||
: coordinate - box.left - HANDLE_WIDTH / 2,
|
||||
0,
|
||||
width,
|
||||
)
|
||||
const valueAt = (coordinate) => min + (width > 0 ? offset(coordinate) / width : 0) * (max - min)
|
||||
|
||||
const startX = event.clientX
|
||||
const start = along(event)
|
||||
const before = values()
|
||||
let dragging = event.pointerType !== 'touch'
|
||||
let index = null
|
||||
|
||||
// The nearest handle; handles on top of each other wait for the first move's direction.
|
||||
const choose = (clientX) => {
|
||||
const choose = (coordinate) => {
|
||||
if (!range) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const x = offset(clientX)
|
||||
const [start, end] = handles.map((handle) => Math.abs(handle - x))
|
||||
const x = offset(coordinate)
|
||||
const [toStart, toEnd] = handles.map((handle) => Math.abs(handle - x))
|
||||
|
||||
if (start !== end) {
|
||||
return start < end ? 0 : 1
|
||||
if (toStart !== toEnd) {
|
||||
return toStart < toEnd ? 0 : 1
|
||||
}
|
||||
|
||||
const moved = rtl ? startX - clientX : clientX - startX
|
||||
const moved = upright || rtl ? start - coordinate : coordinate - start
|
||||
|
||||
return moved === 0 ? null : moved < 0 ? 0 : 1
|
||||
}
|
||||
|
||||
const follow = (clientX) => {
|
||||
index ??= choose(clientX)
|
||||
const follow = (coordinate) => {
|
||||
index ??= choose(coordinate)
|
||||
|
||||
if (index === null) {
|
||||
return
|
||||
@@ -555,7 +577,7 @@ function slider(root) {
|
||||
}
|
||||
|
||||
pressed(index)
|
||||
commit(index, valueAt(clientX))
|
||||
commit(index, valueAt(coordinate))
|
||||
}
|
||||
|
||||
const move = (moveEvent) => {
|
||||
@@ -563,12 +585,12 @@ function slider(root) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!dragging && Math.abs(moveEvent.clientX - startX) < TOUCH_SLOP) {
|
||||
if (!dragging && Math.abs(along(moveEvent) - start) < TOUCH_SLOP) {
|
||||
return
|
||||
}
|
||||
|
||||
dragging = true
|
||||
follow(moveEvent.clientX)
|
||||
follow(along(moveEvent))
|
||||
}
|
||||
|
||||
const end = (endEvent) => {
|
||||
@@ -577,7 +599,7 @@ function slider(root) {
|
||||
}
|
||||
|
||||
if (endEvent.type === 'pointerup' && !dragging) {
|
||||
follow(endEvent.clientX)
|
||||
follow(along(endEvent))
|
||||
}
|
||||
|
||||
release()
|
||||
@@ -602,7 +624,7 @@ function slider(root) {
|
||||
window.addEventListener('pointercancel', end)
|
||||
|
||||
if (dragging) {
|
||||
follow(event.clientX)
|
||||
follow(start)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
(resources/css/components/selection.css).
|
||||
|
||||
Without a label — a "select all" in a table header, a row's tick — the row is only the 18px box,
|
||||
so the box carries `touch-target` and catches presses over M3's 48px minimum. --}}
|
||||
so the box carries `touch-target` and catches presses over M3's 48px minimum.
|
||||
|
||||
Grouping is the caller's, not this component's: M3 asks that from `expanded` (840px) a set of
|
||||
related checkboxes be gathered into a contained region rather than left as one long column
|
||||
(docs/reference/m3/components-navigation-selection-inputs.md § Checkbox, Behaviour). Lay the
|
||||
group out yourself — `<div class="grid gap-4 expanded:grid-cols-2">` around the checkboxes, or a
|
||||
`<x-card>` or side sheet holding them — and give the group a heading that names what it asks. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
its items: `kinds` and `kinds.*`) replaces the hint.
|
||||
|
||||
`scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a narrow
|
||||
screen: the edge it can still scroll towards fades (resources/js/chips.js), and a chip reached
|
||||
with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set.
|
||||
screen. M3's chips accessibility page asks that a row which overflows say so, so the edge it
|
||||
can still scroll towards fades (resources/js/chips.js) and, where the pointer is fine and
|
||||
there is no swipe to reach for, a button sits over each fading edge and scrolls the row by
|
||||
most of its width. The buttons are pointer affordances only — `tabindex="-1"` and
|
||||
`aria-hidden`, because the arrow keys already walk every chip and scroll each one clear of
|
||||
the fade. Removing a focused input chip moves focus within the set.
|
||||
|
||||
The set is one tab stop and the arrow keys move between the chips inside it, with Home and End
|
||||
at the ends — M3's chip keyboard table. Backspace and Delete still remove a focused input
|
||||
@@ -43,15 +47,35 @@
|
||||
@endif
|
||||
|
||||
@if ($scroll)
|
||||
<div
|
||||
data-chip-set
|
||||
x-data="materialChipSet"
|
||||
x-on:keydown="key($event)"
|
||||
x-on:focusin="rove($event.target)"
|
||||
wire:ignore.self
|
||||
class="-mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
|
||||
>
|
||||
{{ $slot }}
|
||||
<div x-data="materialChipSet" class="relative">
|
||||
<div
|
||||
data-chip-set
|
||||
x-ref="row"
|
||||
x-on:keydown="key($event)"
|
||||
x-on:focusin="rove($event.target)"
|
||||
wire:ignore.self
|
||||
class="peer -mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 pointer-fine:scroll-px-10 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
{{-- After the row, so each button can ask whether the row can still scroll its way. --}}
|
||||
@foreach (['start' => 'chevron_left', 'end' => 'chevron_right'] as $edge => $glyph)
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
data-chip-scroll="{{ $edge }}"
|
||||
x-on:click="nudge('{{ $edge }}')"
|
||||
@class([
|
||||
'absolute inset-y-0 my-auto hidden size-8 place-items-center rounded-corner-full bg-surface-container-high text-on-surface-variant shadow-elevation-1',
|
||||
'start-0 peer-data-scroll-start:pointer-fine:grid' => $edge === 'start',
|
||||
'end-0 peer-data-scroll-end:pointer-fine:grid' => $edge === 'end',
|
||||
])
|
||||
>
|
||||
<x-livewire-material::icon :name="$glyph" class="size-4.5 rtl:-scale-x-100" optical="20" />
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div data-chip-set x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)" class="flex flex-wrap gap-2">
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
calendar in a dialog, with a pencil to switch to typing.
|
||||
- `input`: the same dialog, opened on its text field, with a calendar icon to switch back.
|
||||
|
||||
A `range` picker on a compact window (below `medium`) is M3's full-screen range picker instead
|
||||
of the 360px dialog: an app bar with a close button and **Save**, the range as the headline
|
||||
under the supporting text, and the months in one vertically scrolling list rather than stepped
|
||||
one at a time. Nothing changes for a single date, or for a docked picker at `medium` and up.
|
||||
|
||||
The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the
|
||||
value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed
|
||||
into the docked field is the value as soon as it is a whole, allowed date.
|
||||
@@ -265,6 +270,30 @@
|
||||
data-datepicker-picker
|
||||
>
|
||||
<div tabindex="-1" data-datepicker-surface>
|
||||
{{-- M3's full-screen range picker (compact): an app bar with a close button and Save,
|
||||
then the supporting text and the range as the headline, over a divider. --}}
|
||||
<div data-datepicker-full x-cloak x-show="presentation === 'full'">
|
||||
<div data-datepicker-app-bar>
|
||||
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="cancel()" data-datepicker-close />
|
||||
|
||||
<span data-datepicker-app-bar-actions>
|
||||
<span x-show="! typing">
|
||||
<x-livewire-material::button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||
</span>
|
||||
<span x-show="typing">
|
||||
<x-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||
</span>
|
||||
|
||||
<x-livewire-material::button :label="__('Save')" x-on:click="confirm()" data-datepicker-save />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div data-datepicker-full-headline>
|
||||
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
|
||||
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
|
||||
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
|
||||
|
||||
@@ -280,7 +309,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="! typing" data-datepicker-calendar>
|
||||
<div x-show="! typing" x-on:scroll="extendMonths($event)" data-datepicker-calendar>
|
||||
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
|
||||
|
||||
<div data-datepicker-nav x-show="presentation === 'modal'">
|
||||
@@ -350,9 +379,14 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="(week, row) in weeks" :key="row">
|
||||
<template x-for="row in rows" :key="row.key">
|
||||
<tr>
|
||||
<template x-for="(cell, column) in week" :key="column">
|
||||
{{-- Full screen: a month's name heads its weeks, M3's month label. --}}
|
||||
<template x-if="row.label">
|
||||
<th colspan="7" scope="colgroup" x-text="row.label" data-datepicker-month-label></th>
|
||||
</template>
|
||||
|
||||
<template x-for="(cell, column) in (row.cells ?? [])" :key="column">
|
||||
<td
|
||||
role="gridcell"
|
||||
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
|
||||
@@ -469,7 +503,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
|
||||
{{-- The full-screen picker confirms from its app bar, so it draws no action row. --}}
|
||||
<div x-show="presentation !== 'full' && (view === 'days' || typing || presentation === 'modal')" data-datepicker-actions>
|
||||
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
|
||||
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
at the end of the row so the state has two indicators and not only a colour — unless the caller
|
||||
trails the field with something of its own, or the field is `xs` and has no room.
|
||||
|
||||
`counter` is the maximum number of characters, and puts M3's character counter at the end of the
|
||||
supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once
|
||||
the value is past the maximum
|
||||
(docs/reference/m3/components-navigation-selection-inputs.md § Text Fields). It is said as
|
||||
"Character count, 5/20", M3's own label, from a polite region a second after typing stops rather
|
||||
than on every keystroke, which would talk over the typing.
|
||||
|
||||
From `medium` the field is no wider than 40rem, because M3 asks that a text field never span
|
||||
the full width of a large screen (§ Text Fields, Behaviour); a `max-w-*` class from the call
|
||||
site beats that, and `full` takes it off altogether.
|
||||
|
||||
`class` from the call site lands on the outermost element, never the control, so a margin or
|
||||
a width is safe here. --}}
|
||||
|
||||
@@ -30,6 +41,8 @@
|
||||
'variant' => null,
|
||||
'floated' => false,
|
||||
'mono' => false,
|
||||
'counter' => null,
|
||||
'full' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -40,6 +53,7 @@
|
||||
// M3 pairs the error colours with a trailing error icon so the state has two indicators. It
|
||||
// gives way to whatever the caller trails the field with, and to `xs`, which has no room.
|
||||
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
|
||||
$counter = filled($counter) && (int) $counter > 0 ? (int) $counter : null;
|
||||
@endphp
|
||||
|
||||
<div
|
||||
@@ -49,6 +63,7 @@
|
||||
@if ($messages !== []) data-invalid @endif
|
||||
@if ($floated) data-floated @endif
|
||||
@if ($mono) data-mono @endif
|
||||
@if ($full) data-full @endif
|
||||
>
|
||||
<div class="field-box">
|
||||
@if ($icon)
|
||||
@@ -80,13 +95,48 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
<div id="{{ $id }}-support" class="field-support" role="alert">
|
||||
@foreach ($messages as $message)
|
||||
<p>{{ $message }}</p>
|
||||
@endforeach
|
||||
@if ($messages !== [] || filled($hint) || $counter !== null)
|
||||
<div class="field-support-row">
|
||||
@if ($messages !== [])
|
||||
<div id="{{ $id }}-support" class="field-support" role="alert">
|
||||
@foreach ($messages as $message)
|
||||
<p>{{ $message }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (filled($hint))
|
||||
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
|
||||
@endif
|
||||
|
||||
@if ($counter !== null)
|
||||
<span
|
||||
class="field-counter"
|
||||
data-field-counter
|
||||
x-data="{
|
||||
used: 0,
|
||||
max: {{ $counter }},
|
||||
spoken: '',
|
||||
settle: null,
|
||||
init() {
|
||||
const control = this.$el.closest('.field').querySelector('.field-control');
|
||||
|
||||
this.used = control.value.length;
|
||||
control.addEventListener('input', () => (this.used = control.value.length));
|
||||
|
||||
this.$watch('used', () => {
|
||||
clearTimeout(this.settle);
|
||||
this.settle = setTimeout(() => (this.spoken = @js(__('Character count, :used/:max')).replace(':used', this.used).replace(':max', this.max)), 1000);
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(this.settle);
|
||||
},
|
||||
}"
|
||||
x-bind:data-over="used > max"
|
||||
>
|
||||
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
|
||||
<span class="sr-only" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@elseif (filled($hint))
|
||||
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
value, such as a currency or a unit), `variant`, `size` for unlabelled controls in toolbars,
|
||||
and `mono` for a field that holds code. `clearable` adds a trailing button that empties the
|
||||
field once it holds something; `copyable` one that copies its value and says so in a snackbar
|
||||
(a share link, a token). Every other attribute reaches the `<input>`: `type`, `min`, `step`,
|
||||
(a share link, a token). `counter` counts the characters used against `maxlength`, at the end of
|
||||
the supporting-text row; `full` lets the field span its pane past the 40rem M3 bounds it to
|
||||
from `medium`. Every other attribute reaches the `<input>`: `type`, `min`, `step`,
|
||||
`readonly`, `wire:model` and the rest. Errors are read from the bag under the `wire:model` name.
|
||||
|
||||
The placeholder is a single space when none is given, because the label can only tell an
|
||||
@@ -21,6 +23,8 @@
|
||||
'suffix' => null,
|
||||
'clearable' => false,
|
||||
'copyable' => false,
|
||||
'counter' => false,
|
||||
'full' => false,
|
||||
'size' => 'md',
|
||||
'variant' => null,
|
||||
'mono' => false,
|
||||
@@ -33,9 +37,11 @@
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
|
||||
$max = $counter ? $attributes->get('maxlength') : null;
|
||||
@endphp
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<input
|
||||
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
|
||||
id="{{ $id }}"
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
|
||||
`<input type="search">`.
|
||||
|
||||
`trigger="icon"` is M3's second entry point, the search icon button: search as a secondary
|
||||
action in a toolbar or an app bar, a single 48px button that expands into the full-screen view
|
||||
and hands focus to the field (so `docked` has nothing to say about it). The bar itself is the
|
||||
view's header there, and closing gives the button its focus back.
|
||||
|
||||
`suggestions` is a slot shown in the view until the first keystroke — M3's suggestions
|
||||
behaviour: recent searches, popular queries. The results slot takes over once something is
|
||||
typed, and whichever of the two is on screen is what the live region counts.
|
||||
|
||||
The bar is a combobox: the wrapper around the input carries `role="combobox"` with
|
||||
`aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are
|
||||
a list, and a polite live region says how many of them there are whenever they change — M3 asks
|
||||
@@ -23,22 +32,26 @@
|
||||
'label' => null,
|
||||
'icon' => 'search',
|
||||
'docked' => false,
|
||||
'trigger' => 'bar',
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$placeholder ??= __('Search');
|
||||
$id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
|
||||
$trigger = $trigger === 'icon' ? 'icon' : 'bar';
|
||||
|
||||
$announce = [
|
||||
'none' => __('No results'),
|
||||
'one' => __('1 result'),
|
||||
'many' => __(':count results'),
|
||||
'suggestionOne' => __('1 suggestion'),
|
||||
'suggestionMany' => __(':count suggestions'),
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce))"
|
||||
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce), @js($trigger))"
|
||||
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
|
||||
x-on:focusout="leave($event)"
|
||||
x-on:pointerdown.outside="close()"
|
||||
@@ -46,16 +59,34 @@
|
||||
x-bind:data-open="open ? '' : null"
|
||||
x-bind:data-full-screen="fullScreen ? '' : null"
|
||||
data-search
|
||||
data-trigger="{{ $trigger }}"
|
||||
{{ $attributes->only(['class', 'wire:key'])->class(['relative']) }}
|
||||
>
|
||||
<div data-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div>
|
||||
|
||||
@if ($trigger === 'icon')
|
||||
<button
|
||||
type="button"
|
||||
x-ref="trigger"
|
||||
x-show="! open"
|
||||
x-on:click="expand()"
|
||||
aria-label="{{ $label ?? $placeholder }}"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="{{ $id }}-view"
|
||||
aria-expanded="false"
|
||||
x-bind:aria-expanded="open.toString()"
|
||||
data-search-trigger
|
||||
>
|
||||
<x-livewire-material::icon :name="$icon" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
|
||||
<span data-search-leading x-show="! fullScreen">
|
||||
<x-livewire-material::icon :name="$icon" />
|
||||
</span>
|
||||
|
||||
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}">
|
||||
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}">
|
||||
<x-livewire-material::icon name="arrow_back" />
|
||||
</button>
|
||||
|
||||
@@ -78,7 +109,7 @@
|
||||
aria-label="{{ $label ?? $placeholder }}"
|
||||
x-on:focus="focused()"
|
||||
x-on:click="show()"
|
||||
x-on:input="show()"
|
||||
x-on:input="typed($event)"
|
||||
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
|
||||
data-search-input
|
||||
/>
|
||||
@@ -103,10 +134,14 @@
|
||||
x-on:keydown.arrow-up.prevent="step(-1)"
|
||||
x-on:click="choose($event)"
|
||||
>
|
||||
@isset($suggestions)
|
||||
<div data-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
|
||||
@endisset
|
||||
|
||||
@if ($slot->hasActualContent())
|
||||
<div data-search-results role="list">{{ $slot }}</div>
|
||||
<div data-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
|
||||
@elseif (isset($empty))
|
||||
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
|
||||
<div data-search-results @isset($suggestions) x-cloak x-show="query !== ''" @endisset><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
`secondary`, `tertiary`, `error`, `success`, `warning`, `info` — the active track and the
|
||||
handle; the inactive track is its container (secondary-container for primary).
|
||||
|
||||
`orientation="vertical"` stands the slider up, M3 Expressive's second orientation: the same
|
||||
drawing, turned a quarter so the value grows upwards, with the value label beside the handle
|
||||
instead of above it and every size unchanged. The keyboard is the native range's, so Up and
|
||||
Right raise the value and Down and Left lower it. A vertical slider is as wide as a horizontal
|
||||
one is tall and takes its length from the wrapper, so give it an explicit height —
|
||||
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range
|
||||
sliders horizontal ("never use range sliders vertically — too much cognitive load"), so
|
||||
`range` wins over `orientation`.
|
||||
|
||||
`label` is shown above and names the input (with `range`, the group; its handles are "Range
|
||||
start" and "Range end"); `hint` goes below, and a validation message for the bound property
|
||||
(or `name`) replaces it. Other attributes go to the input(s).
|
||||
@@ -71,6 +80,7 @@
|
||||
'valueLabel' => 'drag',
|
||||
'icon' => null,
|
||||
'color' => 'primary',
|
||||
'orientation' => 'horizontal',
|
||||
'disabled' => false,
|
||||
])
|
||||
|
||||
@@ -81,6 +91,8 @@
|
||||
$color = in_array($color, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? $color : 'primary';
|
||||
$valueLabel = in_array($valueLabel, ['always', 'drag', 'never'], true) ? $valueLabel : 'drag';
|
||||
$centered = $centered && ! $range;
|
||||
// M3 § Sliders, Behaviour: "range sliders should stay horizontal only".
|
||||
$vertical = $orientation === 'vertical' && ! $range;
|
||||
|
||||
$minimum = is_numeric($min) ? (float) $min : 0.0;
|
||||
$maximum = is_numeric($max) && (float) $max > $minimum ? (float) $max : $minimum + 100;
|
||||
@@ -267,10 +279,19 @@
|
||||
][$size];
|
||||
// The label's bottom sits 4px above the handle: half of 44, 44, 52, 68 and 108px, plus 4.
|
||||
$labelBottom = ['xs' => 'bottom-[calc(50%+1.625rem)]', 'sm' => 'bottom-[calc(50%+1.625rem)]', 'md' => 'bottom-[calc(50%+1.875rem)]', 'lg' => 'bottom-[calc(50%+2.375rem)]', 'xl' => 'bottom-[calc(50%+3.625rem)]'][$size];
|
||||
// The measure across the slider — the handle's height, and so the slider's own. Standing up it
|
||||
// is the width instead, and the length comes from the wrapper the caller sized.
|
||||
$across = [
|
||||
'xs' => $vertical ? 'w-12' : 'h-12',
|
||||
'sm' => $vertical ? 'w-12' : 'h-12',
|
||||
'md' => $vertical ? 'w-13' : 'h-13',
|
||||
'lg' => $vertical ? 'w-17' : 'h-17',
|
||||
'xl' => $vertical ? 'w-27' : 'h-27',
|
||||
][$size];
|
||||
$thumbs = $range ? ['start', 'end'] : ['start'];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized]) }}>
|
||||
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized && ! $vertical, 'flex w-fit flex-col' => $vertical]) }}>
|
||||
@if (filled($label))
|
||||
@if ($range)
|
||||
<span id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</span>
|
||||
@@ -284,20 +305,31 @@
|
||||
x-on:pointerdown="press($event)"
|
||||
data-slider
|
||||
data-size="{{ $size }}"
|
||||
@if ($vertical) data-orientation="vertical" @endif
|
||||
@if ($centered) data-centered @endif
|
||||
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
|
||||
@class([
|
||||
'group/slider relative cursor-pointer touch-pan-y select-none has-disabled:cursor-not-allowed',
|
||||
'h-12' => in_array($size, ['xs', 'sm'], true),
|
||||
'h-13' => $size === 'md',
|
||||
'h-17' => $size === 'lg',
|
||||
'h-27' => $size === 'xl',
|
||||
// Room above for a label that never hides: half the handle, 4px, and the 44px pill.
|
||||
'mt-12' => $valueLabel === 'always',
|
||||
'group/slider relative cursor-pointer select-none has-disabled:cursor-not-allowed',
|
||||
$across,
|
||||
'touch-pan-y' => ! $vertical,
|
||||
// Standing up: 192px long unless the wrapper says otherwise, and a size container,
|
||||
// which is what lets the drawing below be as long as this is tall.
|
||||
'touch-pan-x h-48 min-h-0 flex-auto [container-type:size]' => $vertical,
|
||||
// Room for a label that never hides: half the handle, 4px and the 44px pill — above the
|
||||
// track when the slider lies down, beside it when it stands up.
|
||||
'mt-12' => $valueLabel === 'always' && ! $vertical,
|
||||
'ms-12' => $valueLabel === 'always' && $vertical,
|
||||
'noscript:h-auto noscript:cursor-auto noscript:space-y-2',
|
||||
])
|
||||
>
|
||||
<div data-slider-drawing wire:ignore aria-hidden="true" class="pointer-events-none absolute inset-y-0 inset-x-0.5 rtl:-scale-x-100 noscript:hidden">
|
||||
<div data-slider-drawing wire:ignore aria-hidden="true" @class([
|
||||
'pointer-events-none absolute noscript:hidden',
|
||||
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical,
|
||||
// The same drawing, laid out as long as the slider is tall and then turned a quarter
|
||||
// anticlockwise so the value grows upwards. Everything inside it — the segments, the
|
||||
// ticks, the handle and its label — is placed as if the slider were lying down.
|
||||
'top-1/2 left-1/2 h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90' => $vertical,
|
||||
])>
|
||||
<div @class(['absolute inset-x-0 top-1/2 -translate-y-1/2', $trackHeight])>
|
||||
@foreach (['start' => $inactiveInk[$color], 'active' => $activeInk[$color], 'end' => $inactiveInk[$color]] as $part => $ink)
|
||||
<span
|
||||
@@ -337,7 +369,14 @@
|
||||
data-track-icon
|
||||
@if (! $iconInActive && ! $iconInInactive) hidden @endif
|
||||
@if ($iconInActive) data-active @endif
|
||||
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
|
||||
@class([
|
||||
'absolute top-1/2 flex -translate-y-1/2',
|
||||
'rtl:-scale-x-100' => ! $vertical,
|
||||
// Turned back the quarter the drawing turns, so the glyph stands up.
|
||||
'rotate-90' => $vertical,
|
||||
$iconInk[$color],
|
||||
'group-has-disabled/slider:text-on-surface/38',
|
||||
])
|
||||
style="left: {{ $calc($iconAt) }}"
|
||||
>
|
||||
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
|
||||
@@ -358,11 +397,15 @@
|
||||
])></span>
|
||||
|
||||
@if ($valueLabel !== 'never')
|
||||
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center rtl:-scale-x-100', $labelBottom])>
|
||||
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center', 'rtl:-scale-x-100' => ! $vertical, $labelBottom])>
|
||||
<span
|
||||
data-value-label
|
||||
@class([
|
||||
'flex h-11 min-w-12 origin-bottom items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
|
||||
'flex h-11 min-w-12 items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
|
||||
'origin-bottom' => ! $vertical,
|
||||
// Turned back the quarter the drawing turns: the pill lands
|
||||
// beside the handle rather than above it, and reads across.
|
||||
'rotate-90' => $vertical,
|
||||
'transition-[opacity,scale] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
|
||||
'opacity-0 scale-75 group-data-pressed/thumb:opacity-100 group-data-pressed/thumb:scale-100 group-data-focused/thumb:opacity-100 group-data-focused/thumb:scale-100' => $valueLabel === 'drag',
|
||||
])
|
||||
@@ -383,6 +426,7 @@
|
||||
step="{{ $continuous ? 'any' : $format($interval) }}"
|
||||
value="{{ $format($values[$index]) }}"
|
||||
data-thumb="{{ $thumb }}"
|
||||
@if ($vertical) aria-orientation="vertical" @endif
|
||||
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
|
||||
@if ($described) aria-describedby="{{ $described }}" @endif
|
||||
@if ($messages !== []) aria-invalid="true" @endif
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
It starts at `rows` lines (3) and grows with what is typed up to `max-rows`, then scrolls;
|
||||
`:autogrow="false"` keeps it at `rows` and lets it be resized by hand, vertically only, so a
|
||||
form's column never moves. The label rests on the first line rather than in the middle of the
|
||||
box. `label`, `hint`, `variant`; every other attribute reaches the `<textarea>`. --}}
|
||||
box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and
|
||||
`full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other
|
||||
attribute reaches the `<textarea>`. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -13,6 +15,8 @@
|
||||
'rows' => 3,
|
||||
'maxRows' => null,
|
||||
'autogrow' => true,
|
||||
'counter' => false,
|
||||
'full' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -22,9 +26,11 @@
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
|
||||
$max = $counter ? $attributes->get('maxlength') : null;
|
||||
@endphp
|
||||
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<textarea
|
||||
{{ $attributes->except(['class', 'id', 'placeholder']) }}
|
||||
id="{{ $id }}"
|
||||
|
||||
@@ -52,8 +52,9 @@
|
||||
</x-chip-set>
|
||||
BLADE,
|
||||
'A set that scrolls' => <<<'BLADE'
|
||||
{{-- M3 asks an overflowing chip row for a visible affordance: the edge it can still scroll towards fades, and on a mouse a button sits over it. --}}
|
||||
<div class="w-full max-w-sm">
|
||||
<x-chip-set label="Sort and filter" scroll>
|
||||
<x-chip-set label="Sort and filter" hint="Drag, or use the arrow keys" scroll>
|
||||
<x-chip type="filter" label="Newest" name="sort[]" value="newest" :selected="true" />
|
||||
<x-chip type="filter" label="Largest" name="sort[]" value="largest" />
|
||||
<x-chip type="filter" label="Expiring soon" name="sort[]" value="expiring" />
|
||||
|
||||
@@ -41,6 +41,24 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Width at medium and above' => <<<'BLADE'
|
||||
<div class="w-full space-y-4">
|
||||
{{-- M3: never let a text field span the full width of a large screen. From medium it stops at 40rem. --}}
|
||||
<x-input label="Bounded, the default" hint="No wider than 40rem from medium, however wide the pane is" />
|
||||
<x-input label="Narrower" class="max-w-xs" hint="A max-w-* class from the call site wins" />
|
||||
<x-input label="The width of the pane" full icon="search" hint="`full` takes the bound off — for a search row or an editor" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Character counter' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 medium:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-input label="Share name" maxlength="40" counter hint="Recipients see this name" />
|
||||
<x-input variant="filled" label="Subject" maxlength="60" counter />
|
||||
</div>
|
||||
|
||||
<x-textarea label="Message for recipients" maxlength="180" counter rows="3" hint="Sent with the link" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Textarea and select' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 medium:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
@@ -74,6 +92,23 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'A checkbox group at expanded' => <<<'BLADE'
|
||||
{{-- M3: from expanded (840px), gather related checkboxes into a contained region instead of one long column. --}}
|
||||
<x-card variant="outlined" class="w-full max-w-2xl">
|
||||
<fieldset>
|
||||
<legend class="mb-4 type-title-sm text-on-surface">Tell me about</legend>
|
||||
|
||||
<div class="grid gap-4 expanded:grid-cols-2">
|
||||
<x-checkbox label="Downloads" name="notify[]" value="downloads" checked />
|
||||
<x-checkbox label="Comments" name="notify[]" value="comments" />
|
||||
<x-checkbox label="Expiring shares" name="notify[]" value="expiring" checked />
|
||||
<x-checkbox label="Failed uploads" name="notify[]" value="failed" />
|
||||
<x-checkbox label="New team members" name="notify[]" value="members" />
|
||||
<x-checkbox label="Weekly summary" name="notify[]" value="summary" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</x-card>
|
||||
BLADE,
|
||||
'Radio buttons' => <<<'BLADE'
|
||||
<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]]" />
|
||||
@@ -134,6 +169,40 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Search entry points and suggestions' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 medium:grid-cols-2">
|
||||
<div x-data="{ query: '' }">
|
||||
<x-search x-model="query" placeholder="Search shares">
|
||||
<x-slot:suggestions>
|
||||
<x-list>
|
||||
<x-list-item title="Recent: holiday photos" icon="history" link="#fields" />
|
||||
<x-list-item title="Recent: contract" icon="history" link="#fields" />
|
||||
<x-list-item title="Shares expiring this week" icon="trending_up" link="#fields" />
|
||||
</x-list>
|
||||
</x-slot:suggestions>
|
||||
|
||||
<x-list>
|
||||
<x-list-item title="holiday-photos.zip" description="248 MB" icon="folder_zip" link="#fields" x-show="'holiday-photos.zip'.includes(query.toLowerCase())" />
|
||||
<x-list-item title="contract.pdf" description="1.2 MB" icon="picture_as_pdf" link="#fields" x-show="'contract.pdf'.includes(query.toLowerCase())" />
|
||||
</x-list>
|
||||
</x-search>
|
||||
</div>
|
||||
|
||||
{{-- M3's search icon button: search as a secondary action, expanding into the full-screen view. --}}
|
||||
<div class="flex items-center gap-2 rounded-corner-full bg-surface-container-high px-2 py-1">
|
||||
<x-button icon="menu" aria-label="Open the menu" />
|
||||
<span class="grow type-title-md text-on-surface">Shares</span>
|
||||
<x-search trigger="icon" label="Search shares">
|
||||
<x-slot:suggestions>
|
||||
<x-list>
|
||||
<x-list-item title="Recent: design review" icon="history" link="#fields" />
|
||||
</x-list>
|
||||
</x-slot:suggestions>
|
||||
<x-slot:empty>No shares match.</x-slot:empty>
|
||||
</x-search>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'A form' => <<<'BLADE'
|
||||
<x-card variant="outlined" class="w-full max-w-xl">
|
||||
<x-form x-on:submit.prevent="materialToast('Share created', { type: 'success' })" separator>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</div>
|
||||
BLADE,
|
||||
'Range' => <<<'BLADE'
|
||||
{{-- Narrow the window below 600px: a range picker becomes M3's full-screen one, with a close button, Save, and the months scrolled through. --}}
|
||||
<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" />
|
||||
@@ -34,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
|
||||
<x-datepicker label="Leave" range mode="modal" x-model="leave" hint="Full screen on a compact window" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
<x-slider label="Balance" value="15" :min="-50" :max="50" centered />
|
||||
</div>
|
||||
BLADE,
|
||||
'Vertical' => <<<'BLADE'
|
||||
{{-- M3 Expressive's second orientation. A vertical slider needs a height: it is on the wrapper, which the label and hint share. --}}
|
||||
<div class="flex flex-wrap items-end gap-10">
|
||||
<x-slider label="Volume" orientation="vertical" value="40" class="h-64" hint="Up and Down" />
|
||||
<x-slider label="Warmth" orientation="vertical" value="6" :max="10" ticks class="h-64" />
|
||||
<x-slider label="Brightness" orientation="vertical" value="70" size="md" icon="light_mode" class="h-64" />
|
||||
<x-slider label="Always labelled" orientation="vertical" value="55" value-label="always" color="tertiary" class="h-64" />
|
||||
<x-slider label="Locked" orientation="vertical" value="25" disabled class="h-64" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Colours and value labels' => <<<'BLADE'
|
||||
<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" />
|
||||
@@ -48,7 +58,7 @@
|
||||
<h2 class="type-headline-md">Sliders</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-slider></code>: M3 Expressive's slider on native range inputs — standard, range and centred, five sizes, ticks, value labels and an inset icon.
|
||||
<code><x-slider></code>: M3 Expressive's slider on native range inputs — standard, range and centred, horizontal or vertical, five sizes, ticks, value labels and an inset icon.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
|
||||
@@ -234,12 +234,35 @@ it('scrolls a chip set on one line with fading edges', function () {
|
||||
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
|
||||
->toContain('aria-label="Sort"')
|
||||
->toContain('x-data="materialChipSet"')
|
||||
->toContain('x-ref="row"')
|
||||
->toContain('wire:ignore.self')
|
||||
->toContain('overflow-x-auto')
|
||||
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
|
||||
->not->toContain('flex-wrap');
|
||||
});
|
||||
|
||||
it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () {
|
||||
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-chip-scroll="start"')
|
||||
->toContain('data-chip-scroll="end"')
|
||||
->toContain('x-on:click="nudge(\'start\')"')
|
||||
->toContain('x-on:click="nudge(\'end\')"')
|
||||
// Only while the row can still scroll that way, and only where the pointer is fine.
|
||||
->toContain('peer-data-scroll-start:pointer-fine:grid')
|
||||
->toContain('peer-data-scroll-end:pointer-fine:grid')
|
||||
// A pointer affordance, not a tab stop: the arrows already walk every chip.
|
||||
->toContain('tabindex="-1"')
|
||||
->toContain('aria-hidden="true"')
|
||||
// A chip the keyboard reaches clears the buttons as well as the fade.
|
||||
->toContain('pointer-fine:scroll-px-10');
|
||||
|
||||
// A wrapping set has no edge to scroll towards.
|
||||
expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>'))
|
||||
->not->toContain('data-chip-scroll');
|
||||
});
|
||||
|
||||
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
|
||||
@@ -54,6 +54,41 @@ it('makes the field a read-only trigger for the modal and modal input pickers',
|
||||
->and(datepickerConfig($html)['mode'])->toBe($mode);
|
||||
})->with(['modal', 'input']);
|
||||
|
||||
it('gives a range picker M3\'s full-screen presentation, with an app bar and Save', function () {
|
||||
$html = (string) $this->blade('<x-datepicker id="trip" label="Trip" range mode="modal" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-datepicker-full')
|
||||
->toContain('x-show="presentation === \'full\'"')
|
||||
->toContain('data-datepicker-app-bar')
|
||||
->toContain('data-datepicker-close')
|
||||
->toContain('data-datepicker-save')
|
||||
->toContain('Save')
|
||||
->toContain('aria-label="Select dates"')
|
||||
// The months come from one list of rows, each headed by its label.
|
||||
->toContain('x-for="row in rows"')
|
||||
->toContain('data-datepicker-month-label')
|
||||
// The action row is the modal's; the full-screen picker confirms from its app bar.
|
||||
->toContain('x-show="presentation !== \'full\' && (view === \'days\' || typing || presentation === \'modal\')"')
|
||||
->toContain('x-on:scroll="extendMonths($event)"');
|
||||
|
||||
expect(datepickerConfig($html))->toMatchArray(['range' => true, 'mode' => 'modal']);
|
||||
});
|
||||
|
||||
it('leaves the docked and single-date pickers as they were', function () {
|
||||
// Only the presentation is new: a single date never reaches it, whatever the window.
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/js/datepicker.js'))
|
||||
->toContain("this.presentation = config.range && this.compact ? 'full' : config.mode === 'docked' && !this.compact ? 'docked' : 'modal'");
|
||||
|
||||
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-datepicker-nav data-docked')
|
||||
->toContain('x-show="presentation === \'docked\'"')
|
||||
->toContain('data-datepicker-menu-button="months"')
|
||||
->toContain('x-show="presentation === \'modal\'"');
|
||||
});
|
||||
|
||||
it('names each dropdown so Shift+M and Shift+Y can reach it', function () {
|
||||
$html = (string) $this->blade('<x-datepicker label="Birthday" />');
|
||||
|
||||
|
||||
@@ -147,6 +147,39 @@ it('collects a file field\'s own errors and each file\'s', function () {
|
||||
->toContain('photo.jpg is larger than 2 GB.');
|
||||
});
|
||||
|
||||
it('counts the characters of a field that has a maximum', function () {
|
||||
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="field-support-row"')
|
||||
->toContain('class="field-counter"')
|
||||
->toContain('maxlength="60"')
|
||||
->toContain('max: 60,')
|
||||
->toContain('>0/60</span>')
|
||||
->toContain('aria-live="polite"')
|
||||
->toContain('Shown on your profile');
|
||||
|
||||
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
|
||||
->toContain('class="field-counter"')
|
||||
->toContain('>0/140</span>');
|
||||
});
|
||||
|
||||
it('shows no counter without `counter` or without a maximum to count against', function () {
|
||||
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter')
|
||||
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
|
||||
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
|
||||
});
|
||||
|
||||
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
|
||||
->toContain('@media (width >= 37.5rem)')
|
||||
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
|
||||
|
||||
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full')
|
||||
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
|
||||
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full');
|
||||
});
|
||||
|
||||
it('lays out a form with its actions under an optional divider', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-form wire:submit="save" separator>
|
||||
|
||||
@@ -47,6 +47,50 @@ it('shows the results as a list, or what to say when there are none', function (
|
||||
->not->toContain('data-search-results role="list"');
|
||||
});
|
||||
|
||||
it('offers M3\'s search-icon entry point', function () {
|
||||
$html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-trigger="icon"')
|
||||
->toContain('data-search-trigger')
|
||||
->toContain('x-ref="trigger"')
|
||||
->toContain('x-on:click="expand()"')
|
||||
->toContain('aria-label="Search shares"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="find-view"')
|
||||
->toContain('materialSearch(false,')
|
||||
->toContain("'icon'")
|
||||
// The bar is still there: it becomes the expanded view's header.
|
||||
->toContain('data-search-bar');
|
||||
|
||||
expect((string) $this->blade('<x-search />'))
|
||||
->toContain('data-trigger="bar"')
|
||||
->not->toContain('data-search-trigger');
|
||||
});
|
||||
|
||||
it('shows suggestions until the first keystroke, then the results', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-search>
|
||||
<a href="/shares/1">holiday.zip</a>
|
||||
<x-slot:suggestions><a href="/recent">Recent shares</a></x-slot:suggestions>
|
||||
</x-search>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search-suggestions role="list"')
|
||||
->toContain('x-show="query === \'\'"')
|
||||
->toContain('Recent shares')
|
||||
->toContain('x-show="query !== \'\'"')
|
||||
->toContain('holiday.zip')
|
||||
->toContain('1 suggestion')
|
||||
->toContain(':count suggestions');
|
||||
|
||||
// Without the slot the results stand alone and are never hidden.
|
||||
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||
->not->toContain('data-search-suggestions')
|
||||
->not->toContain('x-show="query !== \'\'"');
|
||||
});
|
||||
|
||||
it('names the input, trails the bar, and stays docked on request', function () {
|
||||
$html = (string) $this->blade('<x-search label="Search your shares" icon="travel_explore" docked><x-slot:trailing><button>AR</button></x-slot:trailing></x-search>');
|
||||
|
||||
|
||||
@@ -37,6 +37,29 @@ it('gives a selection control without a label a 48px target', function () {
|
||||
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target');
|
||||
});
|
||||
|
||||
it('leaves a checkbox group to the caller and says how M3 wants it laid out', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<fieldset>
|
||||
<legend>Tell me about</legend>
|
||||
<div class="grid gap-4 expanded:grid-cols-2">
|
||||
<x-checkbox label="Downloads" name="notify[]" value="downloads" />
|
||||
<x-checkbox label="Comments" name="notify[]" value="comments" />
|
||||
</div>
|
||||
</fieldset>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('class="grid gap-4 expanded:grid-cols-2"')
|
||||
->toContain('Downloads')
|
||||
->toContain('Comments')
|
||||
->and(substr_count($html, 'data-checkbox'))->toBe(2);
|
||||
|
||||
// The rule is guidance, not markup, so the component's header has to carry it.
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php'))
|
||||
->toContain('expanded')
|
||||
->toContain('contained region');
|
||||
});
|
||||
|
||||
it('puts a checkbox at the end of its row on request', function () {
|
||||
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between');
|
||||
});
|
||||
|
||||
@@ -38,7 +38,8 @@ it('draws the first frame on the server: the track split around the handle, and
|
||||
|
||||
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
|
||||
expect((string) $this->blade('<x-slider value="40" />'))
|
||||
->toContain('flex h-11 min-w-12 origin-bottom')
|
||||
->toContain('flex h-11 min-w-12 items-center')
|
||||
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/')
|
||||
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/');
|
||||
});
|
||||
|
||||
@@ -196,6 +197,33 @@ it('shows the value label on drag, always, or never', function () {
|
||||
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label');
|
||||
});
|
||||
|
||||
it('stands a slider up on request, turning the drawing a quarter', function () {
|
||||
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-orientation="vertical"')
|
||||
->toContain('aria-orientation="vertical"')
|
||||
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
|
||||
->toContain('w-12')
|
||||
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
|
||||
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
|
||||
// The value label is turned back, so it lands beside the handle and reads across.
|
||||
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
|
||||
->not->toContain('origin-bottom')
|
||||
->not->toContain('rtl:-scale-x-100');
|
||||
|
||||
// The sizes are the horizontal ones, across instead of along.
|
||||
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
|
||||
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12');
|
||||
});
|
||||
|
||||
it('keeps a range slider horizontal, as M3 asks', function () {
|
||||
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
|
||||
->not->toContain('data-orientation')
|
||||
->not->toContain('aria-orientation')
|
||||
->toContain('h-12');
|
||||
});
|
||||
|
||||
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
|
||||
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
|
||||
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")
|
||||
|
||||
Reference in New Issue
Block a user