Merge branch 'worktree-agent-a4fc1072037346c95'
This commit is contained in:
+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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user