diff --git a/packages/craftcms-ui/src/components/button/button.a11y.md b/packages/craftcms-ui/src/components/button/button.a11y.md new file mode 100644 index 00000000000..9f051db19f3 --- /dev/null +++ b/packages/craftcms-ui/src/components/button/button.a11y.md @@ -0,0 +1,8 @@ +# `craft-button` accessibility + +## Requirements + +- [x] Visible focus indicator via `:focus-visible` pseudo-class. This includes: + - An offset outline to maintain visibility against the button background color. + - An outline color that uses the adaptive color token (`--c-color-focus-outline`) for visibility in both light and dark modes. +- [x] A loading message is announced via a built-in live region when the loading spinner is toggled on \ No newline at end of file diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts index 856d022a2eb..e9b65cc5685 100644 --- a/packages/craftcms-ui/src/components/button/button.ts +++ b/packages/craftcms-ui/src/components/button/button.ts @@ -1,7 +1,9 @@ import {LionButtonSubmit} from '@lion/ui/button.js'; import {html, nothing} from 'lit'; -import {property, state} from 'lit/decorators.js'; +import {property, state, query} from 'lit/decorators.js'; +import {t} from '@src/utilities/translate'; import styles from './button.styles.js'; +import visuallyHiddenStyles from '@src/styles/visually-hidden.styles.js'; import '../spinner/spinner.js'; import '../icon/icon.js'; import {computeAccessibleName} from 'dom-accessibility-api'; @@ -44,7 +46,7 @@ export type ButtonAppearance = */ export default class CraftButton extends LionButtonSubmit { static override get styles() { - return [...super.styles, variantsStyles, styles]; + return [...super.styles, visuallyHiddenStyles, variantsStyles, styles]; } override connectedCallback() { @@ -58,11 +60,38 @@ export default class CraftButton extends LionButtonSubmit { this.syncLinkHostState(); } + override disconnectedCallback() { + super.disconnectedCallback(); + + if (this.announcementTimer) { + clearTimeout(this.announcementTimer); + this.announcementTimer = null; + } + } + override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('href') || changedProperties.has('disabled')) { this.syncLinkHostState(); } + + if (changedProperties.has('loading')) { + if (this.loading) { + this.announceLoading(); + } + } + } + + private announceLoading() { + this.liveRegion.textContent = t('Loading'); + + if (this.announcementTimer) { + clearTimeout(this.announcementTimer); + } + + this.announcementTimer = setTimeout(() => { + this.liveRegion.textContent = ''; + }, 5000); } private syncLinkHostState() { @@ -156,11 +185,15 @@ export default class CraftButton extends LionButtonSubmit { @property({attribute: 'icon-position'}) iconPosition: 'prefix' | 'suffix' = 'prefix'; + @query('[data-live-region]') liveRegion: HTMLElement; + @state() private _hasAccessibilityError: boolean = false; private linkHostStateApplied = false; + private announcementTimer: ReturnType | null = null; + private get isLink(): boolean { return !!this.href && !this.disabled; } @@ -200,6 +233,7 @@ export default class CraftButton extends LionButtonSubmit { ${this.loading ? html`` : nothing} + `; if (this.isLink) { diff --git a/resources/js/common/components/LiveRegion.vue b/resources/js/common/components/LiveRegion.vue index efab4e481d4..b3a3783742f 100644 --- a/resources/js/common/components/LiveRegion.vue +++ b/resources/js/common/components/LiveRegion.vue @@ -1,21 +1,17 @@ diff --git a/resources/js/common/layouts/AppLayout.vue b/resources/js/common/layouts/AppLayout.vue index 0a00cef18fd..564ca55c45f 100644 --- a/resources/js/common/layouts/AppLayout.vue +++ b/resources/js/common/layouts/AppLayout.vue @@ -194,7 +194,7 @@