web: clear "blanked" placeholder when present (#15)

- Renames "SearchSelect.ts" to "ak-search-select.ts", the better to reflect that it is a web
  component.
- Moves it into an independent folder named "SearchSelect" so that all existing folders that use it
  don't need any renaming or manipulation.
- Refactors SearchSelect.ts in the following ways:
  - Re-arranges the properties declaration so the seven properties actually used by callers are at
    the top; comments and documents every property.
  - Separates out the `renderItem` and `renderEmptyItem` HTML blocks into their own templates.
  - Separates `renderItem` further into `renderItemWithDescription` and
    `RenderItemWithoutDescription`; prior to this, there were multiple conditionals handling the
    description issue
  - Separates `renderItems` into `renderItemsAsGroups` and `renderItems`; this documents what each
    function does and removes multiple conditionals
  - Isolates the `groupedItems()` logic into a single method, moving the *how* away from the *what*.
  - Replaces the manual styling of `renderMenu()` into a lit-element `styleMap()`.  This makes the
    actual render a lot more readable!
  - Refactors the `value` logic into its own method, as a _getter_.
  - Refactors the ad-hoc handlers for `focus`, `input`, and `blur` into functions on the `render()`
    method itself.
    - Alternatively, I could have put the handlers as methods on the ak-search-select Node itself;
      Lit would automatically bind `this` correctly if referenced through the `@event` syntax.
      Moving them *out* of the `render()` method would require significantly more testing, however,
      as that would change the code flow enough it might have risked the original behavior.  By
      leaving them in the `render()` scope, this guarantees their original behavior -- whether that
      behavior is correct or not.
- FIXES #15
  - Having isolated as much functionality as was possible, it was easy to change the `onFocus()`
    event so that when the user focuses on the `<input>` object, if it's currently populated with
    the empty option and the user specified `isBlankable`, clear it.
  - **Notice**: This creates a new, possibly undesirable behavior; since it's not possible to know
    *why* the input object is currently empty, in the event that it is currently empty as a result
    of this clearing there is no way to know when the "empty option" marker needs to be put back.

This is an incredibly complex bit of code, the sort that really shouldn't be written by application
teams. The behavior is undefined in a number of cases, and although none of those cases are fatal,
some of them are quite annoying. I recommend that we seriously consider adopting a third-party
solution.

Selects (and DataLists) are notoriously difficult to get right on the desktop; they are almost
impossible to get right on mobile. Every responsible implementation of Selects has a
"default-to-native" experience on mobile because, for the most part, the mobile native experience is
excellent -- delta wanting two-line `<option>` blocks and `<optiongroup>`s, both of which we do
want.

This component implements:

- Rendering the `<input>` element and handling its behavior
- Rendering the `<select>` element and handling its behavior
- Mediating between these two components
- Fetching the data for the `<select>` component from the back-end
- Filtering the data via a partial-match search through the `<input>` element
- Distinguishing between hard-affirm and soft-affirm "No choice" options
- Dispatching the `<select>` element via a portal, the better to control rendering.

That's a *lot* of responsibilities! And it makes Storybooking this component non-viable. I recommend
breaking this up further, but I've already spent a lot of time just doing the refactoring and
getting the new behavior as right as possible, so for now I'm just going to submit the clean-up and
come back to this later.
This commit is contained in:
Ken Sternberg 2023-06-13 15:01:37 -07:00 committed by Jens Langhammer
parent 48e5823ad6
commit dc655c9283
No known key found for this signature in database
3 changed files with 370 additions and 324 deletions

View File

@ -1,324 +0,0 @@
import { PreventFormSubmit } from "@goauthentik/app/elements/forms/helpers";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils";
import { adaptCSS } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html, render } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFSelect from "@patternfly/patternfly/components/Select/select.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-search-select")
export class SearchSelect<T> extends CustomEmitterElement(AKElement) {
@property()
query?: string;
@property({ attribute: false })
objects?: T[];
@property({ attribute: false })
selectedObject?: T;
@property()
name?: string;
@property({ type: Boolean })
open = false;
@property({ type: Boolean })
blankable = false;
@property()
placeholder: string = msg("Select an object.");
static get styles(): CSSResult[] {
return [PFBase, PFForm, PFFormControl, PFSelect];
}
@property({ attribute: false })
fetchObjects!: (query?: string) => Promise<T[]>;
@property({ attribute: false })
renderElement!: (element: T) => string;
@property({ attribute: false })
renderDescription?: (element: T) => TemplateResult;
@property({ attribute: false })
value!: (element: T | undefined) => unknown;
@property({ attribute: false })
selected?: (element: T, elements: T[]) => boolean;
@property()
emptyOption = "---------";
@property({ attribute: false })
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
return groupBy(items, () => {
return "";
});
};
scrollHandler?: () => void;
observer: IntersectionObserver;
dropdownUID: string;
dropdownContainer: HTMLDivElement;
isFetchingData = false;
constructor() {
super();
if (!document.adoptedStyleSheets.includes(PFDropdown)) {
document.adoptedStyleSheets = adaptCSS([...document.adoptedStyleSheets, PFDropdown]);
}
this.dropdownContainer = document.createElement("div");
this.observer = new IntersectionObserver(() => {
this.open = false;
this.shadowRoot
?.querySelectorAll<HTMLInputElement>(
".pf-c-form-control.pf-c-select__toggle-typeahead",
)
.forEach((input) => {
input.blur();
});
});
this.observer.observe(this);
this.dropdownUID = `dropdown-${randomString(10, ascii_letters + digits)}`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shouldUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("selectedObject")) {
this.dispatchCustomEvent("ak-change", {
value: this.selectedObject,
});
}
return true;
}
toForm(): unknown {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
}
return this.value(this.selectedObject) || "";
}
firstUpdated(): void {
this.updateData();
}
updateData(): void {
if (this.isFetchingData) {
return;
}
this.isFetchingData = true;
this.fetchObjects(this.query).then((objects) => {
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
this.selectedObject = obj;
}
});
this.objects = objects;
this.isFetchingData = false;
});
}
connectedCallback(): void {
super.connectedCallback();
this.dropdownContainer = document.createElement("div");
this.dropdownContainer.dataset["managedBy"] = "ak-search-select";
if (this.name) {
this.dropdownContainer.dataset["managedFor"] = this.name;
}
document.body.append(this.dropdownContainer);
this.updateData();
this.addEventListener(EVENT_REFRESH, this.updateData);
this.scrollHandler = () => {
this.requestUpdate();
};
window.addEventListener("scroll", this.scrollHandler);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(EVENT_REFRESH, this.updateData);
if (this.scrollHandler) {
window.removeEventListener("scroll", this.scrollHandler);
}
this.dropdownContainer.remove();
this.observer.disconnect();
}
/*
* This is a little bit hacky. Because we mainly want to use this field in modal-based forms,
* rendering this menu inline makes the menu not overlay over top of the modal, and cause
* the modal to scroll.
* Hence, we render the menu into the document root, hide it when this menu isn't open
* and remove it on disconnect
* Also to move it to the correct position we're getting this elements's position and use that
* to position the menu
* The other downside this has is that, since we're rendering outside of a shadow root,
* the pf-c-dropdown CSS needs to be loaded on the body.
*/
renderMenu(): void {
if (!this.objects) {
return;
}
const pos = this.getBoundingClientRect();
let groupedItems = this.groupBy(this.objects);
let shouldRenderGroups = true;
if (groupedItems.length === 1) {
if (groupedItems[0].length < 1 || groupedItems[0][0] === "") {
shouldRenderGroups = false;
}
}
if (groupedItems.length === 0) {
shouldRenderGroups = false;
groupedItems = [["", []]];
}
const renderGroup = (items: T[], tabIndexStart: number): TemplateResult => {
return html`${items.map((obj, index) => {
let desc = undefined;
if (this.renderDescription) {
desc = this.renderDescription(obj);
}
return html`
<li>
<button
class="pf-c-dropdown__menu-item ${desc === undefined
? ""
: "pf-m-description"}"
role="option"
@click=${() => {
this.selectedObject = obj;
this.open = false;
}}
tabindex=${index + tabIndexStart}
>
${desc === undefined
? this.renderElement(obj)
: html`
<div class="pf-c-dropdown__menu-item-main">
${this.renderElement(obj)}
</div>
<div class="pf-c-dropdown__menu-item-description">
${desc}
</div>
`}
</button>
</li>
`;
})}`;
};
render(
html`<div
class="pf-c-dropdown pf-m-expanded"
style="position: fixed; inset: 0px auto auto 0px; z-index: 9999; transform: translate(${pos.x}px, ${pos.y +
this.offsetHeight}px); width: ${pos.width}px; ${this.open
? ""
: "visibility: hidden;"}"
>
<ul
class="pf-c-dropdown__menu pf-m-static"
role="listbox"
style="max-height:50vh;overflow-y:auto;"
id=${this.dropdownUID}
tabindex="0"
>
${this.blankable
? html`
<li>
<button
class="pf-c-dropdown__menu-item"
role="option"
@click=${() => {
this.selectedObject = undefined;
this.open = false;
}}
tabindex="0"
>
${this.emptyOption}
</button>
</li>
`
: html``}
${shouldRenderGroups
? html`${groupedItems.map(([group, items], idx) => {
return html`
<section class="pf-c-dropdown__group">
<h1 class="pf-c-dropdown__group-title">${group}</h1>
<ul>
${renderGroup(items, idx)}
</ul>
</section>
`;
})}`
: html`${renderGroup(groupedItems[0][1], 0)}`}
</ul>
</div>`,
this.dropdownContainer,
{ host: this },
);
}
render(): TemplateResult {
this.renderMenu();
let value = "";
if (!this.objects) {
value = msg("Loading...");
} else if (this.selectedObject) {
value = this.renderElement(this.selectedObject);
} else if (this.blankable) {
value = this.emptyOption;
}
return html`<div class="pf-c-select">
<div class="pf-c-select__toggle pf-m-typeahead">
<div class="pf-c-select__toggle-wrapper">
<input
class="pf-c-form-control pf-c-select__toggle-typeahead"
type="text"
placeholder=${this.placeholder}
spellcheck="false"
@input=${(ev: InputEvent) => {
this.query = (ev.target as HTMLInputElement).value;
this.updateData();
}}
@focus=${() => {
this.open = true;
this.renderMenu();
}}
@blur=${(ev: FocusEvent) => {
// For Safari, we get the <ul> element itself here when clicking on one of
// it's buttons, as the container has tabindex set
if (
ev.relatedTarget &&
(ev.relatedTarget as HTMLElement).id === this.dropdownUID
) {
return;
}
// Check if we're losing focus to one of our dropdown items, and if such don't blur
if (ev.relatedTarget instanceof HTMLButtonElement) {
const parentMenu = ev.relatedTarget.closest(
"ul.pf-c-dropdown__menu.pf-m-static",
);
if (parentMenu && parentMenu.id === this.dropdownUID) {
return;
}
}
this.open = false;
this.renderMenu();
}}
.value=${value}
/>
</div>
</div>
</div>`;
}
}

