/** * `materialTimepicker(value, config)`: the behaviour of `` — a field that opens M3's * time picker in a modal ``, as a clock dial or as two text fields. * * The picker edits a draft, `hour` (0–23) 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 1–12 with AM and PM or 0–23 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 12–23 and the outer 00–11: 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') /** "1–12" 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 }, })) })