feat(dialog): add is-alert-dialog option (#2445)

* feat(dialog): add is-alert-dialog option

* chore: make alert-dialog example simpler
This commit is contained in:
gerjanvangeest 2025-01-21 09:21:34 +01:00 committed by GitHub
parent 795237d19e
commit dd598125ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 169 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': minor
---
[dialog] add an option to set role="alertdialog" instead of the default role="dialog"

View file

@ -6,7 +6,6 @@ Its purpose is to make it easy to use our Overlay System declaratively.
```js script
import { html } from '@mdjs/mdjs-preview';
import '@lion/ui/define/lion-dialog.js';
import { demoStyle } from './src/demoStyle.js';
import './src/styled-dialog-content.js';
import './src/slots-dialog-content.js';
@ -23,6 +22,38 @@ import './src/external-dialog.js';
</lion-dialog>
```
## Alert dialog
In some cases the dialog should act like an [alertdialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/examples/alertdialog/), which is a combination of an alert and dialog. If that is the case, you can add `is-alert-dialog` attribute, which sets the correct role on the dialog.
```js preview-story
export const alertDialog = () => {
return html`
<style>
${demoStyle}
</style>
<lion-dialog is-alert-dialog class="dialog">
<button type="button" slot="invoker">Reset</button>
<div slot="content" class="demo-box">
Are you sure you want to clear the input field?
<button
type="button"
@click="${ev => ev.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}"
>
Yes
</button>
<button
type="button"
@click="${ev => ev.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}"
>
No
</button>
</div>
</lion-dialog>
`;
};
```
## External trigger
```js preview-story

View file

@ -67,6 +67,36 @@ export const placementGlobal = () => {
};
```
## isAlertDialog
In some cases the dialog should act like an [alertdialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/examples/alertdialog/), which is a combination of an alert and dialog. If that is the case, you can add `is-alert-dialog` attribute, which sets the correct role on the dialog.
```js preview-story
export const alertDialog = () => {
const placementModeGlobalConfig = { ...withModalDialogConfig(), isAlertDialog: true };
return html`
<demo-el-using-overlaymixin .config="${placementModeGlobalConfig}">
<button slot="invoker">Click me to open the alert dialog!</button>
<div slot="content" class="demo-overlay">
Are you sure you want to perform this action?
<button
type="button"
@click="${ev => ev.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}"
>
Yes
</button>
<button
type="button"
@click="${ev => ev.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}"
>
No
</button>
</div>
</demo-el-using-overlaymixin>
`;
};
```
## isTooltip (placementMode: 'local')
As specified in the [overlay rationale](./rationale.md) there are only two official types of overlays: dialogs and tooltips. And their main differences are:

View file

@ -2,6 +2,18 @@ import { html, LitElement } from 'lit';
import { OverlayMixin, withModalDialogConfig } from '@lion/ui/overlays.js';
export class LionDialog extends OverlayMixin(LitElement) {
/** @type {any} */
static get properties() {
return {
isAlertDialog: { type: Boolean, attribute: 'is-alert-dialog' },
};
}
constructor() {
super();
this.isAlertDialog = false;
}
/**
* @protected
*/
@ -9,6 +21,7 @@ export class LionDialog extends OverlayMixin(LitElement) {
_defineOverlayConfig() {
return {
...withModalDialogConfig(),
isAlertDialog: this.isAlertDialog,
};
}

View file

@ -170,6 +170,27 @@ describe('lion-dialog', () => {
});
describe('Accessibility', () => {
it('passes a11y audit', async () => {
const el = await fixture(html`
<lion-dialog>
<button slot="invoker">Invoker</button>
<div slot="content" class="dialog" aria-label="Dialog">Hey there</div>
</lion-dialog>
`);
await expect(el).to.be.accessible();
});
it('passes a11y audit when opened', async () => {
const el = await fixture(html`
<lion-dialog opened>
<button slot="invoker">Invoker</button>
<div slot="content" class="dialog" aria-label="Dialog">Hey there</div>
</lion-dialog>
`);
// error expected since we put role="none" on the dialog itself, which is valid but not recognized by Axe
await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] });
});
it('does not add [aria-expanded] to invoker button', async () => {
const el = await fixture(
html` <lion-dialog>
@ -187,6 +208,41 @@ describe('lion-dialog', () => {
await aTimeout(0);
expect(invokerButton.getAttribute('aria-expanded')).to.equal(null);
});
it('has role="dialog" by default', async () => {
const el = await fixture(
html` <lion-dialog>
<div slot="content" class="dialog">Hey there</div>
<button slot="invoker">Popup button</button>
</lion-dialog>`,
);
const contentNode = /** @type {HTMLElement} */ (el.querySelector('[slot="content"]'));
expect(contentNode.getAttribute('role')).to.equal('dialog');
});
it('has role="alertdialog" by when "is-alert-dialog" is set', async () => {
const el = await fixture(
html` <lion-dialog is-alert-dialog>
<div slot="content" class="dialog">Hey there</div>
<button slot="invoker">Popup button</button>
</lion-dialog>`,
);
const contentNode = /** @type {HTMLElement} */ (el.querySelector('[slot="content"]'));
expect(contentNode.getAttribute('role')).to.equal('alertdialog');
});
it('passes a11y audit when opened and role="alertdialog"', async () => {
const el = await fixture(html`
<lion-dialog opened is-alert-dialog>
<button slot="invoker">Invoker</button>
<div slot="content" class="dialog" aria-label="Dialog">Hey there</div>
</lion-dialog>
`);
// error expected since we put role="none" on the dialog itself, which is valid but not recognized by Axe
await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] });
});
});
describe('Edge cases', () => {

View file

@ -151,6 +151,7 @@ export class OverlayController extends EventTarget {
hidesOnOutsideEsc: false,
hidesOnOutsideClick: false,
isTooltip: false,
isAlertDialog: false,
invokerRelation: 'description',
visibilityTriggerFunction: undefined,
handlesAccessibility: false,
@ -381,6 +382,14 @@ export class OverlayController extends EventTarget {
return /** @type {boolean} */ (this.config?.isTooltip);
}
/**
* The alertdialog role is to be used on modal alert dialogs that interrupt a user's workflow
* to communicate an important message and require a response.
*/
get isAlertDialog() {
return /** @type {boolean} */ (this.config?.isAlertDialog);
}
/**
* By default, the tooltip content is a 'description' for the invoker (uses aria-describedby).
* Setting this property to 'label' makes the content function as a label (via aria-labelledby)
@ -672,7 +681,10 @@ export class OverlayController extends EventTarget {
if (this.invokerNode && !isModal) {
this.invokerNode.setAttribute('aria-expanded', `${this.isShown}`);
}
if (!this.contentNode.getAttribute('role')) {
if (this.isAlertDialog) {
this.contentNode.setAttribute('role', 'alertdialog');
} else if (!this.contentNode.getAttribute('role')) {
// N.B. if we did not explicitly set `isTooltip` or `isAlertDialog`, a role potentially already provided by a user (like 'listbox') takes precedence
this.contentNode.setAttribute('role', 'dialog');
}
}

View file

@ -1876,6 +1876,22 @@ describe('OverlayController', () => {
it.skip('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => {});
it.skip('disables pointer events and selection on inert elements', async () => {});
describe('Alert dialog', () => {
it('sets role="alertdialog" when isAlertDialog is set', async () => {
const invokerNode = /** @type {HTMLElement} */ (
await fixture('<div role="button">invoker</div>')
);
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
isAlertDialog: true,
invokerNode,
});
expect(ctrl.contentNode?.getAttribute('role')).to.equal('alertdialog');
});
});
describe('Tooltip', () => {
it('adds [aria-describedby] on invoker', async () => {
const invokerNode = /** @type {HTMLElement} */ (

View file

@ -41,7 +41,7 @@ export interface OverlayConfig {
contentNode?: HTMLElement;
/** The wrapper element of contentNode, used to supply inline positioning styles. When a Popper arrow is needed, it acts as parent of the arrow node. Will be automatically created for global and non projected contentNodes. Required when used in shadow dom mode or when Popper arrow is supplied. Essential for allowing webcomponents to style their projected contentNodes */
contentWrapperNode?: HTMLElement;
/** The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true, a backdropNode will be automatically created */
/** The element that is placed behind the contentNode. When not provided and `hasBackdrop` is true, a backdropNode will be automatically created */
backdropNode?: HTMLElement;
/** The element that should be called `.focus()` on after dialog closes */
elementToFocusAfterHide?: HTMLElement;
@ -59,7 +59,7 @@ export interface OverlayConfig {
trapsKeyboardFocus?: boolean;
/** Hides the overlay when pressing [ esc ] */
hidesOnEsc?: boolean;
/** Hides the overlay when clicking next to it, exluding invoker */
/** Hides the overlay when clicking next to it, excluding invoker */
hidesOnOutsideClick?: boolean;
/** Hides the overlay when pressing esc, even when contentNode has no focus */
hidesOnOutsideEsc?: boolean;
@ -82,6 +82,8 @@ export interface OverlayConfig {
/** Has a totally different interaction- and accessibility pattern from all other overlays. Will behave as role="tooltip" element instead of a role="dialog" element */
isTooltip?: boolean;
/** The alertdialog role is to be used on modal alert dialogs that interrupt a user's workflow to communicate an important message and require a response. */
isAlertDialog?: boolean;
/** By default, the tooltip content is a 'description' for the invoker (uses aria-describedby) Setting this property to 'label' makes the content function as a label (via aria-labelledby) */
invokerRelation?: 'label' | 'description';