Merge branch 'worktree-agent-a41c756369d016a29'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:40:58 +02:00
67 changed files with 3434 additions and 1497 deletions
+16 -16
View File
@@ -6,8 +6,8 @@
* chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or
* 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 and — where the pointer is fine, so there is no
* A scrolling set marks the edges it can still scroll towards (`data-md-scroll-start`,
* `data-md-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,
@@ -25,23 +25,23 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialChip', () => ({
init() {
const button = this.removeButton()
const label = this.$root.querySelector('[data-chip-label]')
const label = this.$root.querySelector('[data-md-chip-label]')
// A chip rendered by x-for has no label on the server: its button carries the translated
// words with a `:label` placeholder, filled in once Alpine has written the label.
if (button?.dataset.chipRemove && label) {
if (button?.dataset.mdChipRemove && label) {
this.$nextTick(() => {
const text = label.textContent.trim()
if (text !== '' && !button.hasAttribute('aria-label')) {
button.setAttribute('aria-label', button.dataset.chipRemove.replace(':label', text))
button.setAttribute('aria-label', button.dataset.mdChipRemove.replace(':label', text))
}
})
}
},
removeButton() {
return this.$root.querySelector('[data-chip-remove]')
return this.$root.querySelector('[data-md-chip-remove]')
},
removeOnKey(event) {
@@ -68,15 +68,15 @@ document.addEventListener('alpine:init', () => {
handOffFocus(backwards) {
const chip = this.$root
const set = chip.closest('[data-chip-set]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])]
const set = chip.closest('[data-md-chip-set-row]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-md-chip]') ?? [])]
const index = chips.indexOf(chip)
const before = chips.slice(0, index).reverse()
const after = chips.slice(index + 1)
const onRemove = document.activeElement?.hasAttribute('data-chip-remove')
const onRemove = document.activeElement?.hasAttribute('data-md-chip-remove')
for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
const target = (onRemove && other.querySelector('[data-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
const target = (onRemove && other.querySelector('[data-md-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
if (target) {
target.focus()
@@ -92,17 +92,17 @@ document.addEventListener('alpine:init', () => {
// seconds pass), whenever the set changes and focus has fallen away, focus the same
// control again — the element itself, or the chip with its `wire:key` after a morph.
holdFocus(target, set) {
const key = target.closest('[data-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-chip-remove')
const key = target.closest('[data-md-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-md-chip-remove')
const current = () => {
if (target.isConnected || !key) {
return target.isConnected ? target : null
}
const chip = [...set.querySelectorAll('[data-chip]')].find((other) => other.getAttribute('wire:key') === key)
const chip = [...set.querySelectorAll('[data-md-chip]')].find((other) => other.getAttribute('wire:key') === key)
return chip && (onRemove ? chip.querySelector('[data-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
return chip && (onRemove ? chip.querySelector('[data-md-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
}
const observer = new MutationObserver(() => {
@@ -140,8 +140,8 @@ document.addEventListener('alpine:init', () => {
const travelled = Math.abs(row.scrollLeft)
const room = row.scrollWidth - row.clientWidth
row.toggleAttribute('data-scroll-start', travelled > 1)
row.toggleAttribute('data-scroll-end', room - travelled > 1)
row.toggleAttribute('data-md-scroll-start', travelled > 1)
row.toggleAttribute('data-md-scroll-end', room - travelled > 1)
}
// A filter chip's check changes its width without resizing the row.
+6 -6
View File
@@ -8,13 +8,13 @@
* in.
*
* `<x-checkbox indeterminate>`: HTML has no attribute for a checkbox's mixed state, only the
* `indeterminate` property, so the component marks the input `data-indeterminate` and this keeps
* `indeterminate` property, so the component marks the input `data-md-indeterminate` and this keeps
* the property in step with the mark — when the page loads, and when a render adds or removes the
* mark. A person pressing the box clears the property as the browser always does; the mark stays
* until the server decides again.
*/
const GROWING = 'textarea[data-autogrow]'
const MIXED = 'input[type="checkbox"][data-indeterminate]'
const GROWING = 'textarea[data-md-autogrow]'
const MIXED = 'input[type="checkbox"][data-md-indeterminate]'
const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false
@@ -49,8 +49,8 @@ if (! growsByItself) {
new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes') {
if (record.attributeName === 'data-indeterminate' && record.target instanceof HTMLInputElement) {
record.target.indeterminate = record.target.hasAttribute('data-indeterminate')
if (record.attributeName === 'data-md-indeterminate' && record.target instanceof HTMLInputElement) {
record.target.indeterminate = record.target.hasAttribute('data-md-indeterminate')
} else if (! growsByItself && record.target.matches(GROWING)) {
grow(record.target)
}
@@ -76,7 +76,7 @@ new MutationObserver((records) => {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['data-indeterminate', 'data-autogrow'],
attributeFilter: ['data-md-indeterminate', 'data-md-autogrow'],
})
markAll(document)
+2 -2
View File
@@ -78,11 +78,11 @@ document.addEventListener('alpine:init', () => {
}
// Suggestions and results never show together; count whichever one is on screen.
const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-search-suggestions]')]
const list = [...this.$refs.view.querySelectorAll('[data-md-search-results], [data-md-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'))
const suggesting = Boolean(list?.hasAttribute('data-md-search-suggestions'))
this.announcement = total === 0
? (announce.none ?? '')
+20 -18
View File
@@ -200,8 +200,10 @@ function sliderGeometry({ width, size, fractions, centered = false, range = fals
* effect, and anything reactive read or written on the way to the drawing would re-run it.
*/
function slider(root) {
// The component's root carries the props; `root` is the control inside it the pointer presses.
const host = root.closest('[data-md-slider]') ?? root
const inputs = [...root.querySelectorAll(':scope > input[type="range"]')]
const drawing = root.querySelector('[data-slider-drawing]')
const drawing = root.querySelector('[data-md-slider-drawing]')
const range = inputs.length > 1
const cleanups = []
let queued = false
@@ -228,24 +230,24 @@ function slider(root) {
}
/** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */
const vertical = () => root.dataset.orientation === 'vertical'
const vertical = () => host.dataset.mdOrientation === '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'}"]`)
const thumbOf = (input) => drawing?.querySelector(`[data-md-slider-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`)
const tickFractions = () => [...(drawing?.querySelectorAll('[data-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.tick))
const tickFractions = () => [...(drawing?.querySelectorAll('[data-md-slider-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.mdSliderTick))
const geometry = (width) => {
const { min, max } = bounds()
return sliderGeometry({
width,
size: root.dataset.size,
size: host.dataset.mdSize,
fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)),
centered: root.hasAttribute('data-centered'),
centered: host.hasAttribute('data-md-centered'),
range,
tickFractions: tickFractions(),
})
@@ -257,13 +259,13 @@ function slider(root) {
}
const width = trackLength()
const size = SIZES[root.dataset.size] ?? SIZES.xs
const size = SIZES[host.dataset.mdSize] ?? SIZES.xs
const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width)
const part = (selector) => drawing.querySelector(selector)
const px = (number) => `${Math.round(number * 100) / 100}px`
for (const name of ['start', 'active', 'end']) {
const element = part(`[data-segment="${name}"]`)
const element = part(`[data-md-slider-segment="${name}"]`)
const segment = segments[name]
if (element) {
@@ -277,7 +279,7 @@ function slider(root) {
}
for (const name of ['start', 'end']) {
const element = part(`[data-stop="${name}"]`)
const element = part(`[data-md-slider-stop="${name}"]`)
if (element) {
element.hidden = stops[name] === undefined
@@ -288,20 +290,20 @@ function slider(root) {
const fractions = tickFractions()
const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity
drawing.querySelectorAll('[data-tick]').forEach((element, i) => {
drawing.querySelectorAll('[data-md-slider-tick]').forEach((element, i) => {
const tick = ticks[i]
element.hidden = !tick || spacing < TICK_MIN_SPACING
if (tick) {
element.style.left = px(tick.x)
element.toggleAttribute('data-active', tick.active)
element.toggleAttribute('data-md-active', tick.active)
}
})
handles.forEach((x, i) => {
const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-value-label]')
const thumb = part(`[data-md-slider-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-md-slider-value]')
if (thumb) {
thumb.style.left = px(x)
@@ -313,7 +315,7 @@ function slider(root) {
})
// The inset icon: at the start of the active track while it fits, else of the inactive one.
const icon = part('[data-track-icon]')
const icon = part('[data-md-slider-icon]')
if (icon) {
const room = size.icon + ICON_PADDING * 2
@@ -321,7 +323,7 @@ function slider(root) {
const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room
icon.hidden = !inActive && !inInactive
icon.toggleAttribute('data-active', inActive)
icon.toggleAttribute('data-md-active', inActive)
icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING)
}
}
@@ -371,10 +373,10 @@ function slider(root) {
}
/** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */
const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-focused', shown)
const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-md-focused', shown)
/** Compose narrows a handle and shows its label while it is pressed or dragged. */
const pressed = (index) => drawing?.querySelectorAll('[data-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-pressed', i === index))
const pressed = (index) => drawing?.querySelectorAll('[data-md-slider-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-md-pressed', i === index))
inputs.forEach((input) => {
// A value written by script fires no event: catch it on the input's own setters.
@@ -493,7 +495,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', 'data-orientation', 'dir'] })
mutations.observe(host, { attributes: true, attributeFilter: ['data-md-size', 'data-md-centered', 'data-md-orientation', 'dir'] })
const resizes = new ResizeObserver(schedule)
resizes.observe(root)