diff --git a/web/.eslintrc.precommit.json b/web/.eslintrc.precommit.json deleted file mode 100644 index 6e813487885e..000000000000 --- a/web/.eslintrc.precommit.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:lit/recommended", - "plugin:custom-elements/recommended", - "plugin:storybook/recommended", - "plugin:sonarjs/recommended" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 12, - "sourceType": "module" - }, - "plugins": ["@typescript-eslint", "lit", "custom-elements", "sonarjs"], - "rules": { - "indent": "off", - "linebreak-style": ["error", "unix"], - "quotes": ["error", "double", { "avoidEscape": true }], - "semi": ["error", "always"], - "@typescript-eslint/ban-ts-comment": "off", - "sonarjs/cognitive-complexity": ["warn", 9], - "sonarjs/no-duplicate-string": "off", - "sonarjs/no-nested-template-literals": "off" - } -} diff --git a/web/package-lock.json b/web/package-lock.json index 958d3f03a4a1..37c622f991e5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/legacy-modes": "^6.4.0", "@codemirror/theme-one-dark": "^6.1.2", + "@floating-ui/dom": "^1.6.3", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.5.2", "@goauthentik/api": "^2024.6.1-1720888668", diff --git a/web/package.json b/web/package.json index de4874859ee8..eb7af0b5216d 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/legacy-modes": "^6.4.0", "@codemirror/theme-one-dark": "^6.1.2", + "@floating-ui/dom": "^1.6.3", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.5.2", "@goauthentik/api": "^2024.6.1-1720888668", diff --git a/web/src/components/ak-multi-select.ts b/web/src/components/ak-multi-select.ts index dbf596f985c4..6ef7f21137e6 100644 --- a/web/src/components/ak-multi-select.ts +++ b/web/src/components/ak-multi-select.ts @@ -1,4 +1,4 @@ -import { AKElement } from "@goauthentik/elements/Base"; +import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { TemplateResult, css, html, nothing } from "lit"; @@ -25,7 +25,7 @@ const selectStyles = css` * @part select - The select itself, to override the height specified above. */ @customElement("ak-multi-select") -export class AkMultiSelect extends AKElement { +export class AkMultiSelect extends AkControlElement { constructor() { super(); this.dataset.akControl = "true"; diff --git a/web/src/components/stories/ak-search-select.stories.ts b/web/src/components/stories/ak-search-select.stories.ts deleted file mode 100644 index a2bd77f3787b..000000000000 --- a/web/src/components/stories/ak-search-select.stories.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { groupBy } from "@goauthentik/common/utils"; -import { convertToSlug as slugify } from "@goauthentik/common/utils.js"; -import "@goauthentik/elements/forms/SearchSelect/ak-search-select"; -import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect/ak-search-select"; -import { Meta } from "@storybook/web-components"; - -import { TemplateResult, html } from "lit"; - -type RawSample = [string, string[]]; -type Sample = { name: string; pk: string; season: string[] }; - -// prettier-ignore -const groupedSamples: RawSample[] = [ - ["Spring", [ - "Apples", "Apricots", "Asparagus", "Avocados", "Bananas", "Broccoli", - "Cabbage", "Carrots", "Celery", "Collard Greens", "Garlic", "Herbs", "Kale", "Kiwifruit", "Lemons", - "Lettuce", "Limes", "Mushrooms", "Onions", "Peas", "Pineapples", "Radishes", "Rhubarb", "Spinach", - "Strawberries", "Swiss Chard", "Turnips"]], - ["Summer", [ - "Apples", "Apricots", "Avocados", "Bananas", "Beets", "Bell Peppers", "Blackberries", "Blueberries", - "Cantaloupe", "Carrots", "Celery", "Cherries", "Corn", "Cucumbers", "Eggplant", "Garlic", - "Green Beans", "Herbs", "Honeydew Melon", "Lemons", "Lima Beans", "Limes", "Mangos", "Okra", "Peaches", - "Plums", "Raspberries", "Strawberries", "Summer Squash", "Tomatillos", "Tomatoes", "Watermelon", - "Zucchini"]], - ["Fall", [ - "Apples", "Bananas", "Beets", "Bell Peppers", "Broccoli", "Brussels Sprouts", "Cabbage", "Carrots", - "Cauliflower", "Celery", "Collard Greens", "Cranberries", "Garlic", "Ginger", "Grapes", "Green Beans", - "Herbs", "Kale", "Kiwifruit", "Lemons", "Lettuce", "Limes", "Mangos", "Mushrooms", "Onions", - "Parsnips", "Pears", "Peas", "Pineapples", "Potatoes", "Pumpkin", "Radishes", "Raspberries", - "Rutabagas", "Spinach", "Sweet Potatoes", "Swiss Chard", "Turnips", "Winter Squash"]], - ["Winter", [ - "Apples", "Avocados", "Bananas", "Beets", "Brussels Sprouts", "Cabbage", "Carrots", "Celery", - "Collard Greens", "Grapefruit", "Herbs", "Kale", "Kiwifruit", "Leeks", "Lemons", "Limes", "Onions", - "Oranges", "Parsnips", "Pears", "Pineapples", "Potatoes", "Pumpkin", "Rutabagas", - "Sweet Potatoes", "Swiss Chard", "Turnips", "Winter Squash"]] -]; - -// WAAAAY too many lines to turn the arrays above into a Sample of -// { name: "Apricots", pk: "apple", season: ["Spring", "Summer"] } -// but it does the job. - -const samples = Array.from( - groupedSamples - .reduce((acc, sample) => { - sample[1].forEach((item) => { - const update = (thing: Sample) => ({ - ...thing, - season: [...thing.season, sample[0]], - }); - acc.set( - item, - update(acc.get(item) || { name: item, pk: slugify(item), season: [] }), - ); - return acc; - }, acc); - return acc; - }, new Map()) - .values(), -); -samples.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - -// All we need is a promise to return our dataset. It doesn't have to be a class-based method a'la -// the authentik API. - -const getSamples = (query = "") => - Promise.resolve( - samples.filter((s) => - query !== "" ? s.name.toLowerCase().includes(query.toLowerCase()) : true, - ), - ); - -const metadata: Meta> = { - title: "Elements / Search Select ", - component: "ak-search-select", - parameters: { - docs: { - description: { - component: "An implementation of the Patternfly search select pattern", - }, - }, - }, -}; - -export default metadata; - -const container = (testItem: TemplateResult) => - html`
- - - ${testItem} - -
    -
    `; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const displayChange = (ev: any) => { - document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify( - ev.detail.value, - null, - 2, - )}`; -}; - -export const Default = () => { - return container( - html` sample.name} - .value=${(sample: Sample) => sample.pk} - @ak-change=${displayChange} - >`, - ); -}; - -export const Grouped = () => { - return container( - html` sample.name} - .value=${(sample: Sample) => sample.pk} - .groupBy=${(samples: Sample[]) => - groupBy(samples, (sample: Sample) => sample.season[0] ?? "")} - @ak-change=${displayChange} - >`, - ); -}; - -export const Selected = () => { - return container( - html` sample.name} - .value=${(sample: Sample) => sample.pk} - .selected=${(sample: Sample) => sample.pk === "herbs"} - @ak-change=${displayChange} - >`, - ); -}; diff --git a/web/src/elements/AkControlElement.ts b/web/src/elements/AkControlElement.ts new file mode 100644 index 000000000000..33dc7f2d86dc --- /dev/null +++ b/web/src/elements/AkControlElement.ts @@ -0,0 +1,20 @@ +import { AKElement } from "./Base"; + +/** + * @class - prototype for all of our hand-made input elements + * + * Ensures that the `data-ak-control` property is always set, so that + * scrapers can find it easily, and adds a corresponding method for + * extracting the value. + * + */ +export class AkControlElement extends AKElement { + constructor() { + super(); + this.dataset.akControl = "true"; + } + + json() { + throw new Error("Controllers using this protocol must override this method"); + } +} diff --git a/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts b/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts index 8cebcfc371ed..edd73e448fdf 100644 --- a/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts +++ b/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts @@ -1,4 +1,4 @@ -import { AKElement } from "@goauthentik/elements/Base"; +import { AkControlElement } from "@goauthentik/elements/AkControlElement"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { msg } from "@lit/localize"; @@ -23,7 +23,7 @@ function* kvToPairs(items: CheckboxPair[]): Iterable { } } -const AkElementWithCustomEvents = CustomEmitterElement(AKElement); +const AkElementWithCustomEvents = CustomEmitterElement(AkControlElement); /** * @element ak-checkbox-group diff --git a/web/src/elements/ak-dual-select/ak-dual-select-provider.ts b/web/src/elements/ak-dual-select/ak-dual-select-provider.ts index ea4e62d08e29..1a431cca7c42 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select-provider.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select-provider.ts @@ -1,4 +1,4 @@ -import { AKElement } from "@goauthentik/elements/Base"; +import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; import { debounce } from "@goauthentik/elements/utils/debounce"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; @@ -26,9 +26,8 @@ import type { DataProvider, DualSelectPair } from "./types"; */ @customElement("ak-dual-select-provider") -export class AkDualSelectProvider extends CustomListenerElement(AKElement) { - /** - * A function that takes a page and returns the DualSelectPair[] collection with which to update +export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) { + /** A function that takes a page and returns the DualSelectPair[] collection with which to update * the "Available" pane. * * @attr @@ -84,8 +83,6 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) { constructor() { super(); setTimeout(() => this.fetch(1), 0); - // Notify AkForElementHorizontal how to handle this thing. - this.dataset.akControl = "true"; this.onNav = this.onNav.bind(this); this.onChange = this.onChange.bind(this); this.onSearch = this.onSearch.bind(this); diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index da7f6c10f861..fba8ea9f3f96 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -3,7 +3,6 @@ import { MessageLevel } from "@goauthentik/common/messages"; import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils"; import { AKElement } from "@goauthentik/elements/Base"; import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement"; -import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect"; import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; @@ -74,8 +73,8 @@ export function serializeForm( return; } - const inputElement = element.querySelector("[name]"); - if (element.hidden || !inputElement) { + const inputElement = element.querySelector("[name]"); + if (element.hidden || !inputElement || (element.writeOnly && !element.writeOnlyActivated)) { return; } @@ -84,10 +83,6 @@ export function serializeForm( return; } - // Skip elements that are writeOnly where the user hasn't clicked on the value - if (element.writeOnly && !element.writeOnlyActivated) { - return; - } if ( inputElement.tagName.toLowerCase() === "select" && "multiple" in inputElement.attributes @@ -120,17 +115,6 @@ export function serializeForm( assignValue(inputElement, inputElement.checked, json); } else if ("selectedFlow" in inputElement) { assignValue(inputElement, inputElement.value, json); - } else if (inputElement.tagName.toLowerCase() === "ak-search-select") { - const select = inputElement as unknown as SearchSelect; - try { - const value = select.toForm(); - assignValue(inputElement, value, json); - } catch (exc) { - if (exc instanceof PreventFormSubmit) { - throw new PreventFormSubmit(exc.message, element); - } - throw exc; - } } else { assignValue(inputElement, inputElement.value, json); } diff --git a/web/src/elements/forms/SearchSelect/SearchKeyboardController.ts b/web/src/elements/forms/SearchSelect/SearchKeyboardController.ts new file mode 100644 index 000000000000..adbe0be56436 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/SearchKeyboardController.ts @@ -0,0 +1,142 @@ +import { bound } from "@goauthentik/elements/decorators/bound.js"; +import { match } from "ts-pattern"; + +import { LitElement, ReactiveController, ReactiveControllerHost } from "lit"; + +import { + KeyboardControllerCloseEvent, + KeyboardControllerSelectEvent, +} from "./SearchKeyboardControllerEvents.js"; + +type ReactiveElementHost = Partial & LitElement & { value?: string }; +type ValuedHtmlElement = HTMLElement & { value: string }; + +/** + * @class AkKeyboardController + * + * This reactive controller connects to the host and sets up listeners for keyboard events to manage + * a list of elements. Navigational controls (up, down, home, end) do what you'd expect. Enter and Space + * "select" the current item, which means: + * + * - All other items lose focus and tabIndex + * - The selected item gains focus and tabIndex + * - The value of the selected item is sent to the host as an event + * + * @fires ak-keyboard-controller-select - When an element is selected. Contains the `value` of the + * selected item. + * + * @fires ak-keyboard-controller-close - When `Escape` is pressed. Clients can do with this as they + * wish. + * + */ +export class AkKeyboardController implements ReactiveController { + private host: ReactiveElementHost; + + private index: number = 0; + + private selector: string; + + private highlighter: string; + + private items: ValuedHtmlElement[] = []; + + /** + * @arg selector: The class identifier (it *must* be a class identifier) of the DOM objects + * that this controller will be working with. + * + * NOTE: The objects identified by the selector *must* have a `value` associated with them, and + * as in all things HTML, that value must be a string. + * + * @arg highlighter: The class identifier that clients *may* use to set an alternative focus + * on the object. Note that the object will always receive focus. + * + */ + constructor( + host: ReactiveElementHost, + selector = ".ak-select-item", + highlighter = ".ak-highlight-item", + ) { + this.host = host; + host.addController(this); + this.selector = selector[0] === "." ? selector : `.${selector}`; + this.highlighter = highlighter.replace(/^\./, ""); + } + + hostUpdated() { + this.items = Array.from(this.host.renderRoot.querySelectorAll(this.selector)); + const current = this.items.findIndex((item) => item.value === this.host.value); + if (current >= 0) { + this.index = current; + } + } + + hostConnected() { + this.host.addEventListener("keydown", this.onKeydown); + } + + hostDisconnected() { + this.host.removeEventListener("keydown", this.onKeydown); + } + + hostVisible() { + this.items[this.index].focus(); + } + + get current() { + return this.items[this.index]; + } + + get value() { + return this.current?.value; + } + + set value(v: string) { + const index = this.items.findIndex((i) => i.value === v); + if (index !== undefined) { + this.index = index; + this.performUpdate(); + } + } + + private performUpdate() { + const items = this.items; + items.forEach((item) => { + item.classList.remove(this.highlighter); + item.tabIndex = -1; + }); + items[this.index].classList.add(this.highlighter); + items[this.index].tabIndex = 0; + items[this.index].focus(); + } + + @bound + onKeydown(event: KeyboardEvent) { + const key = event.key; + match({ key }) + .with({ key: "ArrowDown" }, () => { + this.index = Math.min(this.index + 1, this.items.length - 1); + this.performUpdate(); + }) + .with({ key: "ArrowUp" }, () => { + this.index = Math.max(this.index - 1, 0); + this.performUpdate(); + }) + .with({ key: "Home" }, () => { + this.index = 0; + this.performUpdate(); + }) + .with({ key: "End" }, () => { + this.index = this.items.length - 1; + this.performUpdate(); + }) + .with({ key: " " }, () => { + this.host.dispatchEvent(new KeyboardControllerSelectEvent(this.value)); + }) + .with({ key: "Enter" }, () => { + this.host.dispatchEvent(new KeyboardControllerSelectEvent(this.value)); + }) + .with({ key: "Escape" }, () => { + this.host.dispatchEvent(new KeyboardControllerCloseEvent()); + }); + } +} diff --git a/web/src/elements/forms/SearchSelect/SearchKeyboardControllerEvents.ts b/web/src/elements/forms/SearchSelect/SearchKeyboardControllerEvents.ts new file mode 100644 index 000000000000..1ab84dd25c8a --- /dev/null +++ b/web/src/elements/forms/SearchSelect/SearchKeyboardControllerEvents.ts @@ -0,0 +1,20 @@ +export class KeyboardControllerSelectEvent extends Event { + value: string | undefined; + constructor(value: string | undefined) { + super("ak-keyboard-controller-select", { composed: true, bubbles: true }); + this.value = value; + } +} + +export class KeyboardControllerCloseEvent extends Event { + constructor() { + super("ak-keyboard-controller-close", { composed: true, bubbles: true }); + } +} + +declare global { + interface GlobalEventHandlersEventMap { + "ak-keyboard-controller-select": KeyboardControllerSelectEvent; + "ak-keyboard-controller-close": KeyboardControllerCloseEvent; + } +} diff --git a/web/src/elements/forms/SearchSelect/SearchSelectEvents.ts b/web/src/elements/forms/SearchSelect/SearchSelectEvents.ts new file mode 100644 index 000000000000..16257f6d9b6c --- /dev/null +++ b/web/src/elements/forms/SearchSelect/SearchSelectEvents.ts @@ -0,0 +1,63 @@ +/** + * class SearchSelectSelectEvent + * + * Intended meaning: the user selected an item from the entire dialogue, either by clicking on it + * with the mouse, or selecting it with the keyboard controls and pressing Enter or Space. + */ +export class SearchSelectSelectEvent extends Event { + value: string | undefined; + constructor(value: string | undefined) { + super("ak-search-select-select", { composed: true, bubbles: true }); + this.value = value; + } +} + +/** + * class SearchSelectSelectMenuEvent + * + * Intended meaning: the user selected an item from the menu, either by clicking on it with the + * mouse, or selecting it with the keyboard controls and pressing Enter or Space. This is + * intercepted an interpreted internally, usually resulting in a throw of SearchSelectSelectEvent. + * They have to be distinct to avoid an infinite event loop. + */ +export class SearchSelectSelectMenuEvent extends Event { + value: string | undefined; + constructor(value: string | undefined) { + super("ak-search-select-select-menu", { composed: true, bubbles: true }); + this.value = value; + } +} + +/** + * class SearchSelectCloseEvent + * + * Intended meaning: the user requested that the menu dropdown close. Usually triggered by pressing + * the Escape key. + */ +export class SearchSelectCloseEvent extends Event { + constructor() { + super("ak-search-select-close", { composed: true, bubbles: true }); + } +} + +/** + * class SearchSelectInputEvent + * + * Intended meaning: the user made a change to the content of the `` field + */ +export class SearchSelectInputEvent extends Event { + value: string | undefined; + constructor(value: string | undefined) { + super("ak-search-select-input", { composed: true, bubbles: true }); + this.value = value; + } +} + +declare global { + interface GlobalEventHandlersEventMap { + "ak-search-select-select-menu": SearchSelectSelectMenuEvent; + "ak-search-select-select": SearchSelectSelectEvent; + "ak-search-select-input": SearchSelectInputEvent; + "ak-search-select-close": SearchSelectCloseEvent; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-menu-position.ts b/web/src/elements/forms/SearchSelect/ak-search-select-menu-position.ts new file mode 100644 index 000000000000..3dfdbe46db8e --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-menu-position.ts @@ -0,0 +1,185 @@ +import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom"; + +import { LitElement, html, nothing, render } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { Ref, createRef, ref } from "lit/directives/ref.js"; + +import { KeyboardControllerCloseEvent } from "./SearchKeyboardControllerEvents.js"; +import "./ak-search-select-menu.js"; +import { type SearchSelectMenu } from "./ak-search-select-menu.js"; +import type { SearchOptions } from "./types.js"; + +/** + * An intermediate class to handle the menu and its position. + * + * It has no rendering of its own, and mostly is just a pass-through for options to the menu. + * DOTADIW: it tracks the top-of-DOM object into which we render our menu, guaranteeing that it + * appears above everything else, and operates the positioning control for it. + * + * - @fires ak-search-select-close - Fired (by the keyboard controller) when the tethered end loses + * focus. Clients can do with this information as they wish. + */ + +@customElement("ak-search-select-menu-position") +export class SearchSelectMenuPosition extends LitElement { + /** + * The host to which all relevant events will be routed. Useful for managing floating / tethered + * components. + * + * @prop + */ + @property({ type: Object, attribute: false }) + host!: HTMLElement; + + /** + * The host element which will be our reference point for rendering. + * + * @prop + */ + @property({ type: Object, attribute: false }) + anchor!: HTMLElement; + + /** + * Passthrough of the options that we'll be rendering. + * + * @prop + */ + @property({ type: Array, attribute: false }) + options: SearchOptions = []; + + /** + * Passthrough of the current value + * + * @prop + */ + @property() + value?: string; + + /** + * If undefined, there will be no empty option shown + * + * @attr + */ + @property() + emptyOption?: string; + + /** + * Whether or not the menu is visible + * + * @attr + */ + @property({ type: Boolean, reflect: true }) + open = false; + + /** + * The name; used mostly for the management layer. + * + * @attr + */ + @property() + name?: string; + + /** + * The tether object. + */ + dropdownContainer!: HTMLDivElement; + public cleanup?: () => void; + + connected = false; + + /** + *Communicates forward with the menu to detect when the tether has lost focus + */ + menuRef: Ref = createRef(); + + connectedCallback() { + super.connectedCallback(); + this.dropdownContainer = document.createElement("div"); + this.dropdownContainer.dataset["managedBy"] = "ak-search-select"; + if (this.name) { + this.dropdownContainer.dataset["managedFor"] = this.name; + } + document.body.append(this.dropdownContainer); + if (!this.host) { + throw new Error("Tether entrance initialized incorrectly: missing host"); + } + this.connected = true; + } + + disconnectedCallback(): void { + this.connected = false; + this.dropdownContainer?.remove(); + this.cleanup?.(); + super.disconnectedCallback(); + } + + setPosition() { + if (!(this.anchor && this.dropdownContainer)) { + throw new Error("Tether initialized incorrectly: missing anchor or tether destination"); + } + + this.cleanup = autoUpdate(this.anchor, this.dropdownContainer, async () => { + const { x, y } = await computePosition(this.anchor, this.dropdownContainer, { + placement: "bottom-start", + strategy: "fixed", + middleware: [flip(), hide()], + }); + + Object.assign(this.dropdownContainer.style, { + "position": "fixed", + "z-index": "9999", + "top": 0, + "left": 0, + "transform": `translate(${x}px, ${y}px)`, + }); + }); + } + + updated() { + if (this.anchor && this.dropdownContainer && !this.hidden) { + this.setPosition(); + } + } + + hasFocus() { + return ( + this.menuRef.value && + (this.menuRef.value === document.activeElement || + this.menuRef.value.renderRoot.contains(document.activeElement)) + ); + } + + onFocusOut() { + this.dispatchEvent(new KeyboardControllerCloseEvent()); + } + + render() { + // The 'hidden' attribute is a little weird and the current Typescript definition for + // it is incompatible with actual implementations, so we drill `open` all the way down, + // but we set the hidden attribute here, and on the actual menu use CSS and the + // the attribute's presence to hide/show as needed. + render( + html``, + this.dropdownContainer, + ); + // This is a dummy object that just has to exist to be the communications channel between + // the tethered object and its anchor. + return nothing; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-search-select-menu-position": SearchSelectMenuPosition; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-menu.ts b/web/src/elements/forms/SearchSelect/ak-search-select-menu.ts new file mode 100644 index 000000000000..58b305931da4 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-menu.ts @@ -0,0 +1,192 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import { bound } from "@goauthentik/elements/decorators/bound.js"; + +import { PropertyValues, css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; +import PFSelect from "@patternfly/patternfly/components/Select/select.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { AkKeyboardController } from "./SearchKeyboardController.js"; +import { + KeyboardControllerCloseEvent, + KeyboardControllerSelectEvent, +} from "./SearchKeyboardControllerEvents.js"; +import { SearchSelectCloseEvent, SearchSelectSelectMenuEvent } from "./SearchSelectEvents.js"; +import type { GroupedOptions, SearchGroup, SearchOptions, SearchTuple } from "./types.js"; + +/** + * @class SearchSelectMenu + * @element ak-search-select-menu + * + * The actual renderer of our components. Intended to be positioned and controlled automatically + * from the outside. + * + * @fires ak-search-select-select - An element has been selected. Contains the `value` of the + * selected item. + * + * @fires ak-search-select-close - The user has triggered the `close` event. Clients can do with this + * as they wish. + */ + +@customElement("ak-search-select-menu") +export class SearchSelectMenu extends AKElement { + static get styles() { + return [ + PFBase, + PFDropdown, + PFSelect, + css` + :host { + overflow: visible; + z-index: 9999; + } + + :host([hidden]) { + display: none; + } + + .pf-c-dropdown__menu { + max-height: 50vh; + overflow-y: auto; + } + `, + ]; + } + + /** + * The host to which all relevant events will be routed. Useful for managing floating / tethered + * components. + */ + @property({ type: Object, attribute: false }) + host!: HTMLElement; + + /** + * See the search options type, described in the `./types` file, for the relevant types. + */ + @property({ type: Array, attribute: false }) + options: SearchOptions = []; + + @property() + value?: string; + + @property() + emptyOption?: string; + + @property({ type: Boolean, reflect: true }) + open = false; + + private keyboardController: AkKeyboardController; + + constructor() { + super(); + this.keyboardController = new AkKeyboardController(this); + this.addEventListener("ak-keyboard-controller-select", this.onKeySelect); + this.addEventListener("ak-keyboard-controller-close", this.onKeyClose); + } + + // Handles the "easy mode" of just passing an array of tuples. + fixedOptions(): GroupedOptions { + return Array.isArray(this.options) + ? { grouped: false, options: this.options } + : this.options; + } + + @bound + onClick(event: Event, value: string) { + event.stopPropagation(); + this.host.dispatchEvent(new SearchSelectSelectMenuEvent(value)); + this.value = value; + } + + @bound + onEmptyClick(event: Event) { + event.stopPropagation(); + this.host.dispatchEvent(new SearchSelectSelectMenuEvent(undefined)); + this.value = undefined; + } + + @bound + onKeySelect(event: KeyboardControllerSelectEvent) { + event.stopPropagation(); + this.value = event.value; + this.host.dispatchEvent(new SearchSelectSelectMenuEvent(this.value)); + } + + @bound + onKeyClose(event: KeyboardControllerCloseEvent) { + event.stopPropagation(); + this.host.dispatchEvent(new SearchSelectCloseEvent()); + } + + updated(changed: PropertyValues) { + if (changed.has("open") && this.open) { + this.keyboardController.hostVisible(); + } + } + + renderEmptyMenuItem() { + return html`
  • + +
  • `; + } + + renderMenuItems(options: SearchTuple[]) { + return options.map( + ([value, label, desc]: SearchTuple) => html` +
  • + +
  • + `, + ); + } + + renderMenuGroups(options: SearchGroup[]) { + return options.map( + ({ name, options }) => html` +
    +

    ${name}

    +
      + ${this.renderMenuItems(options)} +
    +
    + `, + ); + } + + render() { + const options = this.fixedOptions(); + return html`
    +
      + ${this.emptyOption !== undefined ? this.renderEmptyMenuItem() : nothing} + ${options.grouped + ? this.renderMenuGroups(options.options) + : this.renderMenuItems(options.options)} +
    +
    `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-search-select-menu": SearchSelectMenu; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts new file mode 100644 index 000000000000..b5a728c96ab9 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts @@ -0,0 +1,286 @@ +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; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts index d5e316028312..ac9ba2e2b444 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -1,28 +1,31 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors"; -import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils"; -import { AKElement } from "@goauthentik/elements/Base"; +import { groupBy } from "@goauthentik/common/utils"; +import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; -import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; -import { msg, str } from "@lit/localize"; -import { TemplateResult, html, render } from "lit"; +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { styleMap } from "lit/directives/style-map.js"; +import { ifDefined } from "lit/directives/if-defined.js"; -import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.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"; import { ResponseError } from "@goauthentik/api"; +import { SearchSelectInputEvent, SearchSelectSelectEvent } from "./SearchSelectEvents.js"; +import "./ak-search-select-view.js"; +import type { GroupedOptions, SearchGroup, SearchTuple } from "./types.js"; + type Group = [string, T[]]; @customElement("ak-search-select") -export class SearchSelect extends CustomEmitterElement(AKElement) { +export class SearchSelect extends CustomEmitterElement(AkControlElement) { + static get styles() { + return [PFBase]; + } + // A function which takes the query state object (accepting that it may be empty) and returns a // new collection of objects. @property({ attribute: false }) @@ -75,14 +78,10 @@ export class SearchSelect extends CustomEmitterElement(AKElement) { @property({ attribute: false }) selectedObject?: T; - // Not used in this object. No known purpose. + // Used to inform the form of the name of the object @property() name?: string; - // Whether or not the dropdown component is visible. - @property({ type: Boolean }) - open = false; - // The textual placeholder for the search's object, if currently empty. Used as the // native object's `placeholder` field. @property() @@ -93,46 +92,14 @@ export class SearchSelect extends CustomEmitterElement(AKElement) { @property() emptyOption = "---------"; - // Handle the behavior of the drop-down when the :host scrolls off the page. - scrollHandler?: () => void; - observer: IntersectionObserver; - - // Handle communication between the :host and the portal - dropdownUID: string; - dropdownContainer: HTMLDivElement; - isFetchingData = false; @state() error?: APIErrorTypes; - static get styles() { - return [PFBase, PFForm, PFFormControl, PFSelect]; - } - constructor() { super(); - if (!document.adoptedStyleSheets.includes(PFDropdown)) { - document.adoptedStyleSheets = [ - ...document.adoptedStyleSheets, - ensureCSSStyleSheet(PFDropdown), - ]; - } - 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, ascii_letters + digits)}`; - this.onMenuItemClick = this.onMenuItemClick.bind(this); - this.renderWithMenuGroupTitle = this.renderWithMenuGroupTitle.bind(this); + this.dataset.akControl = "true"; } toForm(): unknown { @@ -142,16 +109,16 @@ export class SearchSelect extends CustomEmitterElement(AKElement) { return this.value(this.selectedObject) || ""; } - firstUpdated(): void { - this.updateData(); + json() { + return this.toForm(); } - updateData(): void { + updateData() { if (this.isFetchingData) { return; } this.isFetchingData = true; - this.fetchObjects(this.query) + return this.fetchObjects(this.query) .then((objects) => { objects.forEach((obj) => { if (this.selected && this.selected(obj, objects || [])) { @@ -173,233 +140,97 @@ export class SearchSelect extends CustomEmitterElement(AKElement) { connectedCallback(): void { super.connectedCallback(); - this.dropdownContainer = document.createElement("div"); - this.dropdownContainer.dataset["managedBy"] = "ak-search-select"; - if (this.name) { - this.dropdownContainer.dataset["managedFor"] = this.name; - } - 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(); } - renderMenuItemWithDescription(obj: T, desc: TemplateResult, index: number) { - return html` -
  • - -
  • - `; - } - - renderMenuItemWithoutDescription(obj: T, index: number) { - return html` -
  • - -
  • - `; - } - - renderEmptyMenuItem() { - return html`
  • - -
  • `; - } + onSearch(event: SearchSelectInputEvent) { + if (event.value === undefined) { + this.selectedObject = undefined; + return; + } - onMenuItemClick(obj: T | undefined) { - return () => { - this.selectedObject = obj; + this.query = event.value; + this.updateData()?.then(() => { this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); - this.open = false; - }; - } - - renderMenuGroup(items: T[], tabIndexStart: number) { - const renderedItems = items.map((obj, index) => { - const desc = this.renderDescription ? this.renderDescription(obj) : null; - const tabIndex = index + tabIndexStart; - return desc - ? this.renderMenuItemWithDescription(obj, desc, tabIndex) - : this.renderMenuItemWithoutDescription(obj, tabIndex); }); - return html`${renderedItems}`; } - renderWithMenuGroupTitle([group, items]: Group, idx: number) { - return html` -
    -

    ${group}

    -
      - ${this.renderMenuGroup(items, idx)} -
    -
    - `; + onSelect(event: SearchSelectSelectEvent) { + if (event.value === undefined) { + this.selectedObject = undefined; + this.dispatchCustomEvent("ak-change", { value: undefined }); + return; + } + const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === event.value); + if (!selected) { + console.warn( + `ak-search-select: No corresponding object found for value (${event.value}`, + ); + } + this.selectedObject = selected; + this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); } - get groupedItems(): [boolean, Group[]] { + getGroupedItems(): GroupedOptions { const items = this.groupBy(this.objects || []); + const makeSearchTuples = (items: T[]): SearchTuple[] => + items.map((item) => [ + `${this.value(item)}`, + this.renderElement(item), + this.renderDescription ? this.renderDescription(item) : undefined, + ]); + + const makeSearchGroups = (items: Group[]): SearchGroup[] => + items.map((group) => ({ + name: group[0], + options: makeSearchTuples(group[1]), + })); + if (items.length === 0) { - return [false, [["", []]]]; + return { grouped: false, options: [] }; } + if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) { - return [false, items]; + return { + grouped: false, + options: makeSearchTuples(items[0][1]), + }; } - return [true, items]; - } - /* - * 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 [shouldRenderGroups, groupedItems] = this.groupedItems; - - const pos = this.getBoundingClientRect(); - const position = { - "position": "fixed", - "inset": "0px auto auto 0px", - "z-index": "9999", - "transform": `translate(${pos.x}px, ${pos.y + this.offsetHeight}px)`, - "width": `${pos.width}px`, - ...(this.open ? {} : { visibility: "hidden" }), + return { + grouped: true, + options: makeSearchGroups(items), }; - - render( - html`
    -
      - ${this.blankable ? this.renderEmptyMenuItem() : html``} - ${shouldRenderGroups - ? html`${groupedItems.map(this.renderWithMenuGroupTitle)}` - : html`${this.renderMenuGroup(groupedItems[0][1], 0)}`} -
    -
    `, - this.dropdownContainer, - { host: this }, - ); } - get renderedValue() { + render() { if (this.error) { - return msg(str`Failed to fetch objects: ${this.error.detail}`); + return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; } + if (!this.objects) { - return msg("Loading..."); + return html`${msg("Loading...")}`; } - if (this.selectedObject) { - return this.renderElement(this.selectedObject); - } - if (this.blankable) { - return this.emptyOption; - } - return ""; - } - - render(): TemplateResult { - this.renderMenu(); - - const onFocus = (ev: FocusEvent) => { - this.open = true; - this.renderMenu(); - if ( - this.blankable && - this.renderedValue === this.emptyOption && - ev.target && - ev.target instanceof HTMLInputElement - ) { - ev.target.value = ""; - } - }; - - const onInput = (ev: InputEvent) => { - this.query = (ev.target as HTMLInputElement).value; - this.updateData(); - }; - - const onBlur = (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 && (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(); - }; - return html`
      -
      -
      - -
      -
      -
      `; + const options = this.getGroupedItems(); + const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined; + + return html` `; } } diff --git a/web/src/elements/forms/SearchSelect/stories/ak-search-select-menu.stories.ts b/web/src/elements/forms/SearchSelect/stories/ak-search-select-menu.stories.ts new file mode 100644 index 000000000000..3df6a8fed9c4 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/stories/ak-search-select-menu.stories.ts @@ -0,0 +1,120 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta, StoryObj } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { TemplateResult, html } from "lit"; + +import { SearchSelectSelectMenuEvent } from "../SearchSelectEvents.js"; +import "../ak-search-select-menu.js"; +import { SearchSelectMenu } from "../ak-search-select-menu.js"; +import { groupedSampleData, sampleData } from "./sampleData.js"; + +const metadata: Meta = { + title: "Elements / Search Select / Tethered Menu", + component: "ak-search-select-menu", + parameters: { + docs: { + description: { + component: "The tethered panel containing the scrollable list of selectable items", + }, + }, + }, + argTypes: { + options: { + type: "string", + description: "An array of [key, label, desc] pairs of what to show", + }, + }, +}; + +export default metadata; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const onClick = (event: SearchSelectSelectMenuEvent) => { + const target = document.querySelector("#action-button-message-pad"); + target!.innerHTML = ""; + target!.append( + new DOMParser().parseFromString(`
    • ${event.value}
    • `, "text/xml").firstChild!, + ); +}; + +const container = (testItem: TemplateResult) => { + window.setTimeout(() => { + const menu = document.getElementById("ak-search-select-menu"); + const container = document.getElementById("the-main-event"); + if (menu && container) { + container.addEventListener("ak-search-select-select-menu", onClick); + (menu as SearchSelectMenu).host = container; + } + }, 250); + + return html`
      + + + ${testItem} +
      +

      Messages received from the menu:

      +
        +
        +
        `; +}; + +type Story = StoryObj; + +const goodForYouPairs = { + grouped: false, + options: sampleData.slice(0, 20).map(({ produce }) => [slug(produce), produce]), +}; + +export const Default: Story = { + render: () => + container( + html` `, + ), +}; + +const longGoodForYouPairs = { + grouped: false, + options: sampleData.map(({ produce }) => [slug(produce), produce]), +}; + +export const Scrolling: Story = { + render: () => + container( + html` `, + ), +}; + +export const Grouped: Story = { + render: () => + container( + html` `, + ), +}; diff --git a/web/src/elements/forms/SearchSelect/stories/ak-search-select-view.stories.ts b/web/src/elements/forms/SearchSelect/stories/ak-search-select-view.stories.ts new file mode 100644 index 000000000000..caaf8c2120a4 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/stories/ak-search-select-view.stories.ts @@ -0,0 +1,72 @@ +import "@goauthentik/elements/forms/SearchSelect/ak-search-select-view.js"; +import { SearchSelectView } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-view.js"; +import { Meta } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { TemplateResult, html } from "lit"; + +import { groupedSampleData, sampleData } from "./sampleData.js"; + +const metadata: Meta = { + title: "Elements / Search Select / View Handler ", + component: "ak-search-select-view", + parameters: { + docs: { + description: { + component: "An implementation of the Patternfly search select pattern", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
        + + + ${testItem} + +
          +
          `; + +const longGoodForYouPairs = { + grouped: false, + options: sampleData.map(({ produce }) => [slug(produce), produce]), +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const displayChange = (ev: any) => { + document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.value, + null, + 2, + )}`; +}; + +export const Default = () => { + return container( + html``, + ); +}; + +export const DescribedGroups = () => { + return container( + html``, + ); +}; diff --git a/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts b/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts new file mode 100644 index 000000000000..a5f0c2c74cc2 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts @@ -0,0 +1,103 @@ +import { groupBy } from "@goauthentik/common/utils"; +import "@goauthentik/elements/forms/SearchSelect/ak-search-select"; +import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect/ak-search-select"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import { sampleData } from "./sampleData.js"; + +type Sample = { name: string; pk: string; season: string[] }; + +const samples = sampleData.map(({ produce, seasons }) => ({ + name: produce, + pk: produce.replace(/\s+/, "").toLowerCase(), + season: seasons, +})); +samples.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + +// All we need is a promise to return our dataset. It doesn't have to be a class-based method a'la +// the authentik API. + +const getSamples = (query = "") => { + if (query === "") { + return Promise.resolve(samples); + } + const check = new RegExp(query); + return Promise.resolve(samples.filter((s) => check.test(s.name))); +}; + +const metadata: Meta> = { + title: "Elements / Search Select / API Interface", + component: "ak-search-select", + parameters: { + docs: { + description: { + component: "An implementation of the Patternfly search select pattern", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
          + + + ${testItem} + +
            +
            `; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const displayChange = (ev: any) => { + document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.detail.value, + null, + 2, + )}`; +}; + +export const Default = () => + container( + html` sample.name} + .value=${(sample: Sample) => sample.pk} + @ak-change=${displayChange} + >`, + ); + +export const Grouped = () => { + return container( + html` sample.name} + .value=${(sample: Sample) => sample.pk} + .groupBy=${(samples: Sample[]) => + groupBy(samples, (sample: Sample) => sample.season[0] ?? "")} + @ak-change=${displayChange} + >`, + ); +}; + +export const SelectedAndBlankable = () => { + return container( + html` sample.name} + .value=${(sample: Sample) => sample.pk} + .selected=${(sample: Sample) => sample.pk === "herbs"} + @ak-change=${displayChange} + >`, + ); +}; diff --git a/web/src/elements/forms/SearchSelect/stories/sampleData.ts b/web/src/elements/forms/SearchSelect/stories/sampleData.ts new file mode 100644 index 000000000000..9fa21611934b --- /dev/null +++ b/web/src/elements/forms/SearchSelect/stories/sampleData.ts @@ -0,0 +1,359 @@ +import { slug } from "github-slugger"; + +import type { TemplateResult } from "lit"; + +// The descriptions were generated by ChatGPT. Don't blame us. + +export type ViewSample = { + produce: string; + seasons: string[]; + desc?: string; +}; + +export const sampleData: ViewSample[] = [ + { + produce: "Apples", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Apples are a sweet and crunchy fruit that can be eaten fresh or used in pies, juice, and ciders.", + }, + { + produce: "Apricots", + seasons: ["Spring", "Summer"], + desc: "Apricots are a sweet and tangy stone fruit with a velvety skin that's often orange-yellow in color", + }, + { + produce: "Asparagus", + seasons: ["Spring"], + desc: "Asparagus is a delicate and nutritious vegetable with a tender spear-like shape", + }, + { + produce: "Avocados", + seasons: ["Spring", "Summer", "Winter"], + desc: "Avocados are a nutritious fruit with a creamy texture and nutty flavor", + }, + { + produce: "Bananas", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Bananas are a type of curved, yellow fruit that grows on banana plants", + }, + { + produce: "Beets", + seasons: ["Summer", "Fall", "Winter"], + desc: "Beets are a sweet and earthy root vegetable that can be pickled, roasted, or boiled", + }, + { + produce: "Bell Peppers", + seasons: ["Summer", "Fall"], + desc: "Bell peppers are a sweet and crunchy type of pepper that can be green, red, yellow, or orange", + }, + { + produce: "Blackberries", + seasons: ["Summer"], + desc: "Blackberries are a type of fruit that are dark purple in color and have a sweet-tart taste", + }, + { + produce: "Blueberries", + seasons: ["Summer"], + desc: "Blueberries are small, round, and sweet-tart berries with a powdery coating and a burst of juicy flavor.", + }, + { + produce: "Broccoli", + seasons: ["Spring", "Fall"], + desc: "Broccoli is a green, cruciferous vegetable with a tree-like shape and a slightly bitter taste.", + }, + { + produce: "Brussels Sprouts", + seasons: ["Fall", "Winter"], + desc: "Brussels sprouts are a cruciferous vegetable that is small, green, and formed like a tiny cabbage head, with a sweet and slightly bitter flavor.", + }, + { + produce: "Cabbage", + seasons: ["Spring", "Fall", "Winter"], + desc: "Cabbage is a crunchy, sweet, and slightly bitter vegetable with a dense head of tightly packed leaves.", + }, + { + produce: "Cantaloupe", + seasons: ["Summer"], + desc: "Cantaloupe is a sweet and juicy melon with a netted or reticulated rind and yellow-orange flesh.", + }, + { + produce: "Carrots", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Carrots are a crunchy and sweet root vegetable commonly eaten raw or cooked in various dishes.", + }, + { + produce: "Cauliflower", + seasons: ["Fall"], + desc: "Cauliflower is a cruciferous vegetable with a white or pale yellow florets resembling tiny trees", + }, + { + produce: "Celery", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Celery is a crunchy, sweet-tasting vegetable with a mild flavor, often used in salads and as a snack.", + }, + { + produce: "Cherries", + seasons: ["Summer"], + desc: "Cherries are a sweet and juicy stone fruit that typically range in color from bright red to dark purple.", + }, + { + produce: "Collard Greens", + seasons: ["Spring", "Fall", "Winter"], + desc: "Collard greens are a type of leafy green vegetable with a slightly bitter and earthy flavor.", + }, + { + produce: "Corn", + seasons: ["Summer"], + desc: "Corn is a sweet and savory grain that can be eaten fresh or used in various dishes, such as soups, salads, and baked goods.", + }, + { + produce: "Cranberries", + seasons: ["Fall"], + desc: "Cranberries are a type of small, tart-tasting fruit native to North America", + }, + { + produce: "Cucumbers", + seasons: ["Summer"], + desc: "Cucumbers are a long, green vegetable that is commonly consumed raw or pickled", + }, + { + produce: "Eggplant", + seasons: ["Summer"], + desc: "Eggplant is a purple vegetable with a spongy texture and a slightly bitter taste.", + }, + { + produce: "Garlic", + seasons: ["Spring", "Summer", "Fall"], + desc: "Garlic is a pungent and flavorful herb with a distinctive aroma and taste", + }, + { + produce: "Ginger", + seasons: ["Fall"], + desc: "Ginger is a spicy, sweet, and tangy root commonly used in Asian cuisine to add warmth and depth", + }, + { + produce: "Grapefruit", + seasons: ["Winter"], + desc: "Grapefruit is a tangy and sweet citrus fruit with a tart flavor profile and a slightly bitter aftertaste.", + }, + { + produce: "Grapes", + seasons: ["Fall"], + desc: "Grapes are a type of fruit that grow in clusters on vines and are often eaten fresh or used to make wine, jam, and juice.", + }, + { + produce: "Green Beans", + seasons: ["Summer", "Fall"], + desc: "Green beans are a type of long, thin, green vegetable that is commonly eaten as a side dish or used in various recipes.", + }, + { + produce: "Herbs", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Herbs are plant parts, such as leaves, stems, or flowers, used to add flavor or aroma", + }, + { + produce: "Honeydew Melon", + seasons: ["Summer"], + desc: "Honeydew melons are sweet and refreshing, with a smooth, pale green rind and juicy, creamy white flesh.", + }, + { + produce: "Kale", + seasons: ["Spring", "Fall", "Winter"], + desc: "Kale is a type of leafy green vegetable that is packed with nutrients and has a slightly bitter, earthy flavor.", + }, + { + produce: "Kiwifruit", + seasons: ["Spring", "Fall", "Winter"], + desc: "Kiwifruit is a small, oval-shaped fruit with a fuzzy exterior and bright green or yellow flesh that tastes sweet and slightly tart.", + }, + { + produce: "Leeks", + seasons: ["Winter"], + desc: "Leeks are a type of vegetable that is similar to onions and garlic, but has a milder flavor and a more delicate texture.", + }, + { + produce: "Lemons", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Lemons are a sour and tangy citrus fruit with a bright yellow color and a strong, distinctive flavor used in cooking, cleaning, and as a natural remedy.", + }, + { + produce: "Lettuce", + seasons: ["Spring", "Fall"], + desc: "Lettuce is a crisp and refreshing green leafy vegetable often used in salads.", + }, + { + produce: "Lima Beans", + seasons: ["Summer"], + desc: "Lima beans are a type of green legume with a mild flavor and soft, creamy texture.", + }, + { + produce: "Limes", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Limes are small, citrus fruits with a sour taste and a bright green color.", + }, + { + produce: "Mangos", + seasons: ["Summer", "Fall"], + desc: "Mangos are sweet and creamy tropical fruits with a velvety texture", + }, + { + produce: "Mushrooms", + seasons: ["Spring", "Fall"], + desc: "Mushrooms are a type of fungus that grow underground or on decaying organic matter", + }, + { + produce: "Okra", + seasons: ["Summer"], + desc: "Okra is a nutritious, green vegetable with a unique texture and flavor", + }, + { + produce: "Onions", + seasons: ["Spring", "Fall", "Winter"], + desc: "Onions are a type of vegetable characterized by their layered, bulbous structure and pungent flavor.", + }, + { + produce: "Oranges", + seasons: ["Winter"], + desc: "Oranges are a sweet and juicy citrus fruit with a thick, easy-to-peel skin.", + }, + { + produce: "Parsnips", + seasons: ["Fall", "Winter"], + desc: "Parsnips are a type of root vegetable that is sweet and nutty in flavor, with a texture similar to carrots.", + }, + { + produce: "Peaches", + seasons: ["Summer"], + desc: "Peaches are sweet and juicy stone fruits with a soft, velvety texture.", + }, + { + produce: "Pears", + seasons: ["Fall", "Winter"], + desc: "Pears are a type of sweet and juicy fruit with a smooth, buttery texture and a mild flavor", + }, + { + produce: "Peas", + seasons: ["Spring", "Fall"], + desc: "Peas are small, round, sweet-tasting legumes that grow on vines and are often eaten as a side dish or added to various recipes.", + }, + { + produce: "Pineapples", + seasons: ["Spring", "Fall", "Winter"], + desc: "Pineapples are a tropical fruit with tough, prickly skin and juicy, sweet flesh.", + }, + { + produce: "Plums", + seasons: ["Summer"], + desc: "Plums are a type of stone fruit characterized by their juicy sweetness and rough, dark skin.", + }, + { + produce: "Potatoes", + seasons: ["Fall", "Winter"], + desc: "Potatoes are a starchy root vegetable that is often brown on the outside and white or yellow on the inside.", + }, + { + produce: "Pumpkin", + seasons: ["Fall", "Winter"], + desc: "Pumpkin is a type of squash that is typically orange in color and is often used to make pies, soups, and other sweet or savory dishes.", + }, + { + produce: "Radishes", + seasons: ["Spring", "Fall"], + desc: "Radishes are a pungent, crunchy and spicy root vegetable that can be eaten raw or cooked,", + }, + { + produce: "Raspberries", + seasons: ["Summer", "Fall"], + desc: "Raspberries are a type of sweet-tart fruit that grows on thorny bushes and is often eaten fresh or used in jams, preserves, and desserts.", + }, + { + produce: "Rhubarb", + seasons: ["Spring"], + desc: "Rhubarb is a perennial vegetable with long, tart stalks that are often used in pies and preserves", + }, + { + produce: "Rutabagas", + seasons: ["Fall", "Winter"], + desc: "Rutabagas are a type of root vegetable that is similar to a cross between a cabbage and a turnip", + }, + { + produce: "Spinach", + seasons: ["Spring", "Fall"], + desc: "Spinach is a nutritious leafy green vegetable that is rich in iron and vitamins A, C, and K.", + }, + { + produce: "Strawberries", + seasons: ["Spring", "Summer"], + desc: "Sweet and juicy, strawberries are a popular type of fruit that grow on low-lying plants with sweet-tasting seeds.", + }, + { + produce: "Summer Squash", + seasons: ["Summer"], + desc: "Summer squash is a type of warm-season vegetable that includes varieties like zucchini, yellow crookneck, and straightneck", + }, + { + produce: "Sweet Potatoes", + seasons: ["Fall", "Winter"], + desc: "Sweet potatoes are a type of root vegetable with a sweet and nutty flavor, often orange in color", + }, + { + produce: "Swiss Chard", + seasons: ["Spring", "Fall", "Winter"], + desc: "Swiss Chard is a leafy green vegetable with a slightly bitter taste and a vibrant red or gold stem", + }, + { + produce: "Tomatillos", + seasons: ["Summer"], + desc: "Tomatillos are a type of fruit that is similar to tomatoes, but with a papery husk and a more tart, slightly sweet flavor.", + }, + { + produce: "Tomatoes", + seasons: ["Summer"], + desc: "Tomatoes are a juicy, sweet, and tangy fruit that is commonly used in salads, sandwiches, and as a topping for various dishes.", + }, + { + produce: "Turnips", + seasons: ["Spring", "Fall", "Winter"], + desc: "Turnips are a root vegetable with a sweet and peppery flavor, often used in soups, stews, and salads.", + }, + { + produce: "Watermelon", + seasons: ["Summer"], + desc: "Watermelon is a juicy and refreshing sweet fruit with a green rind and pink or yellow flesh.", + }, + { + produce: "Winter Squash", + seasons: ["Fall", "Winter"], + desc: "Winter squash is a type of starchy vegetable that is harvested in the fall and has a hard, dry rind that can be stored for several months.", + }, + { + produce: "Zucchini", + seasons: ["Summer"], + desc: "Zucchini is a popular summer squash that is often green or yellow in color and has a mild, slightly sweet flavor.", + }, +]; + +type Seasoned = [string, string, string | TemplateResult]; + +const reseason = (acc: Seasoned[], { produce, seasons, desc }: ViewSample): Seasoned[] => [ + ...acc, + ...seasons.map((s) => [s, produce, desc] as Seasoned), +]; + +export const groupedSampleData = (() => { + const seasoned: Seasoned[] = sampleData.reduce(reseason, [] as Seasoned[]); + const grouped = Object.groupBy(seasoned, ([season]) => season); + const ungrouped = ([_season, label, desc]: Seasoned) => [slug(label), label, desc]; + + if (grouped === undefined) { + throw new Error("Not possible with existing data."); + } + + return { + grouped: true, + options: ["Spring", "Summer", "Fall", "Winter"].map((season) => ({ + name: season, + options: grouped[season]?.map(ungrouped) ?? [], + })), + }; +})(); diff --git a/web/src/elements/forms/SearchSelect/types.ts b/web/src/elements/forms/SearchSelect/types.ts new file mode 100644 index 000000000000..658039a41d06 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/types.ts @@ -0,0 +1,66 @@ +import type { TemplateResult } from "lit"; + +/** + * A search tuple consists of a [key, label, description] + * The description is optional. The key must always be a string. + * + */ +export type SearchTuple = [ + key: string, + label: string, + description: undefined | string | TemplateResult, +]; + +/** + * A search list without groups will always just consist of an array of SearchTuples and the + * `grouped: false` flag. Note that it *is* possible to pass to any of the rendering components an + * array of SearchTuples; they will be automatically mapped to a SearchFlat object. + * + */ +export type SearchFlat = { + grouped: false; + options: SearchTuple[]; +}; + +/** + * A search group consists of a group name and a collection of SearchTuples. + * + */ +export type SearchGroup = { name: string; options: SearchTuple[] }; + +/** + * A grouped search is an array of SearchGroups, of course! + * + */ +export type SearchGrouped = { + grouped: true; + options: SearchGroup[]; +}; + +/** + * Internally, we only work with these two, but we have the `SearchOptions` variant + * below to support the case where you just want to pass in an array of SearchTuples. + * + */ +export type GroupedOptions = SearchGrouped | SearchFlat; +export type SearchOptions = SearchTuple[] | GroupedOptions; + +// These can safely be ignored for now. +export type Group = [string, T[]]; + +export type ElementRendererBase = (element: T) => string; +export type ElementRenderer = ElementRendererBase | S; + +export type DescriptionRendererBase = (element: T) => TemplateResult | string; +export type DescriptionRenderer = ElementRendererBase | S; + +export type ValueExtractorBase = (element: T | undefined) => keyof T | undefined; +export type ValueExtractor = ValueExtractorBase | S; + +export type ValueSelectorBase = (element: T, elements: T[]) => boolean; +export type ValueSelector = S extends S + ? ValueSelectorBase | [T, T[S]] + : never; + +export type GroupByBase = (elements: T[]) => Group[]; +export type GroupBy = GroupByBase | keyof S;