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:
338
web/src/elements/ak-list-select/ak-list-select.ts
Normal file
338
web/src/elements/ak-list-select/ak-list-select.ts
Normal file
@ -0,0 +1,338 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base.js";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound.js";
|
||||
import type {
|
||||
GroupedOptions,
|
||||
SelectGroup,
|
||||
SelectOption,
|
||||
SelectOptions,
|
||||
} from "@goauthentik/elements/types.js";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import { PropertyValueMap, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } 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 { groupOptions, isVisibleInScrollRegion } from "./utils.js";
|
||||
|
||||
export interface IListSelect {
|
||||
options: SelectOptions;
|
||||
value?: string;
|
||||
emptyOption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @class ListSelect
|
||||
* @element ak-list-select
|
||||
*
|
||||
* authentik scrolling list select element
|
||||
*
|
||||
* Provides a menu of elements to be used for selection.
|
||||
*
|
||||
* - @prop options (SelectOption[]): The options to display.
|
||||
* - @attr value (string): the current value of the Component
|
||||
* - @attr emptyOption (string): if defined, the component can be `undefined` and will
|
||||
* display this string at the top.
|
||||
*
|
||||
* - @fires change: When the value of the element has changed
|
||||
*
|
||||
* - @part ak-list-select-wrapper: the `<div>` that contains the whole
|
||||
* - @part ak-list-select: the `<ul>` that defines the list. This is the component
|
||||
* to target if you want to change the max height.
|
||||
* - @part ak-list-select-option: The `<li>` items of the list
|
||||
* - @part ak-list-select-button: The `<button>` element of an item.
|
||||
* - @part ak-list-select-desc: The description element of the list
|
||||
* - @part ak-list-select-group: A section of a grouped list.
|
||||
* - @part ak-list-select-title: The title of a group
|
||||
*/
|
||||
@customElement("ak-list-select")
|
||||
export class ListSelect extends AKElement implements IListSelect {
|
||||
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;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* See the search options type, described in the `./types` file, for the relevant types.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
set options(options: SelectOptions) {
|
||||
this._options = groupOptions(options);
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
_options!: GroupedOptions;
|
||||
|
||||
/**
|
||||
* The current value of the menu.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* The string representation that means an empty option. If not present, no empty option is
|
||||
* possible.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property()
|
||||
emptyOption?: string;
|
||||
|
||||
// We have two different states that we're tracking in this component: the `value`, which is the
|
||||
// element that is currently selected according to the client, and the `index`, which is the
|
||||
// element that is being tracked for keyboard interaction. On a click, the index points to the
|
||||
// value element; on Keydown.Enter, the value becomes whatever the index points to.
|
||||
@state()
|
||||
indexOfFocusedItem = 0;
|
||||
|
||||
@query("#ak-list-select-list")
|
||||
ul!: HTMLUListElement;
|
||||
|
||||
get json(): string {
|
||||
return this.value ?? "";
|
||||
}
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.addEventListener("focus", this.onFocus);
|
||||
this.addEventListener("blur", this.onBlur);
|
||||
}
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute("data-ouia-component-type", "ak-menu-select");
|
||||
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.highlightFocusedItem();
|
||||
}
|
||||
|
||||
public get hasFocus() {
|
||||
return this.renderRoot.contains(document.activeElement) || document.activeElement === this;
|
||||
}
|
||||
|
||||
private get displayedElements(): HTMLElement[] {
|
||||
return Array.from(this.renderRoot.querySelectorAll(".ak-select-item"));
|
||||
}
|
||||
|
||||
public get currentElement(): HTMLElement | undefined {
|
||||
const curIndex = this.indexOfFocusedItem;
|
||||
return curIndex < 0 || curIndex > this.displayedElements.length - 1
|
||||
? undefined
|
||||
: this.displayedElements[curIndex];
|
||||
}
|
||||
|
||||
private setIndexOfFocusedItemFromValue() {
|
||||
const index = this.displayedElements.findIndex((element) => {
|
||||
return element.getAttribute("value") === this.value;
|
||||
});
|
||||
const elementCount = this.displayedElements.length;
|
||||
|
||||
const checkIndex = () => (index === -1 ? 0 : index);
|
||||
return elementCount === 0 ? -1 : checkIndex();
|
||||
}
|
||||
|
||||
private highlightFocusedItem() {
|
||||
this.displayedElements.forEach((item) => {
|
||||
item.classList.remove("ak-highlight-item");
|
||||
item.removeAttribute("aria-selected");
|
||||
item.tabIndex = -1;
|
||||
});
|
||||
const currentElement = this.currentElement;
|
||||
if (!currentElement) {
|
||||
return;
|
||||
}
|
||||
currentElement.classList.add("ak-highlight-item");
|
||||
// This is currently a radio emulation; "selected" is true here.
|
||||
// If this were a checkbox emulation (i.e. multi), "checked" would be appropriate.
|
||||
currentElement.setAttribute("aria-selected", "true");
|
||||
currentElement.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
|
||||
@bound
|
||||
onFocus() {
|
||||
// Allow the event to propagate.
|
||||
this.currentElement?.focus();
|
||||
this.addEventListener("keydown", this.onKeydown);
|
||||
}
|
||||
|
||||
@bound
|
||||
onBlur() {
|
||||
// Allow the event to propagate.
|
||||
this.removeEventListener("keydown", this.onKeydown);
|
||||
this.indexOfFocusedItem = 0;
|
||||
}
|
||||
|
||||
@bound
|
||||
onClick(value: string | undefined) {
|
||||
// let the click through, but include the change event.
|
||||
this.value = value;
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
}
|
||||
|
||||
@bound
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
const lastItem = this.displayedElements.length - 1;
|
||||
const current = this.indexOfFocusedItem;
|
||||
|
||||
const updateIndex = (pos: number) => {
|
||||
event.preventDefault();
|
||||
this.indexOfFocusedItem = pos;
|
||||
this.highlightFocusedItem();
|
||||
this.currentElement?.focus();
|
||||
};
|
||||
|
||||
const setValueAndDispatch = () => {
|
||||
event.preventDefault();
|
||||
this.value = this.currentElement?.getAttribute("value") ?? undefined;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
};
|
||||
|
||||
const pageBy = (direction: number) => {
|
||||
const visibleElementCount =
|
||||
this.displayedElements.filter((element) =>
|
||||
isVisibleInScrollRegion(element, this.ul),
|
||||
).length - 1;
|
||||
return visibleElementCount * direction + current;
|
||||
};
|
||||
|
||||
match({ key })
|
||||
.with({ key: "ArrowDown" }, () => updateIndex(Math.min(current + 1, lastItem)))
|
||||
.with({ key: "ArrowUp" }, () => updateIndex(Math.max(current - 1, 0)))
|
||||
.with({ key: "PageDown" }, () => updateIndex(Math.min(pageBy(1), lastItem)))
|
||||
.with({ key: "PageUp" }, () => updateIndex(Math.max(pageBy(-1), 0)))
|
||||
.with({ key: "Home" }, () => updateIndex(0))
|
||||
.with({ key: "End" }, () => updateIndex(lastItem))
|
||||
.with({ key: " " }, () => setValueAndDispatch())
|
||||
.with({ key: "Enter" }, () => setValueAndDispatch());
|
||||
}
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValueMap<this>) {
|
||||
super.updated(changed);
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
private renderEmptyMenuItem() {
|
||||
return html`<li role="option" class="ak-select-item" part="ak-list-select-option">
|
||||
<button
|
||||
class="pf-c-dropdown__menu-item"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
@click=${() => this.onClick(undefined)}
|
||||
part="ak-list-select-button"
|
||||
>
|
||||
${this.emptyOption}
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
private renderMenuItems(options: SelectOption[]) {
|
||||
return options.map(
|
||||
([value, label, desc]: SelectOption) => html`
|
||||
<li
|
||||
role="option"
|
||||
value=${value}
|
||||
class="ak-select-item"
|
||||
part="ak-list-select-option"
|
||||
>
|
||||
<button
|
||||
class="pf-c-dropdown__menu-item pf-m-description"
|
||||
value="${value}"
|
||||
tabindex="0"
|
||||
@click=${() => this.onClick(value)}
|
||||
part="ak-list-select-button"
|
||||
>
|
||||
<div class="pf-c-dropdown__menu-item-main" part="ak-list-select-label">
|
||||
${label}
|
||||
</div>
|
||||
${desc
|
||||
? html`<div
|
||||
class="pf-c-dropdown__menu-item-description"
|
||||
part="ak-list-select-desc"
|
||||
>
|
||||
${desc}
|
||||
</div>`
|
||||
: nothing}
|
||||
</button>
|
||||
</li>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderMenuGroups(optionGroups: SelectGroup[]) {
|
||||
return optionGroups.map(
|
||||
({ name, options }) => html`
|
||||
<section class="pf-c-dropdown__group" part="ak-list-select-group">
|
||||
<h1 class="pf-c-dropdown__group-title" part="ak-list-select-group-title">
|
||||
${name}
|
||||
</h1>
|
||||
<ul>
|
||||
${this.renderMenuItems(options)}
|
||||
</ul>
|
||||
</section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
public override render() {
|
||||
return html`<div
|
||||
class="pf-c-dropdown pf-m-expanded"
|
||||
tabindex="1"
|
||||
part="ak-list-select-wrapper"
|
||||
>
|
||||
<ul
|
||||
class="pf-c-dropdown__menu pf-m-static"
|
||||
id="ak-list-select-list"
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
part="ak-list-select"
|
||||
>
|
||||
${this.emptyOption === undefined ? nothing : this.renderEmptyMenuItem()}
|
||||
${this._options.grouped
|
||||
? this.renderMenuGroups(this._options.options)
|
||||
: this.renderMenuItems(this._options.options)}
|
||||
</ul>
|
||||
</div> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-list-select": ListSelect;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { EVENT_MESSAGE } from "@goauthentik/common/constants";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { Meta, StoryObj } from "@storybook/web-components";
|
||||
import { slug } from "github-slugger";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../ak-list-select.js";
|
||||
import { ListSelect } from "../ak-list-select.js";
|
||||
import { groupedSampleData, sampleData } from "./sampleData.js";
|
||||
|
||||
const longGoodForYouPairs = {
|
||||
grouped: false,
|
||||
options: sampleData.map(({ produce }) => [slug(produce), produce]),
|
||||
};
|
||||
|
||||
const metadata: Meta<ListSelect> = {
|
||||
title: "Elements / List Select",
|
||||
component: "ak-list-select",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "A scrolling component from which elements can be selected",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
options: {
|
||||
type: "string",
|
||||
description: "An array of [key, label, desc] pairs of what to show",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
const sendMessage = (message: string) =>
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(EVENT_MESSAGE, { bubbles: true, composed: true, detail: { message } }),
|
||||
);
|
||||
|
||||
const container = (testItem: TemplateResult) => {
|
||||
window.setTimeout(() => {
|
||||
const menu = document.getElementById("ak-list-select");
|
||||
if (!menu) {
|
||||
throw new Error("Test was not initialized correctly.");
|
||||
}
|
||||
menu.addEventListener("focusin", () => sendMessage("Element received focus"));
|
||||
menu.addEventListener("blur", () => sendMessage("Element lost focus"));
|
||||
menu.addEventListener("change", (event: Event) =>
|
||||
sendMessage(`Value changed to: ${(event.target as HTMLInputElement)?.value}`),
|
||||
);
|
||||
}, 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>`;
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: () =>
|
||||
container(
|
||||
html` <ak-list-select
|
||||
id="ak-list-select"
|
||||
style="top: 1em; left: 1em"
|
||||
.options=${longGoodForYouPairs}
|
||||
></ak-list-select>`,
|
||||
),
|
||||
};
|
||||
|
||||
export const Grouped: Story = {
|
||||
render: () =>
|
||||
container(
|
||||
html` <ak-list-select
|
||||
id="ak-list-select"
|
||||
style="top: 1em; left: 1em"
|
||||
.options=${groupedSampleData}
|
||||
></ak-list-select>`,
|
||||
),
|
||||
};
|
||||
359
web/src/elements/ak-list-select/stories/sampleData.ts
Normal file
359
web/src/elements/ak-list-select/stories/sampleData.ts
Normal file
@ -0,0 +1,359 @@
|
||||
import { slug } from "github-slugger";
|
||||
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
// The descriptions were generated by ChatGPT. Don't blame us.
|
||||
|
||||
export type ViewSample = {
|
||||
produce: string;
|
||||
seasons: string[];
|
||||
desc?: string;
|
||||
};
|
||||
|
||||
export const sampleData: ViewSample[] = [
|
||||
{
|
||||
produce: "Apples",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Apples are a sweet and crunchy fruit that can be eaten fresh or used in pies, juice, and ciders.",
|
||||
},
|
||||
{
|
||||
produce: "Apricots",
|
||||
seasons: ["Spring", "Summer"],
|
||||
desc: "Apricots are a sweet and tangy stone fruit with a velvety skin that's often orange-yellow in color",
|
||||
},
|
||||
{
|
||||
produce: "Asparagus",
|
||||
seasons: ["Spring"],
|
||||
desc: "Asparagus is a delicate and nutritious vegetable with a tender spear-like shape",
|
||||
},
|
||||
{
|
||||
produce: "Avocados",
|
||||
seasons: ["Spring", "Summer", "Winter"],
|
||||
desc: "Avocados are a nutritious fruit with a creamy texture and nutty flavor",
|
||||
},
|
||||
{
|
||||
produce: "Bananas",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Bananas are a type of curved, yellow fruit that grows on banana plants",
|
||||
},
|
||||
{
|
||||
produce: "Beets",
|
||||
seasons: ["Summer", "Fall", "Winter"],
|
||||
desc: "Beets are a sweet and earthy root vegetable that can be pickled, roasted, or boiled",
|
||||
},
|
||||
{
|
||||
produce: "Bell Peppers",
|
||||
seasons: ["Summer", "Fall"],
|
||||
desc: "Bell peppers are a sweet and crunchy type of pepper that can be green, red, yellow, or orange",
|
||||
},
|
||||
{
|
||||
produce: "Blackberries",
|
||||
seasons: ["Summer"],
|
||||
desc: "Blackberries are a type of fruit that are dark purple in color and have a sweet-tart taste",
|
||||
},
|
||||
{
|
||||
produce: "Blueberries",
|
||||
seasons: ["Summer"],
|
||||
desc: "Blueberries are small, round, and sweet-tart berries with a powdery coating and a burst of juicy flavor.",
|
||||
},
|
||||
{
|
||||
produce: "Broccoli",
|
||||
seasons: ["Spring", "Fall"],
|
||||
desc: "Broccoli is a green, cruciferous vegetable with a tree-like shape and a slightly bitter taste.",
|
||||
},
|
||||
{
|
||||
produce: "Brussels Sprouts",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Brussels sprouts are a cruciferous vegetable that is small, green, and formed like a tiny cabbage head, with a sweet and slightly bitter flavor.",
|
||||
},
|
||||
{
|
||||
produce: "Cabbage",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Cabbage is a crunchy, sweet, and slightly bitter vegetable with a dense head of tightly packed leaves.",
|
||||
},
|
||||
{
|
||||
produce: "Cantaloupe",
|
||||
seasons: ["Summer"],
|
||||
desc: "Cantaloupe is a sweet and juicy melon with a netted or reticulated rind and yellow-orange flesh.",
|
||||
},
|
||||
{
|
||||
produce: "Carrots",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Carrots are a crunchy and sweet root vegetable commonly eaten raw or cooked in various dishes.",
|
||||
},
|
||||
{
|
||||
produce: "Cauliflower",
|
||||
seasons: ["Fall"],
|
||||
desc: "Cauliflower is a cruciferous vegetable with a white or pale yellow florets resembling tiny trees",
|
||||
},
|
||||
{
|
||||
produce: "Celery",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Celery is a crunchy, sweet-tasting vegetable with a mild flavor, often used in salads and as a snack.",
|
||||
},
|
||||
{
|
||||
produce: "Cherries",
|
||||
seasons: ["Summer"],
|
||||
desc: "Cherries are a sweet and juicy stone fruit that typically range in color from bright red to dark purple.",
|
||||
},
|
||||
{
|
||||
produce: "Collard Greens",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Collard greens are a type of leafy green vegetable with a slightly bitter and earthy flavor.",
|
||||
},
|
||||
{
|
||||
produce: "Corn",
|
||||
seasons: ["Summer"],
|
||||
desc: "Corn is a sweet and savory grain that can be eaten fresh or used in various dishes, such as soups, salads, and baked goods.",
|
||||
},
|
||||
{
|
||||
produce: "Cranberries",
|
||||
seasons: ["Fall"],
|
||||
desc: "Cranberries are a type of small, tart-tasting fruit native to North America",
|
||||
},
|
||||
{
|
||||
produce: "Cucumbers",
|
||||
seasons: ["Summer"],
|
||||
desc: "Cucumbers are a long, green vegetable that is commonly consumed raw or pickled",
|
||||
},
|
||||
{
|
||||
produce: "Eggplant",
|
||||
seasons: ["Summer"],
|
||||
desc: "Eggplant is a purple vegetable with a spongy texture and a slightly bitter taste.",
|
||||
},
|
||||
{
|
||||
produce: "Garlic",
|
||||
seasons: ["Spring", "Summer", "Fall"],
|
||||
desc: "Garlic is a pungent and flavorful herb with a distinctive aroma and taste",
|
||||
},
|
||||
{
|
||||
produce: "Ginger",
|
||||
seasons: ["Fall"],
|
||||
desc: "Ginger is a spicy, sweet, and tangy root commonly used in Asian cuisine to add warmth and depth",
|
||||
},
|
||||
{
|
||||
produce: "Grapefruit",
|
||||
seasons: ["Winter"],
|
||||
desc: "Grapefruit is a tangy and sweet citrus fruit with a tart flavor profile and a slightly bitter aftertaste.",
|
||||
},
|
||||
{
|
||||
produce: "Grapes",
|
||||
seasons: ["Fall"],
|
||||
desc: "Grapes are a type of fruit that grow in clusters on vines and are often eaten fresh or used to make wine, jam, and juice.",
|
||||
},
|
||||
{
|
||||
produce: "Green Beans",
|
||||
seasons: ["Summer", "Fall"],
|
||||
desc: "Green beans are a type of long, thin, green vegetable that is commonly eaten as a side dish or used in various recipes.",
|
||||
},
|
||||
{
|
||||
produce: "Herbs",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Herbs are plant parts, such as leaves, stems, or flowers, used to add flavor or aroma",
|
||||
},
|
||||
{
|
||||
produce: "Honeydew Melon",
|
||||
seasons: ["Summer"],
|
||||
desc: "Honeydew melons are sweet and refreshing, with a smooth, pale green rind and juicy, creamy white flesh.",
|
||||
},
|
||||
{
|
||||
produce: "Kale",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Kale is a type of leafy green vegetable that is packed with nutrients and has a slightly bitter, earthy flavor.",
|
||||
},
|
||||
{
|
||||
produce: "Kiwifruit",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Kiwifruit is a small, oval-shaped fruit with a fuzzy exterior and bright green or yellow flesh that tastes sweet and slightly tart.",
|
||||
},
|
||||
{
|
||||
produce: "Leeks",
|
||||
seasons: ["Winter"],
|
||||
desc: "Leeks are a type of vegetable that is similar to onions and garlic, but has a milder flavor and a more delicate texture.",
|
||||
},
|
||||
{
|
||||
produce: "Lemons",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Lemons are a sour and tangy citrus fruit with a bright yellow color and a strong, distinctive flavor used in cooking, cleaning, and as a natural remedy.",
|
||||
},
|
||||
{
|
||||
produce: "Lettuce",
|
||||
seasons: ["Spring", "Fall"],
|
||||
desc: "Lettuce is a crisp and refreshing green leafy vegetable often used in salads.",
|
||||
},
|
||||
{
|
||||
produce: "Lima Beans",
|
||||
seasons: ["Summer"],
|
||||
desc: "Lima beans are a type of green legume with a mild flavor and soft, creamy texture.",
|
||||
},
|
||||
{
|
||||
produce: "Limes",
|
||||
seasons: ["Spring", "Summer", "Fall", "Winter"],
|
||||
desc: "Limes are small, citrus fruits with a sour taste and a bright green color.",
|
||||
},
|
||||
{
|
||||
produce: "Mangos",
|
||||
seasons: ["Summer", "Fall"],
|
||||
desc: "Mangos are sweet and creamy tropical fruits with a velvety texture",
|
||||
},
|
||||
{
|
||||
produce: "Mushrooms",
|
||||
seasons: ["Spring", "Fall"],
|
||||
desc: "Mushrooms are a type of fungus that grow underground or on decaying organic matter",
|
||||
},
|
||||
{
|
||||
produce: "Okra",
|
||||
seasons: ["Summer"],
|
||||
desc: "Okra is a nutritious, green vegetable with a unique texture and flavor",
|
||||
},
|
||||
{
|
||||
produce: "Onions",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Onions are a type of vegetable characterized by their layered, bulbous structure and pungent flavor.",
|
||||
},
|
||||
{
|
||||
produce: "Oranges",
|
||||
seasons: ["Winter"],
|
||||
desc: "Oranges are a sweet and juicy citrus fruit with a thick, easy-to-peel skin.",
|
||||
},
|
||||
{
|
||||
produce: "Parsnips",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Parsnips are a type of root vegetable that is sweet and nutty in flavor, with a texture similar to carrots.",
|
||||
},
|
||||
{
|
||||
produce: "Peaches",
|
||||
seasons: ["Summer"],
|
||||
desc: "Peaches are sweet and juicy stone fruits with a soft, velvety texture.",
|
||||
},
|
||||
{
|
||||
produce: "Pears",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Pears are a type of sweet and juicy fruit with a smooth, buttery texture and a mild flavor",
|
||||
},
|
||||
{
|
||||
produce: "Peas",
|
||||
seasons: ["Spring", "Fall"],
|
||||
desc: "Peas are small, round, sweet-tasting legumes that grow on vines and are often eaten as a side dish or added to various recipes.",
|
||||
},
|
||||
{
|
||||
produce: "Pineapples",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Pineapples are a tropical fruit with tough, prickly skin and juicy, sweet flesh.",
|
||||
},
|
||||
{
|
||||
produce: "Plums",
|
||||
seasons: ["Summer"],
|
||||
desc: "Plums are a type of stone fruit characterized by their juicy sweetness and rough, dark skin.",
|
||||
},
|
||||
{
|
||||
produce: "Potatoes",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Potatoes are a starchy root vegetable that is often brown on the outside and white or yellow on the inside.",
|
||||
},
|
||||
{
|
||||
produce: "Pumpkin",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Pumpkin is a type of squash that is typically orange in color and is often used to make pies, soups, and other sweet or savory dishes.",
|
||||
},
|
||||
{
|
||||
produce: "Radishes",
|
||||
seasons: ["Spring", "Fall"],
|
||||
desc: "Radishes are a pungent, crunchy and spicy root vegetable that can be eaten raw or cooked,",
|
||||
},
|
||||
{
|
||||
produce: "Raspberries",
|
||||
seasons: ["Summer", "Fall"],
|
||||
desc: "Raspberries are a type of sweet-tart fruit that grows on thorny bushes and is often eaten fresh or used in jams, preserves, and desserts.",
|
||||
},
|
||||
{
|
||||
produce: "Rhubarb",
|
||||
seasons: ["Spring"],
|
||||
desc: "Rhubarb is a perennial vegetable with long, tart stalks that are often used in pies and preserves",
|
||||
},
|
||||
{
|
||||
produce: "Rutabagas",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Rutabagas are a type of root vegetable that is similar to a cross between a cabbage and a turnip",
|
||||
},
|
||||
{
|
||||
produce: "Spinach",
|
||||
seasons: ["Spring", "Fall"],
|
||||
desc: "Spinach is a nutritious leafy green vegetable that is rich in iron and vitamins A, C, and K.",
|
||||
},
|
||||
{
|
||||
produce: "Strawberries",
|
||||
seasons: ["Spring", "Summer"],
|
||||
desc: "Sweet and juicy, strawberries are a popular type of fruit that grow on low-lying plants with sweet-tasting seeds.",
|
||||
},
|
||||
{
|
||||
produce: "Summer Squash",
|
||||
seasons: ["Summer"],
|
||||
desc: "Summer squash is a type of warm-season vegetable that includes varieties like zucchini, yellow crookneck, and straightneck",
|
||||
},
|
||||
{
|
||||
produce: "Sweet Potatoes",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Sweet potatoes are a type of root vegetable with a sweet and nutty flavor, often orange in color",
|
||||
},
|
||||
{
|
||||
produce: "Swiss Chard",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Swiss Chard is a leafy green vegetable with a slightly bitter taste and a vibrant red or gold stem",
|
||||
},
|
||||
{
|
||||
produce: "Tomatillos",
|
||||
seasons: ["Summer"],
|
||||
desc: "Tomatillos are a type of fruit that is similar to tomatoes, but with a papery husk and a more tart, slightly sweet flavor.",
|
||||
},
|
||||
{
|
||||
produce: "Tomatoes",
|
||||
seasons: ["Summer"],
|
||||
desc: "Tomatoes are a juicy, sweet, and tangy fruit that is commonly used in salads, sandwiches, and as a topping for various dishes.",
|
||||
},
|
||||
{
|
||||
produce: "Turnips",
|
||||
seasons: ["Spring", "Fall", "Winter"],
|
||||
desc: "Turnips are a root vegetable with a sweet and peppery flavor, often used in soups, stews, and salads.",
|
||||
},
|
||||
{
|
||||
produce: "Watermelon",
|
||||
seasons: ["Summer"],
|
||||
desc: "Watermelon is a juicy and refreshing sweet fruit with a green rind and pink or yellow flesh.",
|
||||
},
|
||||
{
|
||||
produce: "Winter Squash",
|
||||
seasons: ["Fall", "Winter"],
|
||||
desc: "Winter squash is a type of starchy vegetable that is harvested in the fall and has a hard, dry rind that can be stored for several months.",
|
||||
},
|
||||
{
|
||||
produce: "Zucchini",
|
||||
seasons: ["Summer"],
|
||||
desc: "Zucchini is a popular summer squash that is often green or yellow in color and has a mild, slightly sweet flavor.",
|
||||
},
|
||||
];
|
||||
|
||||
type Seasoned = [string, string, string | TemplateResult];
|
||||
|
||||
const reseason = (acc: Seasoned[], { produce, seasons, desc }: ViewSample): Seasoned[] => [
|
||||
...acc,
|
||||
...seasons.map((s) => [s, produce, desc] as Seasoned),
|
||||
];
|
||||
|
||||
export const groupedSampleData = (() => {
|
||||
const seasoned: Seasoned[] = sampleData.reduce(reseason, [] as Seasoned[]);
|
||||
const grouped = Object.groupBy(seasoned, ([season]) => season);
|
||||
const ungrouped = ([_season, label, desc]: Seasoned) => [slug(label), label, desc];
|
||||
|
||||
if (grouped === undefined) {
|
||||
throw new Error("Not possible with existing data.");
|
||||
}
|
||||
|
||||
return {
|
||||
grouped: true,
|
||||
options: ["Spring", "Summer", "Fall", "Winter"].map((season) => ({
|
||||
name: season,
|
||||
options: grouped[season]?.map(ungrouped) ?? [],
|
||||
})),
|
||||
};
|
||||
})();
|
||||
17
web/src/elements/ak-list-select/utils.ts
Normal file
17
web/src/elements/ak-list-select/utils.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { GroupedOptions, SelectOptions } from "@goauthentik/elements/types";
|
||||
|
||||
export function isVisibleInScrollRegion(el: HTMLElement, container: HTMLElement) {
|
||||
const elTop = el.offsetTop;
|
||||
const elBottom = elTop + el.clientHeight;
|
||||
const containerTop = container.scrollTop;
|
||||
const containerBottom = containerTop + container.clientHeight;
|
||||
return (
|
||||
(elTop >= containerTop && elBottom <= containerBottom) ||
|
||||
(elTop < containerTop && containerTop < elBottom) ||
|
||||
(elTop < containerBottom && containerBottom < elBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupOptions(options: SelectOptions): GroupedOptions {
|
||||
return Array.isArray(options) ? { grouped: false, options: options } : options;
|
||||
}
|
||||
Reference in New Issue
Block a user