web/admin: Dual select state management, custom event dispatching. (#14490)

* web/admin: Fix issues surrounding dual select state management.

* web: Fix nested path.

* web: Use PatternFly variable.
This commit is contained in:
Teffen Ellis
2025-05-15 14:47:47 +02:00
committed by GitHub
parent 7440900dac
commit e40c5ac617
21 changed files with 671 additions and 581 deletions

View File

@ -10,6 +10,7 @@ import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import { SidebarToggleEventDetail } from "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
@ -36,7 +37,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
import { SidebarToggleEventDetail } from "../../elements/PageHeader.js";
import {
AdminSidebarEnterpriseEntries,
AdminSidebarEntries,

View File

@ -45,9 +45,9 @@ const providerListArgs = (page: number, search = "") => ({
});
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => {
const label = item.assignedBackchannelApplicationName
? item.assignedBackchannelApplicationName
: item.assignedApplicationName;
const label =
item.assignedBackchannelApplicationName || item.assignedApplicationName || item.name;
return [
`${item.pk}`,
html`<div class="selection-main">${label}</div>

View File

@ -15,7 +15,7 @@ import { DetailedCountry, GeoIPPolicy, PoliciesApi } from "@goauthentik/api";
import { countryCache } from "./CountryCache";
function countryToPair(country: DetailedCountry): DualSelectPair {
return [country.code, country.name];
return [country.code, country.name, country.name];
}
@customElement("ak-policy-geoip-form")
@ -210,17 +210,16 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
.getCountries()
.then((results) => {
if (!search) return results;
return results.filter((result) =>
result.name
.toLowerCase()
.includes(search.toLowerCase()),
);
})
.then((results) => {
return {
.then((results) => ({
options: results.map(countryToPair),
};
});
}));
}}
.selected=${(this.instance?.countriesObj ?? []).map(countryToPair)}
available-label="${msg("Available Countries")}"

View File

@ -12,7 +12,6 @@ import type { DualSelectPair } from "./types.js";
* A top-level component for multi-select elements have dynamically generated "selected"
* lists.
*/
@customElement("ak-dual-select-dynamic-selected")
export class AkDualSelectDynamic extends AkDualSelectProvider {
/**
@ -23,21 +22,25 @@ export class AkDualSelectDynamic extends AkDualSelectProvider {
* @attr
*/
@property({ attribute: false })
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = async (_) => Promise.resolve([]);
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = () => Promise.resolve([]);
private firstUpdateHasRun = false;
#didFirstUpdate = false;
willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// On the first update *only*, even before rendering, when the options are handed up, update
// the selected list with the contents derived from the selector.
if (!this.firstUpdateHasRun && this.options.length > 0) {
this.firstUpdateHasRun = true;
if (this.#didFirstUpdate) return;
if (this.options.length === 0) return;
this.#didFirstUpdate = true;
this.selector(this.options).then((selected) => {
this.selected = selected;
});
}
}
render() {
return html`<ak-dual-select

View File

@ -1,18 +1,16 @@
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter.js";
import { msg } from "@lit/localize";
import { PropertyValues, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
import type { Pagination } from "@goauthentik/api";
import "./ak-dual-select";
import { AkDualSelect } from "./ak-dual-select";
import type { DataProvider, DualSelectPair } from "./types";
import "./ak-dual-select.js";
import { AkDualSelect } from "./ak-dual-select.js";
import { type DataProvider, DualSelectEventType, type DualSelectPair } from "./types.js";
/**
* @element ak-dual-select-provider
@ -22,18 +20,19 @@ import type { DataProvider, DualSelectPair } from "./types";
* between authentik and the generic ak-dual-select component; aside from knowing that
* the Pagination object "looks like Django," the interior components don't know anything
* about authentik at all and could be dropped into Gravity unchanged.)
*
*/
@customElement("ak-dual-select-provider")
export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) {
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
* the "Available" pane.
//#region Properties
/**
* A function that takes a page and returns the {@linkcode DualSelectPair DualSelectPair[]}
* collection with which to update the "Available" pane.
*
* @attr
*/
@property({ type: Object })
provider!: DataProvider;
public provider!: DataProvider;
/**
* The list of selected items. This is the *complete* list, not paginated, as presented by a
@ -42,7 +41,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ type: Array })
selected: DualSelectPair[] = [];
public selected: DualSelectPair[] = [];
/**
* The label for the left ("available") pane
@ -50,7 +49,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ attribute: "available-label" })
availableLabel = msg("Available options");
public availableLabel = msg("Available options");
/**
* The label for the right ("selected") pane
@ -58,7 +57,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ attribute: "selected-label" })
selectedLabel = msg("Selected options");
public selectedLabel = msg("Selected options");
/**
* The debounce for the search as the user is typing in a request
@ -66,103 +65,125 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ attribute: "search-delay", type: Number })
searchDelay = 250;
public searchDelay = 250;
public get value() {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
public json() {
return this.value;
}
//#endregion
//#region State
@state()
options: DualSelectPair[] = [];
protected options: DualSelectPair[] = [];
protected dualSelector: Ref<AkDualSelect> = createRef();
#loading = false;
protected isLoading = false;
#didFirstUpdate = false;
#selected: DualSelectPair[] = [];
private doneFirstUpdate = false;
private internalSelected: DualSelectPair[] = [];
#previousSearchValue = "";
protected pagination?: Pagination;
constructor() {
super();
setTimeout(() => this.fetch(1), 0);
this.onNav = this.onNav.bind(this);
this.onChange = this.onChange.bind(this);
this.onSearch = this.onSearch.bind(this);
this.addCustomListener("ak-pagination-nav-to", this.onNav);
this.addCustomListener("ak-dual-select-change", this.onChange);
this.addCustomListener("ak-dual-select-search", this.onSearch);
//#endregion
//#region Refs
protected dualSelector = createRef<AkDualSelect>();
//#endregion
//#region Lifecycle
public connectedCallback(): void {
super.connectedCallback();
this.addCustomListener(DualSelectEventType.NavigateTo, this.#navigationListener);
this.addCustomListener(DualSelectEventType.Change, this.#changeListener);
this.addCustomListener(DualSelectEventType.Search, this.#searchListener);
this.#fetch(1);
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected") && !this.doneFirstUpdate) {
this.doneFirstUpdate = true;
this.internalSelected = this.selected;
}
if (changedProperties.has("searchDelay")) {
this.doSearch = debounce(
AkDualSelectProvider.prototype.doSearch.bind(this),
this.searchDelay,
);
if (changedProperties.has("selected") && !this.#didFirstUpdate) {
this.#didFirstUpdate = true;
this.#selected = this.selected;
}
if (changedProperties.has("provider")) {
this.pagination = undefined;
this.fetch();
this.#previousSearchValue = "";
this.#fetch();
}
}
async fetch(page?: number, search = "") {
if (this.isLoading) {
return;
}
this.isLoading = true;
const goto = page ?? this.pagination?.current ?? 1;
const data = await this.provider(goto, search);
//#endregion
//#region Private Methods
#fetch = async (page?: number, search = this.#previousSearchValue): Promise<void> => {
if (this.#loading) return;
this.#previousSearchValue = search;
this.#loading = true;
page ??= this.pagination?.current ?? 1;
return this.provider(page, search)
.then((data) => {
this.pagination = data.pagination;
this.options = data.options;
this.isLoading = false;
}
})
.catch((error) => {
console.error(error);
})
.finally(() => {
this.#loading = false;
});
};
onNav(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
}
this.fetch(event.detail);
}
//#endregion
onChange(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.internalSelected = event.detail.value;
this.selected = this.internalSelected;
}
//#region Event Listeners
onSearch(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.doSearch(event.detail);
}
#navigationListener = (event: CustomEvent<number>) => {
this.#fetch(event.detail, this.#previousSearchValue);
};
doSearch(search: string) {
#changeListener = (event: CustomEvent<{ value: DualSelectPair[] }>) => {
this.#selected = event.detail.value;
this.selected = this.#selected;
};
#searchListener = (event: CustomEvent<string>) => {
this.#doSearch(event.detail);
};
#searchTimeoutID?: ReturnType<typeof setTimeout>;
#doSearch = (search: string) => {
clearTimeout(this.#searchTimeoutID);
setTimeout(() => {
this.pagination = undefined;
this.fetch(undefined, search);
}
this.#fetch(undefined, search);
}, this.searchDelay);
};
get value() {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
json() {
return this.value;
}
//#endregion
render() {
return html`<ak-dual-select
${ref(this.dualSelector)}
.options=${this.options}
.pages=${this.pagination}
.selected=${this.internalSelected}
.selected=${this.#selected}
available-label=${this.availableLabel}
selected-label=${this.selectedLabel}
></ak-dual-select>`;

View File

@ -3,6 +3,7 @@ import {
CustomEmitterElement,
CustomListenerElement,
} from "@goauthentik/elements/utils/eventEmitter";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { PropertyValues, html, nothing } from "lit";
@ -15,34 +16,41 @@ import { globalVariables, mainStyles } from "./components/styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./components/ak-dual-select-available-pane";
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane";
import "./components/ak-dual-select-controls";
import "./components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
import "./components/ak-pagination";
import "./components/ak-search-bar";
import "./components/ak-dual-select-available-pane.js";
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane.js";
import "./components/ak-dual-select-controls.js";
import "./components/ak-dual-select-selected-pane.js";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane.js";
import "./components/ak-pagination.js";
import "./components/ak-search-bar.js";
import {
EVENT_ADD_ALL,
EVENT_ADD_ONE,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_ONE,
EVENT_REMOVE_SELECTED,
} from "./constants";
import type { BasePagination, DualSelectPair, SearchbarEvent } from "./types";
BasePagination,
DualSelectEventType,
DualSelectPair,
SearchbarEventDetail,
SearchbarEventSource,
} from "./types.js";
function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
return l < r ? -1 : l > r ? 1 : 0;
function localeComparator(a: DualSelectPair, b: DualSelectPair) {
const aSortBy = a[2];
const bSortBy = b[2];
return aSortBy.localeCompare(bSortBy);
}
function mapDualPairs(pairs: DualSelectPair[]) {
return new Map(pairs.map(([k, v, _]) => [k, v]));
function keyfinder(key: string) {
return ([k]: DualSelectPair) => k === key;
}
const styles = [PFBase, PFButton, globalVariables, mainStyles];
const DelegatedEvents = [
DualSelectEventType.AddSelected,
DualSelectEventType.RemoveSelected,
DualSelectEventType.AddAll,
DualSelectEventType.RemoveAll,
DualSelectEventType.DeleteAll,
DualSelectEventType.AddOne,
DualSelectEventType.RemoveOne,
] as const satisfies DualSelectEventType[];
/**
* @element ak-dual-select
@ -53,24 +61,25 @@ const styles = [PFBase, PFButton, globalVariables, mainStyles];
*
* @fires ak-dual-select-change - A custom change event with the current `selected` list.
*/
const keyfinder =
(key: string) =>
([k]: DualSelectPair) =>
k === key;
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() {
return styles;
}
static styles = [PFBase, PFButton, globalVariables, mainStyles];
/* The list of options to *currently* show. Note that this is not *all* the options, only the
* currently shown list of options from a pagination collection. */
//#region Properties
/**
* The list of options to *currently* show.
*
* Note that this is not *all* the options,
* only the currently shown list of options from a pagination collection.
*/
@property({ type: Array })
options: DualSelectPair[] = [];
/* The list of options selected. This is the *entire* list and will not be paginated. */
/**
* The list of options selected.
* This is the *entire* list and will not be paginated.
*/
@property({ type: Array })
selected: DualSelectPair[] = [];
@ -83,138 +92,133 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
@property({ attribute: "selected-label" })
selectedLabel = msg("Selected options");
//#endregion
//#region State
@state()
selectedFilter: string = "";
protected selectedFilter: string = "";
#selectedKeys: Set<string> = new Set();
//#endregion
//#region Refs
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
selectedKeys: Set<string> = new Set();
//#endregion
//#region Lifecycle
constructor() {
super();
this.handleMove = this.handleMove.bind(this);
this.handleSearch = this.handleSearch.bind(this);
[
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
EVENT_ADD_ONE,
EVENT_REMOVE_ONE,
].forEach((eventName: string) => {
this.addCustomListener(eventName, (event: Event) => this.handleMove(eventName, event));
});
for (const eventName of DelegatedEvents) {
this.addCustomListener(eventName, this.#moveListener);
}
this.addCustomListener("ak-dual-select-move", () => {
this.requestUpdate();
});
this.addCustomListener("ak-search", this.handleSearch);
this.addCustomListener("ak-search", this.#searchListener);
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected")) {
this.selectedKeys = new Set(this.selected.map(([key, _]) => key));
this.#selectedKeys = new Set(this.selected.map(([key]) => key));
}
// Pagination invalidates available moveables.
if (changedProperties.has("options") && this.availablePane.value) {
this.availablePane.value.clearMove();
}
}
handleMove(eventName: string, event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expected move event here, got ${eventName}`);
}
//#endregion
switch (eventName) {
case EVENT_ADD_SELECTED: {
this.addSelected();
break;
}
case EVENT_REMOVE_SELECTED: {
this.removeSelected();
break;
}
case EVENT_ADD_ALL: {
this.addAllVisible();
break;
}
case EVENT_REMOVE_ALL: {
this.removeAllVisible();
break;
}
case EVENT_DELETE_ALL: {
this.removeAll();
break;
}
case EVENT_ADD_ONE: {
this.addOne(event.detail);
break;
}
case EVENT_REMOVE_ONE: {
this.removeOne(event.detail);
break;
}
//#region Event Listeners
#moveListener = (event: CustomEvent<string>) => {
match(event.type)
.with(DualSelectEventType.AddSelected, () => this.addSelected())
.with(DualSelectEventType.RemoveSelected, () => this.removeSelected())
.with(DualSelectEventType.AddAll, () => this.addAllVisible())
.with(DualSelectEventType.RemoveAll, () => this.removeAllVisible())
.with(DualSelectEventType.DeleteAll, () => this.removeAll())
.with(DualSelectEventType.AddOne, () => this.addOne(event.detail))
.with(DualSelectEventType.RemoveOne, () => this.removeOne(event.detail))
.otherwise(() => {
throw new Error(`Expected move event here, got ${event.type}`);
});
this.dispatchCustomEvent(DualSelectEventType.Change, { value: this.value });
default:
throw new Error(
`AkDualSelect.handleMove received unknown event type: ${eventName}`,
);
}
this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
event.stopPropagation();
}
};
protected addSelected() {
if (this.availablePane.value!.moveable.length === 0) return;
addSelected() {
if (this.availablePane.value!.moveable.length === 0) {
return;
}
this.selected = this.availablePane.value!.moveable.reduce(
(acc, key) => {
const value = this.options.find(keyfinder(key));
return value && !acc.find(keyfinder(value[0])) ? [...acc, value] : acc;
},
[...this.selected],
);
// This is where the information gets... lossy. Dammit.
this.availablePane.value!.clearMove();
}
addOne(key: string) {
protected addOne(key: string) {
const requested = this.options.find(keyfinder(key));
if (requested && !this.selected.find(keyfinder(requested[0]))) {
if (!requested) return;
if (this.selected.find(keyfinder(requested[0]))) return;
this.selected = [...this.selected, requested];
}
}
// These are the *currently visible* options; the parent node is responsible for paginating and
// updating the list of currently visible options;
addAllVisible() {
protected addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = mapDualPairs([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries());
const selected = new Map<string, DualSelectPair>([
...this.options.map((pair) => [pair[0], pair] as const),
...this.selected.map((pair) => [pair[0], pair] as const),
]);
this.selected = Array.from(selected.values());
this.availablePane.value!.clearMove();
}
removeSelected() {
if (this.selectedPane.value!.moveable.length === 0) {
return;
}
protected removeSelected() {
if (this.selectedPane.value!.moveable.length === 0) return;
const deselected = new Set(this.selectedPane.value!.moveable);
this.selected = this.selected.filter(([key]) => !deselected.has(key));
this.selectedPane.value!.clearMove();
}
removeOne(key: string) {
protected removeOne(key: string) {
this.selected = this.selected.filter(([k]) => k !== key);
}
removeAllVisible() {
protected removeAllVisible() {
// Remove all the items from selected that are in the *currently visible* options list
const options = new Set(this.options.map(([k, _]) => k));
const options = new Set(this.options.map(([k]) => k));
this.selected = this.selected.filter(([k]) => !options.has(k));
this.selectedPane.value!.clearMove();
}
@ -223,24 +227,25 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove();
}
handleSearch(event: SearchbarEvent) {
switch (event.detail.source) {
case "ak-dual-list-available-search":
return this.handleAvailableSearch(event.detail.value);
case "ak-dual-list-selected-search":
return this.handleSelectedSearch(event.detail.value);
}
event.stopPropagation();
}
#searchListener = (event: CustomEvent<SearchbarEventDetail>) => {
const { source, value } = event.detail;
handleAvailableSearch(value: string) {
this.dispatchCustomEvent("ak-dual-select-search", value);
}
handleSelectedSearch(value: string) {
match(source)
.with(SearchbarEventSource.Available, () => {
this.dispatchCustomEvent(DualSelectEventType.Search, value);
})
.with(SearchbarEventSource.Selected, () => {
this.selectedFilter = value;
this.selectedPane.value!.clearMove();
}
})
.exhaustive();
event.stopPropagation();
};
//#endregion
//#region Public Getters
get value() {
return this.selected;
@ -251,7 +256,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
// added.
const allMoved =
this.options.length ===
this.options.filter(([key, _]) => this.selectedKeys.has(key)).length;
this.options.filter(([key, _]) => this.#selectedKeys.has(key)).length;
return this.options.length > 0 && !allMoved;
}
@ -259,7 +264,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
get canRemoveAll() {
// False if no visible option can be found in the selected list
return (
this.options.length > 0 && !!this.options.find(([key, _]) => this.selectedKeys.has(key))
this.options.length > 0 &&
!!this.options.find(([key, _]) => this.#selectedKeys.has(key))
);
}
@ -267,6 +273,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
return (this.pages?.next ?? 0) > 0 || (this.pages?.previous ?? 0) > 0;
}
//#endregion
//#region Render
render() {
const selected =
this.selectedFilter === ""
@ -282,11 +292,15 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
const availableCount = this.availablePane.value?.toMove.size ?? 0;
const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
const selectedTotal = selected.length;
const availableStatus =
availableCount > 0 ? msg(str`${availableCount} item(s) marked to add.`) : "&nbsp;";
const selectedTotalStatus = msg(str`${selectedTotal} item(s) selected.`);
const selectedCountStatus =
selectedCount > 0 ? " " + msg(str`${selectedCount} item(s) marked to remove.`) : "";
const selectedStatus = `${selectedTotalStatus} ${selectedCountStatus}`;
return html`
@ -310,7 +324,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-available-pane
${ref(this.availablePane)}
.options=${this.options}
.selected=${this.selectedKeys}
.selected=${this.#selectedKeys}
></ak-dual-select-available-pane>
${this.needPagination
? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
@ -344,12 +358,14 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-selected-pane
${ref(this.selectedPane)}
.selected=${selected.toSorted(alphaSort)}
.selected=${selected.toSorted(localeComparator)}
></ak-dual-select-selected-pane>
</div>
</div>
`;
}
//#endregion
}
declare global {

View File

@ -1,26 +1,24 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html, nothing } from "lit";
import { PropertyValues, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { availablePaneStyles, listStyles } from "./styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_ADD_ONE } from "../constants";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
import { DualSelectEventType, DualSelectPair } from "../types.js";
const hostAttributes = [
["aria-labelledby", "dual-list-selector-available-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
] as const satisfies Array<[string, string]>;
/**
* @element ak-dual-select-available-panel
@ -37,81 +35,109 @@ const hostAttributes = [
*
* It is not expected that the `ak-dual-select-available-move-changed` event will be used; instead,
* the attribute will be read by the parent when a control is clicked.
*
*/
@customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEventType>(
AKElement,
) {
static styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
//#region Properties
/* The array of key/value pairs this pane is currently showing */
@property({ type: Array })
readonly options: DualSelectPair[] = [];
/* A set (set being easy for lookups) of keys with all the pairs selected, so that the ones
* currently being shown that have already been selected can be marked and their clicks ignored.
*
/**
* A set (set being easy for lookups) of keys with all the pairs selected,
* so that the ones currently being shown that have already been selected
* can be marked and their clicks ignored.
*/
@property({ type: Object })
readonly selected: Set<string> = new Set();
/* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
//#endregion
//#region State
/**
* This is the only mutator for this object.
* It collects the list of objects the user has clicked on *in this pane*.
*
* It is explicitly marked as "public" to emphasize that the parent orchestrator
* for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*/
@state()
public toMove: Set<string> = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
//#endregion
//#region Refs
protected listRef = createRef<HTMLDivElement>();
//#region Lifecycle
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
for (const [attr, value] of hostAttributes) {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
}
clearMove() {
protected updated(changed: PropertyValues<this>) {
if (changed.has("options")) {
this.listRef.value?.scrollTo(0, 0);
}
}
//#region Public API
public clearMove() {
this.toMove = new Set();
}
onClick(key: string) {
if (this.selected.has(key)) {
return;
}
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
"ak-dual-select-available-move-changed",
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_ADD_ONE, key);
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
//#endregion
//#region Event Listeners
#clickListener(key: string): void {
if (this.selected.has(key)) return;
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
DualSelectEventType.MoveChanged,
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent(DualSelectEventType.Move);
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
#moveListener(key: string): void {
this.toMove.delete(key);
this.dispatchCustomEvent(DualSelectEventType.AddOne, key);
this.requestUpdate();
}
//#region Render
// DO NOT use `Array.map()` instead of Lit's `map()` function. Lit's `map()` is object-aware and
// will not re-arrange or reconstruct the list automatically if the actual sources do not
// change; this allows the available pane to illustrate selected items with the checkmark
@ -119,17 +145,18 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<div ${ref(this.listRef)} class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
@click=${() => this.#clickListener(key)}
@dblclick=${() => this.#moveListener(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
@ -154,6 +181,8 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
</div>
`;
}
//#endregion
}
export default AkDualSelectAvailablePane;

View File

@ -8,15 +8,18 @@ import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
} from "../constants";
import { DualSelectEventType } from "../types.js";
const styles = [
/**
* @element ak-dual-select-controls
*
* The "control box" for a dual-list multi-select. It's controlled by the parent orchestrator as to
* whether or not any of its controls are enabled. It sends a variety of messages to the parent
* orchestrator which will then reconcile the "available" and "selected" panes at need.
*/
@customElement("ak-dual-select-controls")
export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static styles = [
PFBase,
PFButton,
css`
@ -26,7 +29,7 @@ const styles = [
padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft);
}
.pf-c-dual-list-selector {
max-width: 4rem;
max-width: calc(var(--pf-global--spacer-md, 1rem) * 4);
}
.ak-dual-list-selector__controls {
display: grid;
@ -35,72 +38,62 @@ const styles = [
height: 100%;
}
`,
];
];
/**
* @element ak-dual-select-controls
*
* The "control box" for a dual-list multi-select. It's controlled by the parent orchestrator as to
* whether or not any of its controls are enabled. It sends a variety of messages to the parent
* orchestrator which will then reconcile the "available" and "selected" panes at need.
*
*/
@customElement("ak-dual-select-controls")
export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
/* Set to true if any *visible* elements can be added to the selected list
/**
* Set to true if any *visible* elements can be added to the selected list.
*/
@property({ attribute: "add-active", type: Boolean })
addActive = false;
/* Set to true if any elements can be removed from the selected list (essentially,
/**
* Set to true if any elements can be removed from the selected list (essentially,
* if the selected list is not empty)
*/
@property({ attribute: "remove-active", type: Boolean })
removeActive = false;
/* Set to true if *all* the currently visible elements can be moved
/**
* Set to true if *all* the currently visible elements can be moved
* into the selected list (essentially, if any visible elements are
* not currently selected)
* not currently selected).
*/
@property({ attribute: "add-all-active", type: Boolean })
addAllActive = false;
/* Set to true if *any* of the elements currently visible in the available
/**
* Set to true if *any* of the elements currently visible in the available
* pane are available to be moved to the selected list, enabling that
* all of those specific elements be moved out of the selected list
* all of those specific elements be moved out of the selected list.
*/
@property({ attribute: "remove-all-active", type: Boolean })
removeAllActive = false;
/* if deleteAll is enabled, set to true to show that there are elements in the
/**
* if deleteAll is enabled, set to true to show that there are elements in the
* selected list that can be deleted.
*/
@property({ attribute: "delete-all-active", type: Boolean })
enableDeleteAll = false;
/* Set to true if you want the `...AllActive` buttons made available. */
/**
* Set to true if you want the `...AllActive` buttons made available.
*/
@property({ attribute: "enable-select-all", type: Boolean })
selectAll = false;
/* Set to true if you want the `ClearAllSelected` button made available */
/**
* Set to true if you want the `ClearAllSelected` button made available
*/
@property({ attribute: "enable-delete-all", type: Boolean })
deleteAll = false;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(eventName: string) {
this.dispatchCustomEvent(eventName);
}
renderButton(label: string, event: string, active: boolean, direction: string) {
renderButton(
label: string,
eventType: DualSelectEventType,
active: boolean,
direction: string,
) {
return html`
<div class="pf-c-dual-list-selector__controls-item">
<button
@ -109,7 +102,7 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
aria-label=${label}
class="pf-c-button pf-m-plain"
type="button"
@click=${() => this.onClick(event)}
@click=${() => this.dispatchCustomEvent(eventType)}
data-ouia-component-type="AK/Button"
>
<i class="fa ${direction}"></i>
@ -123,7 +116,7 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
<div class="ak-dual-list-selector__controls">
${this.renderButton(
msg("Add"),
EVENT_ADD_SELECTED,
DualSelectEventType.AddSelected,
this.addActive,
"fa-angle-right",
)}
@ -131,13 +124,13 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
? html`
${this.renderButton(
msg("Add All Available"),
EVENT_ADD_ALL,
DualSelectEventType.AddAll,
this.addAllActive,
"fa-angle-double-right",
)}
${this.renderButton(
msg("Remove All Available"),
EVENT_REMOVE_ALL,
DualSelectEventType.RemoveAll,
this.removeAllActive,
"fa-angle-double-left",
)}
@ -145,14 +138,14 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
: nothing}
${this.renderButton(
msg("Remove"),
EVENT_REMOVE_SELECTED,
DualSelectEventType.RemoveSelected,
this.removeActive,
"fa-angle-left",
)}
${this.deleteAll
? html`${this.renderButton(
msg("Remove All"),
EVENT_DELETE_ALL,
DualSelectEventType.DeleteAll,
this.enableDeleteAll,
"fa-times",
)}`

View File

@ -11,16 +11,13 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_REMOVE_ONE } from "../constants";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
import { DualSelectEventType, DualSelectPair } from "../types";
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
] as const satisfies Array<[string, string]>;
/**
* @element ak-dual-select-available-panel
@ -38,68 +35,86 @@ const hostAttributes = [
*
*/
@customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
export class AkDualSelectSelectedPane extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
//#region Properties
/* The array of key/value pairs that are in the selected list. ALL of them. */
@property({ type: Array })
readonly selected: DualSelectPair[] = [];
/*
* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
//#endregion
//#region State
/**
* This is the only mutator for this object.
* It collects the list of objects the user has clicked on *in this pane*.
*
* It is explicitly marked as "public" to emphasize that the parent orchestrator
* for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*/
@state()
public toMove: Set<string> = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
//#endregion
connectedCallback() {
//#region Lifecycle
public connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
for (const [attr, value] of hostAttributes) {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
}
clearMove() {
//#endregion
//#region Public API
public clearMove() {
this.toMove = new Set();
}
onClick(key: string) {
public get moveable() {
return Array.from(this.toMove.values());
}
//#endregion
//#region Event Listeners
#clickListener = (key: string): void => {
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
"ak-dual-select-selected-move-changed",
DualSelectEventType.MoveChanged,
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
};
onMove(key: string) {
#moveListener = (key: string): void => {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
this.dispatchCustomEvent(DualSelectEventType.RemoveOne, key);
this.requestUpdate();
};
//#endregion
//#region Render
render() {
return html`
@ -113,8 +128,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
@click=${() => this.#clickListener(key)}
@dblclick=${() => this.#moveListener(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
@ -134,6 +149,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
</div>
`;
}
//#endregion
}
export default AkDualSelectSelectedPane;

View File

@ -9,53 +9,46 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { BasePagination } from "../types";
import { BasePagination, DualSelectEventType } from "../types.js";
const styles = [
@customElement("ak-pagination")
export class AkPagination extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static styles = [
PFBase,
PFButton,
PFPagination,
css`
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button {
:host([theme="dark"]) {
.pf-c-pagination__nav-control .pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
}
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled {
.pf-c-pagination__nav-control .pf-c-button:disabled {
color: var(--pf-c-button--disabled--Color);
}
`,
];
@customElement("ak-pagination")
export class AkPagination extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
`,
];
@property({ attribute: false })
pages?: BasePagination;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(nav: number | undefined) {
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
}
#clickListener = (nav: number = 0) => {
this.dispatchCustomEvent(DualSelectEventType.NavigateTo, nav);
};
render() {
return this.pages
? html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div
class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md"
>
const { pages } = this;
if (!pages) return nothing;
return html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
)}
${msg(str`${pages.startIndex} - ${pages.endIndex} of ${pages.count}`)}
</span>
</div>
</div>
@ -64,9 +57,9 @@ export class AkPagination extends CustomEmitterElement(AKElement) {
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.previous);
this.#clickListener(pages.previous);
}}
?disabled="${(this.pages?.previous ?? 0) < 1}"
?disabled="${(pages.previous ?? 0) < 1}"
aria-label="${msg("Go to previous page")}"
>
<i class="fas fa-angle-left" aria-hidden="true"></i>
@ -76,9 +69,9 @@ export class AkPagination extends CustomEmitterElement(AKElement) {
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.next);
this.#clickListener(pages.next);
}}
?disabled="${(this.pages?.next ?? 0) <= 0}"
?disabled="${(pages.next ?? 0) <= 0}"
aria-label="${msg("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>
@ -86,8 +79,7 @@ export class AkPagination extends CustomEmitterElement(AKElement) {
</div>
</nav>
</div>
</div>`
: nothing;
</div>`;
}
}

View File

@ -4,47 +4,45 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
import { globalVariables, searchStyles } from "./search.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { SearchbarEvent } from "../types";
const styles = [PFBase, globalVariables, searchStyles];
import type { SearchbarEventDetail, SearchbarEventSource } from "../types.ts";
import { globalVariables, searchStyles } from "./search.css.js";
@customElement("ak-search-bar")
export class AkSearchbar extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
static styles = [PFBase, globalVariables, searchStyles];
@property({ type: String, reflect: true })
value = "";
public value = "";
/**
* If you're using more than one search, this token can help listeners distinguishing between
* those searches. Lit's own helpers sometimes erase the source and current targets.
*/
@property({ type: String })
name = "";
public name?: SearchbarEventSource;
input: Ref<HTMLInputElement> = createRef();
protected inputRef = createRef<HTMLInputElement>();
constructor() {
super();
this.onChange = this.onChange.bind(this);
#changeListener = () => {
const inputElement = this.inputRef.value;
if (inputElement) {
this.value = inputElement.value;
}
onChange(_event: Event) {
if (this.input.value) {
this.value = this.input.value.value;
if (!this.name) {
console.warn("ak-search-bar: no name provided, event will not be dispatched");
return;
}
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
this.dispatchCustomEvent<SearchbarEventDetail>("ak-search", {
source: this.name,
value: this.value,
});
}
};
render() {
return html`
@ -56,8 +54,8 @@ export class AkSearchbar extends CustomEmitterElement(AKElement) {
><input
type="search"
class="pf-c-text-input-group__text-input"
${ref(this.input)}
@input=${this.onChange}
${ref(this.inputRef)}
@input=${this.#changeListener}
value="${this.value}"
/></span>
</div>

View File

@ -1,7 +0,0 @@
export const EVENT_ADD_SELECTED = "ak-dual-select-add";
export const EVENT_REMOVE_SELECTED = "ak-dual-select-remove";
export const EVENT_ADD_ALL = "ak-dual-select-add-all";
export const EVENT_REMOVE_ALL = "ak-dual-select-remove-all";
export const EVENT_DELETE_ALL = "ak-dual-select-remove-everything";
export const EVENT_ADD_ONE = "ak-dual-select-add-one";
export const EVENT_REMOVE_ONE = "ak-dual-select-remove-one";

View File

@ -1,7 +1,7 @@
import { AkDualSelect } from "./ak-dual-select";
import "./ak-dual-select";
import { AkDualSelectProvider } from "./ak-dual-select-provider";
import "./ak-dual-select-provider";
import { AkDualSelectProvider } from "./ak-dual-select-provider.js";
import "./ak-dual-select.js";
import { AkDualSelect } from "./ak-dual-select.js";
export { AkDualSelect, AkDualSelectProvider };
export default AkDualSelect;

View File

@ -9,7 +9,7 @@ import { Pagination } from "@goauthentik/api";
import "../ak-dual-select";
import { AkDualSelect } from "../ak-dual-select";
import type { DualSelectPair } from "../types";
import { DualSelectEventType, type DualSelectPair } from "../types";
const goodForYouRaw = `
Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root,
@ -24,7 +24,8 @@ Rosemary, Rutabaga, Shallot, Soybeans, Spinach, Squash, Strawberries, Sweet pota
Thyme, Tomatillo, Tomato, Turnip, Waterchestnut, Watercress, Watermelon, Yams
`;
const keyToPair = (key: string): DualSelectPair => [slug(key), key];
const keyToPair = (key: string): DualSelectPair => [slug(key), key, key];
const goodForYou: DualSelectPair[] = goodForYouRaw
.split("\n")
.join(" ")
@ -83,7 +84,7 @@ export class AkSbFruity extends LitElement {
totalPages: Math.ceil(this.options.length / this.pageLength),
};
this.onNavigation = this.onNavigation.bind(this);
this.addEventListener("ak-pagination-nav-to", this.onNavigation);
this.addEventListener(DualSelectEventType.NavigateTo, this.onNavigation);
}
onNavigation(evt: Event) {

View File

@ -1,5 +1,4 @@
import "@goauthentik/elements/messages/MessageContainer";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
@ -45,20 +44,24 @@ const displayMessage = (result: any) => {
target!.replaceChildren(doc.firstChild!);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage2 = (result: any) => {
const displayMessage2 = (result: string) => {
console.debug("Huh.");
const doc = new DOMParser().parseFromString(`<p><i>Behavior</i>: ${result}</p>`, "text/xml");
const target = document.querySelector("#action-button-message-pad-2");
target!.replaceChildren(doc.firstChild!);
};
const displayMessage2b = debounce(displayMessage2, 250);
let displayMessage2bTimeoutID: ReturnType<typeof setTimeout>;
window.addEventListener("input", (event: Event) => {
const message = (event.target as HTMLInputElement | undefined)?.value ?? "-- undefined --";
displayMessage(message);
displayMessage2b(message);
clearTimeout(displayMessage2bTimeoutID);
displayMessage2bTimeoutID = setTimeout(() => {
displayMessage2(message);
}, 250);
});
type Story = StoryObj;

View File

@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-pagination";
import { AkPagination } from "../components/ak-pagination";
import { DualSelectEventType } from "../types";
const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control",
@ -54,7 +55,7 @@ const handleMoveChanged = (result: any) => {
);
};
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
window.addEventListener(DualSelectEventType.NavigateTo, handleMoveChanged);
type Story = StoryObj;

View File

@ -12,9 +12,7 @@ import { globalVariables } from "../components/styles.css";
@customElement("sb-dual-select-host-provider")
export class SbHostProvider extends LitElement {
static get styles() {
return globalVariables;
}
static styles = globalVariables;
render() {
return html`<slot></slot>`;

View File

@ -2,19 +2,44 @@ import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api";
//
// - key: string
// - label (string or TemplateResult),
// - sortBy (optional) string to sort by. If the sort string is
// - localMapping: The object the key represents; used by some specific apps. API layers may use
// this as a way to find the preset object.
//
// Note that this is a *tuple*, not a record or map!
export const DualSelectEventType = {
AddSelected: "ak-dual-select-add",
RemoveSelected: "ak-dual-select-remove",
Search: "ak-dual-select-search",
AddAll: "ak-dual-select-add-all",
RemoveAll: "ak-dual-select-remove-all",
DeleteAll: "ak-dual-select-remove-everything",
AddOne: "ak-dual-select-add-one",
RemoveOne: "ak-dual-select-remove-one",
Move: "ak-dual-select-move",
MoveChanged: "ak-dual-select-available-move-changed",
Change: "ak-dual-select-change",
NavigateTo: "ak-pagination-nav-to",
} as const satisfies Record<string, string>;
export type DualSelectPair<T = never> = [
export type DualSelectEventType = (typeof DualSelectEventType)[keyof typeof DualSelectEventType];
/**
* A tuple representing a single object in the dual select list.
*/
export type DualSelectPair<T = unknown> = [
/**
* The key used to identify the object in the API.
*/
key: string,
/**
* A human-readable label for the object.
*/
label: string | TemplateResult,
sortBy?: string,
/**
* A string to sort by. If not provided, the key will be used.
*/
sortBy: string,
/**
* A local mapping of the key to the object. This is used by some specific apps.
*
* API layers may use this as a way to find the preset object.
*/
localMapping?: T,
];
@ -30,9 +55,14 @@ export type DataProvision = {
export type DataProvider = (page: number, search?: string) => Promise<DataProvision>;
export const SearchbarEventSource = {
Available: "ak-dual-list-available-search",
Selected: "ak-dual-list-selected-search",
} as const satisfies Record<string, string>;
export type SearchbarEventSource = (typeof SearchbarEventSource)[keyof typeof SearchbarEventSource];
export interface SearchbarEventDetail {
source: string;
source: SearchbarEventSource;
value: string;
}
export type SearchbarEvent = CustomEvent<SearchbarEventDetail>;

View File

@ -12,7 +12,8 @@ export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & T> & HTMLE
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;
export type LitElementConstructor = new (...args: never[]) => LitElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LitElementConstructor = new (...args: any[]) => LitElement;
/**
* A constructor that has been extended with a mixin.

View File

@ -1,13 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Callback = (...args: any[]) => any;
export function debounce<F extends Callback, T extends object>(callback: F, wait: number) {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<F>) => {
// @ts-ignore
const context: T = this satisfies object;
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => callback.apply(context, args), wait);
};
}

View File

@ -1,18 +1,28 @@
import { createMixin } from "@goauthentik/elements/types";
import {
ConstructorWithMixin,
LitElementConstructor,
createMixin,
} from "@goauthentik/elements/types";
import { CustomEventDetail, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
export interface EmmiterElementHandler {
dispatchCustomEvent<T>(
eventName: string,
detail?: T extends CustomEvent<infer U> ? U : T,
export interface CustomEventEmitterMixin<EventType extends string = string> {
dispatchCustomEvent<D extends CustomEventDetail>(
eventType: EventType,
detail?: D,
eventInit?: EventInit,
): void;
}
export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperClass }) => {
return class EmmiterElementHandler extends SuperClass {
export function CustomEmitterElement<
EventType extends string = string,
T extends LitElementConstructor = LitElementConstructor,
>(SuperClass: T) {
abstract class CustomEventEmmiter
extends SuperClass
implements CustomEventEmitterMixin<EventType>
{
public dispatchCustomEvent<D extends CustomEventDetail>(
eventName: string,
eventType: string,
detail: D = {} as D,
eventInit: EventInit = {},
) {
@ -26,7 +36,7 @@ export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperC
}
this.dispatchEvent(
new CustomEvent(eventName, {
new CustomEvent(eventType, {
composed: true,
bubbles: true,
...eventInit,
@ -34,34 +44,20 @@ export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperC
}),
);
}
};
});
}
/**
* Mixin that enables Lit Elements to handle custom events in a more straightforward manner.
*
*/
// This is a neat trick: this static "class" is just a namespace for these unique symbols. Because
// of all the constraints on them, they're legal field names in Typescript objects! Which means that
// we can use them as identifiers for internal references in a Typescript class with absolutely no
// risk that a future user who wants a name like 'addHandler' or 'removeHandler' will override any
// of those, either in this mixin or in any class that this is mixed into, past or present along the
// chain of inheritance.
class HK {
public static readonly listenHandlers: unique symbol = Symbol();
public static readonly addHandler: unique symbol = Symbol();
public static readonly removeHandler: unique symbol = Symbol();
public static readonly getHandler: unique symbol = Symbol();
return CustomEventEmmiter as unknown as ConstructorWithMixin<
T,
CustomEventEmitterMixin<EventType>
>;
}
type EventHandler = (ev: CustomEvent) => void;
type EventMap = WeakMap<EventHandler, EventHandler>;
type CustomEventListener<D = unknown> = (ev: CustomEvent<D>) => void;
type EventMap<D = unknown> = WeakMap<CustomEventListener<D>, CustomEventListener<D>>;
export interface CustomEventTarget {
addCustomListener(eventName: string, handler: EventHandler): void;
removeCustomListener(eventName: string, handler: EventHandler): void;
export interface CustomEventTarget<EventType extends string = string> {
addCustomListener<D = unknown>(eventType: EventType, handler: CustomEventListener<D>): void;
removeCustomListener<D = unknown>(eventType: EventType, handler: CustomEventListener<D>): void;
}
/**
@ -72,11 +68,15 @@ export interface CustomEventTarget {
*/
export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClass }) => {
return class ListenerElementHandler extends SuperClass implements CustomEventTarget {
private [HK.listenHandlers] = new Map<string, EventMap>();
#listenHandlers = new Map<string, EventMap>();
private [HK.getHandler](eventName: string, handler: EventHandler) {
const internalMap = this[HK.listenHandlers].get(eventName);
return internalMap ? internalMap.get(handler) : undefined;
#getListener<D = unknown>(
eventType: string,
handler: CustomEventListener<D>,
): CustomEventListener<D> | undefined {
const internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
return internalMap?.get(handler);
}
// For every event NAME, we create a WeakMap that pairs the event handler given to us by the
@ -85,50 +85,58 @@ export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClas
// meanwhile, this allows us to remove it from the event listeners if it's still around
// using the original handler's identity as the key.
//
private [HK.addHandler](
eventName: string,
handler: EventHandler,
internalHandler: EventHandler,
#addListener<D = unknown>(
eventType: string,
handler: CustomEventListener<D>,
internalHandler: CustomEventListener<D>,
) {
if (!this[HK.listenHandlers].has(eventName)) {
this[HK.listenHandlers].set(eventName, new WeakMap());
let internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
if (!internalMap) {
internalMap = new WeakMap();
this.#listenHandlers.set(eventType, internalMap as EventMap);
}
const internalMap = this[HK.listenHandlers].get(eventName);
if (internalMap) {
internalMap.set(handler, internalHandler);
}
}
private [HK.removeHandler](eventName: string, handler: EventHandler) {
const internalMap = this[HK.listenHandlers].get(eventName);
#removeListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
const internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
if (internalMap) {
internalMap.delete(handler);
internalMap.delete(listener);
}
}
addCustomListener(eventName: string, handler: EventHandler) {
addCustomListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
const internalHandler = (event: Event) => {
if (!isCustomEvent(event)) {
if (!isCustomEvent<D>(event)) {
console.error(
`Received a standard event for custom event ${eventName}; event will not be handled.`,
`Received a standard event for custom event ${eventType}; event will not be handled.`,
);
return;
}
handler(event);
};
this[HK.addHandler](eventName, handler, internalHandler);
this.addEventListener(eventName, internalHandler);
return null;
}
removeCustomListener(eventName: string, handler: EventHandler) {
const realHandler = this[HK.getHandler](eventName, handler);
return listener(event);
};
this.#addListener(eventType, listener, internalHandler);
this.addEventListener(eventType, internalHandler);
}
removeCustomListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
const realHandler = this.#getListener(eventType, listener);
if (realHandler) {
this.removeEventListener(
eventName,
eventType,
realHandler as EventListenerOrEventListenerObject,
);
}
this[HK.removeHandler](eventName, handler);
this.#removeListener<D>(eventType, listener);
}
};
});