web: provide a "select / select all" tool for the dual list multiselect

**This commit**

1. Re-arrange the contents of the folder so that the sub-components are in their own folder. This
   reduces the clutter and makes it easier to understand where to look for certain things.
2. Re-arranges the contents of the folder so that all the Storybook stories are in their own folder.
   Again, this reduces the clutter; it also helps the compiler understand what not to compile.
3. Strips down the "Available items pane" to a minimal amount of interactivity and annotates the
   passed-in properties as `readonly`, since the purpose of this component is to display those. The
   only internal state kept is the list of items marked-to-move.
4. Does the same thing with the "Selected items pane".
5. Added comments to help guide future maintainers.
6. Restructured the CSS, taking a _lot_ of it into our own hands. Patternfly continues to act as if
   all components are fully available all the time, and that's simply not true in a shadowDOM
   environment. By separating out the global CSS Custom Properties from the grid and style
   definitions of `pf-c-dual-list-selector`, I was able to construct a more simple and
   straightforward grid (with nested grids for the columns inside).
7. Added "Delete ALL Selected" to the controls
8. Added "double-click" as a "move this one NOW" feature.
This commit is contained in:
Ken Sternberg 2023-12-29 09:38:50 -08:00
parent 9996eafe75
commit cef378da82
16 changed files with 1007 additions and 134 deletions

View File

@ -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`
<div class="pf-c-dual-list-selector__controls-item">
<button
?aria-disabled=${this.disabled || !active}
?disabled=${this.disabled || !active}
aria-label=${label}
class="pf-c-button pf-m-plain"
type="button"
@click=${() => this.onClick(event)}
data-ouia-component-type="AK/Button"
>
<i class="fa ${direction}"></i>
</button>
</div>
</div>`;
}
render() {
// prettier-ignore
return html`
<div class="pf-c-dual-list-selector">
<div class="pf-c-dual-list-selector__controls">
${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")}
</div>
</div>
`;
}
}
export default AkDualSelectControls;

View File

