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` +
+
+
+
+
+ ${this.availableLabel} +
+
+
+ +
+ ${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 + > +
+
+
+
+ ${this.selectedLabel} +
+
+
+ +
+ ${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` -
    ${map(this.options, ([key, label]) => { @@ -95,6 +147,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { class="pf-c-dual-list-selector__list-item" aria-selected="false" @click=${() => this.onClick(key)} + @dblclick=${() => this.onMove(key)} role="option" tabindex="-1" > @@ -113,7 +166,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { })}
-
`; } } 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` -
    - ${map(this.options, ([key, label]) => { + ${map(this.selected, ([key, label]) => { const selected = classMap({ "pf-m-selected": this.toMove.has(key), }); @@ -88,6 +118,7 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) { aria-selected="false" id="dual-list-selector-basic-selected-pane-list-option-0" @click=${() => this.onClick(key)} + @dblclick=${() => this.onMove(key)} role="option" tabindex="-1" > @@ -104,7 +135,6 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) { })}
-
`; } } 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``; + } +}