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}
+
+
+
+ );
+ }
+}