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:
parent
795237d19e
commit
dd598125ef
8 changed files with 169 additions and 4 deletions
5
.changeset/cuddly-bottles-camp.md
Normal file
5
.changeset/cuddly-bottles-camp.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
[dialog] add an option to set role="alertdialog" instead of the default role="dialog"
|
||||||
|
|
@ -6,7 +6,6 @@ Its purpose is to make it easy to use our Overlay System declaratively.
|
||||||
```js script
|
```js script
|
||||||
import { html } from '@mdjs/mdjs-preview';
|
import { html } from '@mdjs/mdjs-preview';
|
||||||
import '@lion/ui/define/lion-dialog.js';
|
import '@lion/ui/define/lion-dialog.js';
|
||||||
|
|
||||||
import { demoStyle } from './src/demoStyle.js';
|
import { demoStyle } from './src/demoStyle.js';
|
||||||
import './src/styled-dialog-content.js';
|
import './src/styled-dialog-content.js';
|
||||||
import './src/slots-dialog-content.js';
|
import './src/slots-dialog-content.js';
|
||||||
|
|
@ -23,6 +22,38 @@ import './src/external-dialog.js';
|
||||||
</lion-dialog>
|
</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
|
## External trigger
|
||||||
|
|
||||||
```js preview-story
|
```js preview-story
|
||||||
|
|
|
||||||
|
|
@ -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')
|
## 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:
|
As specified in the [overlay rationale](./rationale.md) there are only two official types of overlays: dialogs and tooltips. And their main differences are:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@ import { html, LitElement } from 'lit';
|
||||||
import { OverlayMixin, withModalDialogConfig } from '@lion/ui/overlays.js';
|
import { OverlayMixin, withModalDialogConfig } from '@lion/ui/overlays.js';
|
||||||
|
|
||||||
export class LionDialog extends OverlayMixin(LitElement) {
|
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
|
* @protected
|
||||||
*/
|
*/
|
||||||
|
|
@ -9,6 +21,7 @@ export class LionDialog extends OverlayMixin(LitElement) {
|
||||||
_defineOverlayConfig() {
|
_defineOverlayConfig() {
|
||||||
return {
|
return {
|
||||||
...withModalDialogConfig(),
|
...withModalDialogConfig(),
|
||||||
|
isAlertDialog: this.isAlertDialog,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,27 @@ describe('lion-dialog', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
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 () => {
|
it('does not add [aria-expanded] to invoker button', async () => {
|
||||||
const el = await fixture(
|
const el = await fixture(
|
||||||
html` <lion-dialog>
|
html` <lion-dialog>
|
||||||
|
|
@ -187,6 +208,41 @@ describe('lion-dialog', () => {
|
||||||
await aTimeout(0);
|
await aTimeout(0);
|
||||||
expect(invokerButton.getAttribute('aria-expanded')).to.equal(null);
|
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', () => {
|
describe('Edge cases', () => {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export class OverlayController extends EventTarget {
|
||||||
hidesOnOutsideEsc: false,
|
hidesOnOutsideEsc: false,
|
||||||
hidesOnOutsideClick: false,
|
hidesOnOutsideClick: false,
|
||||||
isTooltip: false,
|
isTooltip: false,
|
||||||
|
isAlertDialog: false,
|
||||||
invokerRelation: 'description',
|
invokerRelation: 'description',
|
||||||
visibilityTriggerFunction: undefined,
|
visibilityTriggerFunction: undefined,
|
||||||
handlesAccessibility: false,
|
handlesAccessibility: false,
|
||||||
|
|
@ -381,6 +382,14 @@ export class OverlayController extends EventTarget {
|
||||||
return /** @type {boolean} */ (this.config?.isTooltip);
|
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).
|
* 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)
|
* 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) {
|
if (this.invokerNode && !isModal) {
|
||||||
this.invokerNode.setAttribute('aria-expanded', `${this.isShown}`);
|
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');
|
this.contentNode.setAttribute('role', 'dialog');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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('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 () => {});
|
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', () => {
|
describe('Tooltip', () => {
|
||||||
it('adds [aria-describedby] on invoker', async () => {
|
it('adds [aria-describedby] on invoker', async () => {
|
||||||
const invokerNode = /** @type {HTMLElement} */ (
|
const invokerNode = /** @type {HTMLElement} */ (
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export interface OverlayConfig {
|
||||||
contentNode?: HTMLElement;
|
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 */
|
/** 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;
|
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;
|
backdropNode?: HTMLElement;
|
||||||
/** The element that should be called `.focus()` on after dialog closes */
|
/** The element that should be called `.focus()` on after dialog closes */
|
||||||
elementToFocusAfterHide?: HTMLElement;
|
elementToFocusAfterHide?: HTMLElement;
|
||||||
|
|
@ -59,7 +59,7 @@ export interface OverlayConfig {
|
||||||
trapsKeyboardFocus?: boolean;
|
trapsKeyboardFocus?: boolean;
|
||||||
/** Hides the overlay when pressing [ esc ] */
|
/** Hides the overlay when pressing [ esc ] */
|
||||||
hidesOnEsc?: boolean;
|
hidesOnEsc?: boolean;
|
||||||
/** Hides the overlay when clicking next to it, exluding invoker */
|
/** Hides the overlay when clicking next to it, excluding invoker */
|
||||||
hidesOnOutsideClick?: boolean;
|
hidesOnOutsideClick?: boolean;
|
||||||
/** Hides the overlay when pressing esc, even when contentNode has no focus */
|
/** Hides the overlay when pressing esc, even when contentNode has no focus */
|
||||||
hidesOnOutsideEsc?: boolean;
|
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 */
|
/** 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;
|
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) */
|
/** 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';
|
invokerRelation?: 'label' | 'description';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue