diff --git a/src/ak-spinner/ak-spinner.builder.ts b/src/ak-spinner/ak-spinner.builder.ts index c1e357a..cbe22e4 100644 --- a/src/ak-spinner/ak-spinner.builder.ts +++ b/src/ak-spinner/ak-spinner.builder.ts @@ -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> & { inline?: boolean; size?: string }; +export type AkSpinnerProps = Partial> & { + inline?: boolean; + size?: string; +}; /** * @summary Helper function to create a Spinner component programmatically @@ -15,11 +18,11 @@ export type AkSpinnerProps = Partial> & { 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``; } diff --git a/src/ak-spinner/ak-spinner.component.ts b/src/ak-spinner/ak-spinner.component.ts index 5e28241..06a246b 100644 --- a/src/ak-spinner/ak-spinner.component.ts +++ b/src/ak-spinner/ak-spinner.component.ts @@ -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. @@ -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 @@ -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` - + `; } } diff --git a/src/ak-spinner/ak-spinner.css b/src/ak-spinner/ak-spinner.css index af0c889..34716e2 100644 --- a/src/ak-spinner/ak-spinner.css +++ b/src/ak-spinner/ak-spinner.css @@ -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) @@ -54,7 +54,7 @@ --spinner--diameter: var(--spinner--m-xl--diameter); } -[part="circle"] { +[part="shape"] { width: 100%; height: 100%; stroke: var(--spinner--Color); diff --git a/src/ak-spinner/ak-spinner.stories.ts b/src/ak-spinner/ak-spinner.stories.ts index b379fbe..261afcb 100644 --- a/src/ak-spinner/ak-spinner.stories.ts +++ b/src/ak-spinner/ak-spinner.stories.ts @@ -20,7 +20,7 @@ const metadata: Meta = { defaultValue: { summary: "md" }, }, }, - label: { + ariaLabel: { control: "text", description: "Accessible label for screen readers", table: { @@ -39,7 +39,6 @@ const metadata: Meta = { }, args: { size: "md", - label: "Loading...", inline: false, }, parameters: { @@ -115,7 +114,11 @@ export const CustomColor = () => html` `; export const AllSizes = () => html` -
+

Small (sm)

@@ -132,7 +135,7 @@ export const AllSizes = () => html`

Extra Large (xl)

-
+ `; export const InlineSpinners: Story = { @@ -144,7 +147,11 @@ export const InlineSpinners: Story = { }, }, render: () => html` -
+
Small text with inline spinner @@ -161,7 +168,7 @@ export const InlineSpinners: Story = { Extra large text with inline spinner
-
+ `, }; @@ -174,7 +181,11 @@ export const CustomAnimations: Story = { }, }, render: () => html` -
+
Thick stroke
-
+ `, }; @@ -220,24 +231,40 @@ export const LoadingStates: Story = { }, }, render: () => html` -
+
-
- -
Loading page content...
-
+ +
+ Loading page content... +
+ -
- -
Loading card data...
+ +
Loading card data...
-
+
@@ -254,7 +281,7 @@ export const LoadingStates: Story = {
Loading list item...
- + `, }; @@ -267,7 +294,11 @@ export const UsingBuilderFunction: Story = { }, }, render: () => html` -
+

Basic spinner with builder:

@@ -279,7 +310,7 @@ export const UsingBuilderFunction: Story = {

Custom size and label:

- ${akSpinner({ size: "lg", label: "Processing data..." })} + ${akSpinner({ size: "lg", ariaLabel: "Processing data..." })} Large spinner with custom label
@@ -287,11 +318,11 @@ export const UsingBuilderFunction: Story = {

Inline spinner:

- Processing your request ${akSpinner({ inline: true, label: "Processing" })} + Processing your request ${akSpinner({ inline: true, ariaLabel: "Processing" })} please wait...

-
+
`, }; @@ -304,21 +335,20 @@ export const AccessibilityExample: Story = { }, }, render: () => html` -
-
+

Spinners with descriptive labels:

- -
User Profile
+ +
User Profile
- -
File Upload
+ +
File Upload
- -
Save Operation
+ +
Save Operation
@@ -335,6 +365,6 @@ export const AccessibilityExample: Story = {
  • Consider pairing with visually hidden text for additional context
  • -
    + `, }; diff --git a/src/ak-spinner/ak-spinner.test.ts b/src/ak-spinner/ak-spinner.test.ts index e11740e..7911f6d 100644 --- a/src/ak-spinner/ak-spinner.test.ts +++ b/src/ak-spinner/ak-spinner.test.ts @@ -11,11 +11,11 @@ describe("ak-spinner component", function () { // Clean out Lit's cache. afterEach(async () => { await browser.execute(async () => { - await document.body.querySelector("ak-spinner")?.remove(); + document.body.querySelector("ak-spinner")?.remove(); // @ts-expect-error expression of type '"_$litPart$"' is added by Lit if (document.body._$litPart$) { // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - await delete document.body._$litPart$; + delete document.body._$litPart$; } }); }); @@ -23,8 +23,8 @@ describe("ak-spinner component", function () { const renderComponent = async (properties = {}) => { render(html``, document.body); await browser.pause(100); - const spinner = await $("ak-spinner"); - const svg = await spinner.$(">>>svg"); + const spinner = $("ak-spinner"); + const svg = spinner.$(">>>svg"); return [spinner, svg]; }; @@ -60,8 +60,8 @@ describe("ak-spinner component", function () { it("exposes its CSS parts", async () => { const [spinner] = await renderComponent(); - const spinnerPart = await spinner.$(">>>[part='spinner']"); - const circlePart = await spinner.$(">>>[part='circle']"); + const spinnerPart = spinner.$(">>>[part='spinner']"); + const circlePart = spinner.$(">>>[part='circle']"); await expect(spinnerPart).toExist(); await expect(circlePart).toExist(); @@ -73,11 +73,11 @@ describe("ak-spinner component", function () { describe("akSpinner helper function", () => { afterEach(async () => { await browser.execute(async () => { - await document.body.querySelector("ak-spinner")?.remove(); + document.body.querySelector("ak-spinner")?.remove(); // @ts-expect-error expression of type '"_$litPart$"' is added by Lit if (document.body._$litPart$) { // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - await delete document.body._$litPart$; + delete document.body._$litPart$; } }); }); @@ -85,8 +85,8 @@ describe("akSpinner helper function", () => { it("should create a basic spinner", async () => { render(akSpinner(), document.body); - const spinner = await $("ak-spinner"); - const svg = await spinner.$(">>>svg"); + const spinner = $("ak-spinner"); + const svg = spinner.$(">>>svg"); await expect(spinner).toExist(); await expect(svg).toExist(); @@ -96,21 +96,21 @@ describe("akSpinner helper function", () => { it("should create spinner with custom size", async () => { render(akSpinner({ size: "xl" }), document.body); - const spinner = await $("ak-spinner"); + const spinner = $("ak-spinner"); await expect(spinner).toHaveAttribute("size", "xl"); }); it("should create spinner with custom label", async () => { - render(akSpinner({ label: "Building awesome things..." }), document.body); + render(akSpinner({ ariaLabel: "Building awesome things..." }), document.body); - const svg = await $("ak-spinner").$(">>>svg"); + const svg = $("ak-spinner").$(">>>svg"); await expect(svg).toHaveAttribute("aria-label", "Building awesome things..."); }); it("should create inline spinner", async () => { render(akSpinner({ inline: true }), document.body); - const spinner = await $("ak-spinner"); + const spinner = $("ak-spinner"); await expect(spinner).toHaveAttribute("inline"); }); @@ -118,14 +118,14 @@ describe("akSpinner helper function", () => { render( akSpinner({ size: "lg", - label: "Processing complex data structures", + ariaLabel: "Processing complex data structures", inline: false, }), document.body, ); - const spinner = await $("ak-spinner"); - const svg = await spinner.$(">>>svg"); + const spinner = $("ak-spinner"); + const svg = spinner.$(">>>svg"); await expect(spinner).toHaveAttribute("size", "lg"); await expect(spinner).not.toHaveAttribute("inline"); @@ -135,7 +135,7 @@ describe("akSpinner helper function", () => { it("should handle boolean properties correctly", async () => { render(akSpinner({ inline: false }), document.body); - const spinner = await $("ak-spinner"); + const spinner = $("ak-spinner"); await expect(spinner).not.toHaveAttribute("inline"); }); });