/** * With the {@link UwcInputElement#type | `[type]`} attribute set to `"date"` [``](../uwc-input.ts) * will render a date input. * * ## Plain date input * * @example * * ```html * * ``` * * ## Specify a locale * * Similar to [``](./number.ts) this will format its value according to the closest `[lang]` attribute. * * @example * * ```html * * ``` * * ## Custom formats * * You can also configure a custom format pattern by specifying a valid [format string](https://date-fns.org/v4.1.0/docs/lightFormat) * for the {@link UwcInputElement#pattern | `[pattern]`} attribute. * * @example * * ```html * * ``` * * ## Custom placeholders * * It is possible to customize the placeholder as long as the "separators" (the bits that don't represent a value part) * match the current {@link UwcInputElement#pattern | `[pattern]`}. * * @example * * ```html * * ``` * * ## Default placeholders * * Because it is rather tedious to specify a placeholder everywhere just because your app * requires a different placeholder than the standard you can also override the default placeholder. * * Just keep in mind that this is not reactive, so you should do this once for your whole app. * * @example * * ```ts * // specified per locale * UwcInputElement.config.date.defaults["en-US"] = { * pattern: "yyyy/MM/dd", // a sensible default * placeholder: "YYYY/MM/DD", * } * ``` * * ## Time components * * --- TO BE DONE --- * * Because the format string can also include time components it is possible to use this type for date _and_ time inputs. * * @example * * ```html * * ``` * * ## Customize datepicker * * Since this type simply utilizes a [popup](../parts/popover.ts) which you can provide before initialization * it is possible to provide your own datepicker for example. * * @example * * ```html * * *
    *
  • 10th. of October
  • *
*
*
* ``` * * @module */ import { create, createEventRegistrar, dispatch, selectText, text, } from "@hesxenon/prelude/Dom.js"; import { ignore, iife, pipe } from "@hesxenon/prelude/Function.js"; import { fromKeyedArray } from "@hesxenon/prelude/Map.js"; import * as Option from "@hesxenon/prelude/Option.js"; import { schedule } from "@hesxenon/prelude/Scheduler.js"; import * as UTS35 from "@hesxenon/prelude/UTS35.js"; import { Events } from "../events"; import * as Popover from "../parts/popover.js"; import * as Root from "../parts/root.js"; import type { Connect } from "../uwc-input.js"; /** * get meta information and utilities regarding a date part. * * @internal */ const getDatePartInfo = ( part: { type: Exclude; value: string; }, _locale: string, ): { autoCorrect: (from: string, parts: Intl.DateTimeFormatPart[]) => string; increment: (from: string, parts: Intl.DateTimeFormatPart[]) => string; decrement: (from: string, parts: Intl.DateTimeFormatPart[]) => string; isValid: (value: string, parts: Intl.DateTimeFormatPart[]) => boolean; isFull: (value: string, parts: Intl.DateTimeFormatPart[]) => boolean; } => { switch (part.type) { case "day": { return { autoCorrect: (from) => from.slice(-part.value.length).padStart(part.value.length, "0"), increment(from, parts) { return this.autoCorrect( String(Math.min(31, Number(this.autoCorrect(from, parts)) + 1)), parts, ); }, decrement(from, parts) { return this.autoCorrect( String(Math.max(0, Number(this.autoCorrect(from, parts)) - 1)), parts, ); }, isValid: (value) => { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0; }, isFull: (from) => Number(from + "0") > 31, }; } case "month": { return { autoCorrect: (from) => from.slice(-part.value.length).padStart(part.value.length, "0"), increment(from, parts) { return this.autoCorrect( String(Math.min(12, Number(this.autoCorrect(from, parts)) + 1)), parts, ); }, decrement(from, parts) { return this.autoCorrect( String(Math.max(0, Number(this.autoCorrect(from, parts)) - 1)), parts, ); }, isValid: (value) => { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0; }, isFull: (from) => Number(from + "0") > 12, }; } case "year": { return { autoCorrect: (from) => from.slice(-part.value.length).padStart(part.value.length, "0"), increment(from, parts) { return this.autoCorrect( String(Number(this.autoCorrect(from, parts)) + 1), parts, ); }, decrement(from, parts) { return this.autoCorrect( String(Math.max(0, Number(this.autoCorrect(from, parts)) - 1)), parts, ); }, isValid: (value) => { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0; }, isFull: () => false, }; } default: { throw new Error(`could not handle type '${part.type}'`); } } }; const getPlaceholderMap = ( input: UwcInputElement, defaults: { pattern: string; placeholder: string } | undefined, formatString: string, ) => { return pipe( Option.fromNullable(input.placeholder ?? defaults?.placeholder), Option.map((placeholder) => { const separators = UTS35.splitBySeperators(placeholder, formatString); const patternParts = UTS35.patternToParts(formatString); const hasMismatch = separators.length !== patternParts.length; return pipe( hasMismatch ? [] : separators, fromKeyedArray((part) => part.type), ); }), Option.toUndefined, ); }; const getFormatString = ( input: UwcInputElement, defaults: { pattern: string; placeholder: string } | undefined, ) => { return ( input.pattern ?? defaults?.pattern ?? iife(() => { const formatter = new Intl.DateTimeFormat(input.locale); const parts = formatter.formatToParts(new Date()); return parts.reduce((pattern, part) => { return ( pattern + (part.type === "literal" ? part.value : iife(() => { switch (part.type) { case "year": return "yyyy"; case "month": return "MM"; case "day": return "dd"; default: return ""; } })) ); }, ""); }) ); }; const hasChanges = (a: Date | undefined, b: Date | undefined) => a?.valueOf() !== b?.valueOf(); /** * @internal */ export const connectDate: Connect = (input, opts) => { const defaults = opts.config.date.defaults[input.locale] ?? Object.entries(opts.config.date.defaults).find(([locale]) => locale.startsWith(input.locale), )?.[1]; const formatString = getFormatString(input, defaults); const initial = input.value instanceof Date ? new Date(input.value) : undefined; let cache = undefined as undefined | Date; let snapshot = cache; const placeholderMap = getPlaceholderMap(input, defaults, formatString); const syncFormData = () => { dispatch(input, { type: Events.formValuesChanged, detail: !(input.value instanceof Date) || Number.isNaN(input.value.valueOf()) == null ? [] : [input.value.toISOString()], }); }; const validate = () => { dispatch(input, { type: Events.validityStateChanged, detail: { badInput: Number.isNaN(cache?.valueOf()), valueMissing: input.required && cache == null, rangeOverflow: cache != null && input.max !== null && (typeof input.max === "number" || input.max instanceof Date) && cache.valueOf() > input.max.valueOf(), rangeUnderflow: cache != null && input.min !== null && (typeof input.min === "number" || input.min instanceof Date) && cache.valueOf() < input.min.valueOf(), }, }); }; /** * update the inputs value according to the current parts */ const syncValue = () => { const isEmpty = parts.every((part) => part.value === ""); const isIncomplete = parts.some((part) => part.value === ""); cache = isEmpty ? undefined : isIncomplete ? new Date(Number.NaN) : input.value instanceof Date ? input.value : new Date(0, 0, 1, 0, 0, 0, 0); if (cache != null) { UTS35.assign( cache, formatString, parts.map((part) => part.value).join(""), ); } if (!Number.isNaN(cache?.valueOf())) { input.value = cache; } validate(); }; /** * update each parts value according to the current input.value */ const syncParts = () => { /** * associate the parts of the given value to their respective part type */ const valueParts = !(input.value instanceof Date) ? [] : UTS35.formatToParts(input.value, formatString); for (let i = 0; i < parts.length; i++) { const part = parts[i]!; part.value = valueParts[i]?.value ?? ""; } cache = input.value as Date | undefined; }; /** * add a view to the nested datepicker (if the dialog isn't already specified) */ const setupDatepicker = (_parts: Parts) => { const dialog = Popover.getDialog(input, { createIfAbsent: true }); if (dialog == null) { throw new Error("dialog is nullish"); } if (dialog.childElementCount === 0) { const datepicker = create( "uwc-datepicker", { ["selected-date"]: (input.value as Date | undefined)?.toISOString(), min: input.min, max: input.max, }, [], ); dialog.append(datepicker); return datepicker; } }; let lastFocusedPart: Element | undefined; const on = createEventRegistrar(input, opts.disconnect); on("focus", () => parts.find((part) => part.type !== "literal")?.focus()); on("focusin", () => { lastFocusedPart = Root.getFor(input).contains(document.activeElement) ? (document.activeElement ?? undefined) : lastFocusedPart; snapshot = cache; Popover.show(input); }); on("focusout", (e) => { if (input.contains(e.relatedTarget as Node)) { e.stopImmediatePropagation(); return; } if (hasChanges(cache, snapshot)) { dispatch(input, { type: "change" }); } Popover.hide(input); }); on(Events.selectedSuggestionChanged, (e) => { if (e.detail.element.ariaSelected === "true") { input.value = e.detail.idl; } }); on(Events.suggestionSelected, () => { schedule(() => { (document.activeElement as HTMLElement)?.blur(); }); }); on([Events.nameChanged, Events.formAssociated], syncFormData); on(Events.valueChanged, () => { if (cache !== input.value) { validate(); syncParts(); syncFormData(); if (managedDatepicker != null) { managedDatepicker["selected-date"] = input.value as Date | undefined; } } }); on(Events.minChanged, () => { if (managedDatepicker != null) { managedDatepicker.min = input.min; } }); on(Events.maxChanged, () => { if (managedDatepicker != null) { managedDatepicker.max = input.max; } }); on( [ Events.formValuesChanged, Events.minChanged, Events.maxChanged, Events.requiredChanged, ], validate, ); on(Events.formReset, () => { input.value = initial; }); on(Events.clear, () => { input.value = undefined; }); /** * selectable elements that represent each part of a date according to the current formatString */ const parts = UTS35.patternToParts(formatString).map(({ type, pattern }) => { if (type === "literal") { return { element: create("div", { style: { display: "inline-block" } }, [ text(pattern), ]), type, value: pattern, focus: ignore, isEmpty: true, }; } const info = getDatePartInfo({ type, value: pattern }, input.locale); const placeholder = placeholderMap?.get(type)?.value ?? pattern; const content = create( "div", { contentEditable: "true", part: type, onfocus: () => selectText(content.firstChild!), onmousedown: (e) => { e.preventDefault(); selectText(content.firstChild!); }, onkeydown: (e) => { if (e.key === "Tab") { return; } if (e.key === "Backspace" || e.key === "Delete") { part.value = ""; syncValue(); selectText(content.firstChild!); } if (e.key === "ArrowUp") { part.value = info.increment(part.value, parts); syncValue(); selectText(content.firstChild!); } if (e.key === "ArrowDown") { part.value = info.decrement(part.value, parts); syncValue(); selectText(content.firstChild!); } if (!/^.$/u.test(e.key)) { e.preventDefault(); return; } if (content.textContent === placeholder) { content.textContent = ""; } else { // temporarily select only the end, so that the event can // lead to a new textContent selectText(content.firstChild!, content.textContent!.length); } }, oninput: (e) => { const raw = part.value; const before = partCache; if (raw.includes("\n")) { content.textContent = partCache || placeholder; selectText(content.firstChild!); return; } const next = info.autoCorrect(raw, parts); if (!info.isValid(next, parts)) { content.textContent = partCache || placeholder; } else { part.value = next; syncValue(); if (info.isFull(next, parts)) { setTimeout(() => { parts .slice(parts.indexOf(part) + 1) .find(({ type }) => type !== "literal") ?.focus(); }); } } selectText(content.firstChild!); if (partCache === before) { e.stopPropagation(); } }, }, [], ); const part = iife(() => { const part = { element: create("div", { style: { display: "inline-block" } }, [ content, ]), type, focus() { content.focus(); }, get isEmpty() { return content.textContent === placeholder; }, get value() { return part.isEmpty ? "" : (content.textContent ?? ""); }, set value(next) { partCache = next; content.textContent = next || placeholder; }, }; return part as typeof part & { value: string }; }); let partCache = part.value; return part; }); type Parts = typeof parts; const managedDatepicker = setupDatepicker(parts); Root.replaceChildren( input, parts.map((part) => part.element), ); Popover.setup(input, opts.disconnect); syncParts(); syncFormData(); };