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;
|
|
|
@ -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 { 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,6 +27,9 @@ 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>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 { 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>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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,
|
|
@ -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;
|
||||||
|
}
|
||||||
|
`;
|
|
@ -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";
|
|
@ -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 { 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;
|
||||||
|
|
|
@ -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",
|
|
@ -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 { 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",
|
||||||
|
@ -42,7 +43,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>`;
|
||||||
|
@ -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>`,
|
||||||
),
|
),
|
||||||
};
|
};
|
|
@ -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 { 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",
|
|
@ -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 New Issue