import { AKElement } from "@goauthentik/elements/Base"; import { bound } from "@goauthentik/elements/decorators/bound.js"; import "@goauthentik/elements/forms/SearchSelect/ak-search-select-menu-position.js"; import type { SearchSelectMenuPosition } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-menu-position.js"; import { msg } from "@lit/localize"; import { PropertyValues, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { Ref, createRef, ref } from "lit/directives/ref.js"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFSelect from "@patternfly/patternfly/components/Select/select.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { SearchSelectCloseEvent, SearchSelectInputEvent, SearchSelectSelectEvent, SearchSelectSelectMenuEvent, } from "./SearchSelectEvents.js"; import type { SearchOptions, SearchTuple } from "./types.js"; /** * @class SearchSelectView * @element ak-search-select-view * * Main component of ak-search-select, renders the object and controls interaction with the * portaled menu list. * * @fires ak-search-select-input - When the user selects an item from the list. A derivative Event * with the `value` as its payload. * * Note that this is more on the HTML / Web Component side of the operational line: the keys which * represent the values we pass back to clients are always strings here. This component is strictly * for *rendering* and *interacting* with the items as the user sees them. If the host client is * not using strings for the values it ultimately keeps inside, it must map them forward to the * string-based keys we use here (along with the label and description), and map them *back* to * the object that key references when extracting the value for use. * */ @customElement("ak-search-select-view") export class SearchSelectView extends AKElement { /** * The options collection. The simplest variant is just [key, label, optional]. See * the `./types.ts` file for variants and how to use them. * * @prop */ @property({ type: Array, attribute: false }) options: SearchOptions = []; /** * The current value. Must be one of the keys in the options group above. * * @prop */ @property() value?: string; /** * If set to true, this object MAY return undefined in no value is passed in and none is set * during interaction. * * @attr */ @property({ type: Boolean }) blankable = false; /** * The name of the input, for forms * * @attr */ @property() name?: string; /** * Whether or not the portal is open * * @attr */ @property({ type: Boolean, reflect: true }) open = false; /** * The textual placeholder for the search's object, if currently empty. Used as the * native object's `placeholder` field. * * @attr */ @property() placeholder: string = msg("Select an object."); /** * A textual string representing "The user has affirmed they want to leave the selection blank." * Only used if `blankable` above is true. * * @attr */ @property() emptyOption = "---------"; // Handle the behavior of the drop-down when the :host scrolls off the page. scrollHandler?: () => void; observer: IntersectionObserver; @state() displayValue = ""; /** * Permanent identify for the input object, so the floating portal can find where to anchor * itself. */ inputRef: Ref = createRef(); /** * Permanent identity with the portal so focus events can be checked. */ menuRef: Ref = createRef(); /** * Maps a value from the portal to labels to be put into the field> */ optionsMap: Map = new Map(); static get styles() { return [PFBase, PFForm, PFFormControl, PFSelect]; } constructor() { super(); this.observer = new IntersectionObserver(() => { this.open = false; }); this.observer.observe(this); /* These can't be attached with the `@` syntax because they're not passed through to the * menu; the positioner is in the way, and it deliberately renders objects *outside* of the * path from `document` to this object. That's why we pass the positioner (and its target) * the `this` (host) object; so they can send messages to this object despite being outside * the event's bubble path. */ this.addEventListener("ak-search-select-select-menu", this.onSelect); this.addEventListener("ak-search-select-close", this.onClose); } disconnectedCallback(): void { this.observer.disconnect(); super.disconnectedCallback(); } onOpenEvent(event: Event) { this.open = true; if ( this.blankable && this.value === this.emptyOption && event.target && event.target instanceof HTMLInputElement ) { event.target.value = ""; } } @bound onSelect(event: SearchSelectSelectMenuEvent) { this.open = false; this.value = event.value; this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : ""; this.dispatchEvent(new SearchSelectSelectEvent(this.value)); } @bound onClose(event: SearchSelectCloseEvent) { event.stopPropagation(); this.inputRef.value?.focus(); this.open = false; } @bound onFocus(event: FocusEvent) { this.onOpenEvent(event); } @bound onClick(event: Event) { this.onOpenEvent(event); } @bound onInput(_event: InputEvent) { this.value = this.inputRef?.value?.value ?? ""; this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : ""; this.dispatchEvent(new SearchSelectInputEvent(this.value)); } @bound onKeydown(event: KeyboardEvent) { if (event.key === "Escape") { event.stopPropagation(); this.open = false; } } @bound onFocusOut(event: FocusEvent) { event.stopPropagation(); window.setTimeout(() => { if (!this.menuRef.value?.hasFocus()) { this.open = false; } }, 0); } willUpdate(changed: PropertyValues) { if (changed.has("options")) { this.optionsMap = optionsToOptionsMap(this.options); } if (changed.has("value")) { this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : ""; } } updated() { if (!(this.inputRef?.value && this.inputRef?.value?.value === this.displayValue)) { this.inputRef.value && (this.inputRef.value.value = this.displayValue); } } render() { return html`
`; } } type Pair = [string, string]; const justThePair = ([key, label]: SearchTuple): Pair => [key, label]; function optionsToOptionsMap(options: SearchOptions): Map { const pairs: Pair[] = Array.isArray(options) ? options.map(justThePair) : options.grouped ? options.options.reduce( (acc: Pair[], { options }): Pair[] => [...acc, ...options.map(justThePair)], [] as Pair[], ) : options.options.map(justThePair); return new Map(pairs); } declare global { interface HTMLElementTagNameMap { "ak-search-select-view": SearchSelectView; } }