web: search select with focus, autocomplete, and progressive search (#10728)
* web: much better focus discipline Fix the way focus is handled in SearchSelect so that the drop-down isn't grabbing the focus away from the Input when the user wants to type in their selection. Because it was broken otherwise! There's still a bug where it's possible to type in a complete value *Label*, then leave the component's focus (input and menu) completely, in which case the Label remains, looking innocent and correct, but it is *not* reflective of the value as understood by the SearchSelect API controller. Gonna try to fix that next. But I'm saving this as a useful checkpoint. * . * root: insert daphne app in correct order Signed-off-by: Jens Langhammer <jens@goauthentik.io> * web: implement ak-list-select Creates a new element, ak-list-select, which is a scrollable list that reports when an element is clicked or selected by the keyboard. I was hideously over-engineering ak-search-select-menu, and I decided to try something simpler. This is that something. The events we care about are just "change" and "lost focus", and both of those can be attached by the parent regardless of portaling. * web: ak-list-select is complete An extraction of the "menu" and "list" features from SearchSelect and DualSelect, this is a very simplified version of a visible list that emulates the Radio/Select behavior (i.e only one from the collection may be "valued" at the time). It has no visible indicators of selection (aside from some highlighting), as it's meant to be used to present the list rather than be indicative of any state of the list. I was seriously over-engineering the menu. It turns out, it's just not that difficult after all. The only things we care about, really, are "did the user change the selection," "did the user click out of the list," and "did the user press the escape key." Those are pre-existing events (click w/value, blur, and keydown w/keycode, respectively), so there was no need for me to introduce new custom events to handler them. * web: downgrade sonarjs again, because dependabot Dammit, really need to tell that machine to leave our versions alone. * web: search select After a lot of testing and experimenting, it's finally starting to look stable. What a pain in the neck this has all been. * web: hold * web: search select with focus and progressive search - New component: ak-list-select, which allows you to select from a list of elements, with keyboard control. - New component: ak-portal, which manages elements by moving "slotted" content into a distant component, usually one attached to the body, and positions it relative to an existing element. - ak-search-select-view has been revamped to handle focus, change, input, and blur using the browser native event handlers, rather than inventing my own. - ak-search-select has been turned into a simple driver that manages the view. - ak-search-select has a new declarative syntax for the most common use case. I seriously over-engineered this thing, leaning too heavily on outdated knowledge or assumptions about how the browser works. The native event handlers attached at the component's borders works more than fine, and by attaching the event handlers to the portaled component before sending it off to the slots, the correct handlers get the message. This revision leverages the browser a *lot* more, and gets much more effective interaction with much less code. `<ak-list-select>` is a new component that replaces the ad-hoc menu object of the old SearchSelect. It is a standalone component that just shows a list, allows someone to navigate that list with the keyboard or the mouse. By default, it is limited to half the height of the viewport. The list does not have an indicator of "selected" at this time. That's just a side effect of it being developed as an adjunct to search-select. Its design does not preclude extension. It has a *lot* of CSS components that can be customized. The properties and events are documented, but there is only one event: `change`. Consistent with HTML, the value is not sent with the `change` event; clients are expected to extract it with `change:event.target.value`. Like all HTML components, it is completely stringly defined; the value is either a string or undefined. `<ak-portal>` is a somewhat specialized "portal" component that places an `ak-list-select` in an object on top of the existing DOM content. It can generalized to do this with any component, though, and can be extended. It has no events or CSS, since it's "just" managing the portaling relationship. `<ak-search-select-view>` is the heart of the system. It takes a collection options and behaves like an autocomplete component for them. The only unique event it sends out is `change`, and like `ak-list-select`, it expects the client to retrieve the value. Like all HTML components, it is completely stringly defined; the value is either a string or undefined. This is the SearchSelect component we've all known to come and love, but with a better pop-up and cleaner keyboard interaction. It emits only one event, `ak-change`, which *does* carry the value with it. The Storybooks have been updated to show the current version of Search Select, with a (simulated) API layer as well as more blunt stringly-typed tests for the View layer. A handful of tests have been provided to cover a number of edge cases that I discovered during testing. These run fine with the `npx` command, and I would love to see them integrated into CI/CD. The search select fields `renderElement`, `renderDescription`, and `value` properties of `ak-search-select` have been modified to take a string. For example, the search for the list of user looks like this: ``` <ak-search-select .fetchObjects=${async (query?: string): Promise<User[]> => { const args: CoreUsersListRequest = { ordering: "username" }; if (query !== undefined) { args.search = query; } const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args); return users.results; }} .renderElement=${(user: User): string => { return user.username; }} .renderDescription=${(user: User): TemplateResult => { return html`${user.name}`; }} .value=${(user: User | undefined): string | undefined => { return user?.username; }} ></ak-search-select> ``` The most common syntax for the these three fields is "just return the string contents of a field by name," in the case of the description wrapped in a TemplateResult with no DOM components. By automating that initialization in the `connectedCallback` of the `ak-search-select` component, this object would look like: <ak-search-select .fetchObjects=${async (query?: string): Promise<User[]> => { const args: CoreUsersListRequest = { ordering: "username" }; if (query !== undefined) { args.search = query; } const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args); return users.results; }} .renderElement=${"username"} .renderDescription=${"name"} .value=${"username"} ></ak-search-select> ``` Due to a limitation in the way properties (such as functions) are interpreted, the syntax `renderElement="username"` is invalid; it has to be a property expression. Sorry; best I could do. The old syntax works just fine. This is a "detect and extend at runtime" enhancement. * Added comments to the Component Driver Harness. * Added more safety and comments. * web: remove string-based access to API; replace with a consolidated "adapter" layer. Clean out the string-based API layer in SearchSelect. Break SearchSelect into a "Base" that does all the work, and then wrap it in two different front-ends: one that conforms to the old WCAPI, and one with a slightly new WCAPI: ``` <ak-search-select-ez .config=${{ fetchObjects: async (query?: string): Promise<Group[]> => { const args: CoreGroupsListRequest = { ordering: "name", includeUsers: false, }; if (query !== undefined) { args.search = query; } const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList( args, ); return groups.results; }, renderElement: (group: Group): string => group.name, value: (group: Group | undefined): string | undefined => group?.pk, selected: (group: Group): boolean => group.pk === this.instance?.group }} blankable > </ak-search-select-ez> ``` * Prettier had opinions. In one case, an important opinion. * Rename test and fix lint error. * fix lint Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -1,142 +0,0 @@
|
||||
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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
260
web/src/elements/forms/SearchSelect/SearchSelect.ts
Normal file
260
web/src/elements/forms/SearchSelect/SearchSelect.ts
Normal file
@ -0,0 +1,260 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
||||
import type { GroupedOptions, SelectGroup, SelectOption } from "@goauthentik/elements/types.js";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { ResponseError } from "@goauthentik/api";
|
||||
|
||||
import "./ak-search-select-view.js";
|
||||
import { SearchSelectView } from "./ak-search-select-view.js";
|
||||
|
||||
type Group<T> = [string, T[]];
|
||||
|
||||
export interface ISearchSelectBase<T> {
|
||||
blankable: boolean;
|
||||
query?: string;
|
||||
objects?: T[];
|
||||
selectedObject?: T;
|
||||
name?: string;
|
||||
placeholder: string;
|
||||
emptyOption: string;
|
||||
}
|
||||
|
||||
export class SearchSelectBase<T>
|
||||
extends CustomEmitterElement(AkControlElement)
|
||||
implements ISearchSelectBase<T>
|
||||
{
|
||||
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.
|
||||
fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
|
||||
// A function passed to this object that extracts a string representation of items of the
|
||||
// collection under search.
|
||||
renderElement!: (element: T) => string;
|
||||
|
||||
// A function passed to this object that extracts an HTML representation of additional
|
||||
// information for items of the collection under search.
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
value!: (element: T | undefined) => unknown;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
// fetching the data; sets an initial default value.
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
|
||||
// A function passed to this object (or using the default below) that groups objects in the
|
||||
// collection under search into categories.
|
||||
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => {
|
||||
return "";
|
||||
});
|
||||
};
|
||||
|
||||
// Whether or not the dropdown component can be left blank
|
||||
@property({ type: Boolean })
|
||||
blankable = false;
|
||||
|
||||
// An initial string to filter the search contents, and the value of the input which further
|
||||
// serves to restrict the search
|
||||
@property()
|
||||
query?: string;
|
||||
|
||||
// The objects currently available under search
|
||||
@property({ attribute: false })
|
||||
objects?: T[];
|
||||
|
||||
// The currently selected object
|
||||
@property({ attribute: false })
|
||||
selectedObject?: T;
|
||||
|
||||
// Used to inform the form of the name of the object
|
||||
@property()
|
||||
name?: string;
|
||||
|
||||
// The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
// native <input> object's `placeholder` field.
|
||||
@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.
|
||||
@property()
|
||||
emptyOption = "---------";
|
||||
|
||||
isFetchingData = false;
|
||||
|
||||
@state()
|
||||
error?: APIErrorTypes;
|
||||
|
||||
public toForm(): unknown {
|
||||
if (!this.objects) {
|
||||
throw new PreventFormSubmit(msg("Loading options..."));
|
||||
}
|
||||
return this.value(this.selectedObject) || "";
|
||||
}
|
||||
|
||||
public json() {
|
||||
return this.toForm();
|
||||
}
|
||||
|
||||
public async updateData() {
|
||||
if (this.isFetchingData) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.isFetchingData = true;
|
||||
return this.fetchObjects(this.query)
|
||||
.then((objects) => {
|
||||
objects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, objects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
}
|
||||
});
|
||||
this.objects = objects;
|
||||
this.isFetchingData = false;
|
||||
})
|
||||
.catch((exc: ResponseError) => {
|
||||
this.isFetchingData = false;
|
||||
this.objects = undefined;
|
||||
parseAPIError(exc).then((err) => {
|
||||
this.error = err;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setAttribute("data-ouia-component-type", "ak-search-select");
|
||||
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
|
||||
this.dataset.akControl = "true";
|
||||
this.updateData();
|
||||
this.addEventListener(EVENT_REFRESH, this.updateData);
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener(EVENT_REFRESH, this.updateData);
|
||||
}
|
||||
|
||||
private onSearch(event: InputEvent) {
|
||||
const value = (event.target as SearchSelectView).rawValue;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.query = value;
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
});
|
||||
}
|
||||
|
||||
private onSelect(event: InputEvent) {
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
this.dispatchCustomEvent("ak-change", { value: undefined });
|
||||
return;
|
||||
}
|
||||
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
|
||||
if (!selected) {
|
||||
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
|
||||
}
|
||||
this.selectedObject = selected;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
}
|
||||
|
||||
private getGroupedItems(): GroupedOptions {
|
||||
const groupedItems = this.groupBy(this.objects || []);
|
||||
|
||||
const makeSearchTuples = (items: T[]): SelectOption[] =>
|
||||
items.map((item) => [
|
||||
`${this.value(item)}`,
|
||||
this.renderElement(item),
|
||||
this.renderDescription ? this.renderDescription(item) : undefined,
|
||||
]);
|
||||
|
||||
const makeSearchGroups = (items: Group<T>[]): SelectGroup[] =>
|
||||
items.map((group) => ({
|
||||
name: group[0],
|
||||
options: makeSearchTuples(group[1]),
|
||||
}));
|
||||
|
||||
if (groupedItems.length === 0) {
|
||||
return { grouped: false, options: [] };
|
||||
}
|
||||
|
||||
if (
|
||||
groupedItems.length === 1 &&
|
||||
(groupedItems[0].length < 1 || groupedItems[0][0] === "")
|
||||
) {
|
||||
return {
|
||||
grouped: false,
|
||||
options: makeSearchTuples(groupedItems[0][1]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
grouped: true,
|
||||
options: makeSearchGroups(groupedItems),
|
||||
};
|
||||
}
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override render() {
|
||||
if (this.error) {
|
||||
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
|
||||
}
|
||||
|
||||
if (!this.objects) {
|
||||
return html`${msg("Loading...")}`;
|
||||
}
|
||||
|
||||
const options = this.getGroupedItems();
|
||||
const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined;
|
||||
|
||||
return html`<ak-search-select-view
|
||||
managed
|
||||
.options=${options}
|
||||
value=${ifDefined(value)}
|
||||
?blankable=${this.blankable}
|
||||
name=${ifDefined(this.name)}
|
||||
placeholder=${this.placeholder}
|
||||
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
|
||||
@input=${this.onSearch}
|
||||
@change=${this.onSelect}
|
||||
></ak-search-select-view> `;
|
||||
}
|
||||
|
||||
public override updated() {
|
||||
// It is not safe for automated tests to interact with this component while it is fetching
|
||||
// data.
|
||||
if (!this.isFetchingData) {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchSelectBase;
|
||||
@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
143
web/src/elements/forms/SearchSelect/ak-portal.ts
Normal file
143
web/src/elements/forms/SearchSelect/ak-portal.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
|
||||
import { LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
* @class Portal
|
||||
* @element ak-portal
|
||||
*
|
||||
* An intermediate class to handle a 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.
|
||||
*
|
||||
* - @prop anchor (HTMLElement): The component which will be visually associated with the portaled popup.
|
||||
* - @attr open (boolean): whether or not the component is visible
|
||||
* - @attr name (string): (optional) used to managed the relationship the portal mediates.
|
||||
*/
|
||||
|
||||
export interface IPortal {
|
||||
anchor: HTMLElement;
|
||||
open: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@customElement("ak-portal")
|
||||
export class Portal extends LitElement implements IPortal {
|
||||
/**
|
||||
* The host element which will be our reference point for rendering. Is not necessarily
|
||||
* the element that receives the events.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Object, attribute: false })
|
||||
anchor!: HTMLElement;
|
||||
|
||||
/**
|
||||
* Whether or not the content 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;
|
||||
|
||||
content!: Element;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute("data-ouia-component-type", "ak-portal");
|
||||
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
|
||||
this.dropdownContainer = document.createElement("div");
|
||||
this.dropdownContainer.dataset["managedBy"] = "ak-portal";
|
||||
if (this.name) {
|
||||
this.dropdownContainer.dataset["managedFor"] = this.name;
|
||||
}
|
||||
document.body.append(this.dropdownContainer);
|
||||
if (!this.anchor) {
|
||||
throw new Error("Tether entrance initialized incorrectly: missing anchor");
|
||||
}
|
||||
this.connected = true;
|
||||
if (this.firstElementChild) {
|
||||
this.content = this.firstElementChild as Element;
|
||||
} else {
|
||||
throw new Error("No content to be portaled included in the tag");
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"display": "block",
|
||||
"z-index": "9999",
|
||||
"top": 0,
|
||||
"left": 0,
|
||||
"transform": `translate(${x}px, ${y}px)`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.dropdownContainer.appendChild(this.content);
|
||||
// This is a dummy object that just has to exist to be the communications channel between
|
||||
// the tethered object and its anchor.
|
||||
return nothing;
|
||||
}
|
||||
|
||||
updated() {
|
||||
(this.content as HTMLElement).style.display = "none";
|
||||
if (this.anchor && this.dropdownContainer && this.open && !this.hidden) {
|
||||
(this.content as HTMLElement).style.display = "";
|
||||
this.setPosition();
|
||||
}
|
||||
// Testing should always check if this component is open, even if it's set safe.
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-portal": Portal;
|
||||
}
|
||||
}
|
||||
74
web/src/elements/forms/SearchSelect/ak-search-select-ez.ts
Normal file
74
web/src/elements/forms/SearchSelect/ak-search-select-ez.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js";
|
||||
|
||||
export interface ISearchSelectApi<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => unknown;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
|
||||
export interface ISearchSelectEz<T> extends ISearchSelectBase<T> {
|
||||
config: ISearchSelectApi<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @class SearchSelectEz
|
||||
* @element ak-search-select-ez
|
||||
*
|
||||
* The API layer of ak-search-select, now in EZ format!
|
||||
*
|
||||
* - @prop config (Object): A Record <string, function> that fulfills the API needed by Search
|
||||
* Select to retrieve, filter, group, describe, and return elements.
|
||||
* - @attr blankable (boolean): if true, the component is blankable and can return `undefined`
|
||||
* - @attr name (string): The name of the component, for forms
|
||||
* - @attr query (string): The current search criteria for fetching objects
|
||||
* - @attr placeholder (string): What to show when the input is empty
|
||||
* - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only
|
||||
* shown if `blankable`
|
||||
* - @attr selectedObject (Object<T>): The current object, or undefined, selected
|
||||
*
|
||||
* ¹ Due to a limitation in the parsing of properties-vs-attributes, these must be defined as
|
||||
* properties, not attributes. As a consequence, they must be declared in property syntax.
|
||||
* Example:
|
||||
*
|
||||
* `.renderElement=${"name"}`
|
||||
*
|
||||
* - @fires ak-change - When a value from the collection has been positively chosen, either as a
|
||||
* consequence of the user typing or when selecting from the list.
|
||||
*
|
||||
*/
|
||||
|
||||
@customElement("ak-search-select-ez")
|
||||
export class SearchSelectEz<T> extends SearchSelectBase<T> implements ISearchSelectEz<T> {
|
||||
static get styles() {
|
||||
return [PFBase];
|
||||
}
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
config!: ISearchSelectApi<T>;
|
||||
|
||||
connectedCallback() {
|
||||
this.fetchObjects = this.config.fetchObjects;
|
||||
this.renderElement = this.config.renderElement;
|
||||
this.renderDescription = this.config.renderDescription;
|
||||
this.value = this.config.value;
|
||||
this.selected = this.config.selected;
|
||||
this.groupBy = this.config.groupBy;
|
||||
super.connectedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchSelectEz;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-search-select-ez": SearchSelectEz<unknown>;
|
||||
}
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,192 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/ak-list-select/ak-list-select.js";
|
||||
import { ListSelect } from "@goauthentik/elements/ak-list-select/ak-list-select.js";
|
||||
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 "@goauthentik/elements/forms/SearchSelect/ak-portal.js";
|
||||
import type { GroupedOptions, SelectOption, SelectOptions } from "@goauthentik/elements/types.js";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { PropertyValues, html } from "lit";
|
||||
import { PropertyValues, html, nothing } 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";
|
||||
@ -14,14 +17,19 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
|
||||
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 } from "./types.js";
|
||||
import { optionsToOptionsMap } from "./utils.js";
|
||||
import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from "./utils.js";
|
||||
|
||||
export interface ISearchSelectView {
|
||||
options: SelectOptions;
|
||||
value?: string;
|
||||
open: boolean;
|
||||
blankable: boolean;
|
||||
caseSensitive: boolean;
|
||||
name?: string;
|
||||
placeholder: string;
|
||||
managed: boolean;
|
||||
emptyOption: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @class SearchSelectView
|
||||
@ -30,8 +38,26 @@ import { optionsToOptionsMap } from "./utils.js";
|
||||
* 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.
|
||||
* - @prop options! (GroupedOptions): The options passed to the component
|
||||
* - @attr value? (string): The current value. Reflected.
|
||||
* - @attr open (boolean): if the menu dropdown is visible
|
||||
* - @attr blankable (boolean): if true, the component is blankable and can return `undefined`
|
||||
* - @attr managed (boolean): if true, the options and search are managed by a higher-level
|
||||
component.
|
||||
* - @attr caseSensitive (boolean): if `managed`, local searches will be case sensitive. False by
|
||||
default.
|
||||
* - @attr name? (string): The name of the component, for forms
|
||||
* - @attr placeholder (string): What to show when the input is empty
|
||||
* - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only
|
||||
* shown if `blankable`
|
||||
*
|
||||
* - @fires change - When a value from the list has been positively chosen, either as a consequence of
|
||||
* the user typing or when selecting from the list.
|
||||
*
|
||||
* - @part ak-search-select: The main Patternfly div
|
||||
* - @part ak-search-select-toggle: The Patternfly inner div
|
||||
* - @part ak-search-select-wrapper: Yet another Patternfly inner div
|
||||
* - @part ak-search-select-toggle-typeahead: The `<input>` component itself
|
||||
*
|
||||
* 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
|
||||
@ -41,9 +67,8 @@ import { optionsToOptionsMap } from "./utils.js";
|
||||
* the object that key references when extracting the value for use.
|
||||
*
|
||||
*/
|
||||
|
||||
@customElement("ak-search-select-view")
|
||||
export class SearchSelectView extends AKElement {
|
||||
export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
/**
|
||||
* The options collection. The simplest variant is just [key, label, optional<description>]. See
|
||||
* the `./types.ts` file for variants and how to use them.
|
||||
@ -51,16 +76,33 @@ export class SearchSelectView extends AKElement {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
options: SearchOptions = [];
|
||||
set options(options: SelectOptions) {
|
||||
this._options = groupOptions(options);
|
||||
this.flatOptions = optionsToFlat(this._options);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
_options!: GroupedOptions;
|
||||
|
||||
/**
|
||||
* The current value. Must be one of the keys in the options group above.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property()
|
||||
@property({ type: String, reflect: true })
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the dropdown is open
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
|
||||
/**
|
||||
* If set to true, this object MAY return undefined in no value is passed in and none is set
|
||||
* during interaction.
|
||||
@ -70,31 +112,42 @@ export class SearchSelectView extends AKElement {
|
||||
@property({ type: Boolean })
|
||||
blankable = false;
|
||||
|
||||
/**
|
||||
* If not managed, make the matcher case-sensitive during interaction. If managed,
|
||||
* the manager must handle this.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "case-sensitive" })
|
||||
caseSensitive = false;
|
||||
|
||||
/**
|
||||
* The name of the input, for forms
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
@property({ type: String })
|
||||
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()
|
||||
@property({ type: String })
|
||||
placeholder: string = msg("Select an object.");
|
||||
|
||||
/**
|
||||
* If true, the component only sends an input message up to a parent component. If false, the
|
||||
* list of options sent downstream will be filtered by the contents of the `<input>` field
|
||||
* locally.
|
||||
*
|
||||
*@attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
managed = false;
|
||||
|
||||
/**
|
||||
* A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
* Only used if `blankable` above is true.
|
||||
@ -106,136 +159,206 @@ export class SearchSelectView extends AKElement {
|
||||
|
||||
// Handle the behavior of the drop-down when the :host scrolls off the page.
|
||||
scrollHandler?: () => void;
|
||||
observer: IntersectionObserver;
|
||||
|
||||
// observer: IntersectionObserver;
|
||||
|
||||
@state()
|
||||
displayValue = "";
|
||||
|
||||
// Tracks when the inputRef is populated, so we can safely reschedule the
|
||||
// render of the dropdown with respect to it.
|
||||
@state()
|
||||
inputRefIsAvailable = false;
|
||||
|
||||
/**
|
||||
* Permanent identity with the portal so focus events can be checked.
|
||||
*/
|
||||
menuRef: Ref<ListSelect> = createRef();
|
||||
|
||||
/**
|
||||
* 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();
|
||||
flatOptions: [string, SelectOption][] = [];
|
||||
|
||||
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);
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute("data-ouia-component-type", "ak-search-select-view");
|
||||
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.observer.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
// TODO: Reconcile value <-> display value, Reconcile option changes to value <-> displayValue
|
||||
|
||||
onOpenEvent(event: Event) {
|
||||
this.open = true;
|
||||
if (
|
||||
this.blankable &&
|
||||
this.value === this.emptyOption &&
|
||||
event.target &&
|
||||
event.target instanceof HTMLInputElement
|
||||
) {
|
||||
event.target.value = "";
|
||||
}
|
||||
// If the user has changed the content of the input box, they are manipulating the *Label*, not
|
||||
// the value. We'll have to retroactively decide the value and publish it to any listeners.
|
||||
settleValue() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@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();
|
||||
onClick(_ev: Event) {
|
||||
this.open = !this.open;
|
||||
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));
|
||||
setFromMatchList(value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
const probableValue = this.flatOptions.find((option) => option[0] === this.value);
|
||||
if (probableValue && this.inputRef.value) {
|
||||
this.inputRef.value.value = probableValue[1][1];
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
if (event.code === "Escape") {
|
||||
event.stopPropagation();
|
||||
this.open = false;
|
||||
}
|
||||
if (event.code === "ArrowDown" || event.code === "ArrowUp") {
|
||||
this.open = true;
|
||||
}
|
||||
if (event.code === "Tab" && this.open) {
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
this.menuRef.value?.currentElement?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
onFocusOut(event: FocusEvent) {
|
||||
event.stopPropagation();
|
||||
window.setTimeout(() => {
|
||||
if (!this.menuRef.value?.hasFocus()) {
|
||||
this.open = false;
|
||||
onListBlur(event: FocusEvent) {
|
||||
// If we lost focus but the menu got it, don't do anything;
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | undefined;
|
||||
if (
|
||||
relatedTarget &&
|
||||
(this.contains(relatedTarget) ||
|
||||
this.renderRoot.contains(relatedTarget) ||
|
||||
this.menuRef.value?.contains(relatedTarget) ||
|
||||
this.menuRef.value?.renderRoot.contains(relatedTarget))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.open = false;
|
||||
if (this.value === undefined) {
|
||||
if (this.inputRef.value) {
|
||||
this.inputRef.value.value = "";
|
||||
}
|
||||
}, 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 ?? "")
|
||||
: "";
|
||||
this.setValue(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
if (this.inputRef?.value && this.inputRef?.value?.value !== this.displayValue) {
|
||||
this.inputRef.value.value = this.displayValue;
|
||||
setValue(newValue: string | undefined) {
|
||||
this.value = newValue;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
}
|
||||
|
||||
findValueForInput() {
|
||||
const value = this.inputRef.value?.value;
|
||||
if (value === undefined || value.trim() === "") {
|
||||
this.setValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchesFound = findFlatOptions(this.flatOptions, value);
|
||||
if (matchesFound.length > 0) {
|
||||
const newValue = matchesFound[0][0];
|
||||
if (newValue === value) {
|
||||
return;
|
||||
}
|
||||
this.setValue(newValue);
|
||||
} else {
|
||||
this.setValue(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="pf-c-select">
|
||||
<div class="pf-c-select__toggle pf-m-typeahead">
|
||||
<div class="pf-c-select__toggle-wrapper">
|
||||
@bound
|
||||
onInput(_ev: InputEvent) {
|
||||
if (!this.managed) {
|
||||
this.findValueForInput();
|
||||
this.requestUpdate();
|
||||
}
|
||||
this.open = true;
|
||||
}
|
||||
|
||||
@bound
|
||||
onListKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
this.open = false;
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
if (event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
onListChange(event: InputEvent) {
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
if (value !== undefined) {
|
||||
const newDisplayValue = this.findDisplayForValue(value);
|
||||
if (this.inputRef.value) {
|
||||
this.inputRef.value.value = newDisplayValue ?? "";
|
||||
}
|
||||
} else if (this.inputRef.value) {
|
||||
this.inputRef.value.value = "";
|
||||
}
|
||||
this.open = false;
|
||||
this.setValue(value);
|
||||
}
|
||||
|
||||
findDisplayForValue(value: string) {
|
||||
const newDisplayValue = this.flatOptions.find((option) => option[0] === value);
|
||||
return newDisplayValue ? newDisplayValue[1][1] : undefined;
|
||||
}
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("value") && this.value) {
|
||||
const newDisplayValue = this.findDisplayForValue(this.value);
|
||||
if (newDisplayValue) {
|
||||
this.displayValue = newDisplayValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.inputRef.value?.value ?? "";
|
||||
}
|
||||
|
||||
get managedOptions() {
|
||||
return this.managed
|
||||
? this._options
|
||||
: findOptionsSubset(this._options, this.rawValue, this.caseSensitive);
|
||||
}
|
||||
|
||||
public override render() {
|
||||
const emptyOption = this.blankable ? this.emptyOption : undefined;
|
||||
const open = this.open;
|
||||
|
||||
return html`<div class="pf-c-select" part="ak-search-select">
|
||||
<div class="pf-c-select__toggle pf-m-typeahead" part="ak-search-select-toggle">
|
||||
<div class="pf-c-select__toggle-wrapper" part="ak-search-select-wrapper">
|
||||
<input
|
||||
part="ak-search-select-toggle-typeahead"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control pf-c-select__toggle-typeahead"
|
||||
type="text"
|
||||
@ -243,25 +366,45 @@ export class SearchSelectView extends AKElement {
|
||||
placeholder=${this.placeholder}
|
||||
spellcheck="false"
|
||||
@input=${this.onInput}
|
||||
@focus=${this.onFocus}
|
||||
@click=${this.onClick}
|
||||
@blur=${this.onListBlur}
|
||||
@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> `;
|
||||
${this.inputRefIsAvailable
|
||||
? html`
|
||||
<ak-portal
|
||||
name=${ifDefined(this.name)}
|
||||
.anchor=${this.inputRef.value}
|
||||
?open=${open}
|
||||
>
|
||||
<ak-list-select
|
||||
id="menu-${this.getAttribute("data-ouia-component-id")}"
|
||||
${ref(this.menuRef)}
|
||||
.options=${this.managedOptions}
|
||||
value=${ifDefined(this.value)}
|
||||
@change=${this.onListChange}
|
||||
@blur=${this.onListBlur}
|
||||
emptyOption=${ifDefined(emptyOption)}
|
||||
@keydown=${this.onListKeydown}
|
||||
></ak-list-select>
|
||||
</ak-portal>
|
||||
`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
public override updated() {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
public override firstUpdated() {
|
||||
// Route around Lit's scheduling algorithm complaining about re-renders
|
||||
window.setTimeout(() => {
|
||||
this.inputRefIsAvailable = Boolean(this.inputRef?.value);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,27 +1,61 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { ResponseError } from "@goauthentik/api";
|
||||
import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js";
|
||||
|
||||
import { SearchSelectInputEvent, SearchSelectSelectEvent } from "./SearchSelectEvents.js";
|
||||
import "./ak-search-select-view.js";
|
||||
import type { GroupedOptions, SearchGroup, SearchTuple } from "./types.js";
|
||||
export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => unknown;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
|
||||
type Group<T> = [string, T[]];
|
||||
/**
|
||||
* @class SearchSelect
|
||||
* @element ak-search-select
|
||||
*
|
||||
* The API layer of ak-search-select
|
||||
*
|
||||
* - @prop fetchObjects (Function): The function by which objects are retrieved by the API.
|
||||
* - @prop renderElement (Function | string): Either a function that can retrieve the string
|
||||
* "label" of the element, or the name of the field from which the label can be retrieved.¹
|
||||
* - @prop renderDescription (Function | string): Either a function that can retrieve the string
|
||||
* or TemplateResult "description" of the element, or the name of the field from which the
|
||||
* description can be retrieved.¹
|
||||
* - @prop value (Function | string): Either a function that can retrieve the value (the current
|
||||
* API object's primary key) selected or the name of the field from which the value can be
|
||||
* retrieved.¹
|
||||
* - @prop selected (Function): A function that retrieves the current "live" value from the
|
||||
list of objects fetched by the function above.
|
||||
* - @prop groupBy (Function): A function that can group the objects fetched from the API by
|
||||
an internal criteria.
|
||||
* - @attr blankable (boolean): if true, the component is blankable and can return `undefined`
|
||||
* - @attr name (string): The name of the component, for forms
|
||||
* - @attr query (string): The current search criteria for fetching objects
|
||||
* - @attr placeholder (string): What to show when the input is empty
|
||||
* - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only
|
||||
* shown if `blankable`
|
||||
* - @attr selectedObject (Object<T>): The current object, or undefined, selected
|
||||
*
|
||||
* ¹ Due to a limitation in the parsing of properties-vs-attributes, these must be defined as
|
||||
* properties, not attributes. As a consequence, they must be declared in property syntax.
|
||||
* Example:
|
||||
*
|
||||
* `.renderElement=${"name"}`
|
||||
*
|
||||
* - @fires ak-change - When a value from the collection has been positively chosen, either as a
|
||||
* consequence of the user typing or when selecting from the list.
|
||||
*
|
||||
*/
|
||||
|
||||
@customElement("ak-search-select")
|
||||
export class SearchSelect<T> extends CustomEmitterElement(AkControlElement) {
|
||||
export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelect<T> {
|
||||
static get styles() {
|
||||
return [PFBase];
|
||||
}
|
||||
@ -39,7 +73,7 @@ export class SearchSelect<T> extends CustomEmitterElement(AkControlElement) {
|
||||
// A function passed to this object that extracts an HTML representation of additional
|
||||
// information for items of the collection under search.
|
||||
@property({ attribute: false })
|
||||
renderDescription?: (element: T) => TemplateResult;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
@ -60,174 +94,6 @@ export class SearchSelect<T> extends CustomEmitterElement(AkControlElement) {
|
||||
return "";
|
||||
});
|
||||
};
|
||||
|
||||
// Whether or not the dropdown component can be left blank
|
||||
@property({ type: Boolean })
|
||||
blankable = false;
|
||||
|
||||
// An initial string to filter the search contents, and the value of the input which further
|
||||
// serves to restrict the search
|
||||
@property()
|
||||
query?: string;
|
||||
|
||||
// The objects currently available under search
|
||||
@property({ attribute: false })
|
||||
objects?: T[];
|
||||
|
||||
// The currently selected object
|
||||
@property({ attribute: false })
|
||||
selectedObject?: T;
|
||||
|
||||
// Used to inform the form of the name of the object
|
||||
@property()
|
||||
name?: string;
|
||||
|
||||
// The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
// native <input> object's `placeholder` field.
|
||||
@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.
|
||||
@property()
|
||||
emptyOption = "---------";
|
||||
|
||||
isFetchingData = false;
|
||||
|
||||
@state()
|
||||
error?: APIErrorTypes;
|
||||
|
||||
toForm(): unknown {
|
||||
if (!this.objects) {
|
||||
throw new PreventFormSubmit(msg("Loading options..."));
|
||||
}
|
||||
return this.value(this.selectedObject) || "";
|
||||
}
|
||||
|
||||
json() {
|
||||
return this.toForm();
|
||||
}
|
||||
|
||||
async updateData() {
|
||||
if (this.isFetchingData) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.isFetchingData = true;
|
||||
return this.fetchObjects(this.query)
|
||||
.then((objects) => {
|
||||
objects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, objects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
}
|
||||
});
|
||||
this.objects = objects;
|
||||
this.isFetchingData = false;
|
||||
})
|
||||
.catch((exc: ResponseError) => {
|
||||
this.isFetchingData = false;
|
||||
this.objects = undefined;
|
||||
parseAPIError(exc).then((err) => {
|
||||
this.error = err;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.dataset.akControl = "true";
|
||||
this.updateData();
|
||||
this.addEventListener(EVENT_REFRESH, this.updateData);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener(EVENT_REFRESH, this.updateData);
|
||||
}
|
||||
|
||||
onSearch(event: SearchSelectInputEvent) {
|
||||
if (event.value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.query = event.value;
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
});
|
||||
}
|
||||
|
||||
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 html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
|
||||
}
|
||||
|
||||
if (!this.objects) {
|
||||
return html`${msg("Loading...")}`;
|
||||
}
|
||||
|
||||
const options = this.getGroupedItems();
|
||||
const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined;
|
||||
|
||||
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> `;
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchSelect;
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
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;
|
||||
|
||||
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>`,
|
||||
),
|
||||
};
|
||||
@ -1,6 +1,8 @@
|
||||
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 "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez";
|
||||
import { type ISearchSelectApi } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez";
|
||||
import { Meta } from "@storybook/web-components";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
@ -59,11 +61,8 @@ const container = (testItem: TemplateResult) =>
|
||||
|
||||
// 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,
|
||||
)}`;
|
||||
document.getElementById("message-pad")!.innerText =
|
||||
`Value selected: ${JSON.stringify(ev.detail.value, null, 2)}`;
|
||||
};
|
||||
|
||||
export const Default = () =>
|
||||
@ -89,6 +88,23 @@ export const Grouped = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupedAndEz = () => {
|
||||
const config: ISearchSelectApi<Sample> = {
|
||||
fetchObjects: getSamples,
|
||||
renderElement: (sample: Sample) => sample.name,
|
||||
value: (sample: Sample | undefined) => sample?.pk,
|
||||
groupBy: (samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
|
||||
};
|
||||
|
||||
return container(
|
||||
html`<ak-search-select-ez
|
||||
.config=${config}
|
||||
@ak-change=${displayChange}
|
||||
></ak-search-select-ez>`,
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectedAndBlankable = () => {
|
||||
return container(
|
||||
html`<ak-search-select
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import { $, browser } from "@wdio/globals";
|
||||
|
||||
browser.addCommand(
|
||||
"focus",
|
||||
function () {
|
||||
browser.execute(function (domElement) {
|
||||
domElement.focus();
|
||||
// @ts-ignore
|
||||
}, this);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Search Select View Driver
|
||||
*
|
||||
* This class provides a collection of easy-to-use methods for interacting with and examining the
|
||||
* results of an interaction with an `ak-search-select-view` via WebdriverIO.
|
||||
*
|
||||
* It's hoped that with the OUIA tags, we could automate testing further. The OUIA tag would
|
||||
* instruct the test harness "use this driver to test this component," and we could test Forms and
|
||||
* Tables with a small DSL of test language concepts
|
||||
*/
|
||||
|
||||
export class AkSearchSelectViewDriver {
|
||||
constructor(
|
||||
public element: WebdriverIO.Element,
|
||||
public menu: WebdriverIO.Element,
|
||||
) {
|
||||
/* no op */
|
||||
}
|
||||
|
||||
static async build(element: WebdriverIO.Element) {
|
||||
const tagname = await element.getTagName();
|
||||
const comptype = await element.getAttribute("data-ouia-component-type");
|
||||
if (comptype !== "ak-search-select-view") {
|
||||
throw new Error(
|
||||
`SearchSelectView driver passed incorrect component. Expected ak-search-select-view, got ${comptype ? `'${comptype}` : `No test data type, tag name: '${tagname}'`}`,
|
||||
);
|
||||
}
|
||||
const id = await element.getAttribute("data-ouia-component-id");
|
||||
const menu = await $(`[data-ouia-component-id="menu-${id}"]`);
|
||||
return new AkSearchSelectViewDriver(element, menu);
|
||||
}
|
||||
|
||||
get open() {
|
||||
return this.element.getProperty("open");
|
||||
}
|
||||
|
||||
async input() {
|
||||
return await this.element.$(">>>input");
|
||||
}
|
||||
|
||||
async listElements() {
|
||||
return await this.menu.$$(">>>li");
|
||||
}
|
||||
|
||||
async focusOnInput() {
|
||||
// @ts-ignore
|
||||
await (await this.input()).focus();
|
||||
}
|
||||
|
||||
async inputIsVisible() {
|
||||
return await this.element.$(">>>input").isDisplayed();
|
||||
}
|
||||
|
||||
async menuIsVisible() {
|
||||
return (await this.menu.isExisting()) && (await this.menu.isDisplayed());
|
||||
}
|
||||
|
||||
async clickInput() {
|
||||
return await (await this.input()).click();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
import { $, browser } from "@wdio/globals";
|
||||
import { slug } from "github-slugger";
|
||||
import { Key } from "webdriverio";
|
||||
|
||||
import { html, render } from "lit";
|
||||
|
||||
import "../ak-search-select-view.js";
|
||||
import { sampleData } from "../stories/sampleData.js";
|
||||
import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js";
|
||||
|
||||
const longGoodForYouPairs = {
|
||||
grouped: false,
|
||||
options: sampleData.map(({ produce }) => [slug(produce), produce]),
|
||||
};
|
||||
|
||||
describe("Search select: Test Input Field", () => {
|
||||
let select: AkSearchSelectViewDriver;
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
html`<ak-search-select-view .options=${longGoodForYouPairs}> </ak-search-select-view>`,
|
||||
document.body,
|
||||
);
|
||||
// @ts-ignore
|
||||
select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view"));
|
||||
});
|
||||
|
||||
it("should open the menu when the input is clicked", async () => {
|
||||
expect(await select.open).toBe(false);
|
||||
expect(await select.menuIsVisible()).toBe(false);
|
||||
await select.clickInput();
|
||||
expect(await select.open).toBe(true);
|
||||
// expect(await select.menuIsVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not open the menu when the input is focused", async () => {
|
||||
expect(await select.open).toBe(false);
|
||||
await select.focusOnInput();
|
||||
expect(await select.open).toBe(false);
|
||||
expect(await select.menuIsVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("should close the menu when the input is clicked a second time", async () => {
|
||||
expect(await select.open).toBe(false);
|
||||
expect(await select.menuIsVisible()).toBe(false);
|
||||
await select.clickInput();
|
||||
expect(await select.menuIsVisible()).toBe(true);
|
||||
expect(await select.open).toBe(true);
|
||||
await select.clickInput();
|
||||
expect(await select.open).toBe(false);
|
||||
expect(await select.open).toBe(false);
|
||||
});
|
||||
|
||||
it("should open the menu from a focused but closed input when a search is begun", async () => {
|
||||
expect(await select.open).toBe(false);
|
||||
await select.focusOnInput();
|
||||
expect(await select.open).toBe(false);
|
||||
expect(await select.menuIsVisible()).toBe(false);
|
||||
await browser.keys("A");
|
||||
expect(await select.open).toBe(true);
|
||||
expect(await select.menuIsVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("should update the list as the user types", async () => {
|
||||
await select.focusOnInput();
|
||||
await browser.keys("Ap");
|
||||
expect(await select.menuIsVisible()).toBe(true);
|
||||
const elements = Array.from(await select.listElements());
|
||||
expect(elements.length).toBe(2);
|
||||
});
|
||||
|
||||
it("set the value when a match is close", async () => {
|
||||
await select.focusOnInput();
|
||||
await browser.keys("Ap");
|
||||
expect(await select.menuIsVisible()).toBe(true);
|
||||
const elements = Array.from(await select.listElements());
|
||||
expect(elements.length).toBe(2);
|
||||
await browser.keys(Key.Tab);
|
||||
expect(await (await select.input()).getValue()).toBe("Apples");
|
||||
});
|
||||
|
||||
it("should close the menu when the user clicks away", async () => {
|
||||
document.body.insertAdjacentHTML(
|
||||
"afterbegin",
|
||||
'<input id="a-separate-component" type="text" />',
|
||||
);
|
||||
const input = await browser.$("#a-separate-component");
|
||||
|
||||
await select.clickInput();
|
||||
expect(await select.open).toBe(true);
|
||||
await input.click();
|
||||
expect(await select.open).toBe(false);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await document.body.querySelector("#a-separate-component")?.remove();
|
||||
await document.body.querySelector("ak-search-select-view")?.remove();
|
||||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
|
||||
if (document.body["_$litPart$"]) {
|
||||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
|
||||
delete document.body["_$litPart$"];
|
||||
}
|
||||
});
|
||||
});
|
||||
22
web/src/elements/forms/SearchSelect/tests/is-visible.ts
Normal file
22
web/src/elements/forms/SearchSelect/tests/is-visible.ts
Normal file
@ -0,0 +1,22 @@
|
||||
const isStyledVisible = ({ visibility, display }: CSSStyleDeclaration) =>
|
||||
visibility !== "hidden" && display !== "none";
|
||||
|
||||
const isDisplayContents = ({ display }: CSSStyleDeclaration) => display === "contents";
|
||||
|
||||
function computedStyleIsVisible(element: HTMLElement) {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
return (
|
||||
isStyledVisible(computedStyle) &&
|
||||
(isDisplayContents(computedStyle) ||
|
||||
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length))
|
||||
);
|
||||
}
|
||||
|
||||
export function isVisible(element: HTMLElement) {
|
||||
return (
|
||||
element &&
|
||||
element.isConnected &&
|
||||
isStyledVisible(element.style) &&
|
||||
computedStyleIsVisible(element)
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,67 @@
|
||||
import type { SearchOptions, SearchTuple } from "./types.js";
|
||||
import type {
|
||||
GroupedOptions,
|
||||
SelectGrouped,
|
||||
SelectOption,
|
||||
SelectOptions,
|
||||
} from "@goauthentik/elements/types.js";
|
||||
|
||||
type Pair = [string, string];
|
||||
const justThePair = ([key, label]: SearchTuple): Pair => [key, label];
|
||||
type Pair = [string, SelectOption];
|
||||
const mapPair = (option: SelectOption): Pair => [option[0], option];
|
||||
|
||||
export 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);
|
||||
const isSelectOptionsArray = (v: unknown): v is SelectOption[] => Array.isArray(v);
|
||||
|
||||
// prettier-ignore
|
||||
const isGroupedOptionsCollection = (v: unknown): v is SelectGrouped =>
|
||||
v !== null && typeof v === "object" && "grouped" in v && v.grouped === true;
|
||||
|
||||
export const groupOptions = (options: SelectOptions): GroupedOptions =>
|
||||
isSelectOptionsArray(options) ? { grouped: false, options: options } : options;
|
||||
|
||||
export function optionsToFlat(groupedOptions: GroupedOptions): Pair[] {
|
||||
return isGroupedOptionsCollection(groupedOptions)
|
||||
? groupedOptions.options.reduce(
|
||||
(acc: Pair[], { options }): Pair[] => [...acc, ...options.map(mapPair)],
|
||||
[] as Pair[],
|
||||
)
|
||||
: groupedOptions.options.map(mapPair);
|
||||
}
|
||||
|
||||
export function findFlatOptions(options: Pair[], value: string): Pair[] {
|
||||
const fragLength = value.length;
|
||||
return options.filter((option) => (option[1][1] ?? "").substring(0, fragLength) === value);
|
||||
}
|
||||
|
||||
export function findOptionsSubset(
|
||||
groupedOptions: GroupedOptions,
|
||||
value: string,
|
||||
caseSensitive = false,
|
||||
): GroupedOptions {
|
||||
const fragLength = value.length;
|
||||
if (value.trim() === "") {
|
||||
return groupedOptions;
|
||||
}
|
||||
|
||||
const compValue = caseSensitive ? value : value.toLowerCase();
|
||||
const compOption = (option: SelectOption) => {
|
||||
const extractedOption = (option[1] ?? "").substring(0, fragLength);
|
||||
return caseSensitive ? extractedOption : extractedOption.toLowerCase();
|
||||
};
|
||||
|
||||
const optFilter = (options: SelectOption[]) =>
|
||||
options.filter((option) => compOption(option) === compValue);
|
||||
|
||||
return groupedOptions.grouped
|
||||
? {
|
||||
grouped: true,
|
||||
options: groupedOptions.options
|
||||
.map(({ name, options }) => ({
|
||||
name,
|
||||
options: optFilter(options),
|
||||
}))
|
||||
.filter(({ options }) => options.length !== 0),
|
||||
}
|
||||
: {
|
||||
grouped: false,
|
||||
options: optFilter(groupedOptions.options),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user