/** * The _Popover_ is a nested dialog which is used for e.g. autocompletions. * * _Popover_ elements are anchored to the parent [``](../uwc-input.ts) * * They can be added like this: * * @example * * ```html * * *
Some custom popover content
*
*
* ``` * * @module */ import * as Dom from "@hesxenon/prelude/Dom.js"; import { Events } from "../events.js"; import * as ValueElement from "./value-element.js"; import { iife } from "@hesxenon/prelude/Function.js"; /** * marks all elements in the associated popover dialog with `[aria-selected]` if their value occurs in the associated values * * @internal */ export function markSelected(input: UwcInputElement) { const dialog = getDialog(input); if (dialog == null) { return; } const values = ValueElement.getSelected(input) .map((element) => element.content) .concat(input.value?.toString() ?? ""); for (const suggestion of ValueElement.getAllIn(input, dialog)) { const current = suggestion.element.ariaSelected; const next = String( values.some((content) => content === suggestion.content), ); if (next !== current) { suggestion.element.setAttribute("aria-selected", next); } } } /** * given an input listen to changes of aria-selected attributes on contained ValueElements * * @internal */ export function setup( input: UwcInputElement, abortController: { signal: AbortSignal }, ) { const selectSuggestion = (valueElement: ValueElement.ValueElement) => Dom.dispatch(input, { type: Events.suggestionSelected, detail: valueElement, }); input.addEventListener( "click", (e) => { const valueElement = ValueElement.fromEvent(input, e); if (valueElement == null) { return; } selectSuggestion(valueElement); }, abortController, ); input.addEventListener("keydown", (e) => { if ( e.key !== "Space" || document.activeElement == null || !document.activeElement.matches(ValueElement.selector) || !input.contains(document.activeElement) ) { return; } const valueElement = ValueElement.fromElement( input, document.activeElement as HTMLElement, ); selectSuggestion(valueElement); }); const ariaSelectedObserver = new MutationObserver((records) => { for (const record of records) { const target = record.target as HTMLElement; if (target.ariaSelected === record.oldValue) { continue; } if (target.matches(ValueElement.selector)) { const valueElement = ValueElement.fromElement( input, record.target as HTMLElement, ); Dom.dispatch(input, { type: Events.selectedSuggestionChanged, detail: valueElement, }); } } }); ariaSelectedObserver.observe(input, { attributes: true, attributeFilter: ["aria-selected"], subtree: true, attributeOldValue: true, }); abortController.signal.addEventListener("abort", () => { ariaSelectedObserver.disconnect(); }); } /** * get the associated dialog for a given input * * @internal */ export function getDialog( input: UwcInputElement, { createIfAbsent = false, }: { /** * create a dialog if none is found */ createIfAbsent?: boolean; } = {}, ) { const popover = input.querySelector(":scope > dialog[popover]") ?? iife(() => { if (!createIfAbsent) { return; } const popover = Dom.create("dialog", { popover: "" }); input.append(popover); return popover; }); return popover; } /** * get the currently highlighted element * * @internal */ export function getHighlighted(input: UwcInputElement) { return getDialog(input)?.querySelector("[aria-current]"); } /** * remove the highlighting * * @internal */ export function clearHighlight(input: UwcInputElement) { getDialog(input) ?.querySelectorAll("[aria-current]") .forEach((current) => current.removeAttribute("aria-current")); } /** * highlight the given element * * @internal */ export function highlight( input: UwcInputElement, element: Element | undefined, ) { clearHighlight(input); element?.setAttribute("aria-current", "true"); } /** * get an element by a relative offset from the currently highlighted element, if any * * @internal */ export function getRelativeFromCurrent(input: UwcInputElement, offset: number) { const allValueElements = Array.from( getDialog(input)?.querySelectorAll(ValueElement.selector) ?? [], ); const current = getHighlighted(input); if (current == null) { return allValueElements.at(offset > 0 ? offset - 1 : offset); } const nextIndex = allValueElements.indexOf(current) + offset; if (nextIndex < 0) { return; } return allValueElements.at(nextIndex); } /** * highlight the next element * * @internal */ export function highlightNext(input: UwcInputElement) { const target = getRelativeFromCurrent(input, +1); highlight(input, target); target?.scrollIntoView({ block: "end" }); } /** * highlight the previous element * * @internal */ export function highlightPrevious(input: UwcInputElement) { const target = getRelativeFromCurrent(input, -1); highlight(input, target); target?.scrollIntoView({ block: "end" }); } export function show(input: UwcInputElement) { const dialog = getDialog(input); if (dialog == null) { return; } dialog.open = true; } export function hide(input: UwcInputElement) { const dialog = getDialog(input); if (dialog == null) { return; } dialog.open = false; }