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:
parent
9996eafe75
commit
cef378da82
|
@ -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;
|
305
web/src/elements/ak-dual-select/ak-dual-select.ts
Normal file
305
web/src/elements/ak-dual-select/ak-dual-select.ts
Normal 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.`) : " ";
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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<string> = 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<string> = new Set();
|
||||
|
||||
@property({ attribute: "selected", type: Object })
|
||||
selected: Set<string> = 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<string> = 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`
|
||||
<div class="pf-c-dual-list-selector">
|
||||
<div class="pf-c-dual-list-selector__menu">
|
||||
<ul class="pf-c-dual-list-selector__list">
|
||||
${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) {
|
|||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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<string> = 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<string> = 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`
|
||||
<div class="pf-c-dual-list-selector">
|
||||
<div class="pf-c-dual-list-selector__menu">
|
||||
<ul class="pf-c-dual-list-selector__list">
|
||||
${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) {
|
|||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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,
|
122
web/src/elements/ak-dual-select/components/styles.css.ts
Normal file
122
web/src/elements/ak-dual-select/components/styles.css.ts
Normal 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;
|
||||
}
|
||||
`;
|
7
web/src/elements/ak-dual-select/constants.ts
Normal file
7
web/src/elements/ak-dual-select/constants.ts
Normal 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";
|
5
web/src/elements/ak-dual-select/index.ts
Normal file
5
web/src/elements/ak-dual-select/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { AkDualSelect } from "./ak-dual-select";
|
||||
import "./ak-dual-select";
|
||||
|
||||
export { AkDualSelect }
|
||||
export default AkDualSelect;
|
|
@ -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<AkDualSelectAvailablePane> = {
|
||||
title: "Elements / Dual Select / Available Items Pane",
|
||||
|
@ -46,7 +47,9 @@ const container = (testItem: TemplateResult) =>
|
|||
}
|
||||
</style>
|
||||
<ak-message-container></ak-message-container>
|
||||
<sb-dual-select-host-provider>
|
||||
${testItem}
|
||||
</sb-dual-select-host-provider>
|
||||
<p>Messages received from the button:</p>
|
||||
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
|
||||
</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;
|
||||
|
||||
|
@ -109,6 +112,6 @@ export const SomeSelected: Story = {
|
|||
html` <ak-dual-select-available-pane
|
||||
.options=${goodForYouPairs}
|
||||
.selected=${someSelected}
|
||||
></ak-dual-select-available-pane>`,
|
||||
></ak-dual-select-available-pane>`,
|
||||
),
|
||||
};
|
|
@ -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<AkDualSelectControls> = {
|
||||
title: "Elements / Dual Select / Control Panel",
|
|
@ -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>`),
|
||||
};
|
|
@ -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<AkDualSelectSelectedPane> = {
|
||||
title: "Elements / Dual Select / Selected Items Pane",
|
||||
|
@ -41,8 +42,10 @@ const container = (testItem: TemplateResult) =>
|
|||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
<ak-message-container></ak-message-container>
|
||||
<ak-message-container></ak-message-container>
|
||||
<sb-dual-select-host-provider>
|
||||
${testItem}
|
||||
</sb-dual-select-host-provider>
|
||||
<p>Messages received from the button:</p>
|
||||
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
|
||||
</div>`;
|
||||
|
@ -88,7 +91,7 @@ export const Default: Story = {
|
|||
render: () =>
|
||||
container(
|
||||
html` <ak-dual-select-selected-pane
|
||||
.options=${goodForYouPairs}
|
||||
.selected=${goodForYouPairs}
|
||||
></ak-dual-select-selected-pane>`,
|
||||
),
|
||||
};
|
|
@ -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>`),
|
||||
};
|
|
@ -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<AkPagination> = {
|
||||
title: "Elements / Dual Select / Pagination Control",
|
21
web/src/elements/ak-dual-select/stories/sb-host-provider.ts
Normal file
21
web/src/elements/ak-dual-select/stories/sb-host-provider.ts
Normal 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>`;
|
||||
}
|
||||
}
|
Reference in a new issue