import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { groupBy, randomString } from "@goauthentik/common/utils"; import { AKElement } from "@goauthentik/elements/Base"; import { PreventFormSubmit } from "@goauthentik/elements/forms/Form"; import { t } from "@lingui/macro"; import { CSSResult, TemplateResult, html, render } from "lit"; import { customElement, property } from "lit/decorators.js"; import AKGlobal from "@goauthentik/common/styles/authentik.css"; 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"; @customElement("ak-search-select") export class SearchSelect extends AKElement { @property() query?: string; @property({ attribute: false }) objects?: T[]; @property({ attribute: false }) selectedObject?: T; @property() name?: string; @property({ type: Boolean }) open = false; @property({ type: Boolean }) blankable = false; @property() placeholder: string = t`Select an object.`; static get styles(): CSSResult[] { return [PFBase, PFForm, PFFormControl, PFSelect, AKGlobal]; } @property({ attribute: false }) fetchObjects!: (query?: string) => Promise; @property({ attribute: false }) renderElement!: (element: T) => string; @property({ attribute: false }) renderDescription?: (element: T) => TemplateResult; @property({ attribute: false }) value!: (element: T | undefined) => unknown; @property({ attribute: false }) selected?: (element: T, elements: T[]) => boolean; @property() emptyOption = "---------"; @property({ attribute: false }) groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => { return groupBy(items, () => { return ""; }); }; scrollHandler?: () => void; observer: IntersectionObserver; dropdownUID: string; dropdownContainer: HTMLDivElement; constructor() { super(); this.dropdownContainer = document.createElement("div"); this.observer = new IntersectionObserver(() => { this.open = false; this.shadowRoot ?.querySelectorAll( ".pf-c-form-control.pf-c-select__toggle-typeahead", ) .forEach((input) => { input.blur(); }); }); this.observer.observe(this); this.dropdownUID = `dropdown-${randomString(10)}`; } toForm(): unknown { if (!this.objects) { return new PreventFormSubmit(t`Loading options...`); } return this.value(this.selectedObject) || ""; } firstUpdated(): void { this.updateData(); } updateData(): void { this.fetchObjects(this.query).then((objects) => { this.objects = objects; this.objects.forEach((obj) => { if (this.selected && this.selected(obj, this.objects || [])) { this.selectedObject = obj; } }); }); } connectedCallback(): void { super.connectedCallback(); this.dropdownContainer = document.createElement("div"); this.dropdownContainer.dataset["managedBy"] = "ak-search-select"; document.body.append(this.dropdownContainer); this.updateData(); this.addEventListener(EVENT_REFRESH, this.updateData); this.scrollHandler = () => { this.requestUpdate(); }; window.addEventListener("scroll", this.scrollHandler); } disconnectedCallback(): void { super.disconnectedCallback(); this.removeEventListener(EVENT_REFRESH, this.updateData); if (this.scrollHandler) { window.removeEventListener("scroll", this.scrollHandler); } this.dropdownContainer.remove(); this.observer.disconnect(); } /* * This is a little bit hacky. Because we mainly want to use this field in modal-based forms, * rendering this menu inline makes the menu not overlay over top of the modal, and cause * the modal to scroll. * Hence, we render the menu into the document root, hide it when this menu isn't open * and remove it on disconnect * Also to move it to the correct position we're getting this elements's position and use that * to position the menu * The other downside this has is that, since we're rendering outside of a shadow root, * the pf-c-dropdown CSS needs to be loaded on the body. */ renderMenu(): void { if (!this.objects) { return; } const pos = this.getBoundingClientRect(); let groupedItems = this.groupBy(this.objects); let shouldRenderGroups = true; if (groupedItems.length === 1) { if (groupedItems[0].length < 1 || groupedItems[0][0] === "") { shouldRenderGroups = false; } } if (groupedItems.length === 0) { shouldRenderGroups = false; groupedItems = [["", []]]; } const renderGroup = (items: T[], tabIndexStart: number): TemplateResult => { return html`${items.map((obj, index) => { let desc = undefined; if (this.renderDescription) { desc = this.renderDescription(obj); } return html`
  • `; })}`; }; render( html`
      ${this.blankable ? html`
    • ` : html``} ${shouldRenderGroups ? html`${groupedItems.map(([group, items], idx) => { return html`

      ${group}

        ${renderGroup(items, idx)}
      `; })}` : html`${renderGroup(groupedItems[0][1], 0)}`}
    `, this.dropdownContainer, { host: this }, ); } render(): TemplateResult { this.renderMenu(); return html`
    { this.query = (ev.target as HTMLInputElement).value; this.updateData(); }} @focus=${() => { this.open = true; this.renderMenu(); }} @blur=${(ev: FocusEvent) => { // For Safari, we get the
      element itself here when clicking on one of // it's buttons, as the container has tabindex set if ((ev.relatedTarget as HTMLElement).id === this.dropdownUID) { return; } // Check if we're losing focus to one of our dropdown items, and if such don't blur if (ev.relatedTarget instanceof HTMLButtonElement) { const parentMenu = ev.relatedTarget.closest( "ul.pf-c-dropdown__menu.pf-m-static", ); if (parentMenu && parentMenu.id === this.dropdownUID) { return; } } this.open = false; this.renderMenu(); }} .value=${this.selectedObject ? this.renderElement(this.selectedObject) : this.blankable ? this.emptyOption : ""} />
    `; } }