/** * a _ValueElement_ is any element that counts as having a "value" in the context of ``. * * In short its either a `
  • ` element or any other element that has `[data-value]` set. * * ## Custom popover with values * * They can be used to specify options within a [_Popover_](./popover.ts) * * @example * ```html * * *
      *
    • some value
    • *
    *
    *
    * ``` * * ## Pre-selecting values * * With _ValueElement_ it is also possible to "pre-select" values, like this: * * @example * ```html * *
      *
    • some value
    • *
    *
    * ``` * * @privateRemarks * * This element is then "enriched" with a value (idl+content). The IDL and * content attributes are guaranteed to be in sync. * * * @module */ import { create, dispatch, text } from "@hesxenon/prelude/Dom.js"; import * as Either from "@hesxenon/prelude/Either.js"; import { ignore, iife, pipe } from "@hesxenon/prelude/Function.js"; import { valueCodec } from "../codecs.js"; import { Events } from "../events.js"; import { SupportedIdlType } from "../uwc-input.js"; import { ariaAttribute__boolean } from "@hesxenon/prelude/Codec.js"; /** * an attribute name with which template children can specify which attributes * from the source ValueElement to carry over * * TODO: somehow this feels like a generic functionality but I can't find a place for that yet. Don't want to start a rendering library for this */ export const attributeTransferAttributeName = "data-slot-attributes"; /** * @internal */ export const selector = ":where(li, [data-value])"; /** * @internal */ export type ValueElement = { /** * the backing HTML element */ element: HTMLElement; /** * the content attributes value - i.e. a strictly non-localized string representation of the idl attribute */ content: string; /** * the fully parsed value */ idl: SupportedIdlType; }; /** * the minimal properties to uniquely identify a {@link ValueElement} * * @internal */ export type Id = Pick; /** * @internal */ export function fromElement( input: UwcInputElement, element: HTMLElement, ): ValueElement { const getContent = () => { const content = element.getAttribute("data-value") || (element instanceof HTMLLIElement ? (element.textContent ?? "") : ""); if (content == null) { throw new Error( `tried accessing content value but found none - this element is not a valid ValueElement`, ); } return content; }; let cached: SupportedIdlType; return { element, get idl() { return (cached ??= pipe( valueCodec.decode.call(input, getContent()), Either.getOrElse(() => undefined), )); }, set idl(next) { pipe( valueCodec.encode.call(input, next), Either.match( (error) => console.warn( `you supplied a value that couldn't be encoded in the context of a (${error.message})`, ), (value) => { if (cached?.valueOf() === next?.valueOf()) { return; } cached = next; element.setAttribute("data-value", value ?? ""); }, ), ); }, get content() { return getContent(); }, set content(next) { pipe( valueCodec.decode.call(input, getContent()), Either.match(ignore, (value) => { if (this.content === next) { return; } cached = value; element.setAttribute("data-value", next); }), ); }, }; } /** * @internal */ function getContainer(input: UwcInputElement) { return ( input.querySelector(":scope > ul[part=values]") ?? iife(() => { const values = create("ul", { part: "values" }); input.insertAdjacentElement("afterbegin", values); return values; }) ); } /** * @internal */ function getSelectionTemplate(input: UwcInputElement) { const valueElementsListElement = getContainer(input); const template = valueElementsListElement.querySelector("template") ?? iife(() => { const template: HTMLTemplateElement = create("template", {}, [ create( "li", { [attributeTransferAttributeName]: "data-value part", }, [create("slot")], ), ]); valueElementsListElement.append(template); return template; }); if ( template.content.childElementCount === 0 && template.childElementCount > 0 ) { // fixup template, frameworks like react (and the above code) don't // place the templates children in its document fragment template.content.append(...template.children); } return template; } /** * @internal */ export function fromEvent( input: UwcInputElement, event: Event, ): ValueElement | undefined { if (!(event.target instanceof HTMLElement)) { return undefined; } const element = event.target.closest(selector); return element == null ? undefined : fromElement(input, element); } /** * return all ValueElements in a given element * * @internal */ export function getAllIn( input: UwcInputElement, element: HTMLElement, ): ValueElement[] { return Array.from( element.querySelectorAll(":where(li, [data-value]"), ).reduce((elements, element) => { const valueElement = fromElement(input, element); if (!valueElement.content) { return elements; } elements.push(valueElement); return elements; }, [] as ValueElement[]); } /** * @internal */ export function get(input: UwcInputElement, element: Id) { return getSelected(input).find( (valueElement) => valueElement.content === element.content, ); } /** * @internal */ export function has(input: UwcInputElement, element: Id) { return get(input, element) != null; } /** * @internal */ export function add(input: UwcInputElement, valueElement: ValueElement) { if (has(input, valueElement)) { return; } getContainer(input).append(valueElement.element); } /** * add a new ValueElement to the list of selected elements and take the values * from another given ValueElement * * @internal */ export function addFromExisting( input: UwcInputElement, valueElement: ValueElement, ) { if (has(input, valueElement)) { return; } const fragment = getSelectionTemplate(input).content.cloneNode( true, ) as DocumentFragment; const slot = fragment.querySelector("slot"); if (slot != null) { const label = valueElement.element.getAttribute("data-label") || (valueElement.element.textContent ?? ""); slot.replaceWith(text(label)); } fragment .querySelectorAll(`[${attributeTransferAttributeName}]`) .forEach((element) => { element .getAttribute(attributeTransferAttributeName)! .split(" ") .forEach((attributeName) => { const attributeValue = valueElement.element.getAttribute(attributeName) ?? (attributeName === "data-value" ? valueElement.content : undefined); if (attributeValue != null) { element.setAttribute(attributeName, attributeValue); } }); element.removeAttribute(attributeTransferAttributeName); }); getContainer(input).append(fragment); } /** * @internal */ export function remove(input: UwcInputElement, element: Id) { get(input, element)?.element.remove(); } /** * @internal */ export function toggle(input: UwcInputElement, valueElement: ValueElement) { if (has(input, valueElement)) { remove(input, valueElement); } else { addFromExisting(input, valueElement); } } /** * @internal */ export function observe( input: UwcInputElement, onChange: () => void, abortController: { signal: AbortSignal }, ) { const valueChangedObserver = new MutationObserver(() => { getContainer(input).classList.toggle( "empty", getSelected(input).length === 0, ); onChange(); }); valueChangedObserver.observe(getContainer(input), { childList: true, }); abortController.signal.addEventListener("abort", () => valueChangedObserver.disconnect(), ); } /** * @internal */ export function getSelected(input: UwcInputElement) { return getAllIn(input, getContainer(input)); } /** * @internal */ export function getLastSelected(input: UwcInputElement) { return getSelected(input).at(-1); } /** * @internal */ export function removeSelected(input: UwcInputElement) { getSelected(input).forEach((element) => remove(input, element)); } /** * @internal */ export function equals(element: ValueElement, other: ValueElement) { return element.content === other.content; } /** * Update a ValueElements ariaSelected state. * * @internal */ export function updateSelected( element: ValueElement, updateFn: (current: boolean) => boolean, ) { pipe( ariaAttribute__boolean.decode(element.element.ariaSelected), Either.map((current) => updateFn(!!current)), Either.match( ignore, (next) => (element.element.ariaSelected = String(next)), ), ); } /** * @internal */ export function allEqual(elements: ValueElement[], others: ValueElement[]) { return ( new Set(elements.map(({ content }) => content)).symmetricDifference( new Set(others.map(({ content }) => content)), ).size === 0 ); } /** * @internal */ export function hasChanges(input: UwcInputElement, comparedTo: ValueElement[]) { return !allEqual(getSelected(input), comparedTo); } /** * @internal */ export function connectSelected( input: UwcInputElement, abortController: { signal: AbortSignal }, ) { observe( input, () => { dispatch(input, { type: Events.selectedChanged, bubbles: false }); }, abortController, ); getContainer(input).classList.toggle( "empty", getSelected(input).length === 0, ); }