View File

@ -0,0 +1,366 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { PreventFormSubmit } from "@goauthentik/elements/forms/Form";
import { msg } from "@lit/localize";
import { TemplateResult, html, render } from "lit";
import { customElement, property } from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFSelect from "@patternfly/patternfly/components/Select/select.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
type Group<T> = [string, T[]];
@customElement("ak-search-select")
export class SearchSelect<T> extends AKElement {
// A function which takes the query above (accepting that it may be empty) and
// returns a new collection of objects.
@property({ attribute: false })
fetchObjects!: (query?: string) => Promise<T[]>;
// A function passed to this object that extracts a string representation of items of the
// collection under search.
@property({ attribute: false })
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.
@property({ attribute: false })
renderDescription?: (element: T) => TemplateResult;
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
@property({ attribute: false })
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.
@property({ attribute: false })
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.
@property({ attribute: false })
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;
// Not used in this object. No known purpose.
@property()
name?: string;
// Whether or not the dropdown component is visible.
@property({ type: Boolean })
open = false;
// The textual placeholder for the search's <input> object, if currently empty. Used as the
// native <input> object's `placeholder` field.
@property()
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 = "---------";
// Handle the behavior of the drop-down when the :host scrolls off the page.
scrollHandler?: () => void;
observer: IntersectionObserver;
// Handle communication between the :host and the portal
dropdownUID: string;
dropdownContainer: HTMLDivElement;
isFetchingData = false;
static styles = [PFBase, PFForm, PFFormControl, PFSelect];
constructor() {
super();
if (!document.adoptedStyleSheets.includes(PFDropdown)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFDropdown];
}
this.dropdownContainer = document.createElement("div");
this.observer = new IntersectionObserver(() => {
this.open = false;
this.shadowRoot
?.querySelectorAll<HTMLInputElement>(
".pf-c-form-control.pf-c-select__toggle-typeahead",
)
.forEach((input) => {
input.blur();
});
});
this.observer.observe(this);
this.dropdownUID = `dropdown-${randomString(10, ascii_letters + digits)}`;
this.onMenuItemClick = this.onMenuItemClick.bind(this);
}
toForm(): unknown {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
}
return this.value(this.selectedObject) || "";
}
firstUpdated(): void {
this.updateData();
}
updateData(): void {
if (this.isFetchingData) {
return;
}
this.isFetchingData = true;
this.fetchObjects(this.query).then((objects) => {
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
this.selectedObject = obj;
}
});
this.objects = objects;
this.isFetchingData = false;
});
}
connectedCallback(): void {
super.connectedCallback();
this.dropdownContainer = document.createElement("div");
this.dropdownContainer.dataset["managedBy"] = "ak-search-select";
document.body.append(this.dropdownContainer);
this.updateData();
this.addEventListener(EVENT_REFRESH, this.updateData);
this.scrollHandler = () => {
this.requestUpdate();
};
window.addEventListener("scroll", this.scrollHandler);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(EVENT_REFRESH, this.updateData);
if (this.scrollHandler) {
window.removeEventListener("scroll", this.scrollHandler);
}
this.dropdownContainer.remove();
this.observer.disconnect();
}
renderMenuItemWithDescription(obj: T, desc: TemplateResult, index: number) {
return html`
<li>
<button
class="pf-c-dropdown__menu-item pf-m-description"
role="option"
@click=${this.onMenuItemClick(obj)}
tabindex=${index}
>
<div class="pf-c-dropdown__menu-item-main">${this.renderElement(obj)}</div>
<div class="pf-c-dropdown__menu-item-description">${desc}</div>
</button>
</li>
`;
}
renderMenuItemWithoutDescription(obj: T, index: number) {
return html`
<li>
<button
class="pf-c-dropdown__menu-item"
role="option"
@click=${this.onMenuItemClick(obj)}
tabindex=${index}
>
${this.renderElement(obj)}
</button>
</li>
`;
}
renderEmptyMenuItem() {
return html`<li>
<button
class="pf-c-dropdown__menu-item"
role="option"
@click=${this.onMenuItemClick(undefined)}
tabindex="0"
>
${this.emptyOption}
</button>
</li>`;
}
onMenuItemClick(obj: T | undefined) {
return () => {
this.selectedObject = obj;
this.open = false;
};
}
renderMenuGroup(items: T[], tabIndexStart: number) {
const renderedItems = items.map((obj, index) => {
const desc = this.renderDescription ? this.renderDescription(obj) : null;
const tabIndex = index + tabIndexStart;
return desc
? this.renderMenuItemWithDescription(obj, desc, tabIndex)
: this.renderMenuItemWithoutDescription(obj, tabIndex);
});
return html`${renderedItems}`;
}
renderWithMenuGroupTitle([group, items]: Group<T>, idx: number) {
return html`
<section class="pf-c-dropdown__group">
<h1 class="pf-c-dropdown__group-title">${group}</h1>
<ul>
${this.renderMenuGroup(items, idx)}
</ul>
</section>
`;
}
get groupedItems(): [boolean, Group<T>[]] {
const items = this.groupBy(this.objects || []);
if (items.length === 0) {
return [false, [["", []]]];
}
if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) {
return [false, items];
}
return [true, items];
}
/*
* This is a little bit hacky. Because we mainly want to use this field in modal-based forms,
* rendering this menu inline makes the menu not overlay over top of the modal, and cause
* the modal to scroll.
* Hence, we render the menu into the document root, hide it when this menu isn't open
* and remove it on disconnect
* Also to move it to the correct position we're getting this elements's position and use that
* to position the menu
* The other downside this has is that, since we're rendering outside of a shadow root,
* the pf-c-dropdown CSS needs to be loaded on the body.
*/
renderMenu(): void {
if (!this.objects) {
return;
}
const [shouldRenderGroups, groupedItems] = this.groupedItems;
const pos = this.getBoundingClientRect();
const position = {
"position": "fixed",
"inset": "0px auto auto 0px",
"z-index": "9999",
"transform": `translate(${pos.x}px, ${pos.y + this.offsetHeight}px)`,
"width": `${pos.width}px`,
...(this.open ? {} : { visibility: "hidden" }),
};
render(
html`<div style=${styleMap(position)} class="pf-c-dropdown pf-m-expanded">
<ul
class="pf-c-dropdown__menu pf-m-static"
role="listbox"
style="max-height:50vh;overflow-y:auto;"
id=${this.dropdownUID}
tabindex="0"
>
${this.blankable ? this.renderEmptyMenuItem() : html``}
${shouldRenderGroups
? html`groupedItems.map(this.renderWithMenuGroupTitle)`
: html`${this.renderMenuGroup(groupedItems[0][1], 0)}`}
</ul>
</div>`,
this.dropdownContainer,
{ host: this },
);
}
get renderedValue() {
// prettier-ignore
return (!this.objects) ? msg("Loading...")
: (this.selectedObject) ? this.renderElement(this.selectedObject)
: (this.blankable) ? this.emptyOption
: "";
}
render(): TemplateResult {
this.renderMenu();
const onFocus = (ev: FocusEvent) => {
this.open = true;
this.renderMenu();
if (this.blankable && this.renderedValue === this.emptyOption) {
if (ev.target && ev.target instanceof HTMLInputElement) {
ev.target.value = "";
}
}
};
const onInput = (ev: InputEvent) => {
this.query = (ev.target as HTMLInputElement).value;
this.updateData();
};
const onBlur = (ev: FocusEvent) => {
// For Safari, we get the <ul> element itself here when clicking on one of
// it's buttons, as the container has tabindex set
if (ev.relatedTarget && (ev.relatedTarget as HTMLElement).id === this.dropdownUID) {
return;
}
// Check if we're losing focus to one of our dropdown items, and if such don't blur
if (ev.relatedTarget instanceof HTMLButtonElement) {
const parentMenu = ev.relatedTarget.closest("ul.pf-c-dropdown__menu.pf-m-static");
if (parentMenu && parentMenu.id === this.dropdownUID) {
return;
}
}
this.open = false;
this.renderMenu();
};
return html`<div class="pf-c-select">
<div class="pf-c-select__toggle pf-m-typeahead">
<div class="pf-c-select__toggle-wrapper">
<input
class="pf-c-form-control pf-c-select__toggle-typeahead"
type="text"
placeholder=${this.placeholder}
spellcheck="false"
@input=${onInput}
@focus=${onFocus}
@blur=${onBlur}
.value=${this.renderedValue}
/>
</div>
</div>
</div>`;
}
}

View File

@ -0,0 +1,4 @@
import { SearchSelect } from "./ak-search-select";
export { SearchSelect };
export default SearchSelect;