Skip to content

Commit 449e3ae

Browse files
Merge pull request #6 from goauthentik/element/ak-empty-state
element: ak-empty-state
2 parents ce604e9 + 353f51d commit 449e3ae

14 files changed

+1276
-4
lines changed

src/ak-empty-state/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Empty-State is a component in the Patternfly ecosystem meant to show that a page has no information
2+
or that the information has not yet completed loading.
3+
4+
## Analysis
5+
6+
The Empty State is a specialized card component with a distinct layout:
7+
8+
```
9+
.empty-state
10+
.icon-block*
11+
.icon-or-spinner
12+
.content*
13+
.title
14+
.body*
15+
.footer*
16+
.actions*
17+
.secondary-actions*
18+
.footer-message*
19+
```
20+
21+
All of the internal components are optional, displayed in a `flex` column format with a consistent
22+
gap.
23+
24+
## Implementation
25+
26+
All of the innermost content has been handed over to slots; clients can implement whatever gap
27+
between horizontal components they want (for example, if there are multiple buttons or links in the
28+
`actions` block; the Empty State turns on flex wrappers for components based on whether or not the
29+
slot is populated. The sizing has been regularized to the t-shirt sizing on the topmost attribute,
30+
especially when internal rendering is used for the spinner or icons.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import "./ak-empty-state.component.js";
2+
3+
import { EmptyState } from "./ak-empty-state.component.js";
4+
5+
import { match, P } from "ts-pattern";
6+
7+
import { html, TemplateResult } from "lit";
8+
import { ifDefined } from "lit/directives/if-defined.js";
9+
10+
/* The `pick`ed fields here correspond to their types in the EmptyState class above. */
11+
12+
export interface EmptyStateSlots {
13+
icon?: string | TemplateResult;
14+
title?: string | TemplateResult;
15+
body?: string | TemplateResult;
16+
footer?: string | TemplateResult;
17+
actions?: string | TemplateResult;
18+
secondaryActions?: string | TemplateResult;
19+
}
20+
21+
export type EmptyStateProps = Partial<
22+
Pick<EmptyState, "size" | "loading" | "textOnly" | "spinnerOnly">
23+
> &
24+
EmptyStateSlots & { fullHeight?: boolean };
25+
26+
const SLOTNAMES: (keyof EmptyStateSlots)[] = [
27+
"icon",
28+
"title",
29+
"body",
30+
"footer",
31+
"actions",
32+
"secondaryActions",
33+
] as const;
34+
35+
type SlotName = (typeof SLOTNAMES)[number];
36+
37+
type SlotContent = string | TemplateResult | undefined;
38+
39+
/**
40+
* @summary Helper function to create an EmptyState component programmatically
41+
*
42+
* @returns {TemplateResult} A Lit template result containing the configured ak-empty-state element
43+
*
44+
* NOTE: This function does not edit TemplateResults passed in. If you pass in a TemplateResult, it
45+
* *must* indicated what slot it's being added to, even if you think the prop should handle it.
46+
*
47+
* @see {@link EmptyState} - The underlying web component
48+
*/
49+
export function akEmptyState(options: EmptyStateProps) {
50+
const slots = SLOTNAMES.filter((s) => !!options[s]);
51+
52+
const slotRenderer = (s: string, c: string | TemplateResult) => html`<div slot=${s}>${c}</div>`;
53+
54+
const slotHandler = (s: SlotName) =>
55+
match<[SlotContent, SlotName], SlotContent>([options[s], s])
56+
.with([P.string, "secondaryActions"], ([c]) => slotRenderer("secondary-actions", c))
57+
.with([P.string, "title"], ([c]) => html`<h2 slot="title">${c}</h2>`)
58+
.with([P.string, "body"], ([c]) => html`<p slot="body">${c}</p>`)
59+
.with(
60+
[P.string, "icon"],
61+
([option]) => html`<ak-icon slot="icon" icon=${option}></ak-icon>`,
62+
)
63+
.with([P.string, P._], ([option, s]) => slotRenderer(s, option))
64+
.otherwise(() => options[s]);
65+
66+
let opts = {
67+
...options,
68+
...Object.fromEntries(slots.map((s) => [s, slotHandler(s)])),
69+
};
70+
71+
const { size, fullHeight, spinnerOnly, textOnly, loading } = opts;
72+
73+
return html`
74+
<ak-empty-state
75+
?loading=${loading}
76+
?full-height=${fullHeight}
77+
?text-only=${textOnly}
78+
?spinner-only=${spinnerOnly}
79+
size=${ifDefined(String(size))}
80+
>
81+
${slots.map((s) => opts[s])}
82+
</ak-empty-state>
83+
`;
84+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import "../ak-icon/ak-icon.js";
2+
import "../ak-spinner/ak-spinner.js";
3+
4+
import { DynamicSlotController } from "../controllers/dynamic-slot-controller.js";
5+
import styles from "./ak-empty-state.css";
6+
import { type EmptyStateSize, template } from "./ak-empty-state.template.js";
7+
8+
import { msg } from "@lit/localize";
9+
import { LitElement } from "lit";
10+
import { property } from "lit/decorators.js";
11+
12+
/**
13+
* @element ak-empty-state
14+
*
15+
* @summary A placeholder component displayed when no data is available or data is being loaded
16+
*
17+
* @attr {string} size - Size variant: "xs", "sm", "lg", "xl"
18+
* @attr {boolean} loading - Shows spinner and loading text when true
19+
* @attr {boolean} spinner-only - Shows only the spinner, not the (localized) text "Loading..." when `loading` is true
20+
* @attr {boolean} text-only - Hides the default icon when true
21+
* @attr {boolean} full-height - Makes component take full height of container
22+
* @attr {string} icon - The name of an icon, as understood by ak-icon. NOTE: if both this attribute and the `icon` slot are used, the slot takes priority.
23+
*
24+
* @slot icon - Icon displayed at the top of the empty state
25+
* @slot title - Title describing the empty state
26+
* @slot body - Descriptive text providing additional context
27+
* @slot footer - Footer content with links, help text, or action buttons
28+
* @slot actions - Primary action buttons
29+
* @slot secondary-actions - Secondary action buttons
30+
*
31+
* @csspart empty-state - The main container element
32+
* @csspart content - The content container element
33+
* @csspart icon - The container for the icon element
34+
* @csspart title - The container for the title
35+
* @csspart body - The container for the body text
36+
* @csspart footer - The footer container
37+
* @csspart actions - The container for the action buttons
38+
*
39+
* @cssprop --pf-v5-c-empty-state--PaddingTop - Top padding of the empty state
40+
* @cssprop --pf-v5-c-empty-state--PaddingRight - Right padding of the empty state
41+
* @cssprop --pf-v5-c-empty-state--PaddingBottom - Bottom padding of the empty state
42+
* @cssprop --pf-v5-c-empty-state--PaddingLeft - Left padding of the empty state
43+
* @cssprop --pf-v5-c-empty-state__content--MaxWidth - Maximum width of content area
44+
* @cssprop --pf-v5-c-empty-state__icon--FontSize - Font size of the icon
45+
* @cssprop --pf-v5-c-empty-state__icon--Color - Color of the icon
46+
* @cssprop --pf-v5-c-empty-state__icon--MarginBottom - Bottom margin of the icon
47+
* @cssprop --pf-v5-c-empty-state__title-text--FontFamily - Font family of the title
48+
* @cssprop --pf-v5-c-empty-state__title-text--FontSize - Font size of the title
49+
* @cssprop --pf-v5-c-empty-state__title-text--FontWeight - Font weight of the title
50+
* @cssprop --pf-v5-c-empty-state__body--Color - Color of the body text
51+
* @cssprop --pf-v5-c-empty-state__body--MarginTop - Top margin of the body text
52+
* @cssprop --pf-v5-c-empty-state__footer--MarginTop - Top margin of the footer
53+
* @cssprop --pf-v5-c-empty-state__actions--RowGap - Row gap between action elements
54+
* @cssprop --pf-v5-c-empty-state__actions--ColumnGap - Column gap between action elements
55+
*/
56+
57+
export class EmptyState extends LitElement {
58+
static override readonly styles = [styles];
59+
60+
@property({ type: String })
61+
public icon?: string;
62+
63+
@property({ type: Boolean, attribute: "text-only" })
64+
public textOnly = false;
65+
66+
@property({ type: Boolean, attribute: "spinner-only" })
67+
public spinnerOnly = false;
68+
69+
@property({ type: Boolean })
70+
public loading = false;
71+
72+
// A case where external manipulation of must be reflected to trigger CSS effects.
73+
@property({ type: String, reflect: true })
74+
public size: EmptyStateSize = "lg";
75+
76+
#onSlotChange = () => {
77+
this.requestUpdate();
78+
};
79+
80+
#slotsController = new DynamicSlotController(this, this.#onSlotChange);
81+
82+
public override render() {
83+
const [hasTitle, hasBody, hasActions, hasSecondaryActions, hasFooterContent] = [
84+
"title",
85+
"body",
86+
"actions",
87+
"secondary-actions",
88+
"footer",
89+
].map((name) => this.#slotsController.has(name));
90+
91+
const hasFooter = hasActions || hasSecondaryActions || hasFooterContent;
92+
const { icon, textOnly, size, loading, spinnerOnly } = this;
93+
94+
return template({
95+
hasTitle,
96+
hasBody,
97+
hasFooter,
98+
hasActions,
99+
hasSecondaryActions,
100+
hasFooterContent,
101+
useIconSlot: this.#slotsController.has("icon"),
102+
icon,
103+
textOnly,
104+
size,
105+
loading,
106+
showLoading: loading && !spinnerOnly,
107+
});
108+
}
109+
110+
// Double-check this. "No ARIA is better than bad ARIA," and I'm not 100% on my ARIA skills yet.
111+
public override updated() {
112+
if (this.loading) {
113+
this.removeAttribute("aria-label");
114+
this.setAttribute("aria-live", "polite");
115+
this.setAttribute("role", "status");
116+
} else {
117+
this.removeAttribute("aria-live");
118+
this.setAttribute("role", "img");
119+
// Consider aria-label for non-loading states
120+
if (!this.#slotsController.has("title")) {
121+
this.setAttribute("aria-label", msg("Empty state"));
122+
}
123+
}
124+
}
125+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
:host {
2+
--empty-state--PaddingTop: var(--pf-v5-c-empty-state--PaddingTop, 2rem);
3+
--empty-state--PaddingRight: var(--pf-v5-c-empty-state--PaddingRight, 2rem);
4+
--empty-state--PaddingBottom: var(--pf-v5-c-empty-state--PaddingBottom, 2rem);
5+
--empty-state--PaddingLeft: var(--pf-v5-c-empty-state--PaddingLeft, 2rem);
6+
--empty-state--m-xs--PaddingTop: var(--pf-v5-c-empty-state--m-xs--PaddingTop, 1rem);
7+
--empty-state--m-xs--PaddingRight: var(--pf-v5-c-empty-state--m-xs--PaddingRight, 1rem);
8+
--empty-state--m-xs--PaddingBottom: var(--pf-v5-c-empty-state--m-xs--PaddingBottom, 1rem);
9+
--empty-state--m-xs--PaddingLeft: var(--pf-v5-c-empty-state--m-xs--PaddingLeft, 1rem);
10+
--empty-state__content--MaxWidth: var(--pf-v5-c-empty-state__content--MaxWidth, none);
11+
--empty-state--m-xs__content--MaxWidth: var(
12+
--pf-v5-c-empty-state--m-xs__content--MaxWidth,
13+
21.875rem
14+
);
15+
--empty-state--m-sm__content--MaxWidth: var(
16+
--pf-v5-c-empty-state--m-sm__content--MaxWidth,
17+
25rem
18+
);
19+
--empty-state--m-lg__content--MaxWidth: var(
20+
--pf-v5-c-empty-state--m-lg__content--MaxWidth,
21+
37.5rem
22+
);
23+
--empty-state__icon--MarginBottom: var(--pf-v5-c-empty-state__icon--MarginBottom, 1.5rem);
24+
--empty-state__icon--FontSize: var(--pf-v5-c-empty-state__icon--FontSize, 3.375rem);
25+
--empty-state__icon--Color: var(--pf-v5-c-empty-state__icon--Color, #6a6e73);
26+
--empty-state--m-xs__icon--MarginBottom: var(
27+
--pf-v5-c-empty-state--m-xs__icon--MarginBottom,
28+
1rem
29+
);
30+
--empty-state--m-xl__icon--MarginBottom: var(
31+
--pf-v5-c-empty-state--m-xl__icon--MarginBottom,
32+
2rem
33+
);
34+
--empty-state--m-xl__icon--FontSize: var(--pf-v5-c-empty-state--m-xl__icon--FontSize, 6.25rem);
35+
--empty-state__title-text--FontFamily: var(
36+
--pf-v5-c-empty-state__title-text--FontFamily,
37+
'"RedHatDisplay", helvetica, arial, sans-serif'
38+
);
39+
--empty-state__title-text--FontSize: var(--pf-v5-c-empty-state__title-text--FontSize, 1.25rem);
40+
--empty-state__title-text--FontWeight: var(--pf-v5-c-empty-state__title-text--FontWeight, 400);
41+
--empty-state__title-text--LineHeight: var(--pf-v5-c-empty-state__title-text--LineHeight, 1.5);
42+
--empty-state--m-xs__title-text--FontSize: var(
43+
--pf-v5-c-empty-state--m-xs__title-text--FontSize,
44+
1rem
45+
);
46+
--empty-state--m-xl__title-text--FontSize: var(
47+
--pf-v5-c-empty-state--m-xl__title-text--FontSize,
48+
2.25rem
49+
);
50+
--empty-state--m-xl__title-text--LineHeight: var(
51+
--pf-v5-c-empty-state--m-xl__title-text--LineHeight,
52+
1.3
53+
);
54+
--empty-state__body--MarginTop: var(--pf-v5-c-empty-state__body--MarginTop, 1rem);
55+
--empty-state__body--Color: var(--pf-v5-c-empty-state__body--Color, #6a6e73);
56+
--empty-state--body--FontSize: var(--pf-v5-c-empty-state--body--FontSize, 1rem);
57+
--empty-state--m-xs__body--FontSize: var(--pf-v5-c-empty-state--m-xs__body--FontSize, 0.875rem);
58+
--empty-state--m-xs__body--MarginTop: var(--pf-v5-c-empty-state--m-xs__body--MarginTop, 1rem);
59+
--empty-state--m-xl__body--FontSize: var(--pf-v5-c-empty-state--m-xl__body--FontSize, 1.25rem);
60+
--empty-state--m-xl__body--MarginTop: var(--pf-v5-c-empty-state--m-xl__body--MarginTop, 1.5rem);
61+
--empty-state__footer--RowGap: var(--pf-v5-c-empty-state__footer--RowGap, 0.5rem);
62+
--empty-state__footer--MarginTop: var(--pf-v5-c-empty-state__footer--MarginTop, 2rem);
63+
--empty-state--m-xs__footer--MarginTop: var(
64+
--pf-v5-c-empty-state--m-xs__footer--MarginTop,
65+
1rem
66+
);
67+
--empty-state__actions--RowGap: var(--pf-v5-c-empty-state__actions--RowGap, 0.25rem);
68+
--empty-state__actions--ColumnGap: var(--pf-v5-c-empty-state__actions--ColumnGap, 0.25rem);
69+
}
70+
71+
:host(:not([hidden])) {
72+
display: block;
73+
}
74+
75+
[part="empty-state"] {
76+
display: flex;
77+
align-items: center;
78+
justify-content: center;
79+
padding-block-start: var(--empty-state--PaddingTop);
80+
padding-block-end: var(--empty-state--PaddingBottom);
81+
padding-inline-start: var(--empty-state--PaddingLeft);
82+
padding-inline-end: var(--empty-state--PaddingRight);
83+
text-align: center;
84+
}
85+
86+
:host([size="xs"]) {
87+
--empty-state--PaddingTop: var(--empty-state--m-xs--PaddingTop);
88+
--empty-state--PaddingRight: var(--empty-state--m-xs--PaddingRight);
89+
--empty-state--PaddingBottom: var(--empty-state--m-xs--PaddingBottom);
90+
--empty-state--PaddingLeft: var(--empty-state--m-xs--PaddingLeft);
91+
--empty-state__title-text--FontSize: var(--empty-state--m-xs__title-text--FontSize);
92+
--empty-state__content--MaxWidth: var(--empty-state--m-xs__content--MaxWidth);
93+
--empty-state__icon--MarginBottom: var(--empty-state--m-xs__icon--MarginBottom);
94+
--empty-state__body--MarginTop: var(--empty-state--m-xs__body--MarginTop);
95+
--empty-state--body--FontSize: var(--empty-state--m-xs__body--FontSize);
96+
--empty-state__footer--MarginTop: var(--empty-state--m-xs__footer--MarginTop);
97+
}
98+
99+
:host([size="sm"]) {
100+
--empty-state__content--MaxWidth: var(--empty-state--m-sm__content--MaxWidth);
101+
}
102+
103+
:host([size="lg"]) {
104+
--empty-state__content--MaxWidth: var(--empty-state--m-lg__content--MaxWidth);
105+
}
106+
107+
:host([size="xl"]) {
108+
--empty-state__body--MarginTop: var(--empty-state--m-xl__body--MarginTop);
109+
--empty-state--body--FontSize: var(--empty-state--m-xl__body--FontSize);
110+
--empty-state__icon--MarginBottom: var(--empty-state--m-xl__icon--MarginBottom);
111+
--empty-state__icon--FontSize: var(--empty-state--m-xl__icon--FontSize);
112+
--empty-state__title-text--FontSize: var(--empty-state--m-xl__title-text--FontSize);
113+
--empty-state__title-text--LineHeight: var(--empty-state--m-xl__title-text--LineHeight);
114+
}
115+
116+
:host([full-height]) {
117+
height: 100%;
118+
}
119+
120+
[part="content"] {
121+
max-width: var(--empty-state__content--MaxWidth);
122+
}
123+
124+
[part="icon"] {
125+
margin-block-end: var(--empty-state__icon--MarginBottom);
126+
font-size: var(--empty-state__icon--FontSize);
127+
line-height: 1;
128+
color: var(--empty-state__icon--Color);
129+
}
130+
131+
[part="body"] {
132+
margin-block-start: var(--empty-state__body--MarginTop);
133+
font-size: var(--empty-state--body--FontSize);
134+
color: var(--empty-state__body--Color);
135+
}
136+
137+
[part="footer"] {
138+
display: flex;
139+
flex-direction: column;
140+
row-gap: var(--empty-state__footer--RowGap);
141+
align-items: center;
142+
margin-block-start: var(--empty-state__footer--MarginTop);
143+
}
144+
145+
[part="actions"] {
146+
display: flex;
147+
flex-wrap: wrap;
148+
gap: var(--empty-state__actions--RowGap) var(--empty-state__actions--ColumnGap);
149+
justify-content: center;
150+
}
151+
152+
[part="title"] {
153+
font-family: var(--empty-state__title-text--FontFamily);
154+
font-size: var(--empty-state__title-text--FontSize);
155+
font-weight: var(--empty-state__title-text--FontWeight);
156+
line-height: var(--empty-state__title-text--LineHeight);
157+
}

0 commit comments

Comments
 (0)