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

**This commit**

This commit provides the following new features for dual list multiselect:

- The "available" pane, which has all of the entries that are available to be selected.  Items that
  are already selected will remain, but they're marked with a checkmark and can neither be selected
  or moved.
- The "selected" pane, which has *all* of the entries that have been selected.
- The Pagination control, which in this case only sends an event upstream.

**Plan**:

The plan is to have a master control that marries the available-pane, selected-pane,
select-controls, and pagination-controls into a single component that receives the list of
"currently visible" available entries and keeps the list of "currently selected" entries, as well as
a pass-through for the pagination value that allows it to hide the pagination control if there is
only one page.

A master component *above that* will provide the list of currently visible entries and, at need,
read the value of the master control object for the "selected" list. That component will mostly be
data-only; it's render will probably just be `<slot></slot>`; its duty will be only to map entries
to string keys Lit can use, and to provide the lists we want to provide and the pagination ranges we
want to show.

Some judicious use of grid will allow me size the controls properly with/without the pagination
control.

Status and Title are going to be in the master control.

A <slot> will be provided for Search, but I have no plans to integrate that into this control as of
yet.

There is already a planned fallback control; the multi-select experience on mobile is actually
excellent, and we should exploit that appropriately.
This commit is contained in:
Ken Sternberg 2023-12-27 17:00:42 -08:00
parent 1cba9e88cb
commit 9996eafe75
11 changed files with 640 additions and 5 deletions

View File

@ -1,7 +1,7 @@
import type { Preview } from "@storybook/web-components"; import type { Preview } from "@storybook/web-components";
import "@goauthentik/common/styles/authentik.css"; import "@goauthentik/common/styles/authentik.css";
import "@goauthentik/common/styles/theme-dark.css"; // import "@goauthentik/common/styles/theme-dark.css";
import "@patternfly/patternfly/components/Brand/brand.css"; import "@patternfly/patternfly/components/Brand/brand.css";
import "@patternfly/patternfly/components/Page/page.css"; import "@patternfly/patternfly/components/Page/page.css";
// .storybook/preview.js // .storybook/preview.js

13
web/package-lock.json generated
View File

@ -83,6 +83,7 @@
"eslint-plugin-lit": "^1.11.0", "eslint-plugin-lit": "^1.11.0",
"eslint-plugin-sonarjs": "^0.23.0", "eslint-plugin-sonarjs": "^0.23.0",
"eslint-plugin-storybook": "^0.6.15", "eslint-plugin-storybook": "^0.6.15",
"github-slugger": "^2.0.0",
"lit-analyzer": "^2.0.2", "lit-analyzer": "^2.0.2",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.1.1", "prettier": "^3.1.1",
@ -11814,9 +11815,9 @@
"optional": true "optional": true
}, },
"node_modules/github-slugger": { "node_modules/github-slugger": {
"version": "1.5.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
"integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
"dev": true "dev": true
}, },
"node_modules/glob": { "node_modules/glob": {
@ -16171,6 +16172,12 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remark-slug/node_modules/github-slugger": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz",
"integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==",
"dev": true
},
"node_modules/remark-slug/node_modules/mdast-util-to-string": { "node_modules/remark-slug/node_modules/mdast-util-to-string": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz",

View File

@ -108,6 +108,7 @@
"eslint-plugin-lit": "^1.11.0", "eslint-plugin-lit": "^1.11.0",
"eslint-plugin-sonarjs": "^0.23.0", "eslint-plugin-sonarjs": "^0.23.0",
"eslint-plugin-storybook": "^0.6.15", "eslint-plugin-storybook": "^0.6.15",
"github-slugger": "^2.0.0",
"lit-analyzer": "^2.0.2", "lit-analyzer": "^2.0.2",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.1.1", "prettier": "^3.1.1",

View File

@ -0,0 +1,114 @@
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-available-pane";
import { AkDualSelectAvailablePane } from "./ak-dual-select-available-pane";
const metadata: Meta<AkDualSelectAvailablePane> = {
title: "Elements / Dual Select / Available Items Pane",
component: "ak-dual-select-available-pane",
parameters: {
docs: {
description: {
component: "The vertical panel separating two dual-select elements.",
},
},
},
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",
},
toMove: {
type: "string",
description: "An array of items which are to be moved to the receiving pane.",
},
},
};
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.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("ak-dual-select-move-changed", 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-available-pane
.options=${goodForYouPairs}
></ak-dual-select-available-pane>`,
),
};
const someSelected = new Set([
goodForYouPairs[2][0],
goodForYouPairs[8][0],
goodForYouPairs[14][0],
]);
export const SomeSelected: Story = {
render: () =>
container(
html` <ak-dual-select-available-pane
.options=${goodForYouPairs}
.selected=${someSelected}
></ak-dual-select-available-pane>`,
),
};

View File

@ -0,0 +1,121 @@
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 { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.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 type { DualSelectPair } from "./types";
const styles = [
PFBase,
PFButton,
PFDualListSelector,
css`
.pf-c-dual-list-selector__item {
padding: 0.25rem;
}
.pf-c-dual-list-selector__item-text i {
display: inline-block;
margin-left: 0.5rem;
font-weight: 200;
color: var(--pf-global--palette--black-500);
font-size: var(--pf-global--FontSize--xs);
}
`,
];
const hostAttributes = [
["aria-labelledby", "dual-list-selector-available-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
@customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
@property({ type: Array })
options: DualSelectPair[] = [];
@property({ attribute: "to-move", type: Object })
toMove: Set<string> = new Set();
@property({ attribute: "selected", type: Object })
selected: Set<string> = new Set();
@property({ attribute: "disabled", type: Boolean })
disabled = false;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(key: string) {
if (this.selected.has(key)) {
// An already selected item cannot be moved into the "selected" category
return;
}
if (this.toMove.has(key)) {
this.toMove.delete(key);
} 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()));
}
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
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]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
@click=${() => this.onClick(key)}
role="option"
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}${this.selected.has(key)
? html`<i class="fa fa-check"></i>`
: nothing}</span
></span
></span
>
</div>
</li>`;
})}
</ul>
</div>
</div>
`;
}
}
export default AkDualSelectAvailablePane;

View File

@ -0,0 +1,94 @@
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-selected-pane";
import { AkDualSelectSelectedPane } from "./ak-dual-select-selected-pane";
const metadata: Meta<AkDualSelectSelectedPane> = {
title: "Elements / Dual Select / Selected Items Pane",
component: "ak-dual-select-selected-pane",
parameters: {
docs: {
description: {
component: "The vertical panel separating two dual-select elements.",
},
},
},
argTypes: {
options: {
type: "string",
description: "An array of [key, label] pairs of what to show",
},
toMove: {
type: "string",
description: "An array of items which are to be moved to the receiving pane.",
},
},
};
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.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("ak-dual-select-selected-move-changed", 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-selected-pane
.options=${goodForYouPairs}
></ak-dual-select-selected-pane>`,
),
};

