diff --git a/packages/storybook/stories/additional-docs.js b/packages/storybook/stories/additional-docs.js index 53e9d44b5c..6708e2d950 100644 --- a/packages/storybook/stories/additional-docs.js +++ b/packages/storybook/stories/additional-docs.js @@ -56,6 +56,7 @@ export const additionalDocs = { maturityCategory: DONT_USE, maturityLevel: PROPOSED, }, + // MDX 'Tag': { guidanceHref: 'tag', diff --git a/packages/storybook/stories/va-loader.stories.tsx b/packages/storybook/stories/va-loader.stories.tsx new file mode 100644 index 0000000000..773dd9cad1 --- /dev/null +++ b/packages/storybook/stories/va-loader.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { getWebComponentDocs, propStructure, StoryDocs } from './wc-helpers'; + +const loaderDocs = getWebComponentDocs('va-loader'); + +export default { + title: 'Components/Loader', + id: 'components/va-loader', + parameters: { + componentSubtitle: 'va-loader web component', + docs: { + page: () => , + }, + }, +}; + +const defaultArgs = { + 'center-label': 'Loading', + 'loader-role': 'status', + 'aria-live-region': 'polite', + 'busy': 'true', + 'align-left': '0px', + 'align-top': '0px', +}; + +const Template = ({ + 'center-label': centerLabel, + 'loader-role': loaderRole, + 'aria-live-region': ariaLiveRegion, + 'busy': busy, + 'align-left': alignLeft, + 'align-top': alignTop, +}) => { + return ( +
+ +
+ ); +}; + +export const Default = Template.bind(null); +Default.args = { ...defaultArgs }; +Default.argTypes = propStructure(loaderDocs); + +export const AccessibleLoader = Template.bind(null); +AccessibleLoader.args = { ...defaultArgs, 'aria-live-region': 'assertive' }; + +export const CustomPositionLoader = Template.bind(null); +CustomPositionLoader.args = { + ...defaultArgs, + 'center-label': 'Calculating', + 'align-left': '-13px', + 'align-top': '0px', +}; diff --git a/packages/storybook/types.d.ts b/packages/storybook/types.d.ts index 091a4edca3..478542c80e 100644 --- a/packages/storybook/types.d.ts +++ b/packages/storybook/types.d.ts @@ -30,6 +30,7 @@ declare global { 'va-link': WCTypes.VaLink & IsElement; 'va-link-action': WCTypes.VaLinkAction & IsElement; 'va-loading-indicator': WCTypes.VaLoadingIndicator & IsElement; + 'va-loader': WCTypes.VaLoader & IsElement; 'va-maintenance-banner': WCTypes.VaMaintenanceBanner & IsElement; 'va-minimal-footer': WCTypes.VaMinimalFooter & IsElement; 'va-need-help': WCTypes.VaNeedHelp & IsElement; diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts index c4efce8ca9..a35dd090a5 100644 --- a/packages/web-components/src/components.d.ts +++ b/packages/web-components/src/components.d.ts @@ -971,6 +971,37 @@ export namespace Components { */ "type"?: 'primary' | 'secondary' | 'reverse' | 'primary-entry'; } + /** + * @componentName Loader + * @maturityCategory don't use + * @maturityLevel proposed + */ + interface VaLoader { + /** + * Custom left alignment + */ + "alignLeft"?: string; + /** + * Custom top alignment + */ + "alignTop"?: string; + /** + * The ARIA live region setting + */ + "ariaLiveRegion"?: string; + /** + * Whether the loader is currently busy (use 'true' or 'false' as string) + */ + "busy"?: string; + /** + * The text to display in the center of the loader + */ + "centerLabel"?: string; + /** + * The ARIA role for the loader + */ + "loaderRole"?: string; + } /** * @componentName Loading indicator * @maturityCategory use @@ -2875,6 +2906,17 @@ declare global { prototype: HTMLVaLinkActionElement; new (): HTMLVaLinkActionElement; }; + /** + * @componentName Loader + * @maturityCategory don't use + * @maturityLevel proposed + */ + interface HTMLVaLoaderElement extends Components.VaLoader, HTMLStencilElement { + } + var HTMLVaLoaderElement: { + prototype: HTMLVaLoaderElement; + new (): HTMLVaLoaderElement; + }; interface HTMLVaLoadingIndicatorElementEventMap { "component-library-analytics": any; } @@ -3545,6 +3587,7 @@ declare global { "va-language-toggle": HTMLVaLanguageToggleElement; "va-link": HTMLVaLinkElement; "va-link-action": HTMLVaLinkActionElement; + "va-loader": HTMLVaLoaderElement; "va-loading-indicator": HTMLVaLoadingIndicatorElement; "va-maintenance-banner": HTMLVaMaintenanceBannerElement; "va-memorable-date": HTMLVaMemorableDateElement; @@ -4665,6 +4708,37 @@ declare namespace LocalJSX { */ "type"?: 'primary' | 'secondary' | 'reverse' | 'primary-entry'; } + /** + * @componentName Loader + * @maturityCategory don't use + * @maturityLevel proposed + */ + interface VaLoader { + /** + * Custom left alignment + */ + "alignLeft"?: string; + /** + * Custom top alignment + */ + "alignTop"?: string; + /** + * The ARIA live region setting + */ + "ariaLiveRegion"?: string; + /** + * Whether the loader is currently busy (use 'true' or 'false' as string) + */ + "busy"?: string; + /** + * The text to display in the center of the loader + */ + "centerLabel"?: string; + /** + * The ARIA role for the loader + */ + "loaderRole"?: string; + } /** * @componentName Loading indicator * @maturityCategory use @@ -6031,6 +6105,7 @@ declare namespace LocalJSX { "va-language-toggle": VaLanguageToggle; "va-link": VaLink; "va-link-action": VaLinkAction; + "va-loader": VaLoader; "va-loading-indicator": VaLoadingIndicator; "va-maintenance-banner": VaMaintenanceBanner; "va-memorable-date": VaMemorableDate; @@ -6257,6 +6332,12 @@ declare module "@stencil/core" { * @guidanceName Action link */ "va-link-action": LocalJSX.VaLinkAction & JSXBase.HTMLAttributes; + /** + * @componentName Loader + * @maturityCategory don't use + * @maturityLevel proposed + */ + "va-loader": LocalJSX.VaLoader & JSXBase.HTMLAttributes; /** * @componentName Loading indicator * @maturityCategory use diff --git a/packages/web-components/src/components/va-loader/test/va-loader.e2e.ts b/packages/web-components/src/components/va-loader/test/va-loader.e2e.ts new file mode 100644 index 0000000000..8041a889fe --- /dev/null +++ b/packages/web-components/src/components/va-loader/test/va-loader.e2e.ts @@ -0,0 +1,78 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { axeCheck } from '../../../testing/test-helpers'; + +describe('va-loader', () => { + it('renders', async () => { + const page = await newE2EPage(); + await page.setContent(''); + + const element = await page.find('va-loader'); + expect(element).not.toBeNull(); + + const loaderDiv = await page.find('va-loader >>> .va-loader'); + expect(loaderDiv).not.toBeNull(); + expect(await loaderDiv.getAttribute('role')).toBe('status'); + + const textDiv = await page.find('va-loader >>> .va-loader__text'); + const text = await textDiv.getProperty('textContent'); + expect(text).toBe('Loading'); + }); + + it('renders with custom label', async () => { + const customLabel = 'Loading spinner'; + const page = await newE2EPage(); + await page.setContent( + ``, + ); + + const textDiv = await page.find('va-loader >>> .va-loader__text'); + const text = await textDiv.getProperty('textContent'); + expect(text).toBe(customLabel); + }); + + it('updates text based on rotation state', async () => { + const page = await newE2EPage(); + await page.setContent(''); + + // Initial state + let textDiv = await page.find('va-loader >>> .va-loader__text'); + expect(textDiv).not.toBeNull(); + let text = await textDiv.getProperty('textContent'); + expect(text).toBe('Loading'); + + // Wait for first rotation (250ms) + await page.waitForTimeout(250); + textDiv = await page.find('va-loader >>> .va-loader__text'); + expect(textDiv).not.toBeNull(); + text = await textDiv.getProperty('textContent'); + expect(text).toBe('Loading.'); + + // Wait for second rotation (250ms) + await page.waitForTimeout(250); + textDiv = await page.find('va-loader >>> .va-loader__text'); + expect(textDiv).not.toBeNull(); + text = await textDiv.getProperty('textContent'); + expect(text).toBe('Loading..'); + + // Wait for third rotation (250ms) + await page.waitForTimeout(250); + textDiv = await page.find('va-loader >>> .va-loader__text'); + expect(textDiv).not.toBeNull(); + text = await textDiv.getProperty('textContent'); + expect(text).toBe('Loading...'); + + // Wait for fourth rotation (250ms) - back to initial state + await page.waitForTimeout(250); + textDiv = await page.find('va-loader >>> .va-loader__text'); + expect(textDiv).not.toBeNull(); + text = await textDiv.getProperty('textContent'); + expect(text).toBe('Loading'); + }); + + it('passes an axe check', async () => { + const page = await newE2EPage(); + await page.setContent(''); + + await axeCheck(page); + }); +}); diff --git a/packages/web-components/src/components/va-loader/va-loader.css b/packages/web-components/src/components/va-loader/va-loader.css new file mode 100644 index 0000000000..2ca63801ac --- /dev/null +++ b/packages/web-components/src/components/va-loader/va-loader.css @@ -0,0 +1,62 @@ +.va-loader { + position: relative; + width: 12.5rem; + height: 12.5rem; +} + +.va-loader__border { + position: absolute; + top: 0; + left: 0; + border: 1.25rem solid var(--vads-color-base-lightest); + border-top: 1.5625rem solid var(--vads-color-primary); + border-radius: 50%; + width: 100%; + height: 100%; + animation: va-loader-spin 1s linear infinite; +} + +.va-loader__text { + position: absolute; + top: 45%; + left: 30%; + transform: translate(-50%, -50%); + font-family: var(--vads-font-sans); +} + +.va-loader__text span { + color: var(--vads-color-primary-dark); + font-size: 1.25rem; + position: absolute; + width: 8rem; + overflow-wrap: break-word; +} + +@keyframes va-loader-spin { + 100% { + transform: rotate(360deg); + } +} + +.va-loader__border .va-loader__edge { + position: absolute; + border-radius: 50%; + width: 1.4375rem; + height: 1.4375rem; + background: var(--vads-color-primary); +} + +.va-loader .va-loader__edge--left, +.va-loader .va-loader__edge--right { + position: absolute; + top: 10%; + transform: translateY(-53%); +} + +.va-loader .va-loader__edge--left { + left: 2%; +} + +.va-loader .va-loader__edge--right { + right: 2%; +} \ No newline at end of file diff --git a/packages/web-components/src/components/va-loader/va-loader.tsx b/packages/web-components/src/components/va-loader/va-loader.tsx new file mode 100644 index 0000000000..42f3e2f966 --- /dev/null +++ b/packages/web-components/src/components/va-loader/va-loader.tsx @@ -0,0 +1,124 @@ +import { Component, Element, Host, Prop, State, h, Watch } from '@stencil/core'; + +/** + * @componentName Loader + * @maturityCategory caution + * @maturityLevel candidate + */ + +@Component({ + tag: 'va-loader', + styleUrl: 'va-loader.css', + shadow: true, +}) +export class VaLoader { + @Element() el!: HTMLElement; + private textRef?: HTMLDivElement; + private rotationInterval: number; + + @State() rotation: number = 0; + @State() loaderText: string; + + /** + * The text to display in the center of the loader + */ + @Prop() centerLabel?: string = 'Loading'; + + /** + * The ARIA role for the loader + */ + @Prop() loaderRole?: string = 'status'; + + /** + * The ARIA live region setting + */ + @Prop() ariaLiveRegion?: string; + + /** + * Whether the loader is currently busy (use 'true' or 'false' as string) + */ + @Prop() busy?: string; + + /** + * Custom left alignment + */ + @Prop() alignLeft?: string = '0px'; + + /** + * Custom top alignment + */ + @Prop() alignTop?: string = '0px'; + + componentWillLoad() { + this.loaderText = this.centerLabel; + } + + componentDidLoad() { + this.startRotation(); + this.adjustPosition(); + } + + disconnectedCallback() { + window.clearInterval(this.rotationInterval); + } + + @Watch('rotation') + handleRotationChange() { + switch (this.rotation) { + case 0: + this.loaderText = this.centerLabel; + break; + case 90: + this.loaderText = `${this.centerLabel}.`; + break; + case 180: + this.loaderText = `${this.centerLabel}..`; + break; + case 270: + this.loaderText = `${this.centerLabel}...`; + break; + } + this.adjustPosition(); + } + + private startRotation() { + this.rotationInterval = window.setInterval(() => { + this.rotation = (this.rotation + 90) % 360; + }, 250); + } + + private adjustPosition() { + if (this.textRef) { + const containsSpace = this.loaderText.includes(' '); + this.textRef.style.top = containsSpace ? '40%' : '45%'; + this.textRef.style.left = containsSpace ? '35%' : '30%'; + } + } + + render() { + const spanStyle = { + left: this.alignLeft, + top: this.alignTop, + }; + + return ( + +
+
+ + +
+
(this.textRef = el)}> + {this.loaderText} +
+
+
+ ); + } +}