A time box that reads on the clock the user chose
The schedule windows met this account with "12:00 AM", clipped to "12:00 A!" by a box too narrow for it, while the card above them printed 00:00. Both halves of that are the native control: <input type="time"> renders in the browser's locale, and its am/pm did not fit. Nothing on the page moves it. lang= was measured rather than assumed — five inputs set to en-US, en-GB, da-DK, pl-PL and nothing came out identically wide, because the control follows navigator.languages and not the document. That is the wall DateField hit for dates, so the answer has its shape: the typing half is ours, the value stays 24-hour "HH:MM", and what the box shows follows the setting. Four digits, the colon inserted as they are typed, the meridiem its own control rather than something to spell. No picker button: a calendar earns one, four digits do not, and the browser's popup would have brought the 12-hour reading back in with it. A half-typed or impossible time emits nothing rather than the part of it that parses, so a block's Apply leaves that field out instead of writing a time nobody meant. format.js exports which clock is in force now, so what prints a time and what accepts one cannot disagree about it. The box is 80px because 12:30 measures 66 with its padding and the first attempt at 64 clipped — which was the complaint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4ff73e5110
commit
dd636bbe03
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
// A time box that reads on the clock the user chose.
|
||||
//
|
||||
// The same problem DateField solves for dates, and the same shape of answer.
|
||||
// `<input type="time">` renders in the *browser's* locale and nothing on the
|
||||
// page moves it — `lang` included, which was measured rather than assumed: the
|
||||
// control comes out identically wide whatever it is set to, because it follows
|
||||
// navigator.languages and not the document. So a Settings → Time format of
|
||||
// 24-hour still met this account with "12:00 AM" in every schedule field,
|
||||
// disagreeing with the 00:00 the card beside it printed, and the browser's am/pm
|
||||
// did not fit the box either.
|
||||
//
|
||||
// So the typing half is ours: four digits, masked into the clock in force, with
|
||||
// the meridiem as its own control rather than something to be spelled. There is
|
||||
// no picker half — a calendar earns its button, four digits do not, and the
|
||||
// browser's time popup would have brought the same 12-hour reading back with it.
|
||||
//
|
||||
// The value in and out is always 24-hour "HH:MM", which is what the charger's
|
||||
// schedule commands take and what every caller already had.
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { clockIsTwelveHour } from "../lib/format.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
ariaLabel: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const twelve = computed(() => clockIsTwelveHour());
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
|
||||
// "HH:MM" → its two numbers, or null for anything that is not a time of day.
|
||||
function parse(value) {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(String(value || "").trim());
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
return h > 23 || min > 59 ? null : { h, min };
|
||||
}
|
||||
|
||||
// The digits the box shows: the hour as this clock writes it, and the minute.
|
||||
function toText(value) {
|
||||
const p = parse(value);
|
||||
if (!p) return "";
|
||||
return `${pad(twelve.value ? p.h % 12 || 12 : p.h)}:${pad(p.min)}`;
|
||||
}
|
||||
|
||||
// Whether the value sits in the afternoon. Only consulted on a 12-hour clock,
|
||||
// where the box cannot say it and the select has to.
|
||||
function toPm(value) {
|
||||
const p = parse(value);
|
||||
return !!p && p.h >= 12;
|
||||
}
|
||||
|
||||
// What the box and the select hold → "HH:MM", or "" while it is still half
|
||||
// typed. A half-typed time is not a time, and emitting the part of it that
|
||||
// parses would set the charger's schedule to whatever was passed through on the
|
||||
// way to the value somebody meant.
|
||||
function toValue(text, pm) {
|
||||
const digits = String(text).replace(/\D/g, "");
|
||||
if (digits.length !== 4) return "";
|
||||
let h = Number(digits.slice(0, 2));
|
||||
const min = Number(digits.slice(2));
|
||||
if (min > 59) return "";
|
||||
if (twelve.value) {
|
||||
if (h < 1 || h > 12) return "";
|
||||
h = (h % 12) + (pm ? 12 : 0);
|
||||
} else if (h > 23) {
|
||||
return "";
|
||||
}
|
||||
return `${pad(h)}:${pad(min)}`;
|
||||
}
|
||||
|
||||
// Digits regrouped as hh:mm. No trailing colon: it appears with the next digit,
|
||||
// and adding it early only gives backspace something to fight with.
|
||||
function mask(text) {
|
||||
const digits = String(text).replace(/\D/g, "").slice(0, 4);
|
||||
return digits.length > 2 ? `${digits.slice(0, 2)}:${digits.slice(2)}` : digits;
|
||||
}
|
||||
|
||||
const text = ref(toText(props.modelValue));
|
||||
const pm = ref(toPm(props.modelValue));
|
||||
|
||||
// Only re-render the box when the value it is showing is genuinely a different
|
||||
// time. Half-typed input emits "" — there is no time yet — and reacting to that
|
||||
// would wipe the very digits being typed.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (toValue(text.value, pm.value) === (value || "")) return;
|
||||
text.value = toText(value);
|
||||
pm.value = toPm(value);
|
||||
}
|
||||
);
|
||||
|
||||
// Switching the setting in another tab re-lays out what is already in the box,
|
||||
// rather than leaving one field on the old clock.
|
||||
watch(twelve, () => {
|
||||
text.value = toText(props.modelValue);
|
||||
pm.value = toPm(props.modelValue);
|
||||
});
|
||||
|
||||
function onInput(event) {
|
||||
const el = event.target;
|
||||
const masked = mask(el.value);
|
||||
text.value = masked;
|
||||
// Written straight to the DOM: Vue skips the patch when the bound value is
|
||||
// unchanged from the last render, which would leave a stray character the
|
||||
// user just typed sitting in the box.
|
||||
el.value = masked;
|
||||
emit("update:modelValue", toValue(masked, pm.value));
|
||||
}
|
||||
|
||||
function onMeridiem(event) {
|
||||
pm.value = event.target.value === "pm";
|
||||
emit("update:modelValue", toValue(text.value, pm.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
class="dh-input data w-20 shrink-0 text-center"
|
||||
:value="text"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
placeholder="--:--"
|
||||
maxlength="5"
|
||||
@input="onInput"
|
||||
/>
|
||||
<select
|
||||
v-if="twelve"
|
||||
class="dh-input w-16 shrink-0"
|
||||
:value="pm ? 'pm' : 'am'"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
@change="onMeridiem"
|
||||
>
|
||||
<option value="am">am</option>
|
||||
<option value="pm">pm</option>
|
||||
</select>
|
||||
</span>
|
||||
</template>
|
||||
@@ -109,13 +109,25 @@ export function formatTime(value) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const mm = pad(d.getMinutes());
|
||||
|
||||
const mode = prefs.timeFormat;
|
||||
const twelve = mode === "12" || (mode !== "24" && regionReadsTwelveHour());
|
||||
if (!twelve) return `${pad(h)}:${mm}`;
|
||||
if (!clockIsTwelveHour()) return `${pad(h)}:${mm}`;
|
||||
// 12 for both noon and midnight, and midnight is the am one.
|
||||
return `${pad(h % 12 || 12)}:${mm} ${h < 12 ? "am" : "pm"}`;
|
||||
}
|
||||
|
||||
// Whether times are written on a 12-hour clock right now: what the setting says
|
||||
// outright, or what the region says when it is left on auto.
|
||||
//
|
||||
// Exported because printing a time is not the only thing that has to know. A
|
||||
// control that lets somebody *enter* one has to offer the same clock, and a box
|
||||
// that reads 13:45 beside a picker that says 01:45 PM is the disagreement this
|
||||
// setting exists to end (see components/TimeField.vue).
|
||||
export function clockIsTwelveHour() {
|
||||
const mode = prefs.timeFormat;
|
||||
if (mode === "12") return true;
|
||||
if (mode === "24") return false;
|
||||
return regionReadsTwelveHour();
|
||||
}
|
||||
|
||||
// Whether the chosen region tells the time on a 12-hour clock — the one question
|
||||
// "auto" asks it. Cached because this is asked once per timestamp on a page that
|
||||
// can hold a great many, and the answer only changes when the region does.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { prefs } from "../prefs";
|
||||
import { askConfirm } from "../lib/confirm.js";
|
||||
import { api } from "../api";
|
||||
import { formatDateTime } from "../lib/format.js";
|
||||
import TimeField from "../components/TimeField.vue";
|
||||
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
|
||||
import ChargerImportModal from "../components/ChargerImportModal.vue";
|
||||
|
||||
@@ -2592,18 +2593,13 @@ onMounted(async () => {
|
||||
</span>
|
||||
|
||||
<span v-else class="flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
:id="`set-${f.from}`"
|
||||
<TimeField
|
||||
v-model="mqttDraft[f.from]"
|
||||
type="time"
|
||||
class="dh-input w-28"
|
||||
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowStart')}`"
|
||||
/>
|
||||
<span class="text-[11px] text-muted">–</span>
|
||||
<input
|
||||
<TimeField
|
||||
v-model="mqttDraft[f.to]"
|
||||
type="time"
|
||||
class="dh-input w-28"
|
||||
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowEnd')}`"
|
||||
/>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user