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 { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import { SidebarToggleEventDetail } from "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/banner/EnterpriseStatusBanner"; import "@goauthentik/elements/banner/EnterpriseStatusBanner";
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 { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
import { SidebarToggleEventDetail } from "../../elements/PageHeader.js";
import { import {
AdminSidebarEnterpriseEntries, AdminSidebarEnterpriseEntries,
AdminSidebarEntries, AdminSidebarEntries,

View File

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

View File

@ -15,7 +15,7 @@ import { DetailedCountry, GeoIPPolicy, PoliciesApi } from "@goauthentik/api";
import { countryCache } from "./CountryCache"; import { countryCache } from "./CountryCache";
function countryToPair(country: DetailedCountry): DualSelectPair { function countryToPair(country: DetailedCountry): DualSelectPair {
return [country.code, country.name]; return [country.code, country.name, country.name];
} }
@customElement("ak-policy-geoip-form") @customElement("ak-policy-geoip-form")
@ -210,17 +210,16 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
.getCountries() .getCountries()
.then((results) => { .then((results) => {
if (!search) return results; if (!search) return results;
return results.filter((result) => return results.filter((result) =>
result.name result.name
.toLowerCase() .toLowerCase()
.includes(search.toLowerCase()), .includes(search.toLowerCase()),
); );
}) })
.then((results) => { .then((results) => ({
return { options: results.map(countryToPair),
options: results.map(countryToPair), }));
};
});
}} }}
.selected=${(this.instance?.countriesObj ?? []).map(countryToPair)} .selected=${(this.instance?.countriesObj ?? []).map(countryToPair)}
available-label="${msg("Available Countries")}" 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" * A top-level component for multi-select elements have dynamically generated "selected"
* lists. * lists.
*/ */
@customElement("ak-dual-select-dynamic-selected") @customElement("ak-dual-select-dynamic-selected")
export class AkDualSelectDynamic extends AkDualSelectProvider { export class AkDualSelectDynamic extends AkDualSelectProvider {
/** /**
@ -23,20 +22,24 @@ export class AkDualSelectDynamic extends AkDualSelectProvider {
* @attr * @attr
*/ */
@property({ attribute: false }) @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>) { willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed); super.willUpdate(changed);
// On the first update *only*, even before rendering, when the options are handed up, update // 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. // the selected list with the contents derived from the selector.
if (!this.firstUpdateHasRun && this.options.length > 0) {
this.firstUpdateHasRun = true; if (this.#didFirstUpdate) return;
this.selector(this.options).then((selected) => { if (this.options.length === 0) return;
this.selected = selected;
}); this.#didFirstUpdate = true;
}
this.selector(this.options).then((selected) => {
this.selected = selected;
});
} }
render() { render() {

View File

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

View File

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

View File

@ -1,26 +1,24 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; 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 { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js"; import { map } from "lit/directives/map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { availablePaneStyles, listStyles } from "./styles.css"; import { availablePaneStyles, listStyles } from "./styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css"; import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_ADD_ONE } from "../constants"; import { DualSelectEventType, DualSelectPair } from "../types.js";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
const hostAttributes = [ const hostAttributes = [
["aria-labelledby", "dual-list-selector-available-pane-status"], ["aria-labelledby", "dual-list-selector-available-pane-status"],
["aria-multiselectable", "true"], ["aria-multiselectable", "true"],
["role", "listbox"], ["role", "listbox"],
]; ] as const satisfies Array<[string, string]>;
/** /**
* @element ak-dual-select-available-panel * @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, * 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. * the attribute will be read by the parent when a control is clicked.
*
*/ */
@customElement("ak-dual-select-available-pane") @customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEventType>(
static get styles() { AKElement,
return styles; ) {
} static styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
//#region Properties
/* The array of key/value pairs this pane is currently showing */ /* The array of key/value pairs this pane is currently showing */
@property({ type: Array }) @property({ type: Array })
readonly options: DualSelectPair[] = []; 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 }) @property({ type: Object })
readonly selected: Set<string> = new Set(); readonly selected: Set<string> = new Set();
/* This is the only mutator for this object. It collects the list of objects the user has //#endregion
* 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 //#region State
* moved (removed) if the user so requests.
/**
* 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() @state()
public toMove: Set<string> = new Set(); public toMove: Set<string> = new Set();
constructor() { //#endregion
super();
this.onClick = this.onClick.bind(this); //#region Refs
this.onMove = this.onMove.bind(this);
} protected listRef = createRef<HTMLDivElement>();
//#region Lifecycle
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
for (const [attr, value] of hostAttributes) {
if (!this.hasAttribute(attr)) { if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value); 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(); 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() { get moveable() {
return Array.from(this.toMove.values()); 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 // 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 // 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 // change; this allows the available pane to illustrate selected items with the checkmark
@ -119,17 +145,18 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
render() { render() {
return html` 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"> <ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => { ${map(this.options, ([key, label]) => {
const selected = classMap({ const selected = classMap({
"pf-m-selected": this.toMove.has(key), "pf-m-selected": this.toMove.has(key),
}); });
return html` <li return html` <li
class="pf-c-dual-list-selector__list-item" class="pf-c-dual-list-selector__list-item"
aria-selected="false" aria-selected="false"
@click=${() => this.onClick(key)} @click=${() => this.#clickListener(key)}
@dblclick=${() => this.onMove(key)} @dblclick=${() => this.#moveListener(key)}
role="option" role="option"
data-ak-key=${key} data-ak-key=${key}
tabindex="-1" tabindex="-1"
@ -154,6 +181,8 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
</div> </div>
`; `;
} }
//#endregion
} }
export default AkDualSelectAvailablePane; export default AkDualSelectAvailablePane;

View File

@ -8,34 +8,7 @@ import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import { DualSelectEventType } from "../types.js";
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
} from "../constants";
const styles = [
PFBase,
PFButton,
css`
:host {
align-self: center;
padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight);
padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft);
}
.pf-c-dual-list-selector {
max-width: 4rem;
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
`,
];
/** /**
* @element ak-dual-select-controls * @element ak-dual-select-controls
@ -43,64 +16,84 @@ const styles = [
* The "control box" for a dual-list multi-select. It's controlled by the parent orchestrator as to * 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 * 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. * orchestrator which will then reconcile the "available" and "selected" panes at need.
*
*/ */
@customElement("ak-dual-select-controls") @customElement("ak-dual-select-controls")
export class AkDualSelectControls extends CustomEmitterElement(AKElement) { export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static get styles() { static styles = [
return styles; PFBase,
} PFButton,
css`
:host {
align-self: center;
padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight);
padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft);
}
.pf-c-dual-list-selector {
max-width: calc(var(--pf-global--spacer-md, 1rem) * 4);
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
`,
];
/* 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 }) @property({ attribute: "add-active", type: Boolean })
addActive = false; 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) * if the selected list is not empty)
*/ */
@property({ attribute: "remove-active", type: Boolean }) @property({ attribute: "remove-active", type: Boolean })
removeActive = false; 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 * into the selected list (essentially, if any visible elements are
* not currently selected) * not currently selected).
*/ */
@property({ attribute: "add-all-active", type: Boolean }) @property({ attribute: "add-all-active", type: Boolean })
addAllActive = false; 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 * 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 }) @property({ attribute: "remove-all-active", type: Boolean })
removeAllActive = false; 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. * selected list that can be deleted.
*/ */
@property({ attribute: "delete-all-active", type: Boolean }) @property({ attribute: "delete-all-active", type: Boolean })
enableDeleteAll = false; 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 }) @property({ attribute: "enable-select-all", type: Boolean })
selectAll = false; 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 }) @property({ attribute: "enable-delete-all", type: Boolean })
deleteAll = false; deleteAll = false;
constructor() { renderButton(
super(); label: string,
this.onClick = this.onClick.bind(this); eventType: DualSelectEventType,
} active: boolean,
direction: string,
onClick(eventName: string) { ) {
this.dispatchCustomEvent(eventName);
}
renderButton(label: string, event: string, active: boolean, direction: string) {
return html` return html`
<div class="pf-c-dual-list-selector__controls-item"> <div class="pf-c-dual-list-selector__controls-item">
<button <button
@ -109,7 +102,7 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
aria-label=${label} aria-label=${label}
class="pf-c-button pf-m-plain" class="pf-c-button pf-m-plain"
type="button" type="button"
@click=${() => this.onClick(event)} @click=${() => this.dispatchCustomEvent(eventType)}
data-ouia-component-type="AK/Button" data-ouia-component-type="AK/Button"
> >
<i class="fa ${direction}"></i> <i class="fa ${direction}"></i>
@ -123,7 +116,7 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
<div class="ak-dual-list-selector__controls"> <div class="ak-dual-list-selector__controls">
${this.renderButton( ${this.renderButton(
msg("Add"), msg("Add"),
EVENT_ADD_SELECTED, DualSelectEventType.AddSelected,
this.addActive, this.addActive,
"fa-angle-right", "fa-angle-right",
)} )}
@ -131,13 +124,13 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
? html` ? html`
${this.renderButton( ${this.renderButton(
msg("Add All Available"), msg("Add All Available"),
EVENT_ADD_ALL, DualSelectEventType.AddAll,
this.addAllActive, this.addAllActive,
"fa-angle-double-right", "fa-angle-double-right",
)} )}
${this.renderButton( ${this.renderButton(
msg("Remove All Available"), msg("Remove All Available"),
EVENT_REMOVE_ALL, DualSelectEventType.RemoveAll,
this.removeAllActive, this.removeAllActive,
"fa-angle-double-left", "fa-angle-double-left",
)} )}
@ -145,14 +138,14 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
: nothing} : nothing}
${this.renderButton( ${this.renderButton(
msg("Remove"), msg("Remove"),
EVENT_REMOVE_SELECTED, DualSelectEventType.RemoveSelected,
this.removeActive, this.removeActive,
"fa-angle-left", "fa-angle-left",
)} )}
${this.deleteAll ${this.deleteAll
? html`${this.renderButton( ? html`${this.renderButton(
msg("Remove All"), msg("Remove All"),
EVENT_DELETE_ALL, DualSelectEventType.DeleteAll,
this.enableDeleteAll, this.enableDeleteAll,
"fa-times", "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 PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_REMOVE_ONE } from "../constants"; import { DualSelectEventType, DualSelectPair } from "../types";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
const hostAttributes = [ const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"], ["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"], ["aria-multiselectable", "true"],
["role", "listbox"], ["role", "listbox"],
]; ] as const satisfies Array<[string, string]>;
/** /**
* @element ak-dual-select-available-panel * @element ak-dual-select-available-panel
@ -38,68 +35,86 @@ const hostAttributes = [
* *
*/ */
@customElement("ak-dual-select-selected-pane") @customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) { export class AkDualSelectSelectedPane extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static get styles() { static styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
return styles;
}
/* The array of key/value pairs that are in the selected list. ALL of them. */ //#region Properties
/* The array of key/value pairs that are in the selected list. ALL of them. */
@property({ type: Array }) @property({ type: Array })
readonly selected: DualSelectPair[] = []; readonly selected: DualSelectPair[] = [];
/* //#endregion
* 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 //#region State
* 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. /**
* 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() @state()
public toMove: Set<string> = new Set(); public toMove: Set<string> = new Set();
constructor() { //#endregion
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
connectedCallback() { //#region Lifecycle
public connectedCallback() {
super.connectedCallback(); super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
for (const [attr, value] of hostAttributes) {
if (!this.hasAttribute(attr)) { if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value); this.setAttribute(attr, value);
} }
}); }
} }
clearMove() { //#endregion
//#region Public API
public clearMove() {
this.toMove = new Set(); 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)) { if (this.toMove.has(key)) {
this.toMove.delete(key); this.toMove.delete(key);
} else { } else {
this.toMove.add(key); this.toMove.add(key);
} }
this.dispatchCustomEvent( this.dispatchCustomEvent(
"ak-dual-select-selected-move-changed", DualSelectEventType.MoveChanged,
Array.from(this.toMove.values()).sort(), Array.from(this.toMove.values()).sort(),
); );
this.dispatchCustomEvent("ak-dual-select-move"); this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change // Necessary because updating a map won't trigger a state change
this.requestUpdate(); this.requestUpdate();
} };
onMove(key: string) { #moveListener = (key: string): void => {
this.toMove.delete(key); this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
this.requestUpdate();
}
get moveable() { this.dispatchCustomEvent(DualSelectEventType.RemoveOne, key);
return Array.from(this.toMove.values()); this.requestUpdate();
} };
//#endregion
//#region Render
render() { render() {
return html` return html`
@ -113,8 +128,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
class="pf-c-dual-list-selector__list-item" class="pf-c-dual-list-selector__list-item"
aria-selected="false" aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0" id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)} @click=${() => this.#clickListener(key)}
@dblclick=${() => this.onMove(key)} @dblclick=${() => this.#moveListener(key)}
role="option" role="option"
data-ak-key=${key} data-ak-key=${key}
tabindex="-1" tabindex="-1"
@ -134,6 +149,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
</div> </div>
`; `;
} }
//#endregion
} }
export default AkDualSelectSelectedPane; export default AkDualSelectSelectedPane;

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import { Pagination } from "@goauthentik/api";
import "../ak-dual-select"; import "../ak-dual-select";
import { AkDualSelect } from "../ak-dual-select"; import { AkDualSelect } from "../ak-dual-select";
import type { DualSelectPair } from "../types"; import { DualSelectEventType, type DualSelectPair } from "../types";
const goodForYouRaw = ` const goodForYouRaw = `
Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root, 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 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 const goodForYou: DualSelectPair[] = goodForYouRaw
.split("\n") .split("\n")
.join(" ") .join(" ")
@ -83,7 +84,7 @@ export class AkSbFruity extends LitElement {
totalPages: Math.ceil(this.options.length / this.pageLength), totalPages: Math.ceil(this.options.length / this.pageLength),
}; };
this.onNavigation = this.onNavigation.bind(this); this.onNavigation = this.onNavigation.bind(this);
this.addEventListener("ak-pagination-nav-to", this.onNavigation); this.addEventListener(DualSelectEventType.NavigateTo, this.onNavigation);
} }
onNavigation(evt: Event) { onNavigation(evt: Event) {

View File

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

View File

@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-pagination"; import "../components/ak-pagination";
import { AkPagination } from "../components/ak-pagination"; import { AkPagination } from "../components/ak-pagination";
import { DualSelectEventType } from "../types";
const metadata: Meta<AkPagination> = { const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control", 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; type Story = StoryObj;

View File

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

View File

@ -2,19 +2,44 @@ import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api"; import { Pagination } from "@goauthentik/api";
// export const DualSelectEventType = {
// - key: string AddSelected: "ak-dual-select-add",
// - label (string or TemplateResult), RemoveSelected: "ak-dual-select-remove",
// - sortBy (optional) string to sort by. If the sort string is Search: "ak-dual-select-search",
// - localMapping: The object the key represents; used by some specific apps. API layers may use AddAll: "ak-dual-select-add-all",
// this as a way to find the preset object. RemoveAll: "ak-dual-select-remove-all",
// DeleteAll: "ak-dual-select-remove-everything",
// Note that this is a *tuple*, not a record or map! 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, key: string,
/**
* A human-readable label for the object.
*/
label: string | TemplateResult, 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, localMapping?: T,
]; ];
@ -30,9 +55,14 @@ export type DataProvision = {
export type DataProvider = (page: number, search?: string) => Promise<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 { export interface SearchbarEventDetail {
source: string; source: SearchbarEventSource;
value: string; 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 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. * 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"; import { CustomEventDetail, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
export interface EmmiterElementHandler { export interface CustomEventEmitterMixin<EventType extends string = string> {
dispatchCustomEvent<T>( dispatchCustomEvent<D extends CustomEventDetail>(
eventName: string, eventType: EventType,
detail?: T extends CustomEvent<infer U> ? U : T, detail?: D,
eventInit?: EventInit, eventInit?: EventInit,
): void; ): void;
} }
export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperClass }) => { export function CustomEmitterElement<
return class EmmiterElementHandler extends SuperClass { EventType extends string = string,
T extends LitElementConstructor = LitElementConstructor,
>(SuperClass: T) {
abstract class CustomEventEmmiter
extends SuperClass
implements CustomEventEmitterMixin<EventType>
{
public dispatchCustomEvent<D extends CustomEventDetail>( public dispatchCustomEvent<D extends CustomEventDetail>(
eventName: string, eventType: string,
detail: D = {} as D, detail: D = {} as D,
eventInit: EventInit = {}, eventInit: EventInit = {},
) { ) {
@ -26,7 +36,7 @@ export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperC
} }
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(eventName, { new CustomEvent(eventType, {
composed: true, composed: true,
bubbles: true, bubbles: true,
...eventInit, ...eventInit,
@ -34,34 +44,20 @@ export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperC
}), }),
); );
} }
}; }
});
/** return CustomEventEmmiter as unknown as ConstructorWithMixin<
* Mixin that enables Lit Elements to handle custom events in a more straightforward manner. T,
* CustomEventEmitterMixin<EventType>
*/ >;
// 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();
} }
type EventHandler = (ev: CustomEvent) => void; type CustomEventListener<D = unknown> = (ev: CustomEvent<D>) => void;
type EventMap = WeakMap<EventHandler, EventHandler>; type EventMap<D = unknown> = WeakMap<CustomEventListener<D>, CustomEventListener<D>>;
export interface CustomEventTarget { export interface CustomEventTarget<EventType extends string = string> {
addCustomListener(eventName: string, handler: EventHandler): void; addCustomListener<D = unknown>(eventType: EventType, handler: CustomEventListener<D>): void;
removeCustomListener(eventName: string, handler: EventHandler): void; removeCustomListener<D = unknown>(eventType: EventType, handler: CustomEventListener<D>): void;
} }
/** /**
@ -72,11 +68,15 @@ export interface CustomEventTarget {
*/ */
export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClass }) => { export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClass }) => {
return class ListenerElementHandler extends SuperClass implements CustomEventTarget { 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) { #getListener<D = unknown>(
const internalMap = this[HK.listenHandlers].get(eventName); eventType: string,
return internalMap ? internalMap.get(handler) : undefined; 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 // 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 // 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. // using the original handler's identity as the key.
// //
private [HK.addHandler]( #addListener<D = unknown>(
eventName: string, eventType: string,
handler: EventHandler, handler: CustomEventListener<D>,
internalHandler: EventHandler, internalHandler: CustomEventListener<D>,
) { ) {
if (!this[HK.listenHandlers].has(eventName)) { let internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
this[HK.listenHandlers].set(eventName, new WeakMap());
if (!internalMap) {
internalMap = new WeakMap();
this.#listenHandlers.set(eventType, internalMap as EventMap);
} }
const internalMap = this[HK.listenHandlers].get(eventName);
internalMap.set(handler, internalHandler);
}
#removeListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
const internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
if (internalMap) { if (internalMap) {
internalMap.set(handler, internalHandler); internalMap.delete(listener);
} }
} }
private [HK.removeHandler](eventName: string, handler: EventHandler) { addCustomListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
const internalMap = this[HK.listenHandlers].get(eventName);
if (internalMap) {
internalMap.delete(handler);
}
}
addCustomListener(eventName: string, handler: EventHandler) {
const internalHandler = (event: Event) => { const internalHandler = (event: Event) => {
if (!isCustomEvent(event)) { if (!isCustomEvent<D>(event)) {
console.error( 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;
return null;
} }
handler(event);
return listener(event);
}; };
this[HK.addHandler](eventName, handler, internalHandler);
this.addEventListener(eventName, internalHandler); this.#addListener(eventType, listener, internalHandler);
this.addEventListener(eventType, internalHandler);
} }
removeCustomListener(eventName: string, handler: EventHandler) { removeCustomListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
const realHandler = this[HK.getHandler](eventName, handler); const realHandler = this.#getListener(eventType, listener);
if (realHandler) { if (realHandler) {
this.removeEventListener( this.removeEventListener(
eventName, eventType,
realHandler as EventListenerOrEventListenerObject, realHandler as EventListenerOrEventListenerObject,
); );
} }
this[HK.removeHandler](eventName, handler);
this.#removeListener<D>(eventType, listener);
} }
}; };
}); });