Files
DriverVault/Web App/web/src/components/DateField.vue
T
tajniak81andClaude Opus 5 f7caeaf907 A date typed in the order you chose, not the browser's
Settings › Date format steered every date the app printed, but not one it
asked for: `<input type="date">` renders in the browser's own locale and no
page setting can move it, so DD-MM-YYYY tables sat above 08/22/2026 boxes.

DateField takes over the typing half — a masked box whose segment order comes
from the same prefs.dateFormat lib/format.js reads — and leaves the picking
half to the browser, behind a calendar button. The value in and out stays ISO,
so no caller changed. Native validation now also catches a full-but-impossible
date; the old input let a half-typed one through as no date at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:18:18 +02:00

233 lines
8.3 KiB
Vue
Raw 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.
<script setup>
// A date box that reads in the order the user chose.
//
// `<input type="date">` renders in the *browser's* locale and nothing on the
// page can move it: a Settings Date format of DD-MM-YYYY still met the owner
// with 08/22/2026 in every dialog, disagreeing with the DD-MM-YYYY the tables
// beside it printed. So the typing half is ours — a masked text box whose
// segment order comes from prefs.dateFormat, the same setting lib/format.js
// reads — and the picking half stays the browser's, behind the calendar button.
//
// The value in and out is always ISO ("2026-08-22", or "2026-08" at month
// precision), which is what the API stores and what every caller already had.
import { computed, onMounted, ref, watch } from "vue";
import { prefs } from "../prefs.js";
import { t } from "../i18n";
const props = defineProps({
modelValue: { type: String, default: "" },
// "day" is a full date; "month" drops the day segment, for the fields that
// only ever knew a month (a car's build date).
precision: { type: String, default: "day" },
required: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
// The four display formats collapse to three typing orders: a month spelled out
// ("22 Aug 2026") is for reading, not for entry, so DMY and DMY_NUM share one
// numeric layout. The separator follows each format's own convention.
const LAYOUTS = {
YMD: { order: ["y", "m", "d"], sep: "-" },
DMY_NUM: { order: ["d", "m", "y"], sep: "-" },
DMY: { order: ["d", "m", "y"], sep: "-" },
MDY: { order: ["m", "d", "y"], sep: "/" },
};
const WIDTH = { y: 4, m: 2, d: 2 };
const layout = computed(() => {
const spec = LAYOUTS[prefs.dateFormat] || LAYOUTS.YMD;
const order = props.precision === "month" ? spec.order.filter((part) => part !== "d") : spec.order;
return { order, sep: spec.sep };
});
const placeholder = computed(() =>
layout.value.order.map((part) => t(`common.dateUnits.${part}`)).join(layout.value.sep)
);
const digitsWanted = computed(() => layout.value.order.reduce((sum, part) => sum + WIDTH[part], 0));
// ISO → what the box shows.
function toText(value) {
const parsed = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/.exec(String(value || "").trim());
if (!parsed) return "";
const parts = { y: parsed[1], m: parsed[2], d: parsed[3] };
if (layout.value.order.some((part) => !parts[part])) return "";
return layout.value.order.map((part) => parts[part]).join(layout.value.sep);
}
// What the box shows → ISO, or "" while it is still half typed. A date is only
// accepted once every segment is full and the day actually exists in that month
// — 31-02 round-trips through Date as 03-03, and storing March for a February
// somebody typed is worse than holding the field invalid.
function toIso(text) {
const digits = String(text).replace(/\D/g, "");
if (digits.length !== digitsWanted.value) return "";
const parts = {};
let at = 0;
for (const part of layout.value.order) {
parts[part] = digits.slice(at, at + WIDTH[part]);
at += WIDTH[part];
}
const year = Number(parts.y);
const month = Number(parts.m);
const day = parts.d ? Number(parts.d) : 1;
if (month < 1 || month > 12 || day < 1) return "";
const probe = new Date(Date.UTC(year, month - 1, day));
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return "";
return parts.d ? `${parts.y}-${parts.m}-${parts.d}` : `${parts.y}-${parts.m}`;
}
// Digits regrouped into segments. No trailing separator: one appears as soon as
// the next digit is typed, and adding it early only gives backspace something
// to fight with.
function mask(text) {
const digits = String(text).replace(/\D/g, "").slice(0, digitsWanted.value);
const groups = [];
let at = 0;
for (const part of layout.value.order) {
if (at >= digits.length) break;
groups.push(digits.slice(at, at + WIDTH[part]));
at += WIDTH[part];
}
return groups.join(layout.value.sep);
}
const text = ref(toText(props.modelValue));
const textEl = ref(null);
const pickerEl = ref(null);
// Only re-render the box when the value it is showing is genuinely a different
// date. Half-typed input emits "" (there is no date yet), and reacting to that
// would wipe the very digits being typed.
watch(
() => props.modelValue,
(value) => {
if (toIso(text.value) === (value || "")) return;
text.value = toText(value);
}
);
// Switching the setting in another tab — or in Settings with a dialog open —
// re-lays out what is already there rather than leaving one box in the old order.
watch(layout, () => {
text.value = toText(props.modelValue);
});
// Native validation still runs the submit: `required` catches an empty box, and
// this catches a full one that is not a date, so a half-typed "22-08-20" cannot
// slip through as no date at all.
function refreshValidity() {
const el = textEl.value;
if (!el) return;
el.setCustomValidity(text.value && !toIso(text.value) ? t("forms.common.dateInvalid") : "");
}
watch(text, refreshValidity);
onMounted(refreshValidity);
// The caret is put back by counting digits rather than characters, so editing
// the middle of a date does not throw the cursor to the end on every keystroke.
function caretAfterDigits(masked, count) {
if (count <= 0) return 0;
let seen = 0;
for (let i = 0; i < masked.length; i++) {
if (/\d/.test(masked[i]) && ++seen === count) return i + 1;
}
return masked.length;
}
function applyEdit(el) {
const raw = el.value;
const before = raw.slice(0, el.selectionStart ?? raw.length).replace(/\D/g, "").length;
const masked = mask(raw);
text.value = masked;
// Written straight to the DOM: Vue skips the patch when the bound value is
// unchanged from last render, which would leave the stray separator the user
// just typed sitting in the box.
el.value = masked;
const caret = caretAfterDigits(masked, before);
el.setSelectionRange(caret, caret);
// Set here as well as from the watcher: a form submitted in the same tick as
// the last keystroke must see the validity the box has now, not the one it
// had before the digit landed.
refreshValidity();
emit("update:modelValue", toIso(masked));
}
function onInput(event) {
applyEdit(event.target);
}
// Backspace onto a separator takes the digit in front of it too. The separators
// are ours, not the user's — deleting one alone leaves the same digits, which
// the mask puts straight back, and the key reads as broken.
function onBeforeInput(event) {
if (event.inputType !== "deleteContentBackward") return;
const el = event.target;
const at = el.selectionStart ?? 0;
if (at < 2 || at !== el.selectionEnd || /\d/.test(el.value[at - 1])) return;
event.preventDefault();
el.value = el.value.slice(0, at - 2) + el.value.slice(at);
el.setSelectionRange(at - 2, at - 2);
applyEdit(el);
}
function openPicker() {
const el = pickerEl.value;
if (!el) return;
if (typeof el.showPicker === "function") {
try {
el.showPicker();
return;
} catch {
// Not allowed here (no user activation, or an older engine) — fall through.
}
}
el.focus();
el.click();
}
function onPick(event) {
const value = event.target.value;
text.value = toText(value);
emit("update:modelValue", value);
}
</script>
<template>
<div class="relative">
<input
ref="textEl"
type="text"
inputmode="numeric"
autocomplete="off"
class="dh-input data pr-9"
:value="text"
:placeholder="placeholder"
:required="required"
@beforeinput="onBeforeInput"
@input="onInput"
/>
<!-- The browser's own picker, kept out of sight: it is opened by the button
and only ever hands back an ISO value. -->
<input
ref="pickerEl"
:type="precision === 'month' ? 'month' : 'date'"
class="dv-date-picker"
tabindex="-1"
aria-hidden="true"
:value="modelValue"
@input="onPick"
/>
<button
type="button"
class="dv-date-open"
:aria-label="t('forms.common.pickDate')"
:title="t('forms.common.pickDate')"
@click="openPicker"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 3v3m8-3v3M4 9h16M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z" />
</svg>
</button>
</div>
</template>