web: restructure ak-dual-select to avoid using CustomEvent

# What

- Replace all `CustomEvent<T>` handlers with a child class Event.
- Use the standard `dispatchEvent()` and `addEventListener()` clauses.
- Move common methods of the two panes into an abstract parent class.

# Why

CustomEvent is a bit of a side-show in JavaScript; it's perfectly acceptable to inherit from Event,
and there's much better support for type management while using it, plus deconstructing the received
event object is cleaner with child Events than unpacking the `details` object.

This codebase is now more open to change, and is closer to our still-evolving style.

Doing it this way mean that, once you have an event defined, you don't have to remember if you're
sending a custom event or a normal event, and you don't need all that infrastructure for making your
Lit objects sensitive to custom event handling.  There's enough mixin clutter already.

And just like I hate clutter, I hate duplication.  The two panes had a lot in common with ARIA
handling and storing, clearing, and assigning selected items to the "pending move" table.  Moving
all that into a parent class exposed the differences: one is a source and the other a sink; one
reflects changes made, the other possible changes to be made.

# Testing

The Storybook tests have all been updated to match the codebase, and there are standalone tests for
the various components (pagination, pane control, search) that can be exercised before deployment.
This commit is contained in:
Ken Sternberg
2025-01-15 16:20:12 -08:00
parent 50d2f69332
commit 3ec0d30965
15 changed files with 310 additions and 278 deletions

View File

