web: Testing and documenting the simple things

This commit adds unit tests for the Alerts and EmptyState elements. It includes a new
test/documentation feature; elements can now be fully documented with text description and active
controls.

It *removes* the `babel` imports entirely.  Either we don't need them, or the components that do
need them are importing them automatically.

[An outstanding bug in WebDriverIO](https://github.com/webdriverio/webdriverio/issues/12056)
unfortunately means that the tests cannot be run in parallel for the time being.While one test is
running, the compiler for other tests becomes unreliable. They're currently working on this issue.
I have set the `maxInstances` to **1**.

I have updated the `<ak-alert>` component just a bit, providing an attribute alternative to the
`Level` property; now instead of passing it a `<ak-alert level=${Levels.Warning}>` properties, you
can just say `<ak-alert warning>` and it'll work just fine. The old way is still the default
behavior.

The default behavior for `EmptyState` was a little confusing; I've re-arranged it for clarity. Since
I touched it, I also added the `interface` and `HTMLElementTagNameMap` declarations.

Added documentation to all the elements I've touched (so far).
This commit is contained in:
Ken Sternberg
2024-05-16 15:26:35 -07:00
parent 9428fd866e
commit 35f96df66e
26 changed files with 1195 additions and 157 deletions

View File

@ -1,11 +1,20 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CSSResult, TemplateResult, html } from "lit";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface IAlert {
inline?: boolean;
warning?: boolean;
info?: boolean;
success?: boolean;
danger?: boolean;
}
export enum Level {
Warning = "pf-m-warning",
Info = "pf-m-info",
@ -13,20 +22,88 @@ export enum Level {
Danger = "pf-m-danger",
}
/**
* @class Alert
* @element ak-alert
*
* Alerts are in-page elements intended to draw the user's attention and alert them to important
* details. Alerts are used alongside form elements to warn users of potential mistakes they can
* make, as well as in in-line documentation.
*/
@customElement("ak-alert")
export class Alert extends AKElement {
export class Alert extends AKElement implements IAlert {
/**
* Whether or not to display the entire component's contents in-line or not.
*
* @attr
*/
@property({ type: Boolean })
inline = false;
/**
* Fallback method of determining severity
*
* @attr
*/
@property()
level: Level = Level.Warning;
static get styles(): CSSResult[] {
/**
* Highest severity level.
*
* @attr
*/
@property({ type: Boolean })
danger = false;
/**
* Next severity level.
*
* @attr
*/
@property({ type: Boolean })
warning = false;
/**
* Next severity level. The default severity level.
*
* @attr
*/
@property({ type: Boolean })
success = false;
/**
* Lowest severity level.
*
* @attr
*/
@property({ type: Boolean })
info = false;
static get styles() {
return [PFBase, PFAlert];
}
render(): TemplateResult {
return html`<div class="pf-c-alert ${this.inline ? "pf-m-inline" : ""} ${this.level}">
get classmap() {
const leveltags = ["danger", "warning", "success", "info"].filter(
// @ts-ignore
(level) => this[level] && this[level] === true,
);
if (leveltags.length > 1) {
console.warn("ak-alert has multiple levels defined");
}
const level = leveltags.length > 0 ? `pf-m-${leveltags[0]}` : this.level;
return {
"pf-c-alert": true,
"pf-m-inline": this.inline,
[level]: true,
};
}
render() {
return html`<div class="${classMap(this.classmap)}">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
</div>
@ -36,3 +113,9 @@ export class Alert extends AKElement {
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-alert": Alert;
}
}

View File

@ -5,6 +5,14 @@ import { customElement } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* @class Divider
* @element ak-divider
*
* Divider is a horizontal rule, an in-page element to separate displayed items.
*
* @slot - HTML to display in-line in the middle of the horizontal rule.
*/
@customElement("ak-divider")
export class Divider extends AKElement {
static get styles(): CSSResult[] {
@ -39,3 +47,9 @@ export class Divider extends AKElement {
return html`<div class="separator"><slot></slot></div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-divider": Divider;
}
}

View File

@ -9,17 +9,64 @@ import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-sta
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-empty-state")
export class EmptyState extends AKElement {
@property({ type: String })
icon = "";
export interface IEmptyState {
icon?: string;
loading?: boolean;
fullHeight?: boolean;
header?: string;
}
/**
* @class EmptyState
* @element ak-empty-state
*
* The EmptyState is an in-page element to indicate that something is either loading or unavailable.
* When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
* icon is a question mark in a circle.
*
* @slot body - Optional low-priority text that appears beneath the state indicator.
* @slot primary - Optional high-priority text that appears some distance between the state indicator.
*
* The layout of the component is always centered, and from top to bottom:
*
* ```
* icon or spinner
* header
* body
* primary
* ```
*/
@customElement("ak-empty-state")
export class EmptyState extends AKElement implements IEmptyState {
/**
* The Font Awesome icon to display. Defaults to the <20> symbol.
*
* @attr
*/
@property({ type: String })
icon = "fa-question-circle";
/**
* Whether or not to show the spinner, or the end icon
*
* @attr
*/
@property({ type: Boolean })
loading = false;
/**
* If set, will attempt to occupy the full viewport.
*
* @attr
*/
@property({ type: Boolean })
fullHeight = false;
/**
* [Optional] If set, will display a message in large text beneath the icon
*
* @attr
*/
@property()
header = "";
@ -45,8 +92,7 @@ export class EmptyState extends AKElement {
<ak-spinner size=${PFSize.XLarge}></ak-spinner>
</div>`
: html`<i
class="pf-icon fa ${this.icon ||
"fa-question-circle"} pf-c-empty-state__icon"
class="pf-icon fa ${this.icon} pf-c-empty-state__icon"
aria-hidden="true"
></i>`}
<h1 class="pf-c-title pf-m-lg">${this.header}</h1>
@ -60,3 +106,9 @@ export class EmptyState extends AKElement {
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-empty-state": EmptyState;
}
}

View File

@ -1,24 +1,53 @@
import { AKElement } from "@goauthentik/elements/Base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFExpandableSection from "@patternfly/patternfly/components/ExpandableSection/expandable-section.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface IExpand {
expanded?: boolean;
textOpen?: string;
textClosed?: string;
}
/**
* @class Expand
* @element ak-expand
*
* An `ak-expand` is used to hide cluttering details that a user may wish to reveal, such as the raw
* details of an alert or event.
*
* slot - The contents to be hidden or displayed.
*/
@customElement("ak-expand")
export class Expand extends AKElement {
@property({ type: Boolean })
/**
* The state of the expanded content
*
* @attr
*/
@property({ type: Boolean, reflect: true })
expanded = false;
/**
* The text to display next to the open/close control when the accordion is closed.
*
* @attr
*/
@property()
textOpen = msg("Show less");
/**
* The text to display next to the open/close control when the accordion is .
*
* @attr
*/
@property()
textClosed = msg("Show more");
static get styles(): CSSResult[] {
static get styles() {
return [
PFBase,
PFExpandableSection,
@ -30,7 +59,7 @@ export class Expand extends AKElement {
];
}
render(): TemplateResult {
render() {
return html`<div
class="pf-c-expandable-section pf-m-display-lg pf-m-indented ${this.expanded
? "pf-m-expanded"
@ -57,3 +86,9 @@ export class Expand extends AKElement {
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-expand": Expand;
}
}

View File

@ -4,9 +4,9 @@ import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "./ak-action-button";
import AKActionButton from "./ak-action-button";
import { ActionButton } from "./ak-action-button";
const metadata: Meta<AKActionButton> = {
const metadata: Meta<ActionButton> = {
title: "Elements / Action Button",
component: "ak-action-button",
parameters: {

View File

@ -1,9 +1,14 @@
import { MessageLevel } from "@goauthentik/common/messages";
import { BaseTaskButton } from "@goauthentik/elements/buttons/SpinnerButton/BaseTaskButton";
import {
BaseTaskButton,
type IBaseTaskButton,
} from "@goauthentik/elements/buttons/SpinnerButton/BaseTaskButton";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { customElement, property } from "lit/decorators.js";
type IActionButton = IBaseTaskButton & { apiRequest: () => Promise<unknown> };
/**
* A button associated with an event handler for loading data. Takes an asynchronous function as its
* only property.
@ -19,7 +24,7 @@ import { customElement, property } from "lit/decorators.js";
*/
@customElement("ak-action-button")
export class ActionButton extends BaseTaskButton {
export class ActionButton extends BaseTaskButton implements IActionButton {
/**
* The command to run when the button is pressed. Must return a promise. If the promise is a
* reject or throw, we process the content of the promise and deliver it to the Notification
@ -27,7 +32,6 @@ export class ActionButton extends BaseTaskButton {
*
* @attr
*/
@property({ attribute: false })
apiRequest: () => Promise<unknown> = () => {
throw new Error();
@ -52,4 +56,10 @@ export class ActionButton extends BaseTaskButton {
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-action-button": ActionButton;
}
}
export default ActionButton;

View File

@ -36,6 +36,10 @@ const StatusMap = new Map<TaskStatus, string>([
const SPINNER_TIMEOUT = 1000 * 1.5; // milliseconds
export interface IBaseTaskButton {
disabled?: boolean;
}
/**
* BaseTaskButton
*
@ -46,7 +50,6 @@ const SPINNER_TIMEOUT = 1000 * 1.5; // milliseconds
* `onFailure` call their `super.` equivalents.
*
*/
export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
eventPrefix = "ak-button";

View File

@ -1,7 +1,7 @@
import { customElement } from "lit/decorators.js";
import { property } from "lit/decorators.js";
import { BaseTaskButton } from "./BaseTaskButton";
import { BaseTaskButton, type IBaseTaskButton } from "./BaseTaskButton";
/**
* A button associated with an event handler for loading data. Takes an asynchronous function as its
@ -17,8 +17,10 @@ import { BaseTaskButton } from "./BaseTaskButton";
* @fires ak-button-reset - When the button is reset after the async process completes
*/
type ISpinnerButton = IBaseTaskButton & { callAction: () => Promise<unknown> };
@customElement("ak-spinner-button")
export class SpinnerButton extends BaseTaskButton {
export class SpinnerButton extends BaseTaskButton implements ISpinnerButton {
/**
* The command to run when the button is pressed. Must return a promise. We don't do anything
* with that promise other than check if it's a resolve or reject, and rethrow the event after.
@ -29,4 +31,10 @@ export class SpinnerButton extends BaseTaskButton {
callAction!: () => Promise<unknown>;
}
declare global {
interface HTMLElementTagNameMap {
"ak-spinner-button": SpinnerButton;
}
}
export default SpinnerButton;

View File

@ -0,0 +1,43 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as AlertStories from "./Alert.stories";
<Meta of={AlertStories} />
# Alerts
Alerts are in-page elements intended to draw the user's attention and alert them to important
details. Alerts are used alongside form elements to warn users of potential mistakes they can
make, as well as in in-line documentation.
## Usage
```Typescript
import "@goauthentik/elements/Alert.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-alert><p>This is the content of your alert!</p></ak-alert>
```
## Demo
### Default
The default state of an alert is _warning_.
<Story of={AlertStories.DefaultStory} />
### Info
<Story of={AlertStories.InfoAlert} />
### Success
<Story of={AlertStories.SuccessAlert} />
### Danger
<Story of={AlertStories.DangerAlert} />

View File

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { Alert, type IAlert } from "../Alert.js";
import "../Alert.js";
type IAlertForTesting = IAlert & { message: string };
const metadata: Meta<Alert> = {
title: "Elements/<ak-alert>",
component: "ak-alert",
parameters: {
docs: {
description: "An alert",
},
},
argTypes: {
inline: { control: "boolean" },
warning: { control: "boolean" },
info: { control: "boolean" },
success: { control: "boolean" },
danger: { control: "boolean" },
// @ts-ignore
message: { control: "text" },
},
};
export default metadata;
export const DefaultStory: StoryObj = {
args: {
inline: false,
warning: false,
info: false,
success: false,
danger: false,
message: "You should be alarmed.",
},
// @ts-ignore
render: ({ inline, warning, info, success, danger, message }: IAlertForTesting) => {
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-alert {
display: inline-block;
width: 32rem;
max-width: 32rem;
}
</style>
<ak-alert
?inline=${inline}
?warning=${warning}
?info=${info}
?success=${success}
?danger=${danger}
>
<p>${message}</p>
</ak-alert>
</div>`;
},
};
export const SuccessAlert = {
...DefaultStory,
args: { ...DefaultStory, ...{ success: true, message: "He's a tribute to your genius!" } },
};
export const InfoAlert = {
...DefaultStory,
args: { ...DefaultStory, ...{ info: true, message: "An octopus has tastebuds on its arms." } },
};
export const DangerAlert = {
...DefaultStory,
args: { ...DefaultStory, ...{ danger: true, message: "Danger, Will Robinson! Danger!" } },
};

View File

@ -0,0 +1,36 @@
import { $, expect } from "@wdio/globals";
import { html, render } from "lit";
import "../Alert.js";
import { Level } from "../Alert.js";
describe("ak-alert", () => {
it("should render an alert with the enum", async () => {
render(html`<ak-alert level=${Level.Info}>This is an alert</ak-alert>`, document.body);
await expect(await $("ak-alert").$(">>>div")).not.toHaveElementClass("pf-m-inline");
await expect(await $("ak-alert").$(">>>div")).toHaveElementClass("pf-c-alert");
await expect(await $("ak-alert").$(">>>div")).toHaveElementClass("pf-m-info");
await expect(await $("ak-alert").$(">>>.pf-c-alert__title")).toHaveText("This is an alert");
});
it("should render an alert with the attribute", async () => {
render(html`<ak-alert info>This is an alert</ak-alert>`, document.body);
await expect(await $("ak-alert").$(">>>div")).toHaveElementClass("pf-m-info");
await expect(await $("ak-alert").$(">>>.pf-c-alert__title")).toHaveText("This is an alert");
});
it("should render an alert with conflicting attributes in priority order", async () => {
render(html`<ak-alert danger warning>This is an alert</ak-alert>`, document.body);
await expect(await $("ak-alert").$(">>>div")).toHaveElementClass("pf-m-danger");
await expect(await $("ak-alert").$(">>>.pf-c-alert__title")).toHaveText("This is an alert");
});
it("should render an alert with an inline class and the default level", async () => {
render(html`<ak-alert inline>This is an alert</ak-alert>`, document.body);
await expect(await $("ak-alert").$(">>>div")).toHaveElementClass("pf-m-warning");
await expect(await $("ak-alert").$(">>>div")).toHaveElementClass("pf-m-inline");
await expect(await $("ak-alert").$(">>>.pf-c-alert__title")).toHaveText("This is an alert");
});
});

View File

@ -0,0 +1,51 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as DividerStories from "./Divider.stories";
<Meta of={DividerStories} />
# Divider
Divider is a horizontal rule, an in-page element to separate displayed items.
It has no configurable attributes. It does have a single unnamed slot, which is displayed in-line in
the center of the rule. If the CSS Base in loaded into the parent context, icons defined in the base
can be used here.
## Usage
```Typescript
import "@goauthentik/elements/Divider.js";
```
```html
<ak-divider></ak-divider>
```
With content:
```html
<ak-divider><p>Your content here</p></ak-divider>
```
With an icon:
```html
<ak-divider><i class="fa fa-life-ring"></i></ak-divider>
```
## Demo
Note that the Divider inherits its background from its parent component.
### Default Horizontal Rule
<Story of={DividerStories.DefaultStory} />
### With A Message
<Story of={DividerStories.DividerWithSlottedContent} />
### With an Icon
<Story of={DividerStories.DividerWithSlottedIcon} />

View File

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import { Divider } from "../Divider.js";
import "../Divider.js";
const metadata: Meta<Divider> = {
title: "Elements/<ak-divider>",
component: "ak-divider",
parameters: {
docs: {
description: "our most simple divider",
},
},
};
export default metadata;
const container = (content: TemplateResult) =>
html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-divider {
display: inline-block;
width: 32rem;
max-width: 32rem;
}</style
>${content}
</div>`;
export const DefaultStory: StoryObj = {
render: () => container(html` <ak-divider> </ak-divider> `),
};
export const DividerWithSlottedContent: StoryObj = {
render: () => container(html` <ak-divider><p>Time for bed!</p></ak-divider> `),
};
export const DividerWithSlottedIcon: StoryObj = {
render: () => container(html` <ak-divider><i class="fa fa-bed"></i></ak-divider> `),
};

View File

@ -0,0 +1,33 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "../Divider.js";
const render = (body: TemplateResult) => {
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
ensureCSSStyleSheet(PFBase),
ensureCSSStyleSheet(AKGlobal),
];
return litRender(body, document.body);
};
describe("ak-divider", () => {
it("should render the divider", async () => {
render(html`<ak-divider></ak-divider>`);
const empty = await $("ak-divider");
await expect(empty).toExist();
});
it("should render the divider with the specified text", async () => {
render(html`<ak-divider><span>Your Message Here</span></ak-divider>`);
const span = await $("ak-divider").$("span");
await expect(span).toExist();
await expect(span).toHaveText("Your Message Here");
});
});

View File

@ -0,0 +1,59 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as EmptyStateStories from "./EmptyState.stories";
<Meta of={EmptyStateStories} />
# EmptyState
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has two named slots, `body` and `primary`, to communicate further details about the current state
this element is meant to display.
## Usage
```Typescript
import "@goauthentik/elements/EmptyState.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-empty-state icon="fa-eject"
><span slot="primary">This would display in the "primary" slot</span></ak-empty-state
>
```
## Demo
### Default: Loading
The default state is _loading_
<Story of={EmptyStateStories.DefaultStory} />
### Done
<Story of={EmptyStateStories.DefaultAndLoadingDone} />
### Alternative "Done" Icon
This also shows the "header" attribute filled, which is rendered in a large, dark typeface.
<Story of={EmptyStateStories.DoneWithAlternativeIcon} />
### The Body Slot Filled
The body content slot is rendered in a lighter typeface at default size.
<Story of={EmptyStateStories.WithBodySlotFilled} />
### The Body and Primary Slot Filled
The primary content is rendered in the normal dark typeface at default size. It is also spaced
significantly below the spinner itself.
<Story of={EmptyStateStories.WithBodyAndPrimarySlotsFilled} />

View File

@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { EmptyState, type IEmptyState } from "../EmptyState.js";
import "../EmptyState.js";
const metadata: Meta<EmptyState> = {
title: "Elements/<ak-empty-state>",
component: "ak-empty-state",
parameters: {
docs: {
description: "Our empty state spinner",
},
},
argTypes: {
icon: { control: "text" },
loading: { control: "boolean" },
fullHeight: { control: "boolean" },
header: { control: "text" },
},
};
export default metadata;
const container = (content: TemplateResult) =>
html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-divider {
display: inline-block;
width: 32rem;
max-width: 32rem;
}</style
>${content}
</div>`;
export const DefaultStory: StoryObj = {
args: {
icon: undefined,
loading: true,
fullHeight: false,
header: undefined,
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
</ak-empty-state>`,
),
};
export const DefaultAndLoadingDone = {
...DefaultStory,
args: { ...DefaultStory, ...{ loading: false } },
};
export const DoneWithAlternativeIcon = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
};
export const WithBodySlotFilled = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(html`
<ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
<span slot="body">This is the body content</span>
</ak-empty-state>
`),
};
export const WithBodyAndPrimarySlotsFilled = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
<span slot="body">This is the body content slot</span>
<span slot="primary">This is the primary content slot</span>
</ak-empty-state>`,
),
};

View File

@ -7,7 +7,7 @@ import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./EmptyState.js";
import "../EmptyState.js";
const render = (body: TemplateResult) => {
document.adoptedStyleSheets = [
@ -58,6 +58,6 @@ describe("ak-empty-state", () => {
);
const message = await $("ak-empty-state").$(">>>.pf-c-empty-state__body").$(">>>p");
await expect(message).toHaveText("Try again with a different filter");
await expect(message).toHaveText("Try again with a fucked filter");
});
});

View File

@ -0,0 +1,38 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as ExpandStories from "./Expand.stories";
<Meta of={ExpandStories} />
# Expand
Expand is an in-page element used to hid cluttering details that a user may wish to reveal, such as raw
details of an alert or event.
It has one unnamed slot for the content to be displayed.
## Usage
```Typescript
import "@goauthentik/elements/Expand.js";
```
```html
<ak-expand><p>Your primary content goes here</p></ak-expand>
```
To show the expanded content on initial render:
```html
<ak-expand expanded><p>Your primary content goes here</p></ak-expand>
```
## Demo
### Default: The content is hidden
<Story of={ExpandStories.DefaultStory} />
### Expanded
<Story of={ExpandStories.Expanded} />

View File

@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { Expand, type IExpand } from "../Expand.js";
import "../Expand.js";
const metadata: Meta<Expand> = {
title: "Elements/<ak-expand>",
component: "ak-expand",
parameters: {
docs: {
description: "Our accordion component",
},
},
argTypes: {
expanded: { control: "boolean" },
textOpen: { control: "text" },
textClosed: { control: "text" },
},
};
export default metadata;
const container = (content: TemplateResult) =>
html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-divider {
display: inline-block;
width: 32rem;
max-width: 32rem;
}</style
>${content}
</div>`;
export const DefaultStory: StoryObj = {
args: {
expanded: false,
textOpen: undefined,
textClosed: undefined,
},
render: ({ expanded, textOpen, textClosed }: IExpand) =>
container(
html` <ak-expand
?expanded=${expanded}
textOpen=${ifDefined(textOpen)}
textClosed=${ifDefined(textClosed)}
><div>
<p>Μήτ᾽ ἔμοι μέλι μήτε μέλισσα</p>
<p>"Neither the bee nor the honey for me." - Sappho, 600 BC</p>
</div>
</ak-expand>`,
),
};
export const Expanded = {
...DefaultStory,
args: { ...DefaultStory, ...{ expanded: true } },
};

View File

@ -0,0 +1,52 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "../Expand.js";
const render = (body: TemplateResult) => {
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
ensureCSSStyleSheet(PFBase),
ensureCSSStyleSheet(AKGlobal),
];
return litRender(body, document.body);
};
describe("ak-expand", () => {
it("should render the expansion content hidden by default", async () => {
render(html`<ak-expand><p>This is the expanded text</p></ak-expand>`);
const text = await $("ak-expand").$(">>>.pf-c-expandable-section__content");
await expect(text).not.toBeDisplayed();
});
it("should render the expansion content visible on demand", async () => {
render(html`<ak-expand expanded><p>This is the expanded text</p></ak-expand>`);
const paragraph = await $("ak-expand").$(">>>p");
await expect(paragraph).toExist();
await expect(paragraph).toBeDisplayed();
await expect(paragraph).toHaveText("This is the expanded text");
});
it("should respond to the click event", async () => {
render(html`<ak-expand><p>This is the expanded text</p></ak-expand>`);
let content = await $("ak-expand").$(">>>.pf-c-expandable-section__content");
await expect(content).toExist();
await expect(content).not.toBeDisplayed();
const control = await $("ak-expand").$(">>>button");
await control.click();
content = await $("ak-expand").$(">>>.pf-c-expandable-section__content");
await expect(content).toExist();
await expect(content).toBeDisplayed();
await control.click();
content = await $("ak-expand").$(">>>.pf-c-expandable-section__content");
await expect(content).toExist();
await expect(content).not.toBeDisplayed();
});
});