diff --git a/web/src/elements/ak-dual-select/ak-dual-select-controls.ts b/web/src/elements/ak-dual-select/ak-dual-select-controls.ts
deleted file mode 100644
index 0b84f32ae..000000000
--- a/web/src/elements/ak-dual-select/ak-dual-select-controls.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { AKElement } from "@goauthentik/elements/Base";
-import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
-
-import { msg } from "@lit/localize";
-import { html, nothing } from "lit";
-import { customElement, property } from "lit/decorators.js";
-
-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];
-
-@customElement("ak-dual-select-controls")
-export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
- static get styles() {
- return styles;
- }
-
- @property({ attribute: "add-active", type: Boolean })
- addActive = false;
-
- @property({ attribute: "remove-active", type: Boolean })
- removeActive = false;
-
- @property({ attribute: "add-all-active", type: Boolean })
- addAllActive = false;
-
- @property({ attribute: "remove-all-active", type: Boolean })
- removeAllActive = false;
-
- @property({ attribute: "disabled", type: Boolean })
- disabled = false;
-
- @property({ attribute: "enable-select-all", type: Boolean })
- selectAll = false;
-
- constructor() {
- super();
- this.onClick = this.onClick.bind(this);
- }
-
- onClick(eventName: string) {
- this.dispatchCustomEvent(eventName);
- }
-
- renderButton(label: string, event: string, active: boolean, direction: string) {
- return html`
-
-
-
- `;
- }
-
- render() {
- // prettier-ignore
- return html`
-
-
- ${this.renderButton(msg("Add"), "ak-dual-select-add", this.addActive, "fa-angle-right")}
- ${this.selectAll
- ? html`
- ${this.renderButton(msg("Add All"), "ak-dual-select-add-all", this.addAllActive, "fa-angle-double-right")}
- ${this.renderButton(msg("Remove All"), "ak-dual-select-remove-all", this.removeAllActive, "fa-angle-double-left")}
- `
- : nothing}
- ${this.renderButton(msg("Remove"), "ak-dual-select-remove", this.removeActive, "fa-angle-left")}
-
-
- `;
- }
-}
-
-export default AkDualSelectControls;
diff --git a/web/src/elements/ak-dual-select/ak-dual-select.ts b/web/src/elements/ak-dual-select/ak-dual-select.ts
new file mode 100644
index 000000000..4e81bf495
--- /dev/null
+++ b/web/src/elements/ak-dual-select/ak-dual-select.ts
@@ -0,0 +1,305 @@
+import { AKElement } from "@goauthentik/elements/Base";
+import {
+ CustomEmitterElement,
+ CustomListenerElement,
+} from "@goauthentik/elements/utils/eventEmitter";
+
+import { msg, str } from "@lit/localize";
+import { css, html, nothing } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { createRef, ref } from "lit/directives/ref.js";
+import type { Ref } from "lit/directives/ref.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+
+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 "./components/ak-dual-select-available-pane";
+import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane";
+import "./components/ak-dual-select-controls";
+import "./components/ak-dual-select-selected-pane";
+import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
+import "./components/ak-pagination";
+import { globalVariables, mainStyles } from "./components/styles.css";
+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 } from "./types";
+
+const styles = [
+ PFBase,
+ PFButton,
+ globalVariables,
+ mainStyles,
+ css`
+ :host {
+ --pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
+ --pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 28.125rem;
+ }
+ .ak-dual-list-selector {
+ display: grid;
+ grid-template-columns:
+ minmax(
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
+ )
+ min-content
+ minmax(
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
+ );
+ }
+ .ak-available-pane,
+ ak-dual-select-controls,
+ .ak-selected-pane {
+ height: 100%;
+ }
+ `,
+];
+
+@customElement("ak-dual-select")
+export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
+ static get styles() {
+ return styles;
+ }
+
+ @property({ type: Array })
+ options: DualSelectPair[] = [];
+
+ @property({ type: Array })
+ selected: DualSelectPair[] = [];
+
+ @property({ type: Object })
+ pages?: BasePagination;
+
+ @property({ attribute: "available-label" })
+ availableLabel = "Available options";
+
+ @property({ attribute: "selected-label" })
+ selectedLabel = "Selected options";
+
+ availablePane: Ref = createRef();
+
+ selectedPane: Ref = createRef();
+
+ constructor() {
+ super();
+ this.handleMove = this.handleMove.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();
+ });
+ }
+
+ get value() {
+ return this.selected;
+ }
+
+ handleMove(eventName: string, event: Event) {
+ 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("change", { value: this.selected });
+ event.stopPropagation();
+ }
+
+ addSelected() {
+ if (this.availablePane.value!.moveable.length === 0) {
+ return;
+ }
+ const options = new Map(this.options);
+ const selected = new Map(this.selected);
+ this.availablePane.value!.moveable.forEach((key) => {
+ const value = options.get(key);
+ if (value) {
+ selected.set(key, value);
+ }
+ });
+ this.selected = Array.from(selected.entries()).sort();
+ this.availablePane.value!.clearMove();
+ }
+
+ addOne(key: string) {
+ const requested = this.options.find(([k, _]) => k === key);
+ if (requested) {
+ this.selected = Array.from(new Map([...this.selected, requested]).entries()).sort();
+ }
+ }
+
+ removeOne(key: string) {
+ this.selected = this.selected.filter(([k, _]) => k !== key);
+ }
+
+ // You must remember, these are the *currently visible* options; the parent node is responsible
+ // for paginating and updating the list of currently visible options;
+ addAllVisible() {
+ const selected = new Map([...this.options, ...this.selected]);
+ this.selected = Array.from(selected.entries()).sort();
+ this.availablePane.value!.clearMove();
+ }
+
+ removeSelected() {
+ if (this.selectedPane.value!.moveable.length === 0) {
+ return;
+ }
+ const deselected = new Set(this.selectedPane.value!.moveable);
+ this.selected = this.selected.filter(([key, _]) => !deselected.has(key));
+ this.selectedPane.value!.clearMove();
+ }
+
+ // Remove all the items from selected that are in the *currently visible* options list
+ removeAllVisible() {
+ const options = new Set(this.options.map(([k, _]) => k));
+ this.selected = this.selected.filter(([k, _]) => !options.has(k));
+ this.selectedPane.value!.clearMove();
+ }
+
+ removeAll() {
+ this.selected = [];
+ this.selectedPane.value!.clearMove();
+ }
+
+ selectedKeys() {
+ return new Set(this.selected.map(([k, _]) => k));
+ }
+
+ get canAddAll() {
+ // False unless any visible option cannot be found in the selected list, so can still be
+ // added.
+ const selected = this.selectedKeys();
+ return this.options.length > 0 && !!this.options.find(([key, _]) => !selected.has(key));
+ }
+
+ get canRemoveAll() {
+ // False if no visible option can be found in the selected list
+ const selected = this.selectedKeys();
+ return this.options.length > 0 && !!this.options.find(([key, _]) => selected.has(key));
+ }
+
+ get needPagination() {
+ return (this.pages?.next ?? 0) > 0 || (this.pages?.previous ?? 0) > 0;
+ }
+
+ render() {
+ const selected = this.selectedKeys();
+ const availableCount = this.availablePane.value?.toMove.size ?? 0;
+ const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
+ const selectedTotal = this.selected.length;
+ const availableStatus =
+ availableCount > 0 ? msg(str`${availableCount} items marked to add.`) : " ";
+ const selectedTotalStatus = msg(str`${selectedTotal} items selected.`);
+ const selectedCountStatus =
+ selectedCount > 0 ? " " + msg(str`${selectedCount} items marked to remove.`) : "";
+ const selectedStatus = `${selectedTotalStatus}${selectedCountStatus}`;
+
+ return html`
+
+
+
+
+
+ ${unsafeHTML(availableStatus)}
+
+
+ ${this.needPagination
+ ? html`
`
+ : nothing}
+
+
0}
+ ?remove-active=${(this.selectedPane.value?.moveable.length ?? 0) > 0}
+ ?add-all-active=${this.canAddAll}
+ ?remove-all-active=${this.canRemoveAll}
+ ?delete-all-active=${this.selected.length !== 0}
+ enable-select-all
+ enable-delete-all
+ >
+
+
+
+
+ ${unsafeHTML(selectedStatus)}
+
+
+
+
+
+ `;
+ }
+}
diff --git a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts b/web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts
similarity index 55%
rename from web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts
rename to web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts
index d7ce75ffc..7441a8369 100644
--- a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts
+++ b/web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts
@@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { css, html, nothing } from "lit";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
@@ -10,7 +10,8 @@ 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 type { DualSelectPair } from "./types";
+import { EVENT_ADD_ONE } from "../constants";
+import type { DualSelectPair } from "../types";
const styles = [
PFBase,
@@ -26,7 +27,10 @@ const styles = [
font-weight: 200;
color: var(--pf-global--palette--black-500);
font-size: var(--pf-global--FontSize--xs);
- }
+}
+.pf-c-dual-list-selector__menu {
+width: 1fr;
+}
`,
];
@@ -36,32 +40,65 @@ const hostAttributes = [
["role", "listbox"],
];
+/**
+ * @element ak-dual-select-available-panel
+ *
+ * The "available options" or "left" pane in a dual-list multi-select. It receives from its parent a
+ * list of options to show *now*, the list of all "selected" options, and maintains an internal list
+ * of objects selected to move. "selected" options are marked with a checkmark to show they're
+ * already in the "selected" collection and would be pointless to move.
+ *
+ * @fires ak-dual-select-available-move-changed - When the list of "to move" entries changed. Includes the current * `toMove` content.
+ * @fires ak-dual-select-add-one - Doubleclick with the element clicked on.
+ *
+ * It is not expected that the `ak-dual-select-available-move-changed` will be used; instead, the
+ * attribute will be read by the parent when a control is clicked.
+ *
+ */
@customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
+ /* The array of key/value pairs this pane is currently showing */
@property({ type: Array })
- options: DualSelectPair[] = [];
+ readonly options: DualSelectPair[] = [];
- @property({ attribute: "to-move", type: Object })
- toMove: Set = new Set();
+ /* An set (set being easy for lookups) of keys with all the pairs selected, so that the ones
+ * currently being shown that have already been selected can be marked and their clicks ignored.
+ *
+ */
+ @property({ type: Object })
+ readonly selected: Set = new Set();
- @property({ attribute: "selected", type: Object })
- selected: Set = new Set();
-
- @property({ attribute: "disabled", type: Boolean })
- disabled = false;
+ /* 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 = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
+ this.onMove = this.onMove.bind(this);
+ }
+
+ get moveable() {
+ return Array.from(this.toMove.values());
+ }
+
+ clearMove() {
+ this.toMove = new Set();
}
onClick(key: string) {
if (this.selected.has(key)) {
// An already selected item cannot be moved into the "selected" category
+ console.warn(`Attempted to mark '${key}' when it should have been unavailable`);
return;
}
if (this.toMove.has(key)) {
@@ -69,8 +106,19 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
} else {
this.toMove.add(key);
}
- this.requestUpdate(); // Necessary because updating a map won't trigger a state change
- this.dispatchCustomEvent("ak-dual-select-move-changed", Array.from(this.toMove.keys()));
+ 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();
}
connectedCallback() {
@@ -82,9 +130,13 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
});
}
+ // 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() {
return html`
-
-
`;
}
}
diff --git a/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts b/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts
new file mode 100644
index 000000000..b92cf86c8
--- /dev/null
+++ b/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts
@@ -0,0 +1,165 @@
+import { AKElement } from "@goauthentik/elements/Base";
+import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
+
+import { msg } from "@lit/localize";
+import { css, html, nothing } from "lit";
+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";
+
+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
+ *
+ * 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 variet of messages to the parent
+ * orchestrator which will then reconcile the "available" and "selected" panes at need.
+
+ */
+
+@customElement("ak-dual-select-controls")
+export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
+ static get styles() {
+ return styles;
+ }
+
+ /* Set to true if any *visible* elements can be added to the selected list
+ */
+ @property({ attribute: "add-active", type: Boolean })
+ addActive = false;
+
+ /* Set to true if any elements can be removed from the selected list (essentially,
+ * If the selected list is not empty
+ */
+ @property({ attribute: "remove-active", type: Boolean })
+ removeActive = false;
+
+ /* Set to true if *all* the currently visible elements can be moved
+ * into the selected list (essentially, if any visible elemnets are
+ * not currently selected
+ */
+ @property({ attribute: "add-all-active", type: Boolean })
+ addAllActive = false;
+
+ /* Set to true if *any* of the elements currently visible in the available
+ * pane are available to be moved to the selected list, enabling that
+ * all of those specific elements be moved out of the selected list
+ */
+ @property({ attribute: "remove-all-active", type: Boolean })
+ removeAllActive = false;
+
+ /* if deleteAll is enabled, set to true to show that there are elements in the
+ * selected list that can be deleted.
+ */
+ @property({ attribute: "delete-all-active", type: Boolean })
+ enableDeleteAll = false;
+
+ /* Set to true if you want the `...AllActive` buttons made available. */
+ @property({ attribute: "enable-select-all", type: Boolean })
+ selectAll = false;
+
+ /* Set to true if you want the `ClearAllSelected` button made available */
+ @property({ attribute: "enable-delete-all", type: Boolean })
+ deleteAll = false;
+
+ constructor() {
+ super();
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(eventName: string) {
+ this.dispatchCustomEvent(eventName);
+ }
+
+ renderButton(label: string, event: string, active: boolean, direction: string) {
+ return html`
+
+
+
+ `;
+ }
+
+ render() {
+ return html`
+
+ ${this.renderButton(
+ msg("Add"),
+ EVENT_ADD_SELECTED,
+ this.addActive,
+ "fa-angle-right"
+ )}
+ ${this.selectAll
+ ? html`
+ ${this.renderButton(
+ msg("Add All Available"),
+ EVENT_ADD_ALL,
+ this.addAllActive,
+ "fa-angle-double-right"
+ )}
+ ${this.renderButton(
+ msg("Remove All Available"),
+ EVENT_REMOVE_ALL,
+ this.removeAllActive,
+ "fa-angle-double-left"
+ )}
+ `
+ : nothing}
+ ${this.renderButton(
+ msg("Remove"),
+ EVENT_REMOVE_SELECTED,
+ this.removeActive,
+ "fa-angle-left"
+ )}
+ ${this.deleteAll
+ ? html`${this.renderButton(
+ msg("Remove All"),
+ EVENT_DELETE_ALL,
+ this.enableDeleteAll,
+ "fa-times"
+ )}`
+ : nothing}
+
+ `;
+ }
+}
+
+export default AkDualSelectControls;
diff --git a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts b/web/src/elements/ak-dual-select/components/ak-dual-select-selected-pane.ts
similarity index 59%
rename from web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts
rename to web/src/elements/ak-dual-select/components/ak-dual-select-selected-pane.ts
index b0c807b9e..a46209d82 100644
--- a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts
+++ b/web/src/elements/ak-dual-select/components/ak-dual-select-selected-pane.ts
@@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { css, html } from "lit";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
@@ -10,46 +10,69 @@ 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 type { DualSelectPair } from "./types";
+import type { DualSelectPair } from "../types";
+import { EVENT_REMOVE_ONE } from "../constants";
+import { selectedPaneStyles } from "./styles.css";
const styles = [
PFBase,
PFButton,
PFDualListSelector,
- css`
- .pf-c-dual-list-selector__item {
- padding: 0.25rem;
- }
- input[type="checkbox"][readonly] {
- pointer-events: none;
- }
- `,
+ selectedPaneStyles
];
+
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
+/**
+ * @element ak-dual-select-available-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 - Doubleclick 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.
+ *
+ */
@customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
+ /* The array of key/value pairs that are in the selected list. ALL of them. */
@property({ type: Array })
- options: DualSelectPair[] = [];
+ readonly selected: DualSelectPair[] = [];
- @property({ attribute: "to-move", type: Object })
- toMove: Set = new Set();
-
- @property({ attribute: "disabled", type: Boolean })
- disabled = false;
+ /*
+ * 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 = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
+ this.onMove = this.onMove.bind(this);
+ }
+
+ get moveable() {
+ return Array.from(this.toMove.values());
+ }
+
+ clearMove() {
+ this.toMove = new Set();
}
onClick(key: string) {
@@ -58,11 +81,19 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
} else {
this.toMove.add(key);
}
- this.requestUpdate(); // Necessary because updating a map won't trigger a state change
this.dispatchCustomEvent(
"ak-dual-select-selected-move-changed",
- Array.from(this.toMove.keys()),
+ 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_REMOVE_ONE, key);
+ this.requestUpdate();
}
connectedCallback() {
@@ -76,10 +107,9 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
render() {
return html`
-
-
`;
}
}
diff --git a/web/src/elements/ak-dual-select/ak-pagination.ts b/web/src/elements/ak-dual-select/components/ak-pagination.ts
similarity index 96%
rename from web/src/elements/ak-dual-select/ak-pagination.ts
rename to web/src/elements/ak-dual-select/components/ak-pagination.ts
index b919d7bde..4a99a8325 100644
--- a/web/src/elements/ak-dual-select/ak-pagination.ts
+++ b/web/src/elements/ak-dual-select/components/ak-pagination.ts
@@ -8,8 +8,8 @@ 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 { CustomEmitterElement } from "../utils/eventEmitter";
-import type { BasePagination } from "./types";
+import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
+import type { BasePagination } from "../types";
const styles = [
PFBase,
diff --git a/web/src/elements/ak-dual-select/components/styles.css.ts b/web/src/elements/ak-dual-select/components/styles.css.ts
new file mode 100644
index 000000000..c6626ac5c
--- /dev/null
+++ b/web/src/elements/ak-dual-select/components/styles.css.ts
@@ -0,0 +1,122 @@
+import { css } from "lit";
+
+export const globalVariables = css`
+ :host {
+ --pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
+ --pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 28.125rem;
+ --pf-c-dual-list-selector__header--MarginBottom: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__title-text--FontWeight: var(--pf-global--FontWeight--bold);
+ --pf-c-dual-list-selector__tools--MarginBottom: var(--pf-global--spacer--md);
+ --pf-c-dual-list-selector__tools-filter--tools-actions--MarginLeft: var(
+ --pf-global--spacer--sm
+ );
+ --pf-c-dual-list-selector__menu--BorderWidth: var(--pf-global--BorderWidth--sm);
+ --pf-c-dual-list-selector__menu--BorderColor: var(--pf-global--BorderColor--100);
+ --pf-c-dual-list-selector__menu--MinHeight: 12.5rem;
+ --pf-c-dual-list-selector__menu--MaxHeight: 20rem;
+ --pf-c-dual-list-selector__list-item-row--FontSize: var(--pf-global--FontSize--sm);
+ --pf-c-dual-list-selector__list-item-row--BackgroundColor: transparent;
+ --pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
+ --pf-global--BackgroundColor--light-300
+ );
+ --pf-c-dual-list-selector__list-item-row--focus-within--BackgroundColor: var(
+ --pf-global--BackgroundColor--light-300
+ );
+ --pf-c-dual-list-selector__list-item-row--m-selected--BackgroundColor: var(
+ --pf-global--BackgroundColor--light-300
+ );
+ --pf-c-dual-list-selector__list-item--m-ghost-row--BackgroundColor: var(
+ --pf-global--BackgroundColor--100
+ );
+ --pf-c-dual-list-selector__list-item--m-ghost-row--Opacity: 0.4;
+ --pf-c-dual-list-selector__item--PaddingTop: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item--PaddingRight: var(--pf-global--spacer--md);
+ --pf-c-dual-list-selector__item--PaddingBottom: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item--PaddingLeft: var(--pf-global--spacer--md);
+ --pf-c-dual-list-selector__item--m-expandable--PaddingLeft: 0;
+ --pf-c-dual-list-selector__item--indent--base: calc(
+ var(--pf-global--spacer--md) + var(--pf-global--spacer--sm) +
+ var(--pf-c-dual-list-selector__list-item-row--FontSize)
+ );
+ --pf-c-dual-list-selector__item--nested-indent--base: calc(
+ var(--pf-c-dual-list-selector__item--indent--base) - var(--pf-global--spacer--md)
+ );
+ --pf-c-dual-list-selector__draggable--item--PaddingLeft: var(--pf-global--spacer--xs);
+ --pf-c-dual-list-selector__item-text--Color: var(--pf-global--Color--100);
+ --pf-c-dual-list-selector__list-item-row--m-selected__text--Color: var(
+ --pf-global--active-color--100
+ );
+ --pf-c-dual-list-selector__list-item-row--m-selected__text--FontWeight: var(
+ --pf-global--FontWeight--bold
+ );
+ --pf-c-dual-list-selector__list-item--m-disabled__item-text--Color: var(
+ --pf-global--disabled-color--100
+ );
+ --pf-c-dual-list-selector__status--MarginBottom: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__status-text--FontSize: var(--pf-global--FontSize--sm);
+ --pf-c-dual-list-selector__status-text--Color: var(--pf-global--Color--200);
+ --pf-c-dual-list-selector__controls--PaddingRight: var(--pf-global--spacer--md);
+ --pf-c-dual-list-selector__controls--PaddingLeft: var(--pf-global--spacer--md);
+ --pf-c-dual-list-selector__item-toggle--PaddingTop: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item-toggle--PaddingRight: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item-toggle--PaddingBottom: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item-toggle--PaddingLeft: var(--pf-global--spacer--md);
+ --pf-c-dual-list-selector__item-toggle--MarginTop: calc(var(--pf-global--spacer--sm) * -1);
+ --pf-c-dual-list-selector__item-toggle--MarginBottom: calc(
+ var(--pf-global--spacer--sm) * -1
+ );
+ --pf-c-dual-list-selector__list__list__item-toggle--Left: 0;
+ --pf-c-dual-list-selector__list__list__item-toggle--TranslateX: -100%;
+ --pf-c-dual-list-selector__item-check--MarginRight: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item-count--Marginleft: var(--pf-global--spacer--sm);
+ --pf-c-dual-list-selector__item--c-badge--m-read--BackgroundColor: var(
+ --pf-global--disabled-color--200
+ );
+ --pf-c-dual-list-selector__item-toggle-icon--Rotate: 0;
+ --pf-c-dual-list-selector__list-item--m-expanded__item-toggle-icon--Rotate: 90deg;
+ --pf-c-dual-list-selector__item-toggle-icon--Transition: var(--pf-global--Transition);
+ --pf-c-dual-list-selector__item-toggle-icon--MinWidth: var(
+ --pf-c-dual-list-selector__list-item-row--FontSize
+ );
+ --pf-c-dual-list-selector__list-item--m-disabled__item-toggle-icon--Color: var(
+ --pf-global--disabled-color--200
+ );
+ }
+`;
+
+export const mainStyles = css`
+ .pf-c-dual-list-selector__title-text {
+ font-weight: var(--pf-c-dual-list-selector__title-text--FontWeight);
+ }
+
+ .pf-c-dual-list-selector__status-text {
+ font-size: var(--pf-c-dual-list-selector__status-text--FontSize);
+ color: var(--pf-c-dual-list-selector__status-text--Color);
+ }
+
+ .ak-dual-list-selector {
+ display: grid;
+ grid-template-columns:
+ minmax(
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
+ )
+ min-content
+ minmax(
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
+ var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
+ );
+ }
+`;
+
+export const selectedPaneStyles = css`
+.pf-c-dual-list-selector__menu {
+height: 100%;
+}
+.pf-c-dual-list-selector__item {
+padding: 0.25rem;
+}
+input[type="checkbox"][readonly] {
+pointer-events: none;
+}
+`;
diff --git a/web/src/elements/ak-dual-select/constants.ts b/web/src/elements/ak-dual-select/constants.ts
new file mode 100644
index 000000000..8e7db5369
--- /dev/null
+++ b/web/src/elements/ak-dual-select/constants.ts
@@ -0,0 +1,7 @@
+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";
diff --git a/web/src/elements/ak-dual-select/index.ts b/web/src/elements/ak-dual-select/index.ts
new file mode 100644
index 000000000..cd8572289
--- /dev/null
+++ b/web/src/elements/ak-dual-select/index.ts
@@ -0,0 +1,5 @@
+import { AkDualSelect } from "./ak-dual-select";
+import "./ak-dual-select";
+
+export { AkDualSelect }
+export default AkDualSelect;
diff --git a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-available-pane.stories.ts
similarity index 88%
rename from web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts
rename to web/src/elements/ak-dual-select/stories/ak-dual-select-available-pane.stories.ts
index 76b428647..262cfe11c 100644
--- a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts
+++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-available-pane.stories.ts
@@ -4,8 +4,9 @@ import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
-import "./ak-dual-select-available-pane";
-import { AkDualSelectAvailablePane } from "./ak-dual-select-available-pane";
+import "../components/ak-dual-select-available-pane";
+import "./sb-host-provider";
+import { AkDualSelectAvailablePane } from "../components/ak-dual-select-available-pane";
const metadata: Meta = {
title: "Elements / Dual Select / Available Items Pane",
@@ -46,7 +47,9 @@ const container = (testItem: TemplateResult) =>
}
+
${testItem}
+
Messages received from the button:
`;
@@ -60,7 +63,7 @@ const handleMoveChanged = (result: any) => {
});
};
-window.addEventListener("ak-dual-select-move-changed", handleMoveChanged);
+window.addEventListener("ak-dual-select-available-move-changed", handleMoveChanged);
type Story = StoryObj;
@@ -109,6 +112,6 @@ export const SomeSelected: Story = {
html` `,
+>`,
),
};
diff --git a/web/src/elements/ak-dual-select/ak-dual-select-controls.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-controls.stories.ts
similarity index 96%
rename from web/src/elements/ak-dual-select/ak-dual-select-controls.stories.ts
rename to web/src/elements/ak-dual-select/stories/ak-dual-select-controls.stories.ts
index 7c903a618..ad89d535b 100644
--- a/web/src/elements/ak-dual-select/ak-dual-select-controls.stories.ts
+++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-controls.stories.ts
@@ -3,8 +3,8 @@ import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
-import "./ak-dual-select-controls";
-import { AkDualSelectControls } from "./ak-dual-select-controls";
+import "../components/ak-dual-select-controls";
+import { AkDualSelectControls } from "../components/ak-dual-select-controls";
const metadata: Meta = {
title: "Elements / Dual Select / Control Panel",
diff --git a/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts
new file mode 100644
index 000000000..d49591076
--- /dev/null
+++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts
@@ -0,0 +1,151 @@
+import "@goauthentik/elements/messages/MessageContainer";
+import { LitElement, TemplateResult, html } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+
+import { Meta, StoryObj } from "@storybook/web-components";
+import { slug } from "github-slugger";
+
+
+import type { DualSelectPair } from "../types";
+import { Pagination } from "@goauthentik/api";
+
+import "../ak-dual-select";
+import { AkDualSelect } from "../ak-dual-select";
+
+const goodForYouRaw = `
+Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root,
+Blackberry, Blueberry, Bok Choy, Broccoli, Brussels sprouts, Cabbage, Cantaloupes, Carrot,
+Cauliflower, Celery, Chayote, Chives, Cilantro, Coconut, Collard Greens, Corn, Cucumber, Daikon,
+Date, Dill, Eggplant, Endive, Fennel, Fig, Garbanzo Bean, Garlic, Ginger, Gourds, Grape, Guava,
+Honeydew, Horseradish, Iceberg Lettuce, Jackfruit, Jicama, Kale, Kangkong, Kiwi, Kohlrabi, Leek,
+Lentils, Lychee, Macadamia, Mango, Mushroom, Mustard, Nectarine, Okra, Onion, Papaya, Parsley,
+Parsley root, Parsnip, Passion Fruit, Peach, Pear, Peas, Peppers, Persimmon, Pimiento, Pineapple,
+Plum, Plum, Pomegranate, Potato, Pumpkin, Radicchio, Radish, Raspberry, Rhubarb, Romaine Lettuce,
+Rosemary, Rutabaga, Shallot, Soybeans, Spinach, Squash, Strawberries, Sweet potato, Swiss Chard,
+Thyme, Tomatillo, Tomato, Turnip, Waterchestnut, Watercress, Watermelon, Yams
+`;
+
+const keyToPair = (key: string): DualSelectPair => ([slug(key), key]);
+const goodForYou: DualSelectPair[] = goodForYouRaw
+ .replace("\n", " ")
+ .split(",")
+ .map((a: string) => a.trim())
+ .map(keyToPair);
+
+const metadata: Meta = {
+ title: "Elements / Dual Select / Dual Select With Pagination",
+ component: "ak-dual-select",
+ parameters: {
+ docs: {
+ description: {
+ component: "The three-panel assembly",
+ },
+ },
+ },
+ argTypes: {
+ options: {
+ type: "string",
+ description: "An array of [key, label] pairs of what to show",
+ },
+ selected: {
+ type: "string",
+ description: "An array of [key] of what has already been selected",
+ },
+ pages: {
+ type: "string",
+ description: "An authentik pagination object.",
+ },
+ },
+};
+
+export default metadata;
+
+@customElement("ak-sb-fruity")
+class AkSbFruity extends LitElement {
+
+ @property({ type: Array })
+ options: DualSelectPair[] = goodForYou;
+
+ @property({ attribute: "page-length", type: Number })
+ pageLength = 20;
+
+ @state()
+ page: Pagination;
+
+ constructor() {
+ super();
+ this.page = {
+ count: this.options.length,
+ current: 1,
+ startIndex: 1,
+ endIndex: this.options.length > this.pageLength ? this.pageLength : this.options.length,
+ next: this.options.length > this.pageLength ? 2 : 0,
+ previous: 0,
+ totalPages: Math.ceil(this.options.length / this.pageLength)
+ };
+ this.onNavigation = this.onNavigation.bind(this);
+ this.addEventListener('ak-pagination-nav-to',
+ this.onNavigation);
+ }
+
+ onNavigation(evt: Event) {
+ const current: number = (evt as CustomEvent).detail;
+ const index = current - 1;
+ if ((index * this.pageLength) > this.options.length) {
+ console.warn(`Attempted to index from ${index} for options length ${this.options.length}`);
+ return;
+ }
+ const endCount = this.pageLength * (index + 1);
+ const endIndex = Math.min(endCount, this.options.length);
+
+ this.page = {
+ ...this.page,
+ current,
+ startIndex: this.pageLength * index + 1,
+ endIndex,
+ next: ((index + 1) * this.pageLength > this.options.length) ? 0 : current + 1,
+ previous: index
+ };
+ }
+
+ get pageoptions() {
+ return this.options.slice(this.pageLength * (this.page.current - 1),
+ this.pageLength * (this.page.current));
+ }
+
+ render() {
+ return html``;
+ }
+}
+
+
+const container = (testItem: TemplateResult) =>
+ html`
+
+
+ ${testItem}
+
Messages received from the button:
+
+
`;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const handleMoveChanged = (result: any) => {
+ const target = document.querySelector("#action-button-message-pad");
+ target!.innerHTML = "";
+ target!.append(result.detail.value.map(([k, _]) => k).join(", "));
+};
+
+window.addEventListener("change", handleMoveChanged);
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => container(html` `),
+};
diff --git a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-selected-pane.stories.ts
similarity index 87%
rename from web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts
rename to web/src/elements/ak-dual-select/stories/ak-dual-select-selected-pane.stories.ts
index 4fdd48009..dc80e5e30 100644
--- a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts
+++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-selected-pane.stories.ts
@@ -4,8 +4,9 @@ import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
-import "./ak-dual-select-selected-pane";
-import { AkDualSelectSelectedPane } from "./ak-dual-select-selected-pane";
+import "../components/ak-dual-select-selected-pane";
+import "./sb-host-provider";
+import { AkDualSelectSelectedPane } from "../components/ak-dual-select-selected-pane";
const metadata: Meta = {
title: "Elements / Dual Select / Selected Items Pane",
@@ -41,8 +42,10 @@ const container = (testItem: TemplateResult) =>
margin-top: 1em;
}
-
+
+
${testItem}
+
Messages received from the button:
`;
@@ -88,7 +91,7 @@ export const Default: Story = {
render: () =>
container(
html` `,
),
};
diff --git a/web/src/elements/ak-dual-select/stories/ak-dual-select.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select.stories.ts
new file mode 100644
index 000000000..67c0cb3d0
--- /dev/null
+++ b/web/src/elements/ak-dual-select/stories/ak-dual-select.stories.ts
@@ -0,0 +1,93 @@
+import "@goauthentik/elements/messages/MessageContainer";
+import { Meta, StoryObj } from "@storybook/web-components";
+import { slug } from "github-slugger";
+
+import { TemplateResult, html } from "lit";
+
+import "../ak-dual-select";
+import { AkDualSelect } from "../ak-dual-select";
+
+const metadata: Meta = {
+ title: "Elements / Dual Select / Dual Select",
+ component: "ak-dual-select",
+ parameters: {
+ docs: {
+ description: {
+ component: "The three-panel assembly",
+ },
+ },
+ },
+ argTypes: {
+ options: {
+ type: "string",
+ description: "An array of [key, label] pairs of what to show",
+ },
+ selected: {
+ type: "string",
+ description: "An array of [key] of what has already been selected",
+ },
+ pages: {
+ type: "string",
+ description: "An authentik pagination object.",
+ },
+ },
+};
+
+export default metadata;
+
+const container = (testItem: TemplateResult) =>
+ html`
+
+
+ ${testItem}
+
Messages received from the button:
+
+
`;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const handleMoveChanged = (result: any) => {
+ const target = document.querySelector("#action-button-message-pad");
+ target!.innerHTML = "";
+ result.detail.value.forEach((key: string) => {
+ target!.append(new DOMParser().parseFromString(`${key}`, "text/xml").firstChild!);
+ });
+};
+
+window.addEventListener("change", handleMoveChanged);
+
+type Story = StoryObj;
+
+const goodForYou = [
+ "Apple",
+ "Arrowroot",
+ "Artichoke",
+ "Arugula",
+ "Asparagus",
+ "Avocado",
+ "Bamboo",
+ "Banana",
+ "Basil",
+ "Beet Root",
+ "Blackberry",
+ "Blueberry",
+ "Bok Choy",
+ "Broccoli",
+ "Brussels sprouts",
+ "Cabbage",
+ "Cantaloupes",
+ "Carrot",
+ "Cauliflower",
+];
+
+const goodForYouPairs = goodForYou.map((key) => [slug(key), key]);
+
+export const Default: Story = {
+ render: () => container(html` `),
+};
diff --git a/web/src/elements/ak-dual-select/ak-pagination.stories.ts b/web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts
similarity index 95%
rename from web/src/elements/ak-dual-select/ak-pagination.stories.ts
rename to web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts
index 01183bbe5..739855620 100644
--- a/web/src/elements/ak-dual-select/ak-pagination.stories.ts
+++ b/web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts
@@ -3,8 +3,8 @@ import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
-import "./ak-pagination";
-import { AkPagination } from "./ak-pagination";
+import "../components/ak-pagination";
+import { AkPagination } from "../components/ak-pagination";
const metadata: Meta = {
title: "Elements / Dual Select / Pagination Control",
diff --git a/web/src/elements/ak-dual-select/stories/sb-host-provider.ts b/web/src/elements/ak-dual-select/stories/sb-host-provider.ts
new file mode 100644
index 000000000..575bbf6e5
--- /dev/null
+++ b/web/src/elements/ak-dual-select/stories/sb-host-provider.ts
@@ -0,0 +1,21 @@
+import { html, LitElement } from "lit";
+import { globalVariables } from "../components/styles.css";
+import { customElement } from "lit/decorators.js";
+
+/**
+ * @element sb-dual-select-host-provider
+ *
+ * A *very simple* wrapper which provides the CSS Custom Properties used by the components when
+ * being displayed in Storybook or Vite. Not needed for the parent widget since it provides these by itself.
+ */
+
+@customElement("sb-dual-select-host-provider")
+export class SbHostProvider extends LitElement {
+ static get styles() {
+ return globalVariables;
+ }
+
+ render() {
+ return html``;
+ }
+}