@ -1,6 +1,5 @@
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { PropertyValues, html } from "lit";
@ -12,6 +11,11 @@ import type { Pagination } from "@goauthentik/api";
import "./ak-dual-select";
import { AkDualSelect } from "./ak-dual-select";
import {
DualSelectChangeEvent,
DualSelectPaginatorNavEvent,
DualSelectSearchEvent,
} from "./events";
import type { DataProvider, DualSelectPair } from "./types";
/**
@ -26,7 +30,7 @@ import type { DataProvider, DualSelectPair } from "./types";
*/
@customElement("ak-dual-select-provider")
export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) {
export class AkDualSelectProvider extends AkControlElement {
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
* the "Available" pane.
*
@ -86,9 +90,9 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
this.onNav = this.onNav.bind(this);
this.onChange = this.onChange.bind(this);
this.onSearch = this.onSearch.bind(this);
this.addCustomListener("ak-pagination-nav-to", this.onNav);
this.addCustomListener("ak-dual-select-change", this.onChange);
this.addCustomListener("ak-dual-select-search", this.onSearch);
this.addEventListener(DualSelectPaginatorNavEvent.eventName, this.onNav);
this.addEventListener(DualSelectSearchEvent.eventName, this.onSearch);
this.addEventListener(DualSelectChangeEvent.eventName, this.onChange);
}
willUpdate(changedProperties: PropertyValues<this>) {
@ -122,26 +126,16 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
this.isLoading = false;
}
onNav(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
}
this.fetch(event.detail);
onNav(event: DualSelectPaginatorNavEvent) {
this.fetch(event.page);
}
onChange(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.internalSelected = event.detail.value;
this.selected = this.internalSelected;
onChange(event: DualSelectChangeEvent) {
this.selected = this.internalSelected = event.selected;
}
onSearch(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.doSearch(event.detail);
onSearch(event: DualSelectSearchEvent) {
this.doSearch(event.search);
}
doSearch(search: string) {

View File

@ -1,8 +1,5 @@
import { AKElement } from "@goauthentik/elements/Base";
import {
CustomEmitterElement,
CustomListenerElement,
} from "@goauthentik/elements/utils/eventEmitter";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { PropertyValues, html, nothing } from "lit";
@ -23,15 +20,13 @@ import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-p
import "./components/ak-pagination";
import "./components/ak-search-bar";
import {
EVENT_ADD_ALL,
EVENT_ADD_ONE,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_ONE,
EVENT_REMOVE_SELECTED,
} from "./constants";
import type { BasePagination, DualSelectPair, SearchbarEvent } from "./types";
DualSelectChangeEvent,
DualSelectMoveRequestEvent,
DualSelectPanelSearchEvent,
DualSelectSearchEvent,
DualSelectUpdateEvent,
} from "./events";
import type { BasePagination, DualSelectPair } from "./types";
function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
@ -60,7 +55,7 @@ const keyfinder =
k === key;
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
export class AkDualSelect extends AKElement {
static get styles() {
return styles;
}
@ -96,21 +91,9 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
super();
this.handleMove = this.handleMove.bind(this);
this.handleSearch = this.handleSearch.bind(this);
[
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
EVENT_ADD_ONE,
EVENT_REMOVE_ONE,
].forEach((eventName: string) => {
this.addCustomListener(eventName, (event: Event) => this.handleMove(eventName, event));
});
this.addCustomListener("ak-dual-select-move", () => {
this.requestUpdate();
});
this.addCustomListener("ak-search", this.handleSearch);
this.addEventListener(DualSelectMoveRequestEvent.eventName, this.handleMove);
this.addEventListener(DualSelectUpdateEvent.eventName, () => this.requestUpdate());
this.addEventListener(DualSelectPanelSearchEvent.eventName, this.handleSearch);
}
willUpdate(changedProperties: PropertyValues<this>) {
@ -123,47 +106,17 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
}
}
handleMove(eventName: string, event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expected move event here, got ${eventName}`);
}
switch (eventName) {
case EVENT_ADD_SELECTED: {
this.addSelected();
break;
}
case EVENT_REMOVE_SELECTED: {
this.removeSelected();
break;
}
case EVENT_ADD_ALL: {
this.addAllVisible();
break;
}
case EVENT_REMOVE_ALL: {
this.removeAllVisible();
break;
}
case EVENT_DELETE_ALL: {
this.removeAll();
break;
}
case EVENT_ADD_ONE: {
this.addOne(event.detail);
break;
}
case EVENT_REMOVE_ONE: {
this.removeOne(event.detail);
break;
}
default:
throw new Error(
`AkDualSelect.handleMove received unknown event type: ${eventName}`,
);
}
this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
handleMove(event: DualSelectMoveRequestEvent) {
match(event.move)
.with("add-all", () => this.addAllVisible())
.with("add-one", () => this.addOne(event.key))
.with("add-selected", () => this.addSelected())
.with("delete-all", () => this.removeAll())
.with("remove-all", () => this.removeAllVisible())
.with("remove-one", () => this.removeOne(event.key))
.with("remove-selected", () => this.removeSelected())
.exhaustive();
this.dispatchEvent(new DualSelectChangeEvent(this.value));
event.stopPropagation();
}
@ -182,7 +135,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.availablePane.value!.clearMove();
}
addOne(key: string) {
addOne(key?: string) {
if (!key) {
return;
}
const requested = this.options.find(keyfinder(key));
if (requested && !this.selected.find(keyfinder(requested[0]))) {
this.selected = [...this.selected, requested];
@ -207,7 +163,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove();
}
removeOne(key: string) {
removeOne(key?: string) {
if (!key) {
return;
}
this.selected = this.selected.filter(([k]) => k !== key);
}
@ -223,18 +182,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove();
}
handleSearch(event: SearchbarEvent) {
switch (event.detail.source) {
handleSearch(event: DualSelectPanelSearchEvent) {
switch (event.source) {
case "ak-dual-list-available-search":
return this.handleAvailableSearch(event.detail.value);
return this.handleAvailableSearch(event.filterOn);
case "ak-dual-list-selected-search":
return this.handleSelectedSearch(event.detail.value);
return this.handleSelectedSearch(event.filterOn);
}
event.stopPropagation();
}
handleAvailableSearch(value: string) {
this.dispatchCustomEvent("ak-dual-select-search", value);
this.dispatchEvent(new DualSelectSearchEvent(value));
}
handleSelectedSearch(value: string) {

View File

@ -1,26 +1,19 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { bound } from "@goauthentik/elements/decorators/bound";
import { html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import { availablePaneStyles, listStyles } from "./styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { availablePaneStyles } from "./styles.css";
import { EVENT_ADD_ONE } from "../constants";
import {
DualSelectMoveAvailableEvent,
DualSelectMoveRequestEvent,
DualSelectUpdateEvent,
} from "../events";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
const hostAttributes = [
["aria-labelledby", "dual-list-selector-available-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
import { AkDualSelectAbstractPane } from "./ak-dual-select-pane";
/**
* @element ak-dual-select-available-panel
@ -40,9 +33,9 @@ const hostAttributes = [
*
*/
@customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
export class AkDualSelectAvailablePane extends AkDualSelectAbstractPane {
static get styles() {
return styles;
return [...AkDualSelectAbstractPane.styles, availablePaneStyles];
}
/* The array of key/value pairs this pane is currently showing */
@ -56,68 +49,31 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
@property({ type: Object })
readonly selected: Set<string> = new Set();
/* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*
*/
@state()
public toMove: Set<string> = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
clearMove() {
this.toMove = new Set();
}
@bound
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");
this.move(key);
this.dispatchEvent(new DualSelectMoveAvailableEvent(this.moveable.sort()));
this.dispatchEvent(new DualSelectUpdateEvent());
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
@bound
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_ADD_ONE, key);
this.dispatchEvent(new DualSelectMoveRequestEvent("add-one", key));
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
// DO NOT use `Array.map()` instead of Lit's `map()` function. Lit's `map()` is object-aware and
// will not re-arrange or reconstruct the list automatically if the actual sources do not
// change; this allows the available pane to illustrate selected items with the checkmark
// without causing the list to scroll back up to the top.
render() {
override render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">

View File

@ -1,5 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { css, html, nothing } from "lit";
@ -8,13 +7,7 @@ import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
} from "../constants";
import { DualSelectMoveRequestEvent, type MoveEventType } from "../events";
const styles = [
PFBase,
@ -47,7 +40,7 @@ const styles = [
*/
@customElement("ak-dual-select-controls")
export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
export class AkDualSelectControls extends AKElement {
static get styles() {
return styles;
}
@ -96,11 +89,11 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
this.onClick = this.onClick.bind(this);
}
onClick(eventName: string) {
this.dispatchCustomEvent(eventName);
onClick(eventName: MoveEventType) {
this.dispatchEvent(new DualSelectMoveRequestEvent(eventName));
}
renderButton(label: string, event: string, active: boolean, direction: string) {
renderButton(label: string, event: MoveEventType, active: boolean, direction: string) {
return html`
<div class="pf-c-dual-list-selector__controls-item">
<button
@ -121,23 +114,18 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
render() {
return html`
<div class="ak-dual-list-selector__controls">
${this.renderButton(
msg("Add"),
EVENT_ADD_SELECTED,
this.addActive,
"fa-angle-right",
)}
${this.renderButton(msg("Add"), "add-selected", this.addActive, "fa-angle-right")}
${this.selectAll
? html`
${this.renderButton(
msg("Add All Available"),
EVENT_ADD_ALL,
"add-all",
this.addAllActive,
"fa-angle-double-right",
)}
${this.renderButton(
msg("Remove All Available"),
EVENT_REMOVE_ALL,
"remove-all",
this.removeAllActive,
"fa-angle-double-left",
)}
@ -145,14 +133,14 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
: nothing}
${this.renderButton(
msg("Remove"),
EVENT_REMOVE_SELECTED,
"remove-selected",
this.removeActive,
"fa-angle-left",
)}
${this.deleteAll
? html`${this.renderButton(
msg("Remove All"),
EVENT_DELETE_ALL,
"delete-all",
this.enableDeleteAll,
"fa-times",
)}`

View File

@ -0,0 +1,75 @@
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult } from "lit";
import { state } from "lit/decorators.js";
import { listStyles } from "./styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles];
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
/**
* @element ak-dual-select-panel
*
* The "selected options" or "right" pane in a dual-list multi-select. It receives from its parent
* a list of the selected options, and maintains an internal list of objects selected to move.
*
* @fires ak-dual-select-selected-move-changed - When the list of "to move" entries changed.
* Includes the current `toMove` content.
*
* @fires ak-dual-select-remove-one - Double-click with the element clicked on.
*
* It is not expected that the `ak-dual-select-selected-move-changed` will be used; instead, the
* attribute will be read by the parent when a control is clicked.
*
*/
export abstract class AkDualSelectAbstractPane extends AKElement {
static get styles() {
return styles;
}
/*
* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*
*/
@state()
public toMove: Set<string> = new Set();
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
clearMove() {
this.toMove = new Set();
}
move(key: string) {
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
}
get moveable() {
return Array.from(this.toMove.values());
}
abstract render(): TemplateResult;
}

View File

@ -1,26 +1,19 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { bound } from "@goauthentik/elements/decorators/bound";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import { listStyles, selectedPaneStyles } from "./styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { selectedPaneStyles } from "./styles.css";
import { EVENT_REMOVE_ONE } from "../constants";
import {
DualSelectMoveRequestEvent,
DualSelectMoveSelectedEvent,
DualSelectUpdateEvent,
} from "../events";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
import { AkDualSelectAbstractPane } from "./ak-dual-select-pane";
/**
* @element ak-dual-select-available-panel
@ -38,70 +31,32 @@ const hostAttributes = [
*
*/
@customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
export class AkDualSelectSelectedPane extends AkDualSelectAbstractPane {
static get styles() {
return styles;
return [...AkDualSelectAbstractPane.styles, selectedPaneStyles];
}
/* The array of key/value pairs that are in the selected list. ALL of them. */
@property({ type: Array })
readonly selected: DualSelectPair[] = [];
/*
* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*
*/
@state()
public toMove: Set<string> = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
clearMove() {
this.toMove = new Set();
}
@bound
onClick(key: string) {
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
"ak-dual-select-selected-move-changed",
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent("ak-dual-select-move");
this.move(key);
this.dispatchEvent(new DualSelectMoveSelectedEvent(this.moveable.sort()));
this.dispatchEvent(new DualSelectUpdateEvent());
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
@bound
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
this.dispatchEvent(new DualSelectMoveRequestEvent("remove-one", key));
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
render() {
override render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">

View File

@ -1,5 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg, str } from "@lit/localize";
import { css, html, nothing } from "lit";
@ -9,6 +8,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { DualSelectPaginatorNavEvent } from "../events";
import type { BasePagination } from "../types";
const styles = [
@ -27,7 +27,7 @@ const styles = [
];
@customElement("ak-pagination")
export class AkPagination extends CustomEmitterElement(AKElement) {
export class AkPagination extends AKElement {
static get styles() {
return styles;
}
@ -41,7 +41,7 @@ export class AkPagination extends CustomEmitterElement(AKElement) {
}
onClick(nav: number | undefined) {
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
this.dispatchEvent(new DualSelectPaginatorNavEvent(nav ?? 0));
}
render() {

View File

@ -1,5 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
@ -9,12 +8,12 @@ import type { Ref } from "lit/directives/ref.js";
import { globalVariables, searchStyles } from "./search.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { SearchbarEvent } from "../types";
import { DualSelectPanelSearchEvent } from "../events";
const styles = [PFBase, globalVariables, searchStyles];
@customElement("ak-search-bar")
export class AkSearchbar extends CustomEmitterElement(AKElement) {
export class AkSearchbar extends AKElement {
static get styles() {
return styles;
}
@ -40,10 +39,7 @@ export class AkSearchbar extends CustomEmitterElement(AKElement) {
if (this.input.value) {
this.value = this.input.value.value;
}
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
source: this.name,
value: this.value,
});
this.dispatchEvent(new DualSelectPanelSearchEvent(this.name, this.value));
}
render() {

View File

@ -0,0 +1,112 @@
import { DualSelectPair } from "./types";
// Handled by the Server layer provider
// Request to provide a different page of the paginated results in the "available" panel.
export class DualSelectPaginatorNavEvent extends Event {
static readonly eventName = "ak-dual-select-paginator-nav";
constructor(public page: number = 0) {
super(DualSelectPaginatorNavEvent.eventName, { bubbles: true, composed: true });
}
}
// Request to provide a filtered collection for the "available" panel via a search string
export class DualSelectSearchEvent extends Event {
static readonly eventName = "ak-dual-select-search";
constructor(public search: string) {
super(DualSelectSearchEvent.eventName, { bubbles: true, composed: true });
}
}
// Request to update the "selected" list in the provider
export class DualSelectChangeEvent extends Event {
static readonly eventName = "ak-dual-select-change";
constructor(public selected: DualSelectPair[]) {
super(DualSelectChangeEvent.eventName, { bubbles: true, composed: true });
}
}
// Paginator and specific item events
export const moveEvents = [
"add-all",
"add-one",
"add-selected",
"delete-all",
"remove-all",
"remove-one",
"remove-selected",
] as const;
export type MoveEventType = (typeof moveEvents)[number];
// Request to add or remove all, some, or just one item from the "selected" panel
export class DualSelectMoveRequestEvent extends Event {
static readonly eventName = "ak-dual-select-request-move";
constructor(
public move: MoveEventType,
public key?: string,
) {
super(DualSelectMoveRequestEvent.eventName, { bubbles: true, composed: true });
}
}
// Update events
// Request to update the viewset
export class DualSelectUpdateEvent extends Event {
static readonly eventName = "ak-dual-select-update";
constructor() {
super(DualSelectUpdateEvent.eventName, { bubbles: true, composed: true });
}
}
interface DualSelectMoveChangedEvent {
keys: string[];
}
// Request to update the list of "marked for move" items in the "available" panel
export class DualSelectMoveAvailableEvent extends Event implements DualSelectMoveChangedEvent {
static readonly eventName = "ak-dual-select-move-available";
constructor(public keys: string[]) {
super(DualSelectMoveAvailableEvent.eventName, { bubbles: true, composed: true });
}
}
// Request to update the list of "marked for move" items in the "selected" panel
export class DualSelectMoveSelectedEvent extends Event implements DualSelectMoveChangedEvent {
static readonly eventName = "ak-dual-select-move-selected";
constructor(public keys: string[]) {
super(DualSelectMoveSelectedEvent.eventName, { bubbles: true, composed: true });
}
}
// Request to update either panel with a Filter
export class DualSelectPanelSearchEvent extends Event {
static readonly eventName = "ak-dual-select-panel-search";
constructor(
public source: string,
public filterOn: string,
) {
super(DualSelectPanelSearchEvent.eventName, { bubbles: true, composed: true });
}
}
declare global {
interface HTMLElementEventMap {
[DualSelectUpdateEvent.eventName]: DualSelectUpdateEvent;
[DualSelectMoveAvailableEvent.eventName]: DualSelectMoveAvailableEvent;
[DualSelectMoveSelectedEvent.eventName]: DualSelectMoveSelectedEvent;
[DualSelectMoveRequestEvent.eventName]: DualSelectMoveRequestEvent;
[DualSelectPaginatorNavEvent.eventName]: DualSelectPaginatorNavEvent;
[DualSelectSearchEvent.eventName]: DualSelectSearchEvent;
[DualSelectChangeEvent.eventName]: DualSelectChangeEvent;
[DualSelectPanelSearchEvent.eventName]: DualSelectPanelSearchEvent;
}
interface WindowEventMap {
[DualSelectMoveRequestEvent.eventName]: DualSelectMoveRequestEvent;
[DualSelectPaginatorNavEvent.eventName]: DualSelectPaginatorNavEvent;
[DualSelectMoveSelectedEvent.eventName]: DualSelectMoveSelectedEvent;
}
}

View File

@ -6,6 +6,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-dual-select-available-pane";
import { AkDualSelectAvailablePane } from "../components/ak-dual-select-available-pane";
import { DualSelectMoveSelectedEvent } from "../events";
import "./sb-host-provider";
const metadata: Meta<AkDualSelectAvailablePane> = {
@ -53,15 +54,15 @@ const container = (testItem: TemplateResult) =>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const handleMoveChanged = (result: DualSelectMoveSelectedEvent) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
result.detail.forEach((key: string) => {
result.keys.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("ak-dual-select-available-move-changed", handleMoveChanged);
window.addEventListener(DualSelectMoveSelectedEvent.eventName, handleMoveChanged);
type Story = StoryObj;

View File

@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-dual-select-controls";
import { AkDualSelectControls } from "../components/ak-dual-select-controls";
import { DualSelectMoveRequestEvent } from "../events";
const metadata: Meta<AkDualSelectControls> = {
title: "Elements / Dual Select / Control Panel",
@ -59,10 +60,9 @@ const displayMessage = (result: any) => {
target!.appendChild(doc.firstChild!);
};
window.addEventListener("ak-dual-select-add", () => displayMessage("add"));
window.addEventListener("ak-dual-select-remove", () => displayMessage("remove"));
window.addEventListener("ak-dual-select-add-all", () => displayMessage("add all"));
window.addEventListener("ak-dual-select-remove-all", () => displayMessage("remove all"));
window.addEventListener(DualSelectMoveRequestEvent.eventName, (ev: DualSelectMoveRequestEvent) =>
displayMessage(ev.move.toString()),
);
type Story = StoryObj;

View File

@ -9,6 +9,7 @@ import { Pagination } from "@goauthentik/api";
import "../ak-dual-select";
import { AkDualSelect } from "../ak-dual-select";
import { DualSelectPaginatorNavEvent } from "../events";
import type { DualSelectPair } from "../types";
const goodForYouRaw = `
@ -83,11 +84,11 @@ export class AkSbFruity extends LitElement {
totalPages: Math.ceil(this.options.length / this.pageLength),
};
this.onNavigation = this.onNavigation.bind(this);
this.addEventListener("ak-pagination-nav-to", this.onNavigation);
this.addEventListener(DualSelectPaginatorNavEvent.eventName, this.onNavigation);
}
onNavigation(evt: Event) {
const current: number = (evt as CustomEvent).detail;
onNavigation(evt: DualSelectPaginatorNavEvent) {
const current = evt.page;
const index = current - 1;
if (index * this.pageLength > this.options.length) {
console.warn(

View File

@ -6,6 +6,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "../components/ak-dual-select-selected-pane";
import { DualSelectMoveSelectedEvent } from "../events";
import "./sb-host-provider";
const metadata: Meta<AkDualSelectSelectedPane> = {
@ -50,15 +51,15 @@ const container = (testItem: TemplateResult) =>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const handleMoveChanged = (result: DualSelectMoveSelectedEvent) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
result.detail.forEach((key: string) => {
result.keys.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("ak-dual-select-selected-move-changed", handleMoveChanged);
window.addEventListener(DualSelectMoveSelectedEvent.eventName, handleMoveChanged);
type Story = StoryObj;

View File

@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-pagination";
import { AkPagination } from "../components/ak-pagination";
import { DualSelectPaginatorNavEvent } from "../events";
const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control",
@ -43,18 +44,18 @@ const container = (testItem: TemplateResult) =>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const handleMoveChanged = (result: DualSelectPaginatorNavEvent) => {
console.debug(result);
const target = document.querySelector("#action-button-message-pad");
target!.append(
new DOMParser().parseFromString(
`<li>Request to move to page ${result.detail}</li>`,
`<li>Request to move to page ${result.page}</li>`,
"text/xml",
).firstChild!,
);
};
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
window.addEventListener(DualSelectPaginatorNavEvent.eventName, handleMoveChanged);
type Story = StoryObj;

View File

@ -29,10 +29,3 @@ export type DataProvision = {
};
export type DataProvider = (page: number, search?: string) => Promise<DataProvision>;
export interface SearchbarEvent extends CustomEvent {
detail: {
source: string;
value: string;
};
}