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:
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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")}"
|
||||||
|
@ -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() {
|
||||||
|
@ -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>`;
|
||||||
|
@ -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.`) : " ";
|
availableCount > 0 ? msg(str`${availableCount} item(s) marked to add.`) : " ";
|
||||||
|
|
||||||
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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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",
|
||||||
)}`
|
)}`
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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";
|
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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>`;
|
||||||
|
@ -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>;
|
|
||||||
|
@ -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.
|
||||||
|
@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user