Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/craftcms-ui/src/components/button/button.a11y.md
Original file line number Diff line number Diff line change
@@ -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
38 changes: 36 additions & 2 deletions packages/craftcms-ui/src/components/button/button.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -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<string, unknown>) {
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);
}
Comment thread
gcamacho079 marked this conversation as resolved.

private syncLinkHostState() {
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;

private get isLink(): boolean {
return !!this.href && !this.disabled;
}
Expand Down Expand Up @@ -200,6 +233,7 @@ export default class CraftButton extends LionButtonSubmit {
${this.loading
? html`<craft-spinner part="spinner"></craft-spinner>`
: nothing}
<span class="cp-visually-hidden" role="status" data-live-region></span>
`;

if (this.isLink) {
Expand Down
18 changes: 7 additions & 11 deletions resources/js/common/components/LiveRegion.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
<script setup lang="ts">
/**
* A top-level live region that should only have one instance per layer.
*
*/
import {useAnnouncer} from '@/common/composables/useAnnouncer';
Comment thread
gcamacho079 marked this conversation as resolved.
import TransitionFade from '@/common/components/TransitionFade.vue';

const {announcement} = useAnnouncer();
</script>

<template>
<TransitionFade>
<div
id="global-live-region"
class="sr-only"
role="status"
v-if="announcement"
>
{{ announcement }}
</div>
</TransitionFade>
<div class="sr-only" role="status">
{{ announcement }}
</div>
</template>

<style scoped lang="scss"></style>
2 changes: 1 addition & 1 deletion resources/js/common/layouts/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@

<template>
<Head :title="pageTitle" />
<LiveRegion></LiveRegion>
<LiveRegion />
<div class="cp">
<header class="cp__header">
<a
Expand Down
4 changes: 3 additions & 1 deletion resources/js/common/layouts/AuthBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import useCraftData from '@/common/composables/useCraftData';
import craftCmsLogoUrl from '@public/images/craftcms.svg';
import {t} from '@craftcms/ui';
import LiveRegion from '@/common/components/LiveRegion.vue';

const props = withDefaults(
defineProps<{
Expand All @@ -19,6 +20,7 @@
<template>
<Head :title="props.title"></Head>
<main class="cp-login">
<LiveRegion />
<div class="cp-login__wrapper grid gap-3 justify-items-center">
<h1 class="flex justify-center">
<img
Expand Down Expand Up @@ -82,7 +84,7 @@
.cp-login__powered-by {
display: block;
margin-block-start: calc(70rem / 16);
opacity: 0.92;;
opacity: 0.92;
text-align: center;

&:hover,
Expand Down
8 changes: 3 additions & 5 deletions resources/js/modules/auth/components/login/login-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import componentStyles from './login-form.styles.js';
import type {TwoFactorData} from './login-challenge.js';
import './login-challenge.js';
import './login-reset-password.js';

import {useAnnouncer} from '@/common/composables/useAnnouncer';
type View = 'login' | 'reset-password' | 'challenge';

/**
Expand Down Expand Up @@ -206,11 +206,9 @@ export default class CraftLoginForm extends LitElement {
}

#setError(message: string) {
const { announce } = useAnnouncer();
this._error = message.trim();
const live = this.shadowRoot?.querySelector(
'.cp-visually-hidden[role="status"]'
);
if (live) live.textContent = message;
announce(this._error);
}
Comment thread
gcamacho079 marked this conversation as resolved.

#handleSuccess(returnUrl: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {html, LitElement, nothing} from 'lit';
import {property, query, state} from 'lit/decorators.js';
import {actionClient, t} from '@craftcms/ui';
import componentStyles from './login-form.styles.js';
import {useAnnouncer} from '@/common/composables/useAnnouncer';

/**
* @summary Password-reset request form. Fires `reset-back` when the user
Expand Down Expand Up @@ -35,6 +36,12 @@ export default class CraftLoginResetPassword extends LitElement {
return this.useEmailAsUsername ? t('Email') : t('Username or Email');
}

#setError(message: string) {
const trimmedMessage = message.trim();
this._error = trimmedMessage;
useAnnouncer().announce(trimmedMessage);
}

async #onSubmit(event: Event) {
event.preventDefault();

Expand All @@ -55,7 +62,9 @@ export default class CraftLoginResetPassword extends LitElement {
dialog.appendChild(msg);
document.body.appendChild(dialog);
} catch (e: any) {
this._error = e?.response?.data?.message ?? t('A server error occurred.');
this.#setError(
e?.response?.data?.message ?? t('A server error occurred.')
);
} finally {
this._busy = false;
}
Expand Down
Loading