diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 6e084215..80faddb2 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -538,7 +538,9 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d ### `` -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. ### `` @@ -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 `` 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. -- ``: `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`. +- ``: `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`. - ``: a reveal button; `icon`, `size`. -- ``: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one. +- ``: 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 ``, ``, ``) takes the bound off for a field that really is the width of its pane — a search row, an editor. ``'s bar carries M3's own bound, 720px. +- `counter` (on `` and ``) 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. - ``: native ``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 + +``` + ### `` 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 ### `` -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 @@ -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 + + + @foreach ($this->recent as $term) + + @endforeach + + @foreach ($this->results as $share) + + @endforeach + +``` + ### `` The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `
` behind a skip link, and the snackbar host (do not add another ``). It needs `` in ``. diff --git a/resources/css/components/datepicker.css b/resources/css/components/datepicker.css index f55d5c8d..1d10d92e 100644 --- a/resources/css/components/datepicker.css +++ b/resources/css/components/datepicker.css @@ -1,10 +1,12 @@ /* * M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php). * - * One `` 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 `` 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 ; data-presentation="docked" | "modal" + * [data-datepicker-picker] the ; 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] { diff --git a/resources/css/components/field.css b/resources/css/components/field.css index ba761590..bb0e5123 100644 --- a/resources/css/components/field.css +++ b/resources/css/components/field.css @@ -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 { diff --git a/resources/css/components/search.css b/resources/css/components/search.css index 4c81443a..97eb04d5 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -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; diff --git a/resources/js/chips.js b/resources/js/chips.js index 8c4c7011..2b1e86c5 100644 --- a/resources/js/chips.js +++ b/resources/js/chips.js @@ -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 diff --git a/resources/js/datepicker.js b/resources/js/datepicker.js index cc439c99..1ed26d78 100644 --- a/resources/js/datepicker.js +++ b/resources/js/datepicker.js @@ -2,12 +2,15 @@ * `materialDatepicker`: the behaviour of `` — M3's docked, modal and modal-input * date pickers on one ``. * - * 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] diff --git a/resources/js/search.js b/resources/js/search.js index 7342cb5a..fb59822e 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -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() }, diff --git a/resources/js/slider.js b/resources/js/slider.js index 8f8a47aa..f8945571 100644 --- a/resources/js/slider.js +++ b/resources/js/slider.js @@ -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) } }, } diff --git a/resources/views/components/checkbox.blade.php b/resources/views/components/checkbox.blade.php index 150d0a28..9aaea1f8 100644 --- a/resources/views/components/checkbox.blade.php +++ b/resources/views/components/checkbox.blade.php @@ -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 — `
` around the checkboxes, or a + `` or side sheet holding them — and give the group a heading that names what it asks. --}} @props([ 'label' => null, diff --git a/resources/views/components/chip-set.blade.php b/resources/views/components/chip-set.blade.php index df0b765d..9857bf7f 100644 --- a/resources/views/components/chip-set.blade.php +++ b/resources/views/components/chip-set.blade.php @@ -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) -
- {{ $slot }} +
+
+ {{ $slot }} +
+ + {{-- 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) + + @endforeach
@else
diff --git a/resources/views/components/datepicker.blade.php b/resources/views/components/datepicker.blade.php index 033fec61..b869641d 100644 --- a/resources/views/components/datepicker.blade.php +++ b/resources/views/components/datepicker.blade.php @@ -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 >
+ {{-- 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. --}} +
+
+ + + + + + + + + + + + +
+ +
+

{{ $title }}

+

+
+
+

{{ $title }}

@@ -280,7 +309,7 @@
-
+
@@ -350,9 +379,14 @@ -