/** * The "text" variant of an [``](../uwc-input.ts) can be constructed * by setting the {@link UwcInputElement#type | `[type]`} attribute to `"text"`. * * It is also the default type. * * You can do quite a lot with this type and below you'll find common patterns * to use this element with. * * ## Simple text input * * @example * * ```html * * ``` * * ## Disabled input * * ```html * * ``` * * ## Custom popup * * It is possible to add a popup that is anchored to the input element * by specifying a `dialog[popover]` child. * * This can - for example - be used to implement comboboxes. * A combobox generally allows you to type a value (and act on that) while * associating it with content in a popup next to it. * * For more details see [WAI Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) * * Note that everything in the popup that counts as a [value element](../parts/value-element.ts) * can be selected. * * @example * * ```html * * *
    *
  • First
  • *
  • Second
  • *
*
*
* ``` * * ## Select * * If you don't want your users to be able to type some arbitrary values but * to select from a "curated" list you can hide the [root element](../parts/root.ts). * * Note that you can still type some text and the highlighted suggestion will * be selected according to a (exact) text match. * * @example * * ```html * * * *
    *
  • First
  • *
  • Second
  • *
*
*
* ``` * * ## Adding arbitrary content * * You can add content like icons and other stuff before or after * the [root element](../parts/root.ts) by specifying the initial "mountpoint" for the * root element with a `[part=root]` element. * * E.g. to add a "clear" button at the end: * * @example * * ```html * *
* * *
* ``` * * ## Controlling where selected values go with `ul[part=values]` * * You can also control where selected values are added by specifying a * `ul[part=values]` element. * * This can also be used to specify initial values if desired. * * @example * * ```html * * * * ``` * * ## Customizing how multiple values are displayed * * By providing a template inside the `ul[part=values]` element * it is possible to specify how selected values are rendered. * * In short, there are three things to consider: * * 1. `data-value` - or the text content of a `li` element if not specified - is * what's going to be what's added to the form data. * 2. `data-label` - or the text content of a `li` element if not specified - is * what's going to be placed in unnamed slots. * 3. `data-slot-attributes` can be used to copy attributes from the selected element * inside the [popup](../parts/popover.ts) to the new element in `ul[part=values]` * * @example * * ```html * * * *
    *
  • First
  • *
  • * Second * And also some info *
  • *
*
*
* ``` * * @module */ import { create, createEventRegistrar, dispatch, text, } from "@hesxenon/prelude/Dom.js"; import { iife, pipe } from "@hesxenon/prelude/Function.js"; import * as Option from "@hesxenon/prelude/Option.js"; import { schedule } from "@hesxenon/prelude/Scheduler.js"; import { Events } from "../events"; import * as Root from "../parts/root"; import * as ValueElement from "../parts/value-element"; import * as Popover from "../parts/popover"; import { Connect } from "../uwc-input"; import { backup } from "../../utils/ElementBackup"; const getContent = (input: UwcInputElement) => { return input.querySelector("[contenteditable]")!; }; const hasChanges = (a: string[], b: string[]) => a.length !== b.length || a.some((value, index) => value !== b[index]); const initializeInput: Connect = (input, { disconnect }) => { backup(input, disconnect); if ( !input.multiple && typeof input.value === "string" && input.value !== "" ) { ValueElement.removeSelected(input); } input.value = input.value ?? ""; const hasPopup = Popover.getDialog(input) != null; input.role = hasPopup ? "combobox" : "textbox"; input.ariaHasPopup = input.ariaHasPopup ?? String(hasPopup); input.disabled = input.disabled ?? false; }; const addInitiallySelected = (input: UwcInputElement) => { Array.from( Popover.getDialog(input)?.querySelectorAll( "[aria-selected=true]", ) ?? [], ) .slice(0, input.multiple ? undefined : 1) .forEach((preSelected) => input.addSelected(preSelected)); }; /** * @internal */ export const connectText: Connect = (input, opts) => { //#region utils const getCurrentFormValues = () => { const selected = input.selected as string[]; const typed = (input.value as string | undefined) ?? ""; const omitValue = Root.isHidden(input) || input.omitvalue === true || input.omitvalue === "form"; return input.multiple ? omitValue ? selected : selected.concat(typed) : omitValue ? selected.slice(0, 1) : [typed || (selected[0] ?? typed)]; }; const syncFormData = () => { formValues = getCurrentFormValues(); dispatch(input, { type: Events.formValuesChanged, detail: formValues, }); }; const validate = () => { dispatch(input, { type: Events.validityStateChanged, detail: { valueMissing: input.required && !input.value && ValueElement.getSelected(input).length === 0, tooShort: input.minlength != null && typeof input.value === "string" && input.value.length < input.minlength, tooLong: input.maxlength != null && typeof input.value === "string" && input.value.length > input.maxlength, rangeUnderflow: typeof input.min === "number" && input.multiple && formValues.length < input.min, rangeOverflow: typeof input.max === "number" && input.multiple && formValues.length > input.max, patternMismatch: iife(() => { const regex = typeof input.pattern !== "string" ? undefined : new RegExp(input.pattern); return ( regex != null && typeof input.value === "string" && input.selected.concat(input.value).every((value) => { return typeof value === "string" && !regex?.test(value); }) ); }), }, }); }; const handleKeydown = (e: KeyboardEvent) => { if (input.readonly || Root.isReadonly(input)) { e.preventDefault(); return; } if (e.key === "ArrowDown") { Popover.highlightNext(input); } if (e.key === "ArrowUp") { Popover.highlightPrevious(input); } if (e.key === "Enter") { pipe( Option.fromNullable(Popover.getHighlighted(input)), Option.flatMap((el) => Option.fromNullable(ValueElement.fromElement(input, el)), ), Option.map((el) => { e.preventDefault(); // prevent form submission ValueElement.updateSelected(el, (current) => !current); Popover.clearHighlight(input); schedule(() => content.blur()); }), ); } if (e.key === "Backspace" && cache === "") { ValueElement.getLastSelected(input)?.element.remove(); } }; const handleBeforeInput = (e: InputEvent) => { if (e.inputType === "insertParagraph") { e.preventDefault(); } }; const handleInput = iife(() => { let timeouthandle: ReturnType | undefined; return () => { if (Root.isHidden(input)) { const content = getContent(input); if (timeouthandle != null) { clearTimeout(timeouthandle); } timeouthandle = setTimeout(() => { content.innerText = ""; }, 300); pipe( Option.fromNullable(Popover.getDialog(input)), Option.flatMap((dialog) => Option.fromNullable( ValueElement.getAllIn(input, dialog).find((valueElement) => valueElement.element.innerText.startsWith(content.innerText), )?.element, ), ), Option.map((suggestion) => Popover.highlight(input, suggestion)), ); } else { if (!input.multiple) { ValueElement.removeSelected(input); } input.value = cache = !content.innerText.includes("\n") ? content.innerText : (content.innerText = content.innerText.replaceAll("\n", "")); } }; }); //#endregion //#region values let cache = ""; let formValues = getCurrentFormValues(); let snapshot = formValues; const initial = { formValues, valueElements: ValueElement.getSelected(input).map(({ element }) => ValueElement.fromElement( input, element.cloneNode(true) as typeof element, ), ), value: input.value, }; const placeholder = create("div", { part: "placeholder" }, [ text(input.getAttribute("placeholder") ?? ""), ]); const content = create("div", { contentEditable: "true", onkeydown: handleKeydown, onbeforeinput: handleBeforeInput, oninput: handleInput, }); //#endregion //#region event listeners const on = createEventRegistrar(input, opts.disconnect); content.addEventListener("mousedown", (e) => { if (input.disabled) { e.preventDefault(); } }); let preventFocus = false; on("focus", () => { content.focus(); }); on("mousedown", (e) => { preventFocus = e.defaultPrevented; if (preventFocus) { return; } if (!e.composedPath().includes(Root.getFor(input))) { // otherwise a mousedown would remove the focus as soon as it reaches the document e.preventDefault(); } content.focus(); }); on("click", (e) => { if (!e.isTrusted) { // TODO: workaround for https://github.com/testing-library/user-event/issues/1237 e.preventDefault(); } if (!preventFocus) { content.focus(); } const valueElement = ValueElement.fromEvent(input, e); if (valueElement != null) { ValueElement.updateSelected(valueElement, (current) => !current); Popover.clearHighlight(input); if (!input.multiple) { schedule(() => content.blur()); } } }); on("focusin", () => { snapshot = formValues; Popover.show(input); }); on("focusout", () => { if (hasChanges(formValues, snapshot)) { dispatch(input, { type: "change" }); } Popover.hide(input); }); on(Events.valueChanged, () => { if (cache !== input.value) { // value has been update from the outside content.innerText = cache = (input.value as string | undefined) ?? ""; } syncFormData(); }); on( [Events.nameChanged, Events.formAssociated, Events.omitvalueChanged], syncFormData, ); on( [ Events.formValuesChanged, Events.requiredChanged, Events.minlengthChanged, Events.maxlengthChanged, Events.minChanged, Events.maxChanged, Events.patternChanged, ], validate, ); on(Events.selectedChanged, () => { syncFormData(); Popover.markSelected(input); }); on(Events.selectedSuggestionChanged, ({ detail: valueElement }) => { if (input.readonly) { return; } if (valueElement.element.ariaSelected === "true") { if (!input.multiple) { input.value = ""; ValueElement.removeSelected(input); } ValueElement.addFromExisting(input, valueElement); } else { ValueElement.remove(input, valueElement); } }); on(Events.placeholderChanged, () => { placeholder.textContent = input.placeholder ?? ""; }); on(Events.clear, () => { input.value = ""; ValueElement.removeSelected(input); }); on(Events.formReset, () => { input.value = initial.value; ValueElement.removeSelected(input); initial.valueElements.forEach((element) => ValueElement.add(input, element), ); }); on(Events.disabledChanged, () => { content.ariaDisabled = String(input.disabled); content.contentEditable = String(!input.disabled); syncFormData(); }); on("mouseover", (e) => { const valueElement = ValueElement.fromEvent(input, e); if (valueElement == null) { return; } Popover.highlight(input, valueElement.element); }); on("mouseout", (e) => { if (!Popover.getDialog(input)?.contains(e.relatedTarget as Node | null)) { Popover.clearHighlight(input); } }); //#endregion //#region render ValueElement.connectSelected(input, opts.disconnect); Popover.setup(input, opts.disconnect); Root.replaceChildren(input, [content, placeholder]); addInitiallySelected(input); Popover.markSelected(input); //#endregion //#region init initializeInput(input, opts); formValues = getCurrentFormValues(); snapshot = formValues; initial.formValues = formValues; //#endregion };