diff --git a/packages/storybook/stories/va-button-uswds.stories.jsx b/packages/storybook/stories/va-button-uswds.stories.jsx index de19e9bb0..ff0155b34 100644 --- a/packages/storybook/stories/va-button-uswds.stories.jsx +++ b/packages/storybook/stories/va-button-uswds.stories.jsx @@ -24,8 +24,8 @@ const defaultArgs = { 'secondary': undefined, 'primary-alternate': undefined, 'submit': undefined, - 'text': 'Default', 'message-aria-describedby': 'Optional description text for screen readers', + onClick: (e) => console.log(e) }; const Template = ({ @@ -34,12 +34,14 @@ const Template = ({ _continue, disableAnalytics, disabled, + loading, label, secondary, primaryAlternate, submit, text, messageAriaDescribedby, + onClick }) => { return ( console.log(e)} + text={!loading && !text ? 'Default' : text} + onClick= {onClick} message-aria-describedby={messageAriaDescribedby} /> ); @@ -103,8 +106,22 @@ Back.args = { export const Disabled = Template.bind(null); Disabled.args = { ...defaultArgs, - disabled: true, - text: "Disabled", + disabled: true +}; + +export const Loading = Template.bind(null); +Loading.args = { + ...defaultArgs, + text: 'Click to load', + onClick: (e) => { + e.target.setAttribute('text', ''); + e.target.setAttribute('loading', 'true'); + setTimeout(() => { + e.target.setAttribute('text', 'Click to load'); + e.target.setAttribute('loading', 'false'); + }, 5000) + } + }; diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts index 6b707a8e2..11401dc95 100644 --- a/packages/web-components/src/components.d.ts +++ b/packages/web-components/src/components.d.ts @@ -261,6 +261,10 @@ export namespace Components { * The aria-label of the component. */ "label"?: string; + /** + * If `true`, the button will appear disabled, a loading icon will show next to the text, and the click event will not fire. + */ + "loading"?: boolean; /** * An optional message that will be read by screen readers when the input is focused. */ @@ -3502,6 +3506,10 @@ declare namespace LocalJSX { * The aria-label of the component. */ "label"?: string; + /** + * If `true`, the button will appear disabled, a loading icon will show next to the text, and the click event will not fire. + */ + "loading"?: boolean; /** * An optional message that will be read by screen readers when the input is focused. */ diff --git a/packages/web-components/src/components/va-button/test/va-button.e2e.ts b/packages/web-components/src/components/va-button/test/va-button.e2e.ts index 10e8a1ec5..44048ea1f 100644 --- a/packages/web-components/src/components/va-button/test/va-button.e2e.ts +++ b/packages/web-components/src/components/va-button/test/va-button.e2e.ts @@ -9,6 +9,7 @@ describe('va-button', () => { expect(element).toEqualHtml(` + @@ -38,6 +39,7 @@ describe('va-button', () => { expect(element).toEqualHtml(` + @@ -87,6 +91,7 @@ describe('va-button', () => { expect(element).toEqualHtml(` + @@ -102,6 +107,7 @@ describe('va-button', () => { expect(element).toEqualHtml(` + @@ -110,6 +116,61 @@ describe('va-button', () => { `); }); + it('renders a loading button with default text', async () => { + const page = await newE2EPage(); + await page.setContent(''); + const element = await page.find('va-button'); + expect(element).toEqualHtml(` + + + Loading + + + + `); + }); + + it('renders a loading button with prop text', async () => { + const page = await newE2EPage(); + await page.setContent(''); + const element = await page.find('va-button'); + expect(element).toEqualHtml(` + + + Loading + + + + `); + }); + + it('Changes status text on loading prop change', async () => { + const page = await newE2EPage(); + await page.setContent(''); + const element = await page.find('va-button'); + expect(element).toEqualHtml(` + + + Loading + + + + `); + element.setAttribute('loading', 'false'); + await page.waitForChanges(); + const loadingMessageEl = await element.shadowRoot.querySelector('span.loading-message'); + expect(loadingMessageEl.innerHTML).toEqual('Loading complete'); + }); + it('ignores text value and displays Continue when continue is true', async () => { const page = await newE2EPage(); await page.setContent(''); @@ -131,6 +192,7 @@ describe('va-button', () => { expect(element).toEqualHtml(` + @@ -181,6 +243,15 @@ describe('va-button', () => { expect(clickSpy).toHaveReceivedEventTimes(0); }); + it(`doesn't fire click event when loading is true`, async () => { + const page = await newE2EPage(); + await page.setContent(''); + const clickSpy = await page.spyOnEvent('click'); + const button = await page.find('va-button >>> button'); + await button.click(); + expect(clickSpy).toHaveReceivedEventTimes(0); + }); + it('has the correct aria-label when label is given', async () => { const page = await newE2EPage(); await page.setContent( @@ -212,6 +283,7 @@ it(`renders a default submit button variant`, async () => { expect(element).toEqualHtml(` + diff --git a/packages/web-components/src/components/va-button/va-button.scss b/packages/web-components/src/components/va-button/va-button.scss index df5268b3a..7bc09bea2 100644 --- a/packages/web-components/src/components/va-button/va-button.scss +++ b/packages/web-components/src/components/va-button/va-button.scss @@ -7,7 +7,37 @@ :host { display: inline-block; + + .loading-message { + opacity: 0.00001; + position: absolute; + pointer-events: none; + } + + .loading-icon { + animation: spin 1.5s linear infinite; + } + + .loading-icon.chromatic:after { + animation: none; + } + + @media (prefers-reduced-motion) { + .loading-icon { + animation: none; + } + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } } + :host([disabled]:not([disabled='false'])) button { pointer-events: none; } diff --git a/packages/web-components/src/components/va-button/va-button.tsx b/packages/web-components/src/components/va-button/va-button.tsx index 11531675c..86ec7e1fc 100644 --- a/packages/web-components/src/components/va-button/va-button.tsx +++ b/packages/web-components/src/components/va-button/va-button.tsx @@ -7,6 +7,7 @@ import { Listen, Prop, Element, + Watch, } from '@stencil/core'; import classnames from 'classnames'; @@ -23,6 +24,8 @@ import classnames from 'classnames'; shadow: true, }) export class VaButton { + private showCompletedMessage: boolean = false; + @Element() el: HTMLElement; /** @@ -50,6 +53,22 @@ export class VaButton { */ @Prop({ reflect: true }) disabled?: boolean = false; + /** + * If `true`, the button will appear disabled, a loading icon will show next to the text, and the click event will not fire. + */ + @Prop({ reflect: true }) loading?: boolean = false; + + @Watch('loading') + announceLoadingChange(newValue: boolean, oldValue: boolean) { + if (oldValue && !newValue) { + let me = this; + this.showCompletedMessage = true; + setTimeout(() => { + me.showCompletedMessage = false; + }, 3000); + } + } + /** * The aria-label of the component. */ @@ -111,7 +130,7 @@ export class VaButton { private getButtonText = (): string => { if (this.continue) return 'Continue'; if (this.back) return 'Back'; - + if (this.loading && !this.text) return 'Loading...'; return this.text; }; @@ -145,7 +164,7 @@ export class VaButton { */ @Listen('click') handleClickOverride(e: MouseEvent) { - if (this.disabled) { + if (this.disabled || this.loading) { e.preventDefault(); e.stopPropagation(); return; @@ -159,6 +178,7 @@ export class VaButton { back, continue: _continue, disabled, + loading, getButtonText, label, submit, @@ -171,7 +191,7 @@ export class VaButton { const ariaDescribedbyIds = `${messageAriaDescribedby ? 'button-description' : ''}`.trim() || null; - const ariaDisabled = disabled ? 'true' : undefined; + const ariaDisabled = disabled || loading ? 'true' : undefined; const buttonText = getButtonText(); const type = submit !== undefined ? 'submit' : 'button'; @@ -185,18 +205,26 @@ export class VaButton { return ( - + {/* This span must always be present for changes to be announced for the loading prop. It will not show visually or be read without content*/} + + {this.loading ? 'Loading' : this.showCompletedMessage ? 'Loading complete' : null} + + {messageAriaDescribedby && ( {messageAriaDescribedby}