The dial and input time picker in a modal dialog, with the hour cycle from the locale, steps, a min and max that may span midnight, and a landscape layout, ported from Compose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
685 lines
24 KiB
JavaScript
685 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()
|
||
},
|
||
|
||
/** 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
|
||
},
|
||
}))
|
||
})
|