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:
parent
1cba9e88cb
commit
9996eafe75
|
@ -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
13
web/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>`,
|
||||||
|
),
|
||||||
|
};
|
121
web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts
Normal file
121
web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts
Normal 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;
|
|
@ -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>`,
|
||||||
|
),
|
||||||
|
};
|
112
web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts
Normal file
112
web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts
Normal 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;
|
83
web/src/elements/ak-dual-select/ak-pagination.stories.ts
Normal file
83
web/src/elements/ak-dual-select/ak-pagination.stories.ts
Normal 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>`),
|
||||||
|
};
|
94
web/src/elements/ak-dual-select/ak-pagination.ts
Normal file
94
web/src/elements/ak-dual-select/ak-pagination.ts
Normal 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;
|
10
web/src/elements/ak-dual-select/types.ts
Normal file
10
web/src/elements/ak-dual-select/types.ts
Normal 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"
|
||||||
|
>;
|
|
@ -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;
|
||||||
|
|
Reference in a new issue