@ -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<AkDualSelectAvailablePane> = createRef();
selectedPane: Ref<AkDualSelectSelectedPane> = 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.`) : "&nbsp;";
const selectedTotalStatus = msg(str`${selectedTotal} items selected.`);
const selectedCountStatus =
selectedCount > 0 ? " " + msg(str`${selectedCount} items marked to remove.`) : "";
const selectedStatus = `${selectedTotalStatus}${selectedCountStatus}`;
return html`
<div class="ak-dual-list-selector">
<div class="ak-available-pane">
<div class="pf-c-dual-list-selector__header">
<div class="pf-c-dual-list-selector__title">
<div class="pf-c-dual-list-selector__title-text">
${this.availableLabel}
</div>
</div>
</div>
<div class="pf-c-dual-list-selector__status">
<span
class="pf-c-dual-list-selector__status-text"
id="basic-available-status-text"
>${unsafeHTML(availableStatus)}</span
>
</div>
<ak-dual-select-available-pane
${ref(this.availablePane)}
.options=${this.options}
.selected=${selected}
></ak-dual-select-available-pane>
${this.needPagination
? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
: nothing}
</div>
<ak-dual-select-controls
?add-active=${(this.availablePane.value?.moveable.length ?? 0) > 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
></ak-dual-select-controls>
<div class="ak-selected-pane">
<div class="pf-c-dual-list-selector__header">
<div class="pf-c-dual-list-selector__title">
<div class="pf-c-dual-list-selector__title-text">
${this.selectedLabel}
</div>
</div>
</div>
<div class="pf-c-dual-list-selector__status">
<span
class="pf-c-dual-list-selector__status-text"
id="basic-available-status-text"
>${unsafeHTML(selectedStatus)}</span
>
</div>
<ak-dual-select-selected-pane
${ref(this.selectedPane)}
.selected=${this.selected}
></ak-dual-select-selected-pane>
</div>
</div>
`;
}
}

View File

@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { css, html, nothing } from "lit"; 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 { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/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 PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { DualSelectPair } from "./types"; import { EVENT_ADD_ONE } from "../constants";
import type { DualSelectPair } from "../types";
const styles = [ const styles = [
PFBase, PFBase,
@ -26,7 +27,10 @@ const styles = [
font-weight: 200; font-weight: 200;
color: var(--pf-global--palette--black-500); color: var(--pf-global--palette--black-500);
font-size: var(--pf-global--FontSize--xs); font-size: var(--pf-global--FontSize--xs);
} }
.pf-c-dual-list-selector__menu {
width: 1fr;
}
`, `,
]; ];
@ -36,32 +40,65 @@ const hostAttributes = [
["role", "listbox"], ["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") @customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
static get styles() { static get styles() {
return styles; return styles;
} }
/* The array of key/value pairs this pane is currently showing */
@property({ type: Array }) @property({ type: Array })
options: DualSelectPair[] = []; readonly options: DualSelectPair[] = [];
@property({ attribute: "to-move", type: Object }) /* An set (set being easy for lookups) of keys with all the pairs selected, so that the ones
toMove: Set<string> = new Set(); * currently being shown that have already been selected can be marked and their clicks ignored.
*
*/
@property({ type: Object })
readonly selected: Set<string> = new Set();
@property({ attribute: "selected", type: Object }) /* This is the only mutator for this object. It collects the list of objects the user has
selected: Set<string> = new Set(); * 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
@property({ attribute: "disabled", type: Boolean }) * moved (removed) if the user so requests.
disabled = false; *
*/
@state()
public toMove: Set<string> = new Set();
constructor() { constructor() {
super(); super();
this.onClick = this.onClick.bind(this); 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) { onClick(key: string) {
if (this.selected.has(key)) { if (this.selected.has(key)) {
// An already selected item cannot be moved into the "selected" category // An already selected item cannot be moved into the "selected" category
console.warn(`Attempted to mark '${key}' when it should have been unavailable`);
return; return;
} }
if (this.toMove.has(key)) { if (this.toMove.has(key)) {
@ -69,8 +106,19 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
} else { } else {
this.toMove.add(key); this.toMove.add(key);
} }
this.requestUpdate(); // Necessary because updating a map won't trigger a state change this.dispatchCustomEvent(
this.dispatchCustomEvent("ak-dual-select-move-changed", Array.from(this.toMove.keys())); "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() { 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() { render() {
return html` return html`
<div class="pf-c-dual-list-selector">
<div class="pf-c-dual-list-selector__menu"> <div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list"> <ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => { ${map(this.options, ([key, label]) => {
@ -95,6 +147,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
class="pf-c-dual-list-selector__list-item" class="pf-c-dual-list-selector__list-item"
aria-selected="false" aria-selected="false"
@click=${() => this.onClick(key)} @click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option" role="option"
tabindex="-1" tabindex="-1"
> >
@ -113,7 +166,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
})} })}
</ul> </ul>
</div> </div>
</div>
`; `;
} }
} }

View File

@ -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`
<div class="pf-c-dual-list-selector__controls-item">
<button
?aria-disabled=${this.disabled || !active}
?disabled=${this.disabled || !active}
aria-label=${label}
class="pf-c-button pf-m-plain"
type="button"
@click=${() => this.onClick(event)}
data-ouia-component-type="AK/Button"
>
<i class="fa ${direction}"></i>
</button>
</div>
</div>`;
}
render() {
return html`
<div class="ak-dual-list-selector__controls">
${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}
</div>
`;
}
}
export default AkDualSelectControls;

View File

@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { css, html } from "lit"; 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 { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/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 PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { DualSelectPair } from "./types"; import type { DualSelectPair } from "../types";
import { EVENT_REMOVE_ONE } from "../constants";
import { selectedPaneStyles } from "./styles.css";
const styles = [ const styles = [
PFBase, PFBase,
PFButton, PFButton,
PFDualListSelector, PFDualListSelector,
css` selectedPaneStyles
.pf-c-dual-list-selector__item {
padding: 0.25rem;
}
input[type="checkbox"][readonly] {
pointer-events: none;
}
`,
]; ];
const hostAttributes = [ const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"], ["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"], ["aria-multiselectable", "true"],
["role", "listbox"], ["role", "listbox"],
]; ];
/**
* @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") @customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) { export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
static get styles() { static get styles() {
return styles; return styles;
} }
/* The array of key/value pairs that are in the selected list. ALL of them. */
@property({ type: Array }) @property({ type: Array })
options: DualSelectPair[] = []; readonly selected: DualSelectPair[] = [];
@property({ attribute: "to-move", type: Object }) /*
toMove: 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
@property({ attribute: "disabled", type: Boolean }) * orchestrator for the dual-select widget can and will access it to get the list of keys to be
disabled = false; * moved (removed) if the user so requests.
*
*/
@state()
public toMove: Set<string> = new Set();
constructor() { constructor() {
super(); super();
this.onClick = this.onClick.bind(this); 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) { onClick(key: string) {
@ -58,11 +81,19 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
} else { } else {
this.toMove.add(key); this.toMove.add(key);
} }
this.requestUpdate(); // Necessary because updating a map won't trigger a state change
this.dispatchCustomEvent( this.dispatchCustomEvent(
"ak-dual-select-selected-move-changed", "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() { connectedCallback() {
@ -76,10 +107,9 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
render() { render() {
return html` return html`
<div class="pf-c-dual-list-selector">
<div class="pf-c-dual-list-selector__menu"> <div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list"> <ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => { ${map(this.selected, ([key, label]) => {
const selected = classMap({ const selected = classMap({
"pf-m-selected": this.toMove.has(key), "pf-m-selected": this.toMove.has(key),
}); });
@ -88,6 +118,7 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
aria-selected="false" aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0" id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)} @click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option" role="option"
tabindex="-1" tabindex="-1"
> >
@ -104,7 +135,6 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
})} })}
</ul> </ul>
</div> </div>
</div>
`; `;
} }
} }

View File

@ -8,8 +8,8 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css"; import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CustomEmitterElement } from "../utils/eventEmitter"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import type { BasePagination } from "./types"; import type { BasePagination } from "../types";
const styles = [ const styles = [
PFBase, PFBase,

View File

@ -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;
}
`;

View File

@ -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";

View File

@ -0,0 +1,5 @@
import { AkDualSelect } from "./ak-dual-select";
import "./ak-dual-select";
export { AkDualSelect }
export default AkDualSelect;

View File

@ -4,8 +4,9 @@ import { slug } from "github-slugger";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import "./ak-dual-select-available-pane"; import "../components/ak-dual-select-available-pane";
import { AkDualSelectAvailablePane } from "./ak-dual-select-available-pane"; import "./sb-host-provider";
import { AkDualSelectAvailablePane } from "../components/ak-dual-select-available-pane";
const metadata: Meta<AkDualSelectAvailablePane> = { const metadata: Meta<AkDualSelectAvailablePane> = {
title: "Elements / Dual Select / Available Items Pane", title: "Elements / Dual Select / Available Items Pane",
@ -46,7 +47,9 @@ const container = (testItem: TemplateResult) =>
} }
</style> </style>
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<sb-dual-select-host-provider>
${testItem} ${testItem}
</sb-dual-select-host-provider>
<p>Messages received from the button:</p> <p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul> <ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`; </div>`;
@ -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; type Story = StoryObj;
@ -109,6 +112,6 @@ export const SomeSelected: Story = {
html` <ak-dual-select-available-pane html` <ak-dual-select-available-pane
.options=${goodForYouPairs} .options=${goodForYouPairs}
.selected=${someSelected} .selected=${someSelected}
></ak-dual-select-available-pane>`, ></ak-dual-select-available-pane>`,
), ),
}; };