View File

@ -0,0 +1,112 @@
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 { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.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 type { DualSelectPair } from "./types";
const styles = [
PFBase,
PFButton,
PFDualListSelector,
css`
.pf-c-dual-list-selector__item {
padding: 0.25rem;
}
input[type="checkbox"][readonly] {
pointer-events: none;
}
`,
];
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
@customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
@property({ type: Array })
options: DualSelectPair[] = [];
@property({ attribute: "to-move", type: Object })
toMove: Set<string> = new Set();
@property({ attribute: "disabled", type: Boolean })
disabled = false;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(key: string) {
if (this.toMove.has(key)) {
this.toMove.delete(key);
} 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()),
);
}
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
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]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)}
role="option"
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}</span
></span
></span
>
</div>
</li>`;
})}
</ul>
</div>
</div>
`;
}
}
export default AkDualSelectSelectedPane;

View File

@ -0,0 +1,83 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "./ak-pagination";
import { AkPagination } from "./ak-pagination";
const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control",
component: "ak-pagination",
parameters: {
docs: {
description: {
component: "The Pagination Control",
},
},
},
argTypes: {
pages: {
type: "string",
description: "An authentik Pagination struct",
},
},
};
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) => {
console.log(result);
const target = document.querySelector("#action-button-message-pad");
target!.append(
new DOMParser().parseFromString(
`<li>Request to move to page ${result.detail}</li>`,
"text/xml",
).firstChild!,
);
};
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
type Story = StoryObj;
const pages = {
count: 44,
startIndex: 1,
endIndex: 20,
next: 2,
previous: 0,
};
export const Default: Story = {
render: () => container(html` <ak-pagination .pages=${pages}></ak-pagination>`),
};
const morePages = {
count: 86,
startIndex: 21,
endIndex: 40,
next: 3,
previous: 1,
};
export const More: Story = {
render: () => container(html` <ak-pagination .pages=${morePages}></ak-pagination>`),
};

View File

@ -0,0 +1,94 @@
import { AKElement } from "@goauthentik/elements/Base";
import { msg, str } 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 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";
const styles = [
PFBase,
PFButton,
PFPagination,
css`
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
}
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled {
color: var(--pf-c-button--disabled--Color);
}
`,
];
@customElement("ak-pagination")
export class AkPagination extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
@property({ attribute: false })
pages?: BasePagination;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(nav: number | undefined) {
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
}
render() {
return this.pages
? html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div
class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md"
>
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
)}
</span>
</div>
</div>
<nav class="pf-c-pagination__nav" aria-label="Pagination">
<div class="pf-c-pagination__nav-control pf-m-prev">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.previous);
}}
?disabled="${(this.pages?.previous ?? 0) < 1}"
aria-label="${msg("Go to previous page")}"
>
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-pagination__nav-control pf-m-next">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.next);
}}
?disabled="${(this.pages?.next ?? 0) <= 0}"
aria-label="${msg("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
</nav>
</div>
</div>`
: nothing;
}
}
export default AkPagination;

View File

@ -0,0 +1,10 @@
import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api";
export type DualSelectPair = [string, string | TemplateResult];
export type BasePagination = Pick<
Pagination,
"startIndex" | "endIndex" | "count" | "previous" | "next"
>;

View File

@ -14,7 +14,6 @@ export function CustomEmitterElement<T extends Constructor<LitElement>>(supercla
const fullDetail = const fullDetail =
typeof detail === "object" && !Array.isArray(detail) typeof detail === "object" && !Array.isArray(detail)
? { ? {
target: this,
...detail, ...detail,
} }
: detail; : detail;