/** * The `` element tries to emulate the native `` element while extending * it in a sensible manner to enable common use cases like (stylable) autocomplete fields or * comboboxes. * * It offers a wide variety of attributes and provides great flexibility, all while trying to * offer an API that is as familiar as possible. * * For details on its different types please see the relevant {@link UwcInputElement#type | `[type]` sub pages}. * * NOTE: this custom element contains funtionality that takes over the HTML that might have * been rendered by a framework. If you use this with a framework you might need to create * a wrapper to "rehydrate" the changed parts. * * @module * */ import * as Dom from "@hesxenon/prelude/Dom.js"; import { iife, pipe } from "@hesxenon/prelude/Function.js"; import { Codec, contentAttribute__boolean, contentAttribute__number, } from "@hesxenon/prelude/Codec.js"; import { attribute, customElement } from "simple-custom-elements"; import { setValidity } from "../validation"; import { content__minMax, omitValueCodec, valueCodec } from "./codecs"; import { Events } from "./events"; import * as Root from "./parts/root"; import * as ValueElement from "./parts/value-element"; import * as Popover from "./parts/popover"; import { connectDate } from "./types/date"; import { connectNumber } from "./types/number"; import { connectText } from "./types/text"; import { connectRadio } from "./types/radio"; import { connectCheckbox } from "./types/checkbox"; import { connectTextarea } from "./types/textarea"; import { connectFile } from "./types/file"; import { connectRange } from "./types/range"; import * as Either from "@hesxenon/prelude/Either.js"; /** * the IDL types supported by the {@link UwcInputElement#value} attribute, among other things. */ export type SupportedIdlType = | string | number | Date | boolean | File | undefined; /** * connect functions are intended to connect a given input * with its Root Element. * * @internal */ export type Connect = ( input: UwcInputElement, opts: { disconnect: { signal: AbortSignal }; config: (typeof UwcInputElement)["config"]; internals: ElementInternals; }, ) => void; /** * An input element * * For more documentation please consult the [module documentation](./uwc-input.ts) */ @customElement({ tagname: "uwc-input", formAssociated: true, }) export class UwcInputElement extends HTMLElement { static config = { date: { defaults: { "en-US": { pattern: "MM/dd/yyyy", placeholder: "mm/dd/yyyy", }, "de-DE": { pattern: "dd.MM.yyyy", placeholder: "tt.mm.jjjj", }, } as Record, }, file: { defaults: { "en-US": { placeholder: "No file chosen", buttonText: "Choose File", }, }, }, }; /** * add this attribute name to an element with a template to transfer the white-space * separated list of attribute names to this element upon selection of a suggestion */ static readonly SLOT_ATTRIBUTES_NAME = ValueElement.attributeTransferAttributeName; /** * the name attribute is necessary to provide if you want * this element to participate in a surrounding form * * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name | MDN input name attribute} */ @attribute() name?: string; /** * The placeholder to show when the input is empty. * * TODO: use this to enable custom date part placeholders * * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/placeholder | MDN placeholder attribute} */ @attribute() placeholder?: string; /** * When true, this forces the user to select a suggestion. * That is, any custom typed value will not be added to the * selected values or the surrounding form * * @deprecated - use {@link UwcInputElement#omitvalue} instead. */ @attribute({ codec: contentAttribute__boolean }) forceselection = false; /** * if this is not fulfilled the {@link UwcInputElement#value-missing | value-missing} attribute * will be used for the `[data-validationmessage]` attribute. * * @example * * */ @attribute({ codec: contentAttribute__boolean, }) required = false; /** * the given validation message for missing values. */ @attribute() "value-missing"?: string; /** * the given validation severity for missing values. * * Only `"error"` will prevent form submission and trigger `:invalid` css selectors. */ @attribute() "value-missing-severity"?: ValidationSeverity; /** * the minimum length required for values to be valid. Only works with `[type=text]` and `[type=search]`. * * If this is underrun the {@link UwcInputElement#too-short | `[too-short]`} attribute will be used as the * `[data-validationmessage]` attribute. * * @example * * * * @see {@link UwcInputElement#type | type} */ @attribute({ codec: contentAttribute__number, }) minlength?: number; /** * the given validation message for content that isn't long enough */ @attribute() "too-short"?: string; /** * the given validation severity for values that are too short. * * Only `"error"` will prevent form submission and trigger `:invalid` css selectors. */ @attribute() "too-short-severity"?: ValidationSeverity; /** * The minimum valid value. * Should be a number in case of `[type=number]` and an ISO8601 date string for `[type=date]`. * * In case of {@link UwcInputElement#multiple | `[multiple]`} at least this amount of values * must be selected */ @attribute({ codec: content__minMax, }) min?: string | Date | number; /** * the given validation message for range underflows */ @attribute() "range-underflow"?: string; /** * the given validation severity for values that are too low. * * Only `"error"` will prevent form submission and trigger `:invalid` css selectors. */ @attribute() "range-underflow-severity"?: ValidationSeverity; /** * the maximum length for values to still be valid. Only works with `[type=text]` and `[type=search]`. * * If this is overrun the {@link UwcInputElement#too-long | `[too-long]`} attribute will be used as the * `[data-validationmessage]` attribute. * * @example * * * * @see {@link UwcInputElement#type | type} */ @attribute({ codec: contentAttribute__number, }) maxlength?: number; /** * the given validation message for content that isn't long enough */ @attribute() "too-long"?: string; /** * the given validation severity for values that are too long. * * Only `"error"` will prevent form submission and trigger `:invalid` css selectors. */ @attribute() "too-long-severity"?: ValidationSeverity; /** * The maximum valid value. * Should be a number in case of [type=number] and an ISO8601 date string for [type=date] * * In case of {@link UwcInputElement#multiple | `[multiple]`} at most this amount of values * may be selected */ @attribute({ codec: content__minMax, }) max?: string | Date | number; /** * the given validation message for range overflows */ @attribute() "range-overflow"?: string; /** * the given validation severity for values that are too high. * * Only `"error"` will prevent form submission and trigger `:invalid` css selectors. */ @attribute() "range-overflow-severity"?: ValidationSeverity; /** * the pattern the value should match. * * In the case of `[type=date]` this will fall back to a default pattern representing a UTS35 format string. * This format string is determined by the closest detected locale. */ @attribute() pattern?: string; /** * the given validation message for pattern mismatch */ @attribute() "pattern-mismatch"?: string; /** * the given validation severity for values that do not match the provided pattern. * * Only `"error"` will prevent form submission and trigger `:invalid` css selectors. */ @attribute() "pattern-mismatch-severity"?: ValidationSeverity; /** * Akin to the native elements type this determines the type of input rendered. * * Note that contrary to native input elements this will also change the type * of the value IDL attribute. * * Detailed documentation is available in the respective type modules * - [`[text]`](./types/text.ts) * - [`[number]`](./types/number.ts) * - [`[date]`](./types/date.ts) * - [`[checkbox]`](./types/checkbox.ts) * - [`[radio]`](./types/radio.ts) * - [`[file]`](./types/file.ts) * - [`[textarea]`](./types/textarea.ts) * * @example * * ```html * * * * ``` * * @default `"text"` */ @attribute() type: | "text" | "number" | "date" | "textarea" | "checkbox" | "radio" | "search" | "range" | "file" = "text"; /** * enable multiple values. */ @attribute({ codec: contentAttribute__boolean, }) multiple = false; /** * the current value of this uwc-inputs "primary element". * * What the "primary element" is depends on the given {@link UwcInputElement#type | type}. * * For `[type=text]` this is a textbox and the value would be a `string`. For * `[type=number]` it is still a textbox but its type would be `number`. * * For other element types, such as `[type=radio]` it would be whatever the `[value]` * attribute is set to. */ @attribute({ codec: valueCodec, reflect: false, }) value?: SupportedIdlType; /** * whether or not this element is disabled. * Disabled elements do not participate in constraint validation or form submission and are not focusable. */ @attribute({ codec: contentAttribute__boolean, set(next) { this.setAttribute("aria-disabled", globalThis.String(next)); }, }) disabled = false; /** * indicates that the whole input is readonly. This means changing it won't be possible but it will * still be focusable. * * Provided suggestions will also not show in this case as selecting them would not have any effect. * * If you only want to make a textbox readonly you should set `aria-readonly="true"` on a given [part=root] element. */ @attribute({ codec: contentAttribute__boolean, set(next) { this.setAttribute("aria-readonly", globalThis.String(next)); }, }) readonly = false; /** * the checked state of a radio button or checkbox. */ @attribute({ codec: contentAttribute__boolean, }) checked = false; /** * the indeterminate attribute means that e.g. a {@link UwcInputElement#type | `[type=checkbox]`} * might relate to children of which some are checked and some are not */ @attribute({ codec: contentAttribute__boolean, }) indeterminate?: boolean; /** * set the value of the lower slider of a [type=range][multiple] input */ @attribute({ codec: contentAttribute__number, }) start?: string | number; /** * set the value of the upper slider of a [type=range][multiple] input */ @attribute({ codec: contentAttribute__number, }) end?: string | number; /** * set the increments in which the {@link UwcInputElement.value | `.value`} (or the {@link UwcInputElement.start | `.start`} and {@link UwcInputElement.end | `.end`}) attributes change */ @attribute({ codec: contentAttribute__number, }) step?: string | number; @attribute({ attributeName: "tabindex", codec: contentAttribute__number as Codec, }) tabIndex = 0; /** * omit the value of the .value attribute. According to the passed value and the {@link UwcInputElement.type | `.type`} this has different meanings. * * | value | type | behaviour | * | --- | --- | --- | * | undefined, false, not present | text | the .value attribute is included in every aspect | * | true, present | text | the same as "form" | * | "form" | text | the value is omitted from the surrounding form values | */ @attribute({ codec: omitValueCodec, }) omitvalue?: boolean | "form"; #files = null as FileList | null; get files() { return this.#files; } set files(next) { this.#files = next; Dom.dispatch(this, { type: Events.filesChanged }); } /** * the current values of this element as an Array. May include * multiple entries if {@link UwcInputElement#multiple | [multiple]} is set. */ get selected(): SupportedIdlType[] { return ValueElement.getSelected(this).map((element) => element.idl); } /** * the closest surrounding form element. * * if this input has no name set `null` is returned because * it does not participate in the forms API */ get form() { return this.name == null ? null : this.closest("form"); } /** * the current locale as given by the closest `[lang]` attribute */ get locale() { return Dom.getClosestAttribute("lang", this) ?? navigator.language; } /** * get the current validity state */ get validity() { return this.#internals.validity; } /** * get the current validation message */ get validationMessage() { return this.#internals.validationMessage; } #internals = this.attachInternals(); #disconnect = new AbortController(); #rerender = new AbortController(); connectedCallback(): void { this.#disconnect = new AbortController(); this.classList.add("pristine"); const createFormData = (from: FormDataEntryValue[]) => this.name == null ? null : iife(() => { const fd = new FormData(); for (const value of from) { fd.append(this.name!, value); } return fd; }); this.addEventListener("focusin", (e) => { if (this.contains(e.relatedTarget as Node)) { e.stopPropagation(); } this.classList.remove("pristine"); this.classList.add("touched"); }); let initial: FormDataEntryValue[]; let formValues: FormDataEntryValue[]; this.addEventListener("input", async () => { this.classList.remove("pristine"); this.classList.toggle( "changed", initial.length !== formValues.length || (await iife(async () => { const getPrimitive = async (entries: FormDataEntryValue[]) => { return new Set( await Promise.all( entries.map((entry) => entry instanceof File ? entry.text() : entry, ), ), ); }; const [a, b] = await Promise.all([ getPrimitive(initial), getPrimitive(formValues), ]); return a.symmetricDifference(b).size > 0; })), ); }); this.addEventListener(Events.formValuesChanged, (e) => { initial ??= e.detail; formValues = e.detail; this.#internals.setFormValue(createFormData(formValues)); }); this.addEventListener(Events.validityStateChanged, (e) => { setValidity( !this.#internals.willValidate ? {} : e.detail, this.#internals, this, ); }); this.addEventListener(Events.typeChanged, () => { this.#render(); }); this.addEventListener(Events.formReset, () => { this.classList.add("pristine"); this.classList.remove("touched"); this.classList.remove("changed"); }); this.#render(); this.style.setProperty("--anchor-name", `--${crypto.randomUUID()}`); } disconnectedCallback() { this.#disconnect.abort(); this.#rerender.abort(); } formAssociatedCallback(): void { if (!this.name) { return; } Dom.dispatch(this, { type: Events.formAssociated, bubbles: false }); } formResetCallback(): void { if (!this.name) { return; } Dom.dispatch(this, { type: Events.formReset, bubbles: false }); } checkValidity = () => { return this.#internals.checkValidity(); }; reportValidity() { return this.#internals.reportValidity(); } /** * add elements unconditionally to the values list. * * While you may pass any element here only {@link ValueElement | `ValueElement`s} are going to be accepted */ addSelected = (...elements: HTMLElement[]) => { for (const element of elements) { const valueElement = ValueElement.fromElement(this, element); if (valueElement == null) { continue; } ValueElement.addFromExisting(this, valueElement); } }; /** * remove elements unconditionally from the values list. Passing the magic string "all" will remove all currently selected elements. * * While you may pass any element here only {@link ValueElement | `ValueElement`s} are going to be accepted. * * Elements that aren't found in the values list will be ignored. */ removeSelected = ( ...[head, ...tail]: ["all" | HTMLElement, ...HTMLElement[]] ) => { if (head === "all") { ValueElement.removeSelected(this); } else { for (const element of [head, ...tail]) { const valueElement = ValueElement.fromElement(this, element); if (valueElement == null) { continue; } ValueElement.remove(this, valueElement); } } }; clear = (): void => { Dom.dispatch(this, { type: Events.clear }); }; showPicker = () => { Popover.show(this); }; #render = (): void => { this.#rerender.abort(); this.#rerender = new AbortController(); const connect = iife(() => { switch (this.type) { case "search": case "text": return connectText; case "number": return connectNumber; case "date": return connectDate; case "radio": return connectRadio; case "checkbox": return connectCheckbox; case "textarea": return connectTextarea; case "file": return connectFile; case "range": return connectRange; } }); if (connect == null) { this.type = "text"; return; } // try to carry over existing values into a new type representation // this can easily happen by accident when specifying the value before the type this.value = pipe( valueCodec.decode.call(this, this.value), Either.getOrElse(() => this.value), ); connect(this, { disconnect: this.#rerender, config: UwcInputElement.config, internals: this.#internals, }); Dom.forwardEvents(Root.getFor(this), this, undefined, { signal: this.#rerender.signal, keepComposedPath: true, }); }; } declare global { const UwcInputElement: typeof import("./uwc-input.ts").UwcInputElement; type UwcInputElement = import("./uwc-input.ts").UwcInputElement; interface HTMLElementTagNameMap { "uwc-input": UwcInputElement; } } Object.assign(globalThis, { UwcInputElement });