View File

@ -3,8 +3,8 @@ import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import "./ak-dual-select-controls"; import "../components/ak-dual-select-controls";
import { AkDualSelectControls } from "./ak-dual-select-controls"; import { AkDualSelectControls } from "../components/ak-dual-select-controls";
const metadata: Meta<AkDualSelectControls> = { const metadata: Meta<AkDualSelectControls> = {
title: "Elements / Dual Select / Control Panel", title: "Elements / Dual Select / Control Panel",

View File

@ -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<AkDualSelect> = {
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`<ak-dual-select .options=${this.pageoptions} .pages=${this.page}></ak-dual-select>`;
}
}
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<div id="action-button-message-pad" style="margin-top: 1em"></div>
</div>`;
// 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` <ak-sb-fruity .options=${goodForYou}></ak-sb-fruity>`),
};

View File

@ -4,8 +4,9 @@ import { slug } from "github-slugger";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import "./ak-dual-select-selected-pane"; import "../components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "./ak-dual-select-selected-pane"; import "./sb-host-provider";
import { AkDualSelectSelectedPane } from "../components/ak-dual-select-selected-pane";
const metadata: Meta<AkDualSelectSelectedPane> = { const metadata: Meta<AkDualSelectSelectedPane> = {
title: "Elements / Dual Select / Selected Items Pane", title: "Elements / Dual Select / Selected Items Pane",
@ -41,8 +42,10 @@ const container = (testItem: TemplateResult) =>
margin-top: 1em; margin-top: 1em;
} }
</style> </style>
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<sb-dual-select-host-provider>
${testItem} ${testItem}
</sb-dual-select-host-provider>
<p>Messages received from the button:</p> <p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul> <ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`; </div>`;
@ -88,7 +91,7 @@ export const Default: Story = {
render: () => render: () =>
container( container(
html` <ak-dual-select-selected-pane html` <ak-dual-select-selected-pane
.options=${goodForYouPairs} .selected=${goodForYouPairs}
></ak-dual-select-selected-pane>`, ></ak-dual-select-selected-pane>`,
), ),
}; };

View File

@ -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<AkDualSelect> = {
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` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// 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(`<li>${key}</li>`, "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` <ak-dual-select .options=${goodForYouPairs}></ak-dual-select>`),
};

View File

@ -3,8 +3,8 @@ import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import "./ak-pagination"; import "../components/ak-pagination";
import { AkPagination } from "./ak-pagination"; import { AkPagination } from "../components/ak-pagination";
const metadata: Meta<AkPagination> = { const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control", title: "Elements / Dual Select / Pagination Control",

View File

@ -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`<slot></slot>`;
}
}