Files
Andreas Reinhold / reiniandClaude Opus 5 d46b07c13c Draw the timepicker without Tailwind
Plan step 36 (inputs group, last of the "not started" pair): <x-timepicker>
renders data-md-timepicker-* throughout (data-md-mode, data-md-cycle,
data-md-view, data-md-inner, data-md-dragging, data-md-disabled,
data-md-error and data-md-value replace their unprefixed hooks), and
timepicker.css moves into material.components, importing field.css,
icon.css and button.css. The dialog's Tailwind utility class (position,
the scrim, the spatial-fast pop and its @starting-style) becomes plain
declarations under data-md-timepicker-dialog, matching the datepicker's
modal dialog. The landscape rule stays keyed on orientation and viewport
height, in px (560/364/346), never a breakpoint, as M3 asks.

The hour and minute boxes and the period buttons swap their Tailwind
`state-layer focus-ring` utilities for `md-state-layer md-focus-ring`:
same declarations, so nothing else in the stylesheet changes for them.
The dial swaps in `md-focus-ring` for its own hand-rolled, byte-identical
outline-on-:focus-visible rule, which timepicker.css no longer carries.
The period selector was already the radio-in-a-list M3's labelling table
asks for (role="radio" in a role="radiogroup", moved by the arrow keys),
and Shift+M/Y already reached the date picker's dropdowns — both
predate this rewrite.

field.css's data-md-field-button note is tidied now that every component
that selects it (input, password, datepicker, timepicker) is rewritten.

Browser tests written but not run (the inputs group's Chromium run
follows this stream): the period radios moving with the arrow keys, and
the dial lying on its side below the landscape height threshold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:19:34 +02:00

700 lines
24 KiB
JavaScript
Raw Permalink 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. */
nearest(time) {
for (let distance = 0; distance <= 720; distance++) {
for (const candidate of [time - distance, time + distance]) {
const wrapped = (candidate + 1440) % 1440
if (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 time = this.nearestInPeriod(pm, hour * 60 + this.minute)
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 allowed time in a period nearest to `time` (periodAllowed has said there is one). */
nearestInPeriod(pm, time) {
let best = null
for (let candidate = pm ? 720 : 0; candidate < (pm ? 1440 : 720); candidate++) {
if (this.allowed(Math.floor(candidate / 60), candidate % 60) && (best === null || Math.abs(candidate - time) < Math.abs(best - time))) {
best = candidate
}
}
return best ?? time
},
// ---- 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 release = (upEvent, cancelled = false) => {
dial.removeEventListener('pointermove', move)
dial.removeEventListener('pointerup', release)
dial.removeEventListener('pointercancel', abandon)
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)
}
}
const abandon = (cancelEvent) => release(cancelEvent, true)
dial.addEventListener('pointermove', move)
dial.addEventListener('pointerup', release)
dial.addEventListener('pointercancel', abandon)
},
/**
* 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
},
}))
})