Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions src/ak-spinner/ak-spinner.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { type Spinner } from "./ak-spinner.js";
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";

export type AkSpinnerProps = Partial<Pick<Spinner, "label">> & { inline?: boolean; size?: string };
export type AkSpinnerProps = Partial<Pick<Spinner, "ariaLabel">> & {
inline?: boolean;
size?: string;
};

/**
* @summary Helper function to create a Spinner component programmatically
Expand All @@ -15,11 +18,11 @@ export type AkSpinnerProps = Partial<Pick<Spinner, "label">> & { inline?: boolea
* @see {@link Spinner} - The underlying web component
*/
export function akSpinner(options: AkSpinnerProps = { inline: false }) {
const { size, label, inline } = options;
const { size, ariaLabel, inline } = options;

return html`<ak-spinner
size=${ifDefined(size)}
label=${ifDefined(label)}
aria-label=${ifDefined(ariaLabel!)}
?inline=${!!inline}
></ak-spinner>`;
}
55 changes: 46 additions & 9 deletions src/ak-spinner/ak-spinner.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import styles from "./ak-spinner.css";
import { msg } from "@lit/localize";
import { html, LitElement } from "lit";
import { property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";

/**
* Spinner size variants. Prefer T-shirt sizes when possible.
Expand All @@ -31,8 +30,8 @@ export interface ISpinner {
* The spinner also supports an `inline` boolean attribute that sets the diameter
* to 1em, allowing it to scale with surrounding text.
*
* @csspart spinner - The SVG element for the spinner container
* @csspart circle - The SVG circle element for the actual spinning part
* @csspart container - The SVG element for the spinner container
* @csspart shape - The SVG circle element for the actual spinning part
*
* @cssprop --pf-v5-c-spinner--AnimationDuration - Duration of the spinning animation
* @cssprop --pf-v5-c-spinner--AnimationTimingFunction - Timing function for the animation
Expand All @@ -53,17 +52,55 @@ export interface ISpinner {
export class Spinner extends LitElement {
static override readonly styles = [styles, keyframes];

@property()
public label = msg("Loading...");
#internals = this.attachInternals();

@property({ type: String, useDefault: true })
public size: SpinnerSize = "md";

@property({ type: Boolean, useDefault: true })
public inline = false;

public override get role() {
return this.#internals.role || "progressbar";
}

public override set role(value: string) {
this.#internals.role = value;
}

#defaultAriaLabel = msg("Loading spinner", {
id: "ak-spinner.ariaLabel",
desc: "Accessible label for spinner",
});

public override get ariaLabel(): string | null {
return (
this.getAttribute("aria-label") || this.#internals.ariaLabel || this.#defaultAriaLabel
);
}

public override set ariaLabel(value: string | null) {
this.#internals.ariaLabel = value;
}

public override connectedCallback() {
super.connectedCallback();

this.role = "progressbar";

const initialRoleDescription = this.getAttribute("aria-label");

this.#internals.ariaLabel = initialRoleDescription || this.#defaultAriaLabel;
}

public override render() {
return html`<svg
part="spinner"
role="progressbar"
part="container"
viewBox="0 0 100 100"
aria-label=${ifDefined(this.label)}
role="img"
aria-label=${msg("Spinner icon")}
>
<circle part="circle" cx="50" cy="50" r="45" fill="none" />
<circle part="shape" cx="50" cy="50" r="45" fill="none" />
</svg>`;
}
}
4 changes: 2 additions & 2 deletions src/ak-spinner/ak-spinner.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
--spinner--Height: var(--spinner--diameter);
}

[part="spinner"] {
[part="container"] {
width: var(--spinner--Width);
height: var(--spinner--Height);
animation: pf-v5-c-spinner-animation-rotate calc(var(--spinner--AnimationDuration) * 2)
Expand All @@ -54,7 +54,7 @@
--spinner--diameter: var(--spinner--m-xl--diameter);
}

[part="circle"] {
[part="shape"] {
width: 100%;
height: 100%;
stroke: var(--spinner--Color);
Expand Down
92 changes: 61 additions & 31 deletions src/ak-spinner/ak-spinner.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const metadata: Meta<AkSpinnerProps> = {
defaultValue: { summary: "md" },
},
},
label: {
ariaLabel: {
control: "text",
description: "Accessible label for screen readers",
table: {
Expand All @@ -39,7 +39,6 @@ const metadata: Meta<AkSpinnerProps> = {
},
args: {
size: "md",
label: "Loading...",
inline: false,
},
parameters: {
Expand Down Expand Up @@ -115,7 +114,11 @@ export const CustomColor = () => html`
`;

export const AllSizes = () => html`
<div style="display: flex; flex-direction: column; gap: 16px;">
<main
tabindex="0"
aria-label="All sizes"
style="display: flex; flex-direction: column; gap: 16px;"
>
<div>
<h3>Small (sm)</h3>
<ak-spinner size="sm"></ak-spinner>
Expand All @@ -132,7 +135,7 @@ export const AllSizes = () => html`
<h3>Extra Large (xl)</h3>
<ak-spinner size="xl"></ak-spinner>
</div>
</div>
</main>
`;

export const InlineSpinners: Story = {
Expand All @@ -144,7 +147,11 @@ export const InlineSpinners: Story = {
},
},
render: () => html`
<div style="display: flex; flex-direction: column; gap: 1rem;">
<main
tabindex="0"
aria-label="Inline spinners"
style="display: flex; flex-direction: column; gap: 1rem;"
>
<div style="font-size: 14px;">
Small text with <ak-spinner inline label="Loading small"></ak-spinner> inline
spinner
Expand All @@ -161,7 +168,7 @@ export const InlineSpinners: Story = {
Extra large text with
<ak-spinner inline label="Loading extra large"></ak-spinner> inline spinner
</div>
</div>
</main>
`,
};

Expand All @@ -174,7 +181,11 @@ export const CustomAnimations: Story = {
},
},
render: () => html`
<div style="display: flex; gap: 2rem; align-items: center; flex-wrap: wrap;">
<main
tabindex="0"
aria-label="Custom animations"
style="display: flex; gap: 2rem; align-items: center; flex-wrap: wrap;"
>
<div style="text-align: center;">
<ak-spinner
size="lg"
Expand Down Expand Up @@ -207,7 +218,7 @@ export const CustomAnimations: Story = {
></ak-spinner>
<div style="margin-top: 0.5rem; font-size: 0.875rem;">Thick stroke</div>
</div>
</div>
</main>
`,
};

Expand All @@ -220,24 +231,40 @@ export const LoadingStates: Story = {
},
},
render: () => html`
<div style="display: flex; flex-direction: column; gap: 2rem;">
<main
tabindex="0"
aria-label="Loading states"
style="display: flex; flex-direction: column; gap: 2rem;"
>
<!-- Page loading -->
<div
<section
id="demo-region-1"
style="text-align: center; padding: 3rem; border: 1px dashed #ccc; border-radius: 8px;"
aria-busy="true"
aria-live="polite"
aria-label="Demo Region 1"
aria-describedby="demo-region-1-description"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read as:

Demo Region 1, Loading page content...

>
<ak-spinner size="xl" label="Loading page content"></ak-spinner>
<div style="margin-top: 1rem; color: #666;">Loading page content...</div>
</div>
<ak-spinner size="xl"></ak-spinner>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read as:

Loading spinner, indeterminate, progress indicator

<div id="demo-region-1-description" style="margin-top: 1rem; color: #666;">
Loading page content...
</div>
</section>

<!-- Card loading -->
<div
<section
style="padding: 1.5rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb;"
id="demo-region-1"
aria-busy="true"
aria-live="polite"
aria-label="Demo Region 2"
aria-describedby="demo-region-2-description"
>
<div style="display: flex; align-items: center; gap: 1rem;">
<ak-spinner size="md" label="Loading card data"></ak-spinner>
<div>Loading card data...</div>
<ak-spinner size="md"></ak-spinner>
<div id="demo-region-2-description">Loading card data...</div>
</div>
</div>
</section>

<!-- List item loading -->
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
Expand All @@ -254,7 +281,7 @@ export const LoadingStates: Story = {
<div>Loading list item...</div>
</div>
</div>
</div>
</main>
`,
};

Expand All @@ -267,7 +294,11 @@ export const UsingBuilderFunction: Story = {
},
},
render: () => html`
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<main
tabindex="0"
aria-label="Using Builder Function"
style="display: flex; flex-direction: column; gap: 1.5rem;"
>
<div>
<h4>Basic spinner with builder:</h4>
<div style="display: flex; align-items: center; gap: 1rem;">
Expand All @@ -279,19 +310,19 @@ export const UsingBuilderFunction: Story = {
<div>
<h4>Custom size and label:</h4>
<div style="display: flex; align-items: center; gap: 1rem;">
${akSpinner({ size: "lg", label: "Processing data..." })}
${akSpinner({ size: "lg", ariaLabel: "Processing data..." })}
<span>Large spinner with custom label</span>
</div>
</div>

<div>
<h4>Inline spinner:</h4>
<p>
Processing your request ${akSpinner({ inline: true, label: "Processing" })}
Processing your request ${akSpinner({ inline: true, ariaLabel: "Processing" })}
please wait...
</p>
</div>
</div>
</main>
`,
};

Expand All @@ -304,21 +335,20 @@ export const AccessibilityExample: Story = {
},
},
render: () => html`
<div style="display: flex; flex-direction: column; gap: 2rem;">
<div>
<main tabindex="0" aria-label="All sizes">
<h4>Spinners with descriptive labels:</h4>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<div style="text-align: center;">
<ak-spinner size="md" label="Loading user profile data"></ak-spinner>
<div style="margin-top: 0.5rem; font-size: 0.875rem;">User Profile</div>
<ak-spinner id="demo-accessibility-spinner-1" size="md" aria-label="Loading user profile data"></ak-spinner>
<div aria-busy="true" aria-describedby="demo-accessibility-spinner-1" style="margin-top: 0.5rem; font-size: 0.875rem;">User Profile</div>
</div>
<div style="text-align: center;">
<ak-spinner size="md" label="Uploading document, please wait"></ak-spinner>
<div style="margin-top: 0.5rem; font-size: 0.875rem;">File Upload</div>
<ak-spinner id="demo-accessibility-spinner-2" size="md" aria-label="Uploading document, please wait"></ak-spinner>
<div aria-busy="true" aria-describedby="demo-accessibility-spinner-2" style="margin-top: 0.5rem; font-size: 0.875rem;">File Upload</div>
</div>
<div style="text-align: center;">
<ak-spinner size="md" label="Saving changes to database"></ak-spinner>
<div style="margin-top: 0.5rem; font-size: 0.875rem;">Save Operation</div>
<ak-spinner id="demo-accessibility-spinner-3" size="md" aria-label="Saving changes to database"></ak-spinner>
<div aria-busy="true" aria-describedby="demo-accessibility-spinner-3" style="margin-top: 0.5rem; font-size: 0.875rem;">Save Operation</div>
</div>
</div>
</div>
Expand All @@ -335,6 +365,6 @@ export const AccessibilityExample: Story = {
<li>Consider pairing with visually hidden text for additional context</li>
</ul>
</div>
</div>
</main>
`,
};
Loading