diff --git a/web/.storybook/preview.ts b/web/.storybook/preview.ts index 08bd2119e..258afd215 100644 --- a/web/.storybook/preview.ts +++ b/web/.storybook/preview.ts @@ -1,7 +1,7 @@ import type { Preview } from "@storybook/web-components"; 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/Page/page.css"; // .storybook/preview.js diff --git a/web/package-lock.json b/web/package-lock.json index 8caa84294..9b3d02f5c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -83,6 +83,7 @@ "eslint-plugin-lit": "^1.11.0", "eslint-plugin-sonarjs": "^0.23.0", "eslint-plugin-storybook": "^0.6.15", + "github-slugger": "^2.0.0", "lit-analyzer": "^2.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.1.1", @@ -11814,9 +11815,9 @@ "optional": true }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "dev": true }, "node_modules/glob": { @@ -16171,6 +16172,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 4d7002840..776963070 100644 --- a/web/package.json +++ b/web/package.json @@ -108,6 +108,7 @@ "eslint-plugin-lit": "^1.11.0", "eslint-plugin-sonarjs": "^0.23.0", "eslint-plugin-storybook": "^0.6.15", + "github-slugger": "^2.0.0", "lit-analyzer": "^2.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.1.1", diff --git a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts new file mode 100644 index 000000000..76b428647 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.stories.ts @@ -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 = { + 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`
+ + + ${testItem} +

Messages received from the button:

+ +
`; + +// 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(`
  • ${key}
  • `, "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` `, + ), +}; + +const someSelected = new Set([ + goodForYouPairs[2][0], + goodForYouPairs[8][0], + goodForYouPairs[14][0], +]); + +export const SomeSelected: Story = { + render: () => + container( + html` `, + ), +}; diff --git a/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts new file mode 100644 index 000000000..d7ce75ffc --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-available-pane.ts @@ -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 = new Set(); + + @property({ attribute: "selected", type: Object }) + selected: Set = 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` +
    +
    +
      + ${map(this.options, ([key, label]) => { + const selected = classMap({ + "pf-m-selected": this.toMove.has(key), + }); + return html`
    • this.onClick(key)} + role="option" + tabindex="-1" + > +
      + + + ${label}${this.selected.has(key) + ? html`` + : nothing} +
      +
    • `; + })} +
    +
    +
    + `; + } +} + +export default AkDualSelectAvailablePane; diff --git a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts new file mode 100644 index 000000000..4fdd48009 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.stories.ts @@ -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 = { + 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`
    + + + ${testItem} +

    Messages received from the button:

    +
      +
      `; + +// 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(`
    • ${key}
    • `, "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` `, + ), +}; diff --git a/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts new file mode 100644 index 000000000..b0c807b9e --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-selected-pane.ts @@ -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 = 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` +
      +
      +
        + ${map(this.options, ([key, label]) => { + const selected = classMap({ + "pf-m-selected": this.toMove.has(key), + }); + return html`
      • this.onClick(key)} + role="option" + tabindex="-1" + > +
        + + + ${label} +
        +
      • `; + })} +
      +
      +
      + `; + } +} + +export default AkDualSelectSelectedPane; diff --git a/web/src/elements/ak-dual-select/ak-pagination.stories.ts b/web/src/elements/ak-dual-select/ak-pagination.stories.ts new file mode 100644 index 000000000..01183bbe5 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-pagination.stories.ts @@ -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 = { + 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`
      + + + ${testItem} +

      Messages received from the button:

      +
        +
        `; + +// 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( + `
      • Request to move to page ${result.detail}
      • `, + "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` `), +}; + +const morePages = { + count: 86, + startIndex: 21, + endIndex: 40, + next: 3, + previous: 1, +}; + +export const More: Story = { + render: () => container(html` `), +}; diff --git a/web/src/elements/ak-dual-select/ak-pagination.ts b/web/src/elements/ak-dual-select/ak-pagination.ts new file mode 100644 index 000000000..b919d7bde --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-pagination.ts @@ -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`
        +
        +
        +
        + + ${msg( + str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`, + )} + +
        +
        + +
        +
        ` + : nothing; + } +} + +export default AkPagination; diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts new file mode 100644 index 000000000..65a8d090d --- /dev/null +++ b/web/src/elements/ak-dual-select/types.ts @@ -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" +>; diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index 54b472825..6184fe0cd 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -14,7 +14,6 @@ export function CustomEmitterElement>(supercla const fullDetail = typeof detail === "object" && !Array.isArray(detail) ? { - target: this, ...detail, } : detail;