Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
ba1d9b2
Added empty state to collection.
kensternberg-authentik Aug 27, 2025
0f44da0
Merge branch 'element/ak-icon' into elements/ak-empty-state
kensternberg-authentik Aug 27, 2025
fa7aa60
Merge branch 'element/ak-spinner' into elements/ak-empty-state
kensternberg-authentik Aug 27, 2025
ef08ecb
Empty State underway.
kensternberg-authentik Aug 27, 2025
1372550
Merge branch 'element/ak-icon' into elements/ak-empty-state
kensternberg-authentik Aug 27, 2025
abfa46b
The sizes are still not quite right. Review what the React component…
kensternberg-authentik Aug 27, 2025
c189739
Merge branch 'elements/infrastructure-and-brand' into element/ak-empt…
kensternberg-authentik Aug 28, 2025
8b951b8
Preferred sizes fixed.
kensternberg-authentik Aug 28, 2025
0792332
Merge branch 'element/ak-icon' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
d8da41b
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
b057490
Re-arranged spinner sizes for loading.
kensternberg-authentik Aug 28, 2025
1bd7e73
Tests are finally passing after reconfiguring to use operator.
kensternberg-authentik Aug 28, 2025
f4011c4
Added types and fixed a lint bug.
kensternberg-authentik Aug 28, 2025
0ca683c
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
6a2de16
Merge branch 'element/ak-icon' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
d0cb5d6
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
12a30c9
Merge branch 'element/ak-icon' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
7f673c1
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Aug 28, 2025
62c68fd
I was very unhappy with some of the visuals. I had a whole bunch of …
kensternberg-authentik Aug 29, 2025
94e7e6a
Merge remote-tracking branch 'refs/remotes/origin/element/ak-empty-st…
kensternberg-authentik Aug 29, 2025
31a2801
Adding ARIA compliance and fixing some weak ergonomics.
kensternberg-authentik Aug 29, 2025
0f5caf4
Icon command priority order was wrong; added a Storybook entry and a …
kensternberg-authentik Aug 29, 2025
ab0a478
Added documentation.
kensternberg-authentik Sep 1, 2025
04c583d
Merge branch 'element/ak-icon' into element/ak-empty-state
kensternberg-authentik Sep 3, 2025
a72452d
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Sep 3, 2025
7befd45
Removing dependency on customElement.
kensternberg-authentik Sep 3, 2025
3f5bb2f
Added note that our size check is for consistency with related compon…
kensternberg-authentik Sep 3, 2025
643c0d4
Updated ak-empty-state to use the controller.
kensternberg-authentik Sep 24, 2025
a224ef6
Lintpicking!
kensternberg-authentik Sep 26, 2025
7367e1d
Merge branch 'elements/infrastructure-and-brand' into element/ak-empt…
kensternberg-authentik Sep 26, 2025
fcacfec
Merge branch 'element/ak-icon' into element/ak-empty-state
kensternberg-authentik Sep 26, 2025
b750eec
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Sep 26, 2025
311d42d
Removing TODO that made Eslint whine.
kensternberg-authentik Sep 26, 2025
c31ed19
Merge branch 'main' into element/ak-empty-state
kensternberg-authentik Nov 10, 2025
5d2f37e
Empty state now has root CSS definition, making it available for cust…
kensternberg-authentik Nov 11, 2025
368eda3
Merge branch 'main' into element/ak-empty-state
kensternberg-authentik Nov 13, 2025
e7028f9
Just maintainging a 'prettier' codebase.
kensternberg-authentik Nov 13, 2025
68c8618
Merge branch 'main' into element/ak-empty-state
kensternberg-authentik Jan 8, 2026
fdc7f91
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Jan 8, 2026
4255fba
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Jan 8, 2026
9025140
Playwright-ifying the tests.
kensternberg-authentik Jan 8, 2026
94dcdf7
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Jan 8, 2026
429c95a
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Jan 8, 2026
2d2225b
Consistency pass: fixed some poor name choices and removed the useles…
kensternberg-authentik Jan 9, 2026
e5de2b5
elements: fix inconsistent API, UI, and DX issues
kensternberg-authentik Jan 12, 2026
e93ee1d
Merge branch 'main' into element/ak-empty-state
kensternberg-authentik Jan 14, 2026
374a40d
Merge branch 'element/ak-spinner' into element/ak-empty-state
kensternberg-authentik Jan 14, 2026
8168b5b
element/ak-empty-state: consistency check
kensternberg-authentik Jan 14, 2026
353f51d
Merge branch 'main' into element/ak-empty-state
kensternberg-authentik Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/ak-empty-state/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Empty-State is a component in the Patternfly ecosystem meant to show that a page has no information
or that the information has not yet completed loading.

