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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d4033dbcef
commit
f7caeaf907
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
import PartialDateField from "./PartialDateField.vue";
|
import PartialDateField from "./PartialDateField.vue";
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ async function submit() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.car.firstRegistration") }}</label>
|
<label class="dh-label">{{ t("forms.car.firstRegistration") }}</label>
|
||||||
<input v-model="form.firstRegistrationDate" type="date" class="dh-input data" />
|
<DateField v-model="form.firstRegistrationDate" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { api } from "../api";
|
|||||||
import { applyAttachment } from "../lib/attachment.js";
|
import { applyAttachment } from "../lib/attachment.js";
|
||||||
import { t, tSplit } from "../i18n";
|
import { t, tSplit } from "../i18n";
|
||||||
import AttachmentField from "./AttachmentField.vue";
|
import AttachmentField from "./AttachmentField.vue";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -88,7 +89,7 @@ function payload() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.charging.date") }}</label>
|
<label class="dh-label">{{ t("forms.charging.date") }}</label>
|
||||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
<DateField v-model="form.date" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.charging.odometer") }}</label>
|
<label class="dh-label">{{ t("forms.charging.odometer") }}</label>
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<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>
|
||||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
|||||||
import { applyAttachment } from "../lib/attachment.js";
|
import { applyAttachment } from "../lib/attachment.js";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import AttachmentField from "./AttachmentField.vue";
|
import AttachmentField from "./AttachmentField.vue";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -100,11 +101,11 @@ function payload() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.document.issued") }}</label>
|
<label class="dh-label">{{ t("forms.document.issued") }}</label>
|
||||||
<input v-model="form.issueDate" type="date" class="dh-input data" />
|
<DateField v-model="form.issueDate" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.document.renewalDate") }}</label>
|
<label class="dh-label">{{ t("forms.document.renewalDate") }}</label>
|
||||||
<input v-model="form.expiryDate" type="date" class="dh-input data" />
|
<DateField v-model="form.expiryDate" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted">{{ t("forms.document.renewalHint") }}</p>
|
<p class="text-xs text-muted">{{ t("forms.document.renewalHint") }}</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
|||||||
import { applyAttachment } from "../lib/attachment.js";
|
import { applyAttachment } from "../lib/attachment.js";
|
||||||
import { t, tSplit } from "../i18n";
|
import { t, tSplit } from "../i18n";
|
||||||
import AttachmentField from "./AttachmentField.vue";
|
import AttachmentField from "./AttachmentField.vue";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -82,7 +83,7 @@ function payload() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.fuel.date") }}</label>
|
<label class="dh-label">{{ t("forms.fuel.date") }}</label>
|
||||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
<DateField v-model="form.date" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.fuel.odometer") }}</label>
|
<label class="dh-label">{{ t("forms.fuel.odometer") }}</label>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { formatMoney } from "../lib/format.js";
|
|||||||
import { applyAttachment } from "../lib/attachment.js";
|
import { applyAttachment } from "../lib/attachment.js";
|
||||||
import { t, tSplit } from "../i18n";
|
import { t, tSplit } from "../i18n";
|
||||||
import AttachmentField from "./AttachmentField.vue";
|
import AttachmentField from "./AttachmentField.vue";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -94,7 +95,7 @@ function payload() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.maintenance.date") }}</label>
|
<label class="dh-label">{{ t("forms.maintenance.date") }}</label>
|
||||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
<DateField v-model="form.date" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.maintenance.odometer") }}</label>
|
<label class="dh-label">{{ t("forms.maintenance.odometer") }}</label>
|
||||||
@@ -161,7 +162,7 @@ function payload() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.maintenance.warrantyUntil") }}</label>
|
<label class="dh-label">{{ t("forms.maintenance.warrantyUntil") }}</label>
|
||||||
<input v-model="form.warrantyUntil" type="date" class="dh-input data" />
|
<DateField v-model="form.warrantyUntil" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
// prints back at exactly the precision it was given.
|
// prints back at exactly the precision it was given.
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
|
|
||||||
const props = defineProps({ modelValue: { type: String, default: "" } });
|
const props = defineProps({ modelValue: { type: String, default: "" } });
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
@@ -74,21 +75,19 @@ watch(year, (value) => {
|
|||||||
<option value="year">{{ t("forms.car.precision.year") }}</option>
|
<option value="year">{{ t("forms.car.precision.year") }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- One control per precision, each the browser's own: a date picker, a
|
<!-- One control per precision: a date, a month, and a plain box for the
|
||||||
month picker, and a plain box for the year. -->
|
year. The first two are DateField, so a build date is typed in the same
|
||||||
<input
|
order as every other date in the app. -->
|
||||||
|
<DateField
|
||||||
v-if="precision === 'day'"
|
v-if="precision === 'day'"
|
||||||
type="date"
|
:model-value="modelValue"
|
||||||
class="dh-input data"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
:value="modelValue"
|
|
||||||
@input="emit('update:modelValue', $event.target.value)"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<DateField
|
||||||
v-else-if="precision === 'month'"
|
v-else-if="precision === 'month'"
|
||||||
type="month"
|
precision="month"
|
||||||
class="dh-input data"
|
:model-value="modelValue"
|
||||||
:value="modelValue"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
@input="emit('update:modelValue', $event.target.value)"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { formatKm } from "../lib/format.js";
|
import { formatKm } from "../lib/format.js";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -93,7 +94,7 @@ function payload() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.reminder.onDate") }}</label>
|
<label class="dh-label">{{ t("forms.reminder.onDate") }}</label>
|
||||||
<input v-model="form.dueDate" type="date" class="dh-input data" />
|
<DateField v-model="form.dueDate" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.reminder.atOdometer") }}</label>
|
<label class="dh-label">{{ t("forms.reminder.atOdometer") }}</label>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { applyAttachment } from "../lib/attachment.js";
|
|||||||
import { SERVICE_PARTS } from "../lib/serviceParts.js";
|
import { SERVICE_PARTS } from "../lib/serviceParts.js";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import AttachmentField from "./AttachmentField.vue";
|
import AttachmentField from "./AttachmentField.vue";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -74,7 +75,7 @@ async function submit() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.service.date") }}</label>
|
<label class="dh-label">{{ t("forms.service.date") }}</label>
|
||||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
<DateField v-model="form.date" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.service.odometer") }}</label>
|
<label class="dh-label">{{ t("forms.service.odometer") }}</label>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { formatDate } from "../lib/format.js";
|
|||||||
import { applyAttachment } from "../lib/attachment.js";
|
import { applyAttachment } from "../lib/attachment.js";
|
||||||
import { t, tSplit } from "../i18n";
|
import { t, tSplit } from "../i18n";
|
||||||
import AttachmentField from "./AttachmentField.vue";
|
import AttachmentField from "./AttachmentField.vue";
|
||||||
|
import DateField from "./DateField.vue";
|
||||||
import Modal from "./Modal.vue";
|
import Modal from "./Modal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -82,7 +83,7 @@ async function submit() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.technical.date") }}</label>
|
<label class="dh-label">{{ t("forms.technical.date") }}</label>
|
||||||
<input v-model="form.date" type="date" required class="dh-input data" />
|
<DateField v-model="form.date" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.technical.result") }}</label>
|
<label class="dh-label">{{ t("forms.technical.result") }}</label>
|
||||||
@@ -95,7 +96,7 @@ async function submit() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("forms.technical.validUntil") }}</label>
|
<label class="dh-label">{{ t("forms.technical.validUntil") }}</label>
|
||||||
<input v-model="form.validUntil" type="date" class="dh-input data" />
|
<DateField v-model="form.validUntil" />
|
||||||
<p v-if="form.result === 'failed'" class="mt-1 text-xs text-muted">
|
<p v-if="form.result === 'failed'" class="mt-1 text-xs text-muted">
|
||||||
{{ t("forms.technical.failedHint") }}
|
{{ t("forms.technical.failedHint") }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"empty": "—",
|
"empty": "—",
|
||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"no": "Nej"
|
"no": "Nej",
|
||||||
|
"dateUnits": { "y": "åååå", "m": "mm", "d": "dd" }
|
||||||
},
|
},
|
||||||
|
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -643,6 +644,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"forms": {
|
"forms": {
|
||||||
|
"common": {
|
||||||
|
"pickDate": "Åbn kalender",
|
||||||
|
"dateInvalid": "Indtast en gyldig dato i dette format."
|
||||||
|
},
|
||||||
"car": {
|
"car": {
|
||||||
"addTitle": "Tilføj en bil",
|
"addTitle": "Tilføj en bil",
|
||||||
"editTitle": "Rediger bil",
|
"editTitle": "Rediger bil",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"empty": "—",
|
"empty": "—",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No",
|
||||||
|
"dateUnits": { "y": "yyyy", "m": "mm", "d": "dd" }
|
||||||
},
|
},
|
||||||
|
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -642,6 +643,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"forms": {
|
"forms": {
|
||||||
|
"common": {
|
||||||
|
"pickDate": "Open calendar",
|
||||||
|
"dateInvalid": "Enter a real date in this format."
|
||||||
|
},
|
||||||
"car": {
|
"car": {
|
||||||
"addTitle": "Add a car",
|
"addTitle": "Add a car",
|
||||||
"editTitle": "Edit car",
|
"editTitle": "Edit car",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"download": "Pobierz",
|
"download": "Pobierz",
|
||||||
"empty": "—",
|
"empty": "—",
|
||||||
"yes": "Tak",
|
"yes": "Tak",
|
||||||
"no": "Nie"
|
"no": "Nie",
|
||||||
|
"dateUnits": { "y": "rrrr", "m": "mm", "d": "dd" }
|
||||||
},
|
},
|
||||||
|
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -657,6 +658,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"forms": {
|
"forms": {
|
||||||
|
"common": {
|
||||||
|
"pickDate": "Otwórz kalendarz",
|
||||||
|
"dateInvalid": "Wpisz istniejącą datę w tym formacie."
|
||||||
|
},
|
||||||
"car": {
|
"car": {
|
||||||
"addTitle": "Dodaj samochód",
|
"addTitle": "Dodaj samochód",
|
||||||
"editTitle": "Edytuj samochód",
|
"editTitle": "Edytuj samochód",
|
||||||
|
|||||||
@@ -324,6 +324,43 @@ body {
|
|||||||
box-shadow: var(--shadow-focus);
|
box-shadow: var(--shadow-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DateField: the browser's picker parked out of sight behind the calendar
|
||||||
|
button, so the visible box can keep the user's own segment order. Not
|
||||||
|
display:none — showPicker() needs a rendered element to open against. */
|
||||||
|
.dv-date-picker {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.dv-date-open {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.25rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.dv-date-open:hover {
|
||||||
|
color: var(--text-strong);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
}
|
||||||
|
.dv-date-open:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-strong);
|
||||||
|
box-shadow: var(--shadow-focus);
|
||||||
|
}
|
||||||
|
|
||||||
.dh-label {
|
.dh-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0.375rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user