web: enhance search select with portal, overflow, and keyboard controls (#9517)
* web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach (<anonymous>) at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: enhance search select Patternfly doesn't even *have* a setting for "selected but not hovered," so I had to invent one. I borrowed a trick from MUI and used the light blue "info" color, making it darker on "selected+hovered." This commit starts the revision process for search select. The goal is to have it broken down into four major components: The inline-DOM component, which just shows the current value (or placeholder, if none) and creates the portal for the floating component, then have a higher-level component for the SearchSelect behavior, and a sidecar to manage the keyboard interactivity. This is the portaled component: the actual list. * web: enhance search select. Break menu and Input items into separate handlers. * web: search select: added keyboard controller. * web: search select - the isolation continues This commit brings us to the position of having an independently rendered menu that listens for click events on its contents, records the value internally *and* sends it upstream as an event. This commit also includes a KeyboardController reactor that listens for keyboard events on a list of objects, moving the focus up and down and sending a both a "selected" event when the user presses Enter or Space, and a "close" event when the user presses Escape. A lot of this is just infrastructure. None of these *do* very much; they're just tools for making SearchSelect better. AkSearchSelectView is next: it's responsible for rendering the input and menu items, and then for forwarding the `value` component up to whoever cares. `ak-search-select` will ultimately be responsible for fetching the data and mapping the string tokens from AkSearchSelectView back into the objects that Authentik cares about. * web: search select - a functioning search select So search select is now separated into the following components: - SearchSelectView: Takes the renderables and the selected() Value and draws the Value in a box, then forwards the Options to a portaled drop-down component. - SearchSelectMenuPosition: A web component that renders the Menu into the <BODY> element and positions it with respect to an anchor provided by SearchSelectView. - SearchSelectMenu: Renders the Menu and listens for events indicating an Item has been selected. Sends events through a reference to the View. - SearchKeyboardController: A specialized listener that keeps an independent list of indices and tabstops, and listens for keyboard events to move the index forward or backward, as well as for Event or Space for "select" and Escape for "close". Doesn't actually _do_ these things; they're just semantics implied by the event names, it just sends an event up to the host, which can do what it wants with them. What's not done: - SearchSelect: The interface with the API. Maps to and from API values to renderable Options. One thing of note: of the 35 uses of SearchSelect in our product, 28 of them have `renderElement` annotations of a single field. Six of them use the same annotation (renderFlow), and only one (in EventMatcherPolicyForm) is at all complex. The 28 are: - 7: group.name; - 1: item.label; - 5: item.name; - 1: policy.name; - 1: role.name; - 1: source.name; - 3: stage.name; - 9: user.username; I propose to modify `.renderElement` to take a string of `keyof T`, where T is the type passed to the SearchSelect; it will simply look that up in the object passed in and use that as the Label. `.renderDescription` is more or less similar, except it has _no_ special cases: - 6: html`${flow.name}`; - 1: html`${source.verboseName}`; - 9: html`${user.name}`; - 2: html`${flow.slug}`; Given that, it makes sense to modify this as well to take a field key as a look up and render it, making all that function calling somewhat moot. Selected has a similar issue; passing it a value that is _not_ a function would be a signal to find this specific element in the corresponding 'pk'. Or we could pass a tuple of [keyof T] and value, so we didn't have to hard-code 'pk' into the thing. - 1 return item.pk === this.instance?.createUsersGroup; - 1 return item.pk === this.instance?.filterGroup; - 2 return item.pk === this.instance?.group; - 1 return item.pk === this.instance?.parent; - 1 return item.pk === this.instance?.searchGroup; - 1 return item.pk === this.instance?.syncParentGroup; - 1 return item.pk === this.instance?.policy; - 1 return item.pk === this.instance?.source; - 1 return item.pk === this.instance?.passwordStage; - 1 return item.pk === this.instance?.stage; - 1 return item.pk === this.instance?.user; - 2 return item.pk === this.previewUser?.pk; - 5 return item.pk === this.instance?.configureFlow; - 1 return item.pk === this.instance?.mapping; - 1 return item.pk === this.instance?.nameIdMapping; - 1 return item.pk === this.instance?.user; - 1 return item.pk === this.instance?.webhookMapping; - 1 return item.component === this.instance?.action; - 1 return item.path === this.instance?.path; - 1 return item.name === this.instance?.model; - 1 return item.name === this.instance?.app; - 1 return user.pk.toString() === this.request?.toString(); - 2 return this.request?.user.toString() === user.pk.toString(); And of course, `.value` kinda sorta has the same thing going on: - 6: flow?.pk; - 3: group ? group.pk : undefined; - 4: group?.pk; - 1: item?.component; - 2: item?.name; - 1: item?.path; - 4: item?.pk; - 1: policy?.pk; - 1: role?.pk; - 1: source?.pk; - 3: stage?.pk; - 8: user?.pk; - 1: user?.username; All in all, the _protocol_ for SearchSelect could be streamlined. A _lot_. And still retain the existing power. * Old take; not keeping. * Didn't need this either. * web: search select - a functioning search select with API interface So many edge cases! Because the propagation here is sometimes KeyboardEvent -> MenuEvent -> SearchSelectEvent, I had to rename some of the events to prevent them from creating infinite loops of event handling. This resulted in having to define separate events for Input, Close, and Select. I struggled like heck to get the `<input>` object to show the value after updating. Ultimately, I had to special case the `updated()` method to make sure it was showing the currently chosen display value. Looking through Stack Overflow, there's a lot of contention about the meaning of the `value` field on HTMLInputElements. The API layer distinguishes between a "search" event, which triggers the query to run, and the "select" event, which triggers the component to pick an object as _the_ `.value`. The API layer handles the conversion to GroupedItems and makes sure that the View receives either FlatSelect or GroupedSelect options collections (see ./types, but in practice users should never care too much about this.) * web: completed the search select update * web: search-select reveals a weakness in our plans While testing SearchSelect, I realized that the protocol for our "custom input elements" was neither specified nor documented. I have attempted to fix that, and am finding edge cases and buggy implementations that required addressing. I've described the protocol by creating a class that implements it: AkControlElement. It extends the constructor to always provide the "this is an data-ak-control element," and provides a `json()` method that throws an exception in the base class, so it must always be overriden and never called as super(). I've also fixed ak-dual-select so it carries its name properly into the Forms parser. * web: search select (and friends) This commit finalizes the search select quest! Headline: Search Select is now keyboard-friendly *and* CSS friendly; the styling needed for position is small enough to fit in a `styleMap`, and the styling for the menu itself can be safely locked into a web component. Primarily, I was forgetting to map the value to its displayValue whenever the value was changed from an external source. It should have been an easy catch, but I missed it the first dozen times through. * Not using this yet. ESLint-9 experiment that was loosely left here for some reason. * Added lots of comments. * Added new comments, fixed error message. * Removing a console.log * Fixed an incorrect comment. * Added comments about workaround. * web: focus fixes. Fixes several issues with the drop-down, including primarily how "loss of focus" does not result in the pop-up being banished. Also, the type definition for the attribute `hidden` is inconsistent between Typescript, the attribute, and the related property; I've chosen to route around that problem by using a custom attribute and setting `hidden` in the template, where `lit-analyze` has a workable definition and allows it to pass. Finally, on `open` the focus is passed to the current value, if any.
This commit is contained in:
@ -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"
|
||||
}
|
||||
}
|
1
web/package-lock.json
generated
1
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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";
|
||||
|
@ -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<string, Sample>())
|
||||
.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<SearchSelect<Sample>> = {
|
||||
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` <div style="background: #fff; padding: 2em">
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
${testItem}
|
||||
|
||||
<ul id="message-pad" style="margin-top: 1em"></ul>
|
||||
</div>`;
|
||||
|
||||
// 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`<ak-search-select
|
||||
.fetchObjects=${getSamples}
|
||||
.renderElement=${(sample: Sample) => sample.name}
|
||||
.value=${(sample: Sample) => sample.pk}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select>`,
|
||||
);
|
||||
};
|
||||
|
||||
export const Grouped = () => {
|
||||
return container(
|
||||
html`<ak-search-select
|
||||
.fetchObjects=${getSamples}
|
||||
.renderElement=${(sample: Sample) => sample.name}
|
||||
.value=${(sample: Sample) => sample.pk}
|
||||
.groupBy=${(samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? "")}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select>`,
|
||||
);
|
||||
};
|
||||
|
||||
export const Selected = () => {
|
||||
return container(
|
||||
html`<ak-search-select
|
||||
.fetchObjects=${getSamples}
|
||||
.renderElement=${(sample: Sample) => sample.name}
|
||||
.value=${(sample: Sample) => sample.pk}
|
||||
.selected=${(sample: Sample) => sample.pk === "herbs"}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select>`,
|
||||
);
|
||||
};
|
20
web/src/elements/AkControlElement.ts
Normal file
20
web/src/elements/AkControlElement.ts
Normal file
@ -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");
|
||||
}
|
||||
}
|
@ -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<CheckboxPr> {
|
||||
}
|
||||
}
|
||||
|
||||
const AkElementWithCustomEvents = CustomEmitterElement(AKElement);
|
||||
const AkElementWithCustomEvents = CustomEmitterElement(AkControlElement);
|
||||
|
||||
/**
|
||||
* @element ak-checkbox-group
|
||||
|
@ -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);
|
||||
|
@ -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<T extends KeyUnknown>(
|
||||
return;
|
||||
}
|
||||
|
||||
const inputElement = element.querySelector<HTMLInputElement>("[name]");
|
||||
if (element.hidden || !inputElement) {
|
||||
const inputElement = element.querySelector<AkControlElement>("[name]");
|
||||
if (element.hidden || !inputElement || (element.writeOnly && !element.writeOnlyActivated)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -84,10 +83,6 @@ export function serializeForm<T extends KeyUnknown>(
|
||||
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<T extends KeyUnknown>(
|
||||
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<unknown>;
|
||||
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);
|
||||
}
|
||||
|
142
web/src/elements/forms/SearchSelect/SearchKeyboardController.ts
Normal file
142
web/src/elements/forms/SearchSelect/SearchKeyboardController.ts
Normal file
@ -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<ReactiveControllerHost> & 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());
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
63
web/src/elements/forms/SearchSelect/SearchSelectEvents.ts
Normal file
63
web/src/elements/forms/SearchSelect/SearchSelectEvents.ts
Normal file
@ -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 `<input>` 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;
|
||||
}
|
||||
}
|
@ -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<SearchSelectMenu> = 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`<ak-search-select-menu
|
||||
.options=${this.options}
|
||||
value=${ifDefined(this.value)}
|
||||
.host=${this.host}
|
||||
.emptyOption=${this.emptyOption}
|
||||
@focusout=${this.onFocusOut}
|
||||
?open=${this.open}
|
||||
?hidden=${!this.open}
|
||||
${ref(this.menuRef)}
|
||||
></ak-search-select-menu>`,
|
||||
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;
|
||||
}
|
||||
}
|
192
web/src/elements/forms/SearchSelect/ak-search-select-menu.ts
Normal file
192
web/src/elements/forms/SearchSelect/ak-search-select-menu.ts
Normal file
@ -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<this>) {
|
||||
if (changed.has("open") && this.open) {
|
||||
this.keyboardController.hostVisible();
|
||||
}
|
||||
}
|
||||
|
||||
renderEmptyMenuItem() {
|
||||
return html`<li>
|
||||
<button class="pf-c-dropdown__menu-item" role="option" @click=${this.onEmptyClick}>
|
||||
${this.emptyOption}
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
renderMenuItems(options: SearchTuple[]) {
|
||||
return options.map(
|
||||
([value, label, desc]: SearchTuple) => html`
|
||||
<li>
|
||||
<button
|
||||
class="pf-c-dropdown__menu-item pf-m-description ak-select-item"
|
||||
role="option"
|
||||
value=${value}
|
||||
@click=${(ev: Event) => {
|
||||
this.onClick(ev, value);
|
||||
}}
|
||||
@keypress=${() => {
|
||||
/* noop */
|
||||
}}
|
||||
>
|
||||
<div class="pf-c-dropdown__menu-item-main">${label}</div>
|
||||
${desc
|
||||
? html`<div class="pf-c-dropdown__menu-item-description">${desc}</div>`
|
||||
: nothing}
|
||||
</button>
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
renderMenuGroups(options: SearchGroup[]) {
|
||||
return options.map(
|
||||
({ name, options }) => html`
|
||||
<section class="pf-c-dropdown__group">
|
||||
<h1 class="pf-c-dropdown__group-title">${name}</h1>
|
||||
<ul>
|
||||
${this.renderMenuItems(options)}
|
||||
</ul>
|
||||
</section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const options = this.fixedOptions();
|
||||
return html`<div class="pf-c-dropdown pf-m-expanded">
|
||||
<ul class="pf-c-dropdown__menu pf-m-static" role="listbox" tabindex="0">
|
||||
${this.emptyOption !== undefined ? this.renderEmptyMenuItem() : nothing}
|
||||
${options.grouped
|
||||
? this.renderMenuGroups(options.options)
|
||||
: this.renderMenuItems(options.options)}
|
||||
</ul>
|
||||
</div> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-search-select-menu": SearchSelectMenu;
|
||||
}
|
||||
}
|
286
web/src/elements/forms/SearchSelect/ak-search-select-view.ts
Normal file
286
web/src/elements/forms/SearchSelect/ak-search-select-view.ts
Normal file
@ -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 <input> 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<description>]. 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 <input> object, if currently empty. Used as the
|
||||
* native <input> 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<HTMLInputElement> = createRef();
|
||||
|
||||
/**
|
||||
* Permanent identity with the portal so focus events can be checked.
|
||||
*/
|
||||
menuRef: Ref<SearchSelectMenuPosition> = createRef();
|
||||
|
||||
/**
|
||||
* Maps a value from the portal to labels to be put into the <input> field>
|
||||
*/
|
||||
optionsMap: Map<string, string> = 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<this>) {
|
||||
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`<div class="pf-c-select">
|
||||
<div class="pf-c-select__toggle pf-m-typeahead">
|
||||
<div class="pf-c-select__toggle-wrapper">
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control pf-c-select__toggle-typeahead"
|
||||
type="text"
|
||||
${ref(this.inputRef)}
|
||||
placeholder=${this.placeholder}
|
||||
spellcheck="false"
|
||||
@input=${this.onInput}
|
||||
@focus=${this.onFocus}
|
||||
@click=${this.onClick}
|
||||
@keydown=${this.onKeydown}
|
||||
@focusout=${this.onFocusOut}
|
||||
value=${this.displayValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ak-search-select-menu-position
|
||||
name=${ifDefined(this.name)}
|
||||
.options=${this.options}
|
||||
value=${ifDefined(this.value)}
|
||||
.host=${this}
|
||||
.anchor=${this.inputRef.value}
|
||||
.emptyOption=${(this.blankable && this.emptyOption) || undefined}
|
||||
${ref(this.menuRef)}
|
||||
?open=${this.open}
|
||||
></ak-search-select-menu-position> `;
|
||||
}
|
||||
}
|
||||
|
||||
type Pair = [string, string];
|
||||
const justThePair = ([key, label]: SearchTuple): Pair => [key, label];
|
||||
|
||||
function optionsToOptionsMap(options: SearchOptions): Map<string, string> {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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<T> = [string, T[]];
|
||||
|
||||
@customElement("ak-search-select")
|
||||
export class SearchSelect<T> extends CustomEmitterElement(AKElement) {
|
||||
export class SearchSelect<T> 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<T> 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 <input> object, if currently empty. Used as the
|
||||
// native <input> object's `placeholder` field.
|
||||
@property()
|
||||
@ -93,46 +92,14 @@ export class SearchSelect<T> 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<HTMLInputElement>(
|
||||
".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<T> 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<T> 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`
|
||||
<li>
|
||||
<button
|
||||
class="pf-c-dropdown__menu-item pf-m-description"
|
||||
role="option"
|
||||
@click=${this.onMenuItemClick(obj)}
|
||||
tabindex=${index}
|
||||
>
|
||||
<div class="pf-c-dropdown__menu-item-main">${this.renderElement(obj)}</div>
|
||||
<div class="pf-c-dropdown__menu-item-description">${desc}</div>
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
renderMenuItemWithoutDescription(obj: T, index: number) {
|
||||
return html`
|
||||
<li>
|
||||
<button
|
||||
class="pf-c-dropdown__menu-item"
|
||||
role="option"
|
||||
@click=${this.onMenuItemClick(obj)}
|
||||
tabindex=${index}
|
||||
>
|
||||
${this.renderElement(obj)}
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
renderEmptyMenuItem() {
|
||||
return html`<li>
|
||||
<button
|
||||
class="pf-c-dropdown__menu-item"
|
||||
role="option"
|
||||
@click=${this.onMenuItemClick(undefined)}
|
||||
tabindex="0"
|
||||
>
|
||||
${this.emptyOption}
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
onMenuItemClick(obj: T | undefined) {
|
||||
return () => {
|
||||
this.selectedObject = obj;
|
||||
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<T>, idx: number) {
|
||||
return html`
|
||||
<section class="pf-c-dropdown__group">
|
||||
<h1 class="pf-c-dropdown__group-title">${group}</h1>
|
||||
<ul>
|
||||
${this.renderMenuGroup(items, idx)}
|
||||
</ul>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
get groupedItems(): [boolean, Group<T>[]] {
|
||||
const items = this.groupBy(this.objects || []);
|
||||
if (items.length === 0) {
|
||||
return [false, [["", []]]];
|
||||
}
|
||||
if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) {
|
||||
return [false, items];
|
||||
}
|
||||
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) {
|
||||
onSearch(event: SearchSelectInputEvent) {
|
||||
if (event.value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
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" }),
|
||||
};
|
||||
|
||||
render(
|
||||
html`<div style=${styleMap(position)} class="pf-c-dropdown pf-m-expanded">
|
||||
<ul
|
||||
class="pf-c-dropdown__menu pf-m-static"
|
||||
role="listbox"
|
||||
style="max-height:50vh;overflow-y:auto;"
|
||||
id=${this.dropdownUID}
|
||||
tabindex="0"
|
||||
>
|
||||
${this.blankable ? this.renderEmptyMenuItem() : html``}
|
||||
${shouldRenderGroups
|
||||
? html`${groupedItems.map(this.renderWithMenuGroupTitle)}`
|
||||
: html`${this.renderMenuGroup(groupedItems[0][1], 0)}`}
|
||||
</ul>
|
||||
</div>`,
|
||||
this.dropdownContainer,
|
||||
{ host: this },
|
||||
);
|
||||
this.query = event.value;
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
});
|
||||
}
|
||||
|
||||
get renderedValue() {
|
||||
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 });
|
||||
}
|
||||
|
||||
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<T>[]): SearchGroup[] =>
|
||||
items.map((group) => ({
|
||||
name: group[0],
|
||||
options: makeSearchTuples(group[1]),
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
return { grouped: false, options: [] };
|
||||
}
|
||||
|
||||
if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) {
|
||||
return {
|
||||
grouped: false,
|
||||
options: makeSearchTuples(items[0][1]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
grouped: true,
|
||||
options: makeSearchGroups(items),
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error) {
|
||||
return msg(str`Failed to fetch objects: ${this.error.detail}`);
|
||||
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
|
||||
}
|
||||
|
||||
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 options = this.getGroupedItems();
|
||||
const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined;
|
||||
|
||||
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 <ul> 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`<div class="pf-c-select">
|
||||
<div class="pf-c-select__toggle pf-m-typeahead">
|
||||
<div class="pf-c-select__toggle-wrapper">
|
||||
<input
|
||||
class="pf-c-form-control pf-c-select__toggle-typeahead"
|
||||
type="text"
|
||||
placeholder=${this.placeholder}
|
||||
spellcheck="false"
|
||||
@input=${onInput}
|
||||
@focus=${onFocus}
|
||||
@blur=${onBlur}
|
||||
.value=${this.renderedValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
return html`<ak-search-select-view
|
||||
.options=${options}
|
||||
.value=${value}
|
||||
?blankable=${this.blankable}
|
||||
name=${ifDefined(this.name)}
|
||||
placeholder=${this.placeholder}
|
||||
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
|
||||
@ak-search-select-input=${this.onSearch}
|
||||
@ak-search-select-select=${this.onSelect}
|
||||
></ak-search-select-view> `;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<SearchSelectMenu> = {
|
||||
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(`<li>${event.value}</li>`, "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` <div
|
||||
style="background: #fff; padding: 2em; position: relative"
|
||||
id="the-main-event"
|
||||
>
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#the-answer-block {
|
||||
padding-top: 3em;
|
||||
}
|
||||
</style>
|
||||
<ak-message-container></ak-message-container>
|
||||
${testItem}
|
||||
<div id="the-answer-block">
|
||||
<p>Messages received from the menu:</p>
|
||||
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
const goodForYouPairs = {
|
||||
grouped: false,
|
||||
options: sampleData.slice(0, 20).map(({ produce }) => [slug(produce), produce]),
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: () =>
|
||||
container(
|
||||
html` <ak-search-select-menu
|
||||
id="ak-search-select-menu"
|
||||
style="top: 1em; left: 1em"
|
||||
.options=${goodForYouPairs}
|
||||
></ak-search-select-menu>`,
|
||||
),
|
||||
};
|
||||
|
||||
const longGoodForYouPairs = {
|
||||
grouped: false,
|
||||
options: sampleData.map(({ produce }) => [slug(produce), produce]),
|
||||
};
|
||||
|
||||
export const Scrolling: Story = {
|
||||
render: () =>
|
||||
container(
|
||||
html` <ak-search-select-menu
|
||||
id="ak-search-select-menu"
|
||||
style="top: 1em; left: 1em"
|
||||
.options=${longGoodForYouPairs}
|
||||
.host=${document}
|
||||
></ak-search-select-menu>`,
|
||||
),
|
||||
};
|
||||
|
||||
export const Grouped: Story = {
|
||||
render: () =>
|
||||
container(
|
||||
html` <ak-search-select-menu
|
||||
id="ak-search-select-menu"
|
||||
style="top: 1em; left: 1em"
|
||||
.options=${groupedSampleData}
|
||||
.host=${document}
|
||||
></ak-search-select-menu>`,
|
||||
),
|
||||
};
|
@ -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<SearchSelectView> = {
|
||||
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` <div style="background: #fff; padding: 2em">
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
${testItem}
|
||||
|
||||
<ul id="message-pad" style="margin-top: 1em"></ul>
|
||||
</div>`;
|
||||
|
||||
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`<ak-search-select-view
|
||||
.options=${longGoodForYouPairs}
|
||||
blankable
|
||||
@ak-search-select-select=${displayChange}
|
||||
></ak-search-select-view>`,
|
||||
);
|
||||
};
|
||||
|
||||
export const DescribedGroups = () => {
|
||||
return container(
|
||||
html`<ak-search-select-view
|
||||
.options=${groupedSampleData}
|
||||
blankable
|
||||
@ak-search-select-select=${displayChange}
|
||||
></ak-search-select-view>`,
|
||||
);
|
||||
};
|
@ -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<SearchSelect<Sample>> = {
|
||||
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` <div style="background: #fff; padding: 2em">
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
${testItem}
|
||||
|
||||
<ul id="message-pad" style="margin-top: 1em"></ul>
|
||||
</div>`;
|
||||
|
||||
// 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`<ak-search-select
|
||||
.fetchObjects=${getSamples}
|
||||
.renderElement=${(sample: Sample) => sample.name}
|
||||
.value=${(sample: Sample) => sample.pk}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select>`,
|
||||
);
|
||||
|
||||
export const Grouped = () => {
|
||||
return container(
|
||||
html`<ak-search-select
|
||||
.fetchObjects=${getSamples}
|
||||
.renderElement=${(sample: Sample) => sample.name}
|
||||
.value=${(sample: Sample) => sample.pk}
|
||||
.groupBy=${(samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? "")}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select>`,
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectedAndBlankable = () => {
|
||||
return container(
|
||||
html`<ak-search-select
|
||||
blankable
|
||||
.fetchObjects=${getSamples}
|
||||
.renderElement=${(sample: Sample) => sample.name}
|
||||
.value=${(sample: Sample) => sample.pk}
|
||||
.selected=${(sample: Sample) => sample.pk === "herbs"}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select>`,
|
||||
);
|
||||
};
|
359
web/src/elements/forms/SearchSelect/stories/sampleData.ts
Normal file
359
web/src/elements/forms/SearchSelect/stories/sampleData.ts
Normal file
@ -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) ?? [],
|
||||
})),
|
||||
};
|
||||
})();
|
66
web/src/elements/forms/SearchSelect/types.ts
Normal file
66
web/src/elements/forms/SearchSelect/types.ts
Normal file
@ -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<T> = [string, T[]];
|
||||
|
||||
export type ElementRendererBase<T> = (element: T) => string;
|
||||
export type ElementRenderer<T, S = keyof T> = ElementRendererBase<T> | S;
|
||||
|
||||
export type DescriptionRendererBase<T> = (element: T) => TemplateResult | string;
|
||||
export type DescriptionRenderer<T, S = keyof T> = ElementRendererBase<T> | S;
|
||||
|
||||
export type ValueExtractorBase<T> = (element: T | undefined) => keyof T | undefined;
|
||||
export type ValueExtractor<T, S = keyof T> = ValueExtractorBase<T> | S;
|
||||
|
||||
export type ValueSelectorBase<T> = (element: T, elements: T[]) => boolean;
|
||||
export type ValueSelector<T, S extends keyof T> = S extends S
|
||||
? ValueSelectorBase<T> | [T, T[S]]
|
||||
: never;
|
||||
|
||||
export type GroupByBase<T> = (elements: T[]) => Group<T>[];
|
||||
export type GroupBy<T, S = keyof T> = GroupByBase<T> | keyof S;
|
Reference in New Issue
Block a user