## Analysis

The Empty State is a specialized card component with a distinct layout:

```
.empty-state
.icon-block*
.icon-or-spinner
.content*
.title
.body*
.footer*
.actions*
.secondary-actions*
.footer-message*
```

All of the internal components are optional, displayed in a `flex` column format with a consistent
gap.

## Implementation

All of the innermost content has been handed over to slots; clients can implement whatever gap
between horizontal components they want (for example, if there are multiple buttons or links in the
`actions` block; the Empty State turns on flex wrappers for components based on whether or not the
slot is populated. The sizing has been regularized to the t-shirt sizing on the topmost attribute,
especially when internal rendering is used for the spinner or icons.
84 changes: 84 additions & 0 deletions src/ak-empty-state/ak-empty-state.builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import "./ak-empty-state.component.js";

import { EmptyState } from "./ak-empty-state.component.js";

import { match, P } from "ts-pattern";

import { html, TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";

/* The `pick`ed fields here correspond to their types in the EmptyState class above. */

export interface EmptyStateSlots {
icon?: string | TemplateResult;
title?: string | TemplateResult;
body?: string | TemplateResult;
footer?: string | TemplateResult;
actions?: string | TemplateResult;
secondaryActions?: string | TemplateResult;
}

export type EmptyStateProps = Partial<
Pick<EmptyState, "size" | "loading" | "textOnly" | "spinnerOnly">
> &
EmptyStateSlots & { fullHeight?: boolean };

const SLOTNAMES: (keyof EmptyStateSlots)[] = [
"icon",
"title",
"body",
"footer",
"actions",
"secondaryActions",
] as const;

type SlotName = (typeof SLOTNAMES)[number];

type SlotContent = string | TemplateResult | undefined;

/**
* @summary Helper function to create an EmptyState component programmatically
*
* @returns {TemplateResult} A Lit template result containing the configured ak-empty-state element
*
* NOTE: This function does not edit TemplateResults passed in. If you pass in a TemplateResult, it
* *must* indicated what slot it's being added to, even if you think the prop should handle it.
*
* @see {@link EmptyState} - The underlying web component
*/
export function akEmptyState(options: EmptyStateProps) {
const slots = SLOTNAMES.filter((s) => !!options[s]);

const slotRenderer = (s: string, c: string | TemplateResult) => html`<div slot=${s}>${c}</div>`;

const slotHandler = (s: SlotName) =>
match<[SlotContent, SlotName], SlotContent>([options[s], s])
.with([P.string, "secondaryActions"], ([c]) => slotRenderer("secondary-actions", c))
.with([P.string, "title"], ([c]) => html`<h2 slot="title">${c}</h2>`)
.with([P.string, "body"], ([c]) => html`<p slot="body">${c}</p>`)
.with(
[P.string, "icon"],
([option]) => html`<ak-icon slot="icon" icon=${option}></ak-icon>`,
)
.with([P.string, P._], ([option, s]) => slotRenderer(s, option))
.otherwise(() => options[s]);

let opts = {
...options,
...Object.fromEntries(slots.map((s) => [s, slotHandler(s)])),
};

const { size, fullHeight, spinnerOnly, textOnly, loading } = opts;

return html`
<ak-empty-state
?loading=${loading}
?full-height=${fullHeight}
?text-only=${textOnly}
?spinner-only=${spinnerOnly}
size=${ifDefined(String(size))}
>
${slots.map((s) => opts[s])}
</ak-empty-state>
`;
}
125 changes: 125 additions & 0 deletions src/ak-empty-state/ak-empty-state.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import "../ak-icon/ak-icon.js";
import "../ak-spinner/ak-spinner.js";

import { DynamicSlotController } from "../controllers/dynamic-slot-controller.js";
import styles from "./ak-empty-state.css";
import { type EmptyStateSize, template } from "./ak-empty-state.template.js";

import { msg } from "@lit/localize";
import { LitElement } from "lit";
import { property } from "lit/decorators.js";

/**
* @element ak-empty-state
*
* @summary A placeholder component displayed when no data is available or data is being loaded
*
* @attr {string} size - Size variant: "xs", "sm", "lg", "xl"
* @attr {boolean} loading - Shows spinner and loading text when true
* @attr {boolean} spinner-only - Shows only the spinner, not the (localized) text "Loading..." when `loading` is true
* @attr {boolean} text-only - Hides the default icon when true
* @attr {boolean} full-height - Makes component take full height of container
* @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.
*
* @slot icon - Icon displayed at the top of the empty state
* @slot title - Title describing the empty state
* @slot body - Descriptive text providing additional context
* @slot footer - Footer content with links, help text, or action buttons
* @slot actions - Primary action buttons
* @slot secondary-actions - Secondary action buttons
*
* @csspart empty-state - The main container element
* @csspart content - The content container element
* @csspart icon - The container for the icon element
* @csspart title - The container for the title
* @csspart body - The container for the body text
* @csspart footer - The footer container
* @csspart actions - The container for the action buttons
*
* @cssprop --pf-v5-c-empty-state--PaddingTop - Top padding of the empty state
* @cssprop --pf-v5-c-empty-state--PaddingRight - Right padding of the empty state
* @cssprop --pf-v5-c-empty-state--PaddingBottom - Bottom padding of the empty state
* @cssprop --pf-v5-c-empty-state--PaddingLeft - Left padding of the empty state
* @cssprop --pf-v5-c-empty-state__content--MaxWidth - Maximum width of content area
* @cssprop --pf-v5-c-empty-state__icon--FontSize - Font size of the icon
* @cssprop --pf-v5-c-empty-state__icon--Color - Color of the icon
* @cssprop --pf-v5-c-empty-state__icon--MarginBottom - Bottom margin of the icon
* @cssprop --pf-v5-c-empty-state__title-text--FontFamily - Font family of the title
* @cssprop --pf-v5-c-empty-state__title-text--FontSize - Font size of the title
* @cssprop --pf-v5-c-empty-state__title-text--FontWeight - Font weight of the title
* @cssprop --pf-v5-c-empty-state__body--Color - Color of the body text
* @cssprop --pf-v5-c-empty-state__body--MarginTop - Top margin of the body text
* @cssprop --pf-v5-c-empty-state__footer--MarginTop - Top margin of the footer
* @cssprop --pf-v5-c-empty-state__actions--RowGap - Row gap between action elements
* @cssprop --pf-v5-c-empty-state__actions--ColumnGap - Column gap between action elements
*/

export class EmptyState extends LitElement {
static override readonly styles = [styles];

@property({ type: String })
public icon?: string;

@property({ type: Boolean, attribute: "text-only" })
public textOnly = false;

@property({ type: Boolean, attribute: "spinner-only" })
public spinnerOnly = false;

@property({ type: Boolean })
public loading = false;

// A case where external manipulation of must be reflected to trigger CSS effects.
@property({ type: String, reflect: true })
public size: EmptyStateSize = "lg";

#onSlotChange = () => {
this.requestUpdate();
};

#slotsController = new DynamicSlotController(this, this.#onSlotChange);

public override render() {
const [hasTitle, hasBody, hasActions, hasSecondaryActions, hasFooterContent] = [
"title",
"body",
"actions",
"secondary-actions",
"footer",
].map((name) => this.#slotsController.has(name));

const hasFooter = hasActions || hasSecondaryActions || hasFooterContent;
const { icon, textOnly, size, loading, spinnerOnly } = this;

return template({
hasTitle,
hasBody,
hasFooter,
hasActions,
hasSecondaryActions,
hasFooterContent,
useIconSlot: this.#slotsController.has("icon"),
icon,
textOnly,
size,
loading,
showLoading: loading && !spinnerOnly,
});
}

// Double-check this. "No ARIA is better than bad ARIA," and I'm not 100% on my ARIA skills yet.
public override updated() {
if (this.loading) {
this.removeAttribute("aria-label");
this.setAttribute("aria-live", "polite");
this.setAttribute("role", "status");
} else {
this.removeAttribute("aria-live");
this.setAttribute("role", "img");
// Consider aria-label for non-loading states
if (!this.#slotsController.has("title")) {
this.setAttribute("aria-label", msg("Empty state"));
}
}
}
}
157 changes: 157 additions & 0 deletions src/ak-empty-state/ak-empty-state.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
:host {
--empty-state--PaddingTop: var(--pf-v5-c-empty-state--PaddingTop, 2rem);
--empty-state--PaddingRight: var(--pf-v5-c-empty-state--PaddingRight, 2rem);
--empty-state--PaddingBottom: var(--pf-v5-c-empty-state--PaddingBottom, 2rem);
--empty-state--PaddingLeft: var(--pf-v5-c-empty-state--PaddingLeft, 2rem);
--empty-state--m-xs--PaddingTop: var(--pf-v5-c-empty-state--m-xs--PaddingTop, 1rem);
--empty-state--m-xs--PaddingRight: var(--pf-v5-c-empty-state--m-xs--PaddingRight, 1rem);
--empty-state--m-xs--PaddingBottom: var(--pf-v5-c-empty-state--m-xs--PaddingBottom, 1rem);
--empty-state--m-xs--PaddingLeft: var(--pf-v5-c-empty-state--m-xs--PaddingLeft, 1rem);
--empty-state__content--MaxWidth: var(--pf-v5-c-empty-state__content--MaxWidth, none);
--empty-state--m-xs__content--MaxWidth: var(
--pf-v5-c-empty-state--m-xs__content--MaxWidth,
21.875rem
);
--empty-state--m-sm__content--MaxWidth: var(
--pf-v5-c-empty-state--m-sm__content--MaxWidth,
25rem
);
--empty-state--m-lg__content--MaxWidth: var(
--pf-v5-c-empty-state--m-lg__content--MaxWidth,
37.5rem
);
--empty-state__icon--MarginBottom: var(--pf-v5-c-empty-state__icon--MarginBottom, 1.5rem);
--empty-state__icon--FontSize: var(--pf-v5-c-empty-state__icon--FontSize, 3.375rem);
--empty-state__icon--Color: var(--pf-v5-c-empty-state__icon--Color, #6a6e73);
--empty-state--m-xs__icon--MarginBottom: var(
--pf-v5-c-empty-state--m-xs__icon--MarginBottom,
1rem
);
--empty-state--m-xl__icon--MarginBottom: var(
--pf-v5-c-empty-state--m-xl__icon--MarginBottom,
2rem
);
--empty-state--m-xl__icon--FontSize: var(--pf-v5-c-empty-state--m-xl__icon--FontSize, 6.25rem);
--empty-state__title-text--FontFamily: var(
--pf-v5-c-empty-state__title-text--FontFamily,
'"RedHatDisplay", helvetica, arial, sans-serif'
);
--empty-state__title-text--FontSize: var(--pf-v5-c-empty-state__title-text--FontSize, 1.25rem);
--empty-state__title-text--FontWeight: var(--pf-v5-c-empty-state__title-text--FontWeight, 400);
--empty-state__title-text--LineHeight: var(--pf-v5-c-empty-state__title-text--LineHeight, 1.5);
--empty-state--m-xs__title-text--FontSize: var(
--pf-v5-c-empty-state--m-xs__title-text--FontSize,
1rem
);
--empty-state--m-xl__title-text--FontSize: var(
--pf-v5-c-empty-state--m-xl__title-text--FontSize,
2.25rem
);
--empty-state--m-xl__title-text--LineHeight: var(
--pf-v5-c-empty-state--m-xl__title-text--LineHeight,
1.3
);
--empty-state__body--MarginTop: var(--pf-v5-c-empty-state__body--MarginTop, 1rem);
--empty-state__body--Color: var(--pf-v5-c-empty-state__body--Color, #6a6e73);
--empty-state--body--FontSize: var(--pf-v5-c-empty-state--body--FontSize, 1rem);
--empty-state--m-xs__body--FontSize: var(--pf-v5-c-empty-state--m-xs__body--FontSize, 0.875rem);
--empty-state--m-xs__body--MarginTop: var(--pf-v5-c-empty-state--m-xs__body--MarginTop, 1rem);
--empty-state--m-xl__body--FontSize: var(--pf-v5-c-empty-state--m-xl__body--FontSize, 1.25rem);
--empty-state--m-xl__body--MarginTop: var(--pf-v5-c-empty-state--m-xl__body--MarginTop, 1.5rem);
--empty-state__footer--RowGap: var(--pf-v5-c-empty-state__footer--RowGap, 0.5rem);
--empty-state__footer--MarginTop: var(--pf-v5-c-empty-state__footer--MarginTop, 2rem);
--empty-state--m-xs__footer--MarginTop: var(
--pf-v5-c-empty-state--m-xs__footer--MarginTop,
1rem
);
--empty-state__actions--RowGap: var(--pf-v5-c-empty-state__actions--RowGap, 0.25rem);
--empty-state__actions--ColumnGap: var(--pf-v5-c-empty-state__actions--ColumnGap, 0.25rem);
}

:host(:not([hidden])) {
display: block;
}

[part="empty-state"] {
display: flex;
align-items: center;
justify-content: center;
padding-block-start: var(--empty-state--PaddingTop);
padding-block-end: var(--empty-state--PaddingBottom);
padding-inline-start: var(--empty-state--PaddingLeft);
padding-inline-end: var(--empty-state--PaddingRight);
text-align: center;
}

:host([size="xs"]) {
--empty-state--PaddingTop: var(--empty-state--m-xs--PaddingTop);
--empty-state--PaddingRight: var(--empty-state--m-xs--PaddingRight);
--empty-state--PaddingBottom: var(--empty-state--m-xs--PaddingBottom);
--empty-state--PaddingLeft: var(--empty-state--m-xs--PaddingLeft);
--empty-state__title-text--FontSize: var(--empty-state--m-xs__title-text--FontSize);
--empty-state__content--MaxWidth: var(--empty-state--m-xs__content--MaxWidth);
--empty-state__icon--MarginBottom: var(--empty-state--m-xs__icon--MarginBottom);
--empty-state__body--MarginTop: var(--empty-state--m-xs__body--MarginTop);
--empty-state--body--FontSize: var(--empty-state--m-xs__body--FontSize);
--empty-state__footer--MarginTop: var(--empty-state--m-xs__footer--MarginTop);
}

:host([size="sm"]) {
--empty-state__content--MaxWidth: var(--empty-state--m-sm__content--MaxWidth);
}

:host([size="lg"]) {
--empty-state__content--MaxWidth: var(--empty-state--m-lg__content--MaxWidth);
}

:host([size="xl"]) {
--empty-state__body--MarginTop: var(--empty-state--m-xl__body--MarginTop);
--empty-state--body--FontSize: var(--empty-state--m-xl__body--FontSize);
--empty-state__icon--MarginBottom: var(--empty-state--m-xl__icon--MarginBottom);
--empty-state__icon--FontSize: var(--empty-state--m-xl__icon--FontSize);
--empty-state__title-text--FontSize: var(--empty-state--m-xl__title-text--FontSize);
--empty-state__title-text--LineHeight: var(--empty-state--m-xl__title-text--LineHeight);
}

:host([full-height]) {
height: 100%;
}

[part="content"] {
max-width: var(--empty-state__content--MaxWidth);
}

[part="icon"] {
margin-block-end: var(--empty-state__icon--MarginBottom);
font-size: var(--empty-state__icon--FontSize);
line-height: 1;
color: var(--empty-state__icon--Color);
}

[part="body"] {
margin-block-start: var(--empty-state__body--MarginTop);
font-size: var(--empty-state--body--FontSize);
color: var(--empty-state__body--Color);
}

[part="footer"] {
display: flex;
flex-direction: column;
row-gap: var(--empty-state__footer--RowGap);
align-items: center;
margin-block-start: var(--empty-state__footer--MarginTop);
}

[part="actions"] {
display: flex;
flex-wrap: wrap;
gap: var(--empty-state__actions--RowGap) var(--empty-state__actions--ColumnGap);
justify-content: center;
}

[part="title"] {
font-family: var(--empty-state__title-text--FontFamily);
font-size: var(--empty-state__title-text--FontSize);
font-weight: var(--empty-state__title-text--FontWeight);
line-height: var(--empty-state__title-text--LineHeight);
}
Loading