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
700 lines
24 KiB
JavaScript
700 lines
24 KiB
JavaScript
/**
|
||
* `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` (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
|
||
},
|
||
}))
|
||
})
|