Files
livewire-material/resources/js/timepicker.js
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

689 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `materialTimepicker(value, config)`: the behaviour of `<x-timepicker>` — a field that opens M3's
* time picker in a modal `<dialog>`, as a clock dial or as two text fields.
*
* The picker edits a draft, `hour` (023) and `minute`; only OK writes it to `value` as `H:i`.
* Cancel, Escape and a press on the scrim leave `value` alone, and every close hands focus back to
* the field. Whether the hours run 112 with AM and PM or 023 comes from `config.format` or, without
* one, from the locale (`Intl.DateTimeFormat(locale, { hour: 'numeric' })`'s hour cycle).
*
* The dial:
* - A press sets the value where it lands, and the selector turns there on the default spatial
* spring; a drag makes the handle follow the pointer and settles on the nearest value when
* released. Hours then move on to minutes, as Compose does after a tap (100ms later) or a drag.
* - On a 24-hour dial the inner ring holds 1223 and the outer 0011: a press nearer the centre than
* 74dp (scaled with the dial) is on the inner ring.
* - A tap picks minutes in fives (or in `step`s, when `step` is not a divisor of five); a drag in ones
* (or `step`s).
* - The dial is a slider for the keyboard: the arrows change the hour or minute it shows, Home and End
* go to the first and last allowed, Enter confirms. The keyboard never moves on to minutes by
* itself; the hour and minute boxes above the dial switch between them.
*
* `min` and `max` (`H:i`, inclusive; `min` later than `max` is a range across midnight) and `step`
* (minutes) limit what can be chosen: a tap on a number outside them does nothing, a drag and the
* arrows skip to the nearest allowed value, a period switch that would leave them lands on the
* nearest allowed time, and typed values outside them are errors. They are a
* convenience for the person choosing, not validation — validate on the server too.
*
* The selector's angle is a registered custom property (`--timepicker-angle`, components/
* timepicker.css), so one CSS transition turns the line, moves the handle, and moves the clip that
* shows the number under the handle in on-primary, all on the same spring.
*
* ---------------------------------------------------------------------------------------
* Behaviour and geometry follow androidx Compose Material 3 (https://github.com/androidx/androidx),
* commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
*
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
* (AnalogTimePickerState.rotateTo, endValueForAnimation, moveSelector, onTap, ClockDialNode,
* selectorPos, TimeInputImpl, shouldSwitchFocusToMinute, the ring and distance constants)
*
* Copyright 2022-2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ---------------------------------------------------------------------------------------
*/
/** ClockDialContainerSize: the dial's own coordinate space. */
const DIAL = 256
/** MaxDistance: nearer the centre than this, a 24-hour dial is on its inner ring. */
const INNER_REACH = 74
/** onTap's delay(100) before a tapped hour moves on to minutes. */
const MOVE_ON_MS = 100
/** How far a pointer moves before a press on the dial is a drag. */
const DRAG_SLOP = 4
const pad = (number) => String(number).padStart(2, '0')
/** "112" stays on one line in a 96px column: word joiners either side of a dash between numbers. */
const keepRange = (text) => text.replace(/(\d)\s*([-])\s*(\d)/g, '$1\u2060$2\u2060$3')
/** The shortest way round from one angle to another, in degrees. */
const turn = (from, to) => ((((to - from) % 360) + 540) % 360) - 180
function parse(value) {
const match = /^(\d{1,2}):(\d{2})/.exec(typeof value === 'string' ? value : '')
if (!match) {
return null
}
const hour = Number(match[1])
const minute = Number(match[2])
return hour < 24 && minute < 60 ? hour * 60 + minute : null
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialTimepicker', (value = null, config = {}) => ({
value,
open: false,
mode: 'dial',
view: 'hour',
hour: 0,
minute: 0,
angle: 0,
dragging: false,
scrimPressed: false,
hourText: '',
minuteText: '',
attempted: false,
is24: false,
step: 1,
earliest: null,
latest: null,
moveOn: null,
formatter: null,
periods: ['AM', 'PM'],
init() {
const locale = config.locale || undefined
try {
this.is24 = config.format ? Number(config.format) === 24 : ['h23', 'h24'].includes(new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle)
this.formatter = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', hourCycle: this.is24 ? 'h23' : 'h12' })
const dayPeriod = (hour) => new Intl.DateTimeFormat(locale, { hour: 'numeric', hourCycle: 'h12' }).formatToParts(new Date(2000, 0, 1, hour)).find((part) => part.type === 'dayPeriod')?.value
this.periods = [dayPeriod(9) ?? config.strings.am, dayPeriod(21) ?? config.strings.pm]
} catch {
this.is24 = Number(config.format) === 24
this.formatter = null
this.periods = [config.strings.am, config.strings.pm]
}
this.step = Math.min(Math.max(Math.trunc(Number(config.step) || 1), 1), 60)
this.earliest = parse(config.min)
this.latest = parse(config.max)
},
// ---- What is shown ---------------------------------------------------------------------
get display() {
const time = parse(this.value)
return time === null ? '' : this.format(time)
},
format(time) {
const date = new Date(2000, 0, 1, Math.floor(time / 60), time % 60)
return this.formatter ? this.formatter.format(date) : `${pad(date.getHours())}:${pad(date.getMinutes())}`
},
get isPm() {
return this.hour >= 12
},
get hourLabel() {
return this.is24 ? pad(this.hour) : pad(this.hour % 12 || 12)
},
get minuteLabel() {
return pad(this.minute)
},
/** selectorPos: a 24-hour dial's afternoon hours are on the inner ring. */
get inner() {
return this.is24 && this.view === 'hour' && this.hour >= 12
},
get valueText() {
const strings = config.strings
if (this.view === 'minute') {
return strings.minutes.replace(':minute', this.minute)
}
return this.is24 ? strings.hours.replace(':hour', this.hour) : `${strings.oclock.replace(':hour', this.hour % 12 || 12)} ${this.periods[this.isPm ? 1 : 0]}`
},
// ---- What may be chosen ----------------------------------------------------------------
inRange(time) {
const { earliest, latest } = this
if (earliest !== null && latest !== null && earliest > latest) {
return time >= earliest || time <= latest
}
return (earliest === null || time >= earliest) && (latest === null || time <= latest)
},
allowed(hour, minute) {
return minute % this.step === 0 && this.inRange(hour * 60 + minute)
},
hourAllowed(hour) {
for (let minute = 0; minute < 60; minute += this.step) {
if (this.allowed(hour, minute)) {
return true
}
}
return false
},
minuteAllowed(minute) {
return this.allowed(this.hour, minute)
},
periodAllowed(pm) {
for (let hour = pm ? 12 : 0; hour < (pm ? 24 : 12); hour++) {
if (this.hourAllowed(hour)) {
return true
}
}
return false
},
/**
* The allowed time nearest to one that is not, searching outwards a minute at a time; `pm`
* limits the search to that half of the day, for a period switch that lands outside it.
*/
nearest(time, pm = null) {
for (let distance = 0; distance <= 720; distance++) {
for (const candidate of [time - distance, time + distance]) {
const wrapped = (candidate + 1440) % 1440
if ((pm === null || (wrapped >= 720) === pm) && this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
return wrapped
}
}
}
return null
},
/** The minute nearest to this one that the current hour allows. */
nearestMinute(minute) {
for (let distance = 0; distance <= 30; distance++) {
for (const candidate of [minute - distance, minute + distance]) {
const wrapped = (candidate + 60) % 60
if (this.minuteAllowed(wrapped)) {
return wrapped
}
}
}
return null
},
/** The hour nearest to this one on its own ring (period), then on the other. */
nearestHour(hour) {
const base = hour >= 12 ? 12 : 0
for (const ring of [base, 12 - base]) {
for (let distance = 0; distance <= 6; distance++) {
for (const candidate of [hour - distance, hour + distance]) {
const inRing = ring + ((((candidate - base) % 12) + 12) % 12)
if (this.hourAllowed(inRing)) {
return inRing
}
}
}
if (!this.is24) {
return null
}
}
return null
},
settle(time) {
const allowed = this.allowed(Math.floor(time / 60), time % 60) ? time : this.nearest(time)
if (allowed !== null) {
this.hour = Math.floor(allowed / 60)
this.minute = allowed % 60
}
},
/** An hour chosen: keep the minute if the new hour allows it, otherwise the nearest one it does. */
setHour(hour) {
this.hour = hour
if (!this.minuteAllowed(this.minute)) {
this.minute = this.nearestMinute(this.minute) ?? this.minute
}
},
// ---- The dialog ------------------------------------------------------------------------
show() {
if (this.open || this.$refs.input.disabled) {
return
}
const now = new Date()
const time = parse(this.value) ?? now.getHours() * 60 + Math.round(now.getMinutes() / this.step) * this.step
this.settle(time % 1440)
this.view = 'hour'
this.attempted = false
this.fillText()
this.angle = this.angleFor()
this.open = true
// After Alpine has drawn the draft, so nothing animates from the last time it was open.
this.$nextTick(() => {
this.$refs.dialog.showModal()
this.focusMode()
})
},
cancel() {
this.$refs.dialog.close()
},
confirm() {
if (this.mode === 'input' && !this.readText()) {
return
}
if (!this.allowed(this.hour, this.minute)) {
return
}
this.value = `${pad(this.hour)}:${pad(this.minute)}`
this.$refs.dialog.close()
},
closed() {
clearTimeout(this.moveOn)
this.open = false
this.dragging = false
this.$refs.input.focus({ preventScroll: true })
},
focusMode() {
if (this.mode === 'input') {
const field = this.view === 'minute' ? this.$refs.minuteInput : this.$refs.hourInput
field.focus()
field.select()
} else {
this.$refs.dial.focus({ preventScroll: true })
}
},
toggleMode() {
clearTimeout(this.moveOn)
if (this.mode === 'dial') {
this.mode = 'input'
this.attempted = false
this.fillText()
} else {
this.mode = 'dial'
this.angle = this.angleFor()
}
this.$nextTick(() => this.focusMode())
},
/** The hour or minute box above the dial. */
choose(view) {
clearTimeout(this.moveOn)
this.view = view
this.aim()
},
setPeriod(pm) {
if (this.isPm === pm || !this.periodAllowed(pm)) {
return
}
const hour = this.hour + (pm ? 12 : -12)
if (this.hourAllowed(hour)) {
this.setHour(hour)
} else {
const raw = hour * 60 + this.minute
const time = this.nearest(raw, pm) ?? raw
this.hour = Math.floor(time / 60)
this.minute = time % 60
}
this.fillText()
this.aim()
},
/**
* AM and PM are a radio group, which M3's labelling table asks for: an arrow key moves to
* the other option and selects it, and focus follows, as WAI-ARIA's radio group does.
*/
movePeriod(event) {
const pm = !this.isPm
if (!this.periodAllowed(pm)) {
return
}
this.setPeriod(pm)
event.currentTarget.querySelector(`[data-md-timepicker-period-option="${pm ? 'pm' : 'am'}"]`)?.focus()
},
// ---- The dial --------------------------------------------------------------------------
angleFor() {
return this.view === 'hour' ? (this.hour % 12) * 30 : this.minute * 6
},
/** endValueForAnimation: turn the short way round, so 11 to 1 never spins backwards. */
aim(degrees = this.angleFor()) {
this.angle += turn(this.angle, degrees)
},
press(event) {
if (event.button !== 0 || !event.isPrimary) {
return
}
event.preventDefault()
clearTimeout(this.moveOn)
const dial = this.$refs.dial
const startX = event.clientX
const startY = event.clientY
let dragged = false
dial.focus({ preventScroll: true })
try {
dial.setPointerCapture(event.pointerId)
} catch {
// A pointer the browser does not know (a synthetic event) cannot be captured.
}
const move = (moveEvent) => {
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < DRAG_SLOP) {
return
}
dragged = true
this.dragging = true
this.pick(moveEvent, false)
}
const controller = new AbortController()
const release = (upEvent, cancelled = false) => {
controller.abort()
this.dragging = false
if (cancelled) {
this.aim()
return
}
if (dragged) {
this.aim()
} else if (!this.pick(upEvent, true)) {
return
}
if (this.view === 'hour') {
this.moveOn = setTimeout(() => this.choose('minute'), dragged ? 0 : MOVE_ON_MS)
}
}
dial.addEventListener('pointermove', move, { signal: controller.signal })
dial.addEventListener('pointerup', release, { signal: controller.signal })
dial.addEventListener('pointercancel', (cancelEvent) => release(cancelEvent, true), { signal: controller.signal })
},
/**
* The value under the pointer. A tap turns the selector to the value it chose; a drag keeps the
* handle under the pointer (rotateTo without animation) until it is released.
*/
pick(event, tap) {
const box = this.$refs.dial.getBoundingClientRect()
const x = event.clientX - (box.left + box.width / 2)
const y = event.clientY - (box.top + box.height / 2)
const degrees = ((Math.atan2(x, -y) * 180) / Math.PI + 360) % 360
if (this.view === 'hour') {
let hour = Math.round(degrees / 30) % 12
if (this.is24 ? Math.hypot(x, y) < (INNER_REACH * box.width) / DIAL : this.isPm) {
hour += 12
}
// A tap on a number outside the limits does nothing; a drag settles on the nearest allowed.
hour = this.hourAllowed(hour) ? hour : tap ? null : this.nearestHour(hour)
if (hour === null) {
return false
}
this.setHour(hour)
} else {
const unit = tap && 5 % this.step === 0 ? 5 : this.step
let minute = (Math.round(degrees / 6 / unit) * unit) % 60
minute = this.minuteAllowed(minute) ? minute : tap ? null : this.nearestMinute(minute)
if (minute === null) {
return false
}
this.minute = minute
}
this.aim(tap ? this.angleFor() : degrees)
return true
},
key(event) {
const moves = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }
if (event.key === 'Enter') {
event.preventDefault()
this.confirm()
} else if (event.key in moves) {
event.preventDefault()
this.nudge(moves[event.key])
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
this.extreme(event.key === 'End')
}
},
nudge(direction) {
clearTimeout(this.moveOn)
if (this.view === 'hour') {
for (let offset = 1; offset <= 24; offset++) {
const hour = (((this.hour + direction * offset) % 24) + 24) % 24
if (this.hourAllowed(hour)) {
this.setHour(hour)
break
}
}
} else {
const aligned = Math.round(this.minute / this.step) * this.step
for (let offset = aligned === this.minute ? 1 : 0; offset <= 60; offset++) {
const minute = (((aligned + direction * offset * this.step) % 60) + 60) % 60
if (this.minuteAllowed(minute)) {
this.minute = minute
break
}
}
}
this.aim()
},
extreme(last) {
const values = this.view === 'hour'
? [...Array(24).keys()].filter((hour) => this.hourAllowed(hour))
: [...Array(60).keys()].filter((minute) => this.minuteAllowed(minute))
if (values.length === 0) {
return
}
if (this.view === 'hour') {
this.setHour(last ? values.at(-1) : values[0])
} else {
this.minute = last ? values.at(-1) : values[0]
}
this.aim()
},
// ---- The text fields -------------------------------------------------------------------
fillText() {
this.hourText = this.hourLabel
this.minuteText = this.minuteLabel
},
get hourTextValid() {
const number = Number(this.hourText)
return /^\d{1,2}$/.test(this.hourText) && (this.is24 ? number <= 23 : number >= 1 && number <= 12)
},
get minuteTextValid() {
return /^\d{1,2}$/.test(this.minuteText) && Number(this.minuteText) <= 59
},
get minuteTextOnStep() {
return !this.minuteTextValid || Number(this.minuteText) % this.step === 0
},
get hourError() {
if (this.hourTextValid || (this.hourText === '' && !this.attempted)) {
return null
}
return keepRange(this.is24 ? config.strings.hourError24 : config.strings.hourError12)
},
get minuteError() {
if (this.minuteText === '' && !this.attempted) {
return null
}
if (!this.minuteTextValid) {
return keepRange(config.strings.minuteError)
}
return this.minuteTextOnStep ? null : config.strings.stepError.replace(':step', this.step)
},
get rangeError() {
if (this.earliest === null && this.latest === null) {
return null
}
if (this.mode !== 'input' || !this.hourTextValid || !this.minuteTextValid || this.inRange(this.hour * 60 + this.minute)) {
return null
}
const strings = config.strings
if (this.earliest !== null && this.latest !== null) {
return strings.between.replace(':min', this.format(this.earliest)).replace(':max', this.format(this.latest))
}
return this.earliest !== null ? strings.after.replace(':min', this.format(this.earliest)) : strings.before.replace(':max', this.format(this.latest))
},
typeHour(event) {
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
event.target.value = text
this.hourText = text
if (this.hourTextValid) {
const number = Number(text)
this.hour = this.is24 ? number : (number % 12) + (this.isPm ? 12 : 0)
}
// shouldSwitchFocusToMinute: two valid digits typed at the end move on to the minute.
if (event.inputType?.startsWith('insert') && text.length === 2 && this.hourTextValid && event.target.selectionStart === 2) {
this.view = 'minute'
this.$refs.minuteInput.focus()
this.$refs.minuteInput.select()
}
},
typeMinute(event) {
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
event.target.value = text
this.minuteText = text
if (this.minuteTextValid) {
this.minute = Number(text)
}
},
/** Reads both fields for OK; on an error, says so and puts focus on the field to fix. */
readText() {
this.attempted = true
const invalid = !this.hourTextValid
? this.$refs.hourInput
: !this.minuteTextValid || !this.minuteTextOnStep
? this.$refs.minuteInput
: !this.inRange(this.hour * 60 + this.minute)
? this.$refs.hourInput
: null
if (invalid) {
invalid.focus()
invalid.select()
return false
}
return true
},
}))
})