import { createEventRegistrar, getClosestAttribute, } from "@hesxenon/prelude/Dom.js"; import { iso8601ContentAttribute__maybeDate } from "@hesxenon/prelude/Codec.js"; import { pipe } from "@hesxenon/prelude/Function.js"; import * as Option from "@hesxenon/prelude/Option.js"; import { attribute, customElement } from "simple-custom-elements"; import { Entry, Idl } from "./entry.js"; import { Events } from "./events.js"; import { StartOfWeek } from "./format.js"; import { dayView } from "./views/day.js"; import { monthView } from "./views/month.js"; import { yearView } from "./views/year.js"; export type Connect = ( datepicker: UwcDatepickerElement, opts: { disconnect: AbortController }, ) => void; /** * a view implements a given [Intl.DateTimeFormatPartTypes type](MDN LINK) */ export type View = { /** * add one unit to a given date * * I.e. for a days view this would return the start of the next day. */ addOneUnit: (date: Date) => Date; /** * implement this to integrate your view with the datepicker instance */ connect: Connect; }; @customElement({ tagname: "uwc-datepicker", }) export class UwcDatepickerElement extends HTMLElement { private static defaults = { views: { day: dayView, month: monthView, year: yearView, } as Partial>, order: "day month year", startOfWeek: "sunday" as StartOfWeek, yearsPerPage: 12, }; /** * configure various aspects of the datepicker application wide */ static config = { /** * the views, keyed by {@link Intl#DateTimeFormatPartTypes}. * * @note you can extend the default views by "extending" (merging) the existing `Intl.DateTimeFormatPartRegistry` interface, like this: * * @example * * ```ts * declare global { * namespace UwcDatepickerElement { * interface ViewTypesRegistry { * quarter: any; * } * } * } * ``` */ views: { ...UwcDatepickerElement.defaults.views, }, /** * the default order of views, see {@link UwcDatepickerElement.order | `[order]`} * for more info */ order: UwcDatepickerElement.defaults.order, startOfWeek: UwcDatepickerElement.defaults.startOfWeek, yearsPerPage: UwcDatepickerElement.defaults.yearsPerPage, } satisfies (typeof UwcDatepickerElement)["defaults"]; /** * set the value in form of an ISO string or Date object */ @attribute({ codec: iso8601ContentAttribute__maybeDate, reflect: false, }) "selected-date"?: Idl; /** * The date this datepicker should be anchored to. * * If left empty (or invalid) it will default to the current date. * * @see UwcDatepickerElement.anchorDate */ @attribute({ codec: iso8601ContentAttribute__maybeDate, }) "anchor-date"?: Idl; /** * set a minimum boundary in form of an iso string or Date. It won't be * possible to select values on any resolution smaller than this */ @attribute({ codec: iso8601ContentAttribute__maybeDate, }) min?: Idl; /** * set a maximum boundary in form of an iso string or Date. It won't be * possible to select values on any resolution larger than this */ @attribute({ codec: iso8601ContentAttribute__maybeDate, }) max?: Idl; /** * The order in which the given views appear. * * Should be a white-space separated list of Intl.DateTimeFormatPartTypes. * * WARNING: this is an experimental feature and currently it relies on the order of views being in ascending order of resolution. It is therefor not possible to mimic e.g. the US american order as the lowest view would drop the day part. * * @default "day month year" */ @attribute() order = UwcDatepickerElement.config.order; @attribute() view: Intl.DateTimeFormatPartTypes = "day"; @attribute() "start-of-week" = UwcDatepickerElement.config.startOfWeek; @attribute() "years-per-page" = UwcDatepickerElement.config.yearsPerPage; get locale() { return getClosestAttribute("lang", this) ?? undefined; } /** * the date this datepicker is currently anchored to */ get anchorDate() { return this["anchor-date"] == null ? (this["anchor-date"] = new Date()) : Idl.asDate(this["anchor-date"]); } set anchorDate(next) { this["anchor-date"] = next; } get #views() { return this.order .split(" ") .filter( (view): view is Intl.DateTimeFormatPartTypes => view in UwcDatepickerElement.config.views, ); } get views(): View[] { return this.#views.map((part) => { return UwcDatepickerElement.config.views[part]!; }); } get currentView() { return ( UwcDatepickerElement.config.views[this.view] ?? UwcDatepickerElement.defaults.views[this.view]! ); } get #previousView() { return this.#getViewFromCurrent(-1); } get #nextView() { return this.#getViewFromCurrent(1); } get #entries() { return Array.from(this.querySelectorAll("[data-value]")) .reduce((entries, element) => { const entry = Entry.fromElement(this, element); if (entry == null) { return entries; } entries.push(entry); return entries; }, [] as Entry[]) .sort((a, b) => (a.content < b.content ? -1 : 1)); } #disconnect!: AbortController; #disconnectCurrentView!: AbortController; connectedCallback() { this.#disconnect = new AbortController(); this.#disconnectCurrentView = new AbortController(); this["anchor-date"] ??= this["selected-date"]; this.#connectView(); this.addEventListener(Events.viewChanged, this.#connectView); this.addEventListener("click", (e) => { const value = Entry.fromEventTarget(this, e.target)?.start; if (value != null) { if (this.#previousView == null) { this["selected-date"] = value ?? this["selected-date"]; } else { this["anchor-date"] = value; this.view = this.#previousView; } } }); this.addEventListener("keydown", () => {}); this.addEventListener("mouseover", (e) => { const option = Entry.fromEventTarget(this, e.target); if (option == null) { return; } this.#setCurrent(option.element); }); this.addEventListener("mouseout", () => { this.#setCurrent(null); }); this.addEventListener(Events.drillUp, () => { this.view = this.#nextView ?? this.view; }); } disconnectedCallback() { this.#disconnect.abort(); } #connectView = () => { this.#disconnectCurrentView.abort(); this.#disconnectCurrentView = new AbortController(); this.addEventListener( "focusout", (e) => { // clearing the html will trigger a focusout event for // active elements within the current dom tree. // Since this is always the consequence of changing views // we don't want these events to bubble outside of the // datepicker. e.stopImmediatePropagation(); }, { once: true }, ); this.innerHTML = ""; this.currentView?.connect(this, { disconnect: this.#disconnectCurrentView, }); this.#updateSelected(); this.#updateDisabled(); const on = createEventRegistrar(this, this.#disconnectCurrentView); on( [ Events["selected-dateChanged"], Events.afterViewInit, Events["anchor-dateChanged"], ], this.#updateSelected, ); on( [ Events.minChanged, Events.maxChanged, Events.afterViewInit, Events["anchor-dateChanged"], ], this.#updateDisabled, ); }; #getViewFromCurrent = (offset: number) => { const views = this.#views; return views[views.indexOf(this.view) + offset]; }; #setCurrent = (element: HTMLElement | null) => { this.querySelectorAll("[aria-selected=true]").forEach((selected) => { if (element == null) { selected.setAttribute("tabindex", "0"); } else { selected.removeAttribute("tabindex"); } }); this.querySelectorAll("[aria-current]").forEach((current) => { current.ariaCurrent = null; }); if (element != null) { element.ariaCurrent = "time"; } }; #updateSelected = () => { const selectedElement = this["selected-date"] == null ? null : (this.#findByValue(this["selected-date"])?.element ?? null); for (const entry of this.#entries) { entry.element.ariaSelected = String(entry.element === selectedElement); } }; #updateDisabled = () => { const currentView = this.currentView; if (currentView == null) { return; } for (const entry of this.#entries) { entry.element.ariaDisabled = String(this.#isEntryDisabled(entry)); } }; #isEntryDisabled = (entry: Entry) => { const currentView = this.currentView; if (currentView == null) { return false; } return ( pipe( Option.fromNullable(this.max), Option.map(Idl.asDate), Option.match( () => false, (max) => max < entry.start, ), ) || pipe( Option.fromNullable(this.min), Option.map(Idl.asDate), Option.match( () => false, (min) => { return !( entry.start >= min || this.views.some((view) => view.addOneUnit(min) < entry.end) ); }, ), ) ); }; #findByValue = (value: Idl) => { const date = Idl.asDate(value); return this.#entries.find( (entry) => entry.start <= date && date < entry.end, ); }; } declare global { const UwcDatepickerElement: typeof import("./uwc-datepicker.ts").UwcDatepickerElement; type UwcDatepickerElement = import("./uwc-datepicker.ts").UwcDatepickerElement; interface HTMLElementTagNameMap { "uwc-datepicker": UwcDatepickerElement; } namespace UwcDatepickerElement { // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface ViewTypesRegistry extends Intl.DateTimeFormatPartTypesRegistry {} } } Object.assign(globalThis, { UwcDatepickerElement });