Files
authentik/web/src/elements/forms/SearchSelect/ak-search-select-view.ts
Marc 'risson' Schmitt 409934196c web: fix lint (#10524)
2024-07-16 15:42:07 +00:00

287 lines
9.2 KiB
TypeScript

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;
}
}