add support for external modals; fixes #1571 (#1575)

pull/1586/head
Cory LaViska 2023-09-26 08:50:11 -04:00 zatwierdzone przez GitHub
rodzic 9b969339a1
commit cbd4336773
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
4 zmienionych plików z 32 dodań i 11 usunięć

Wyświetl plik

@ -14,6 +14,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
## Next
- Added the `modal` property to `<sl-dialog>` and `<sl-drawer>` to support third-party modals [#1571]
- Fixed a bug in the autoloader causing it to register non-Shoelace elements [#1563]
- Fixed a bug in `<sl-switch>` that resulted in improper spacing between the label and the required asterisk [#1540]
- Removed error when a missing popup anchor is provided [#1548]

Wyświetl plik

@ -60,6 +60,10 @@ import type { CSSResultGroup } from 'lit';
* @animation dialog.denyClose - The animation to use when a request to close the dialog is denied.
* @animation dialog.overlay.show - The animation to use when showing the dialog's overlay.
* @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay.
*
* @property modal - Exposes the internal modal utility that controls focus trapping. To temporarily disable focus
* trapping and allow third-party modals spawned from an active Shoelace modal, call `modal.activateExternal()` when
* the third-party modal opens. Upon closing, call `modal.deactivateExternal()` to restore Shoelace's focus trapping.
*/
export default class SlDialog extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@ -69,8 +73,8 @@ export default class SlDialog extends ShoelaceElement {
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal = new Modal(this);
private originalTrigger: HTMLElement | null;
public modal = new Modal(this);
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;

Wyświetl plik

@ -68,6 +68,10 @@ import type { CSSResultGroup } from 'lit';
* @animation drawer.denyClose - The animation to use when a request to close the drawer is denied.
* @animation drawer.overlay.show - The animation to use when showing the drawer's overlay.
* @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay.
*
* @property modal - Exposes the internal modal utility that controls focus trapping. To temporarily disable focus
* trapping and allow third-party modals spawned from an active Shoelace modal, call `modal.activateExternal()` when
* the third-party modal opens. Upon closing, call `modal.deactivateExternal()` to restore Shoelace's focus trapping.
*/
export default class SlDrawer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@ -75,8 +79,8 @@ export default class SlDrawer extends ShoelaceElement {
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal = new Modal(this);
private originalTrigger: HTMLElement | null;
public modal = new Modal(this);
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;

Wyświetl plik

@ -5,6 +5,7 @@ let activeModals: HTMLElement[] = [];
export default class Modal {
element: HTMLElement;
isExternalActivated: boolean;
tabDirection: 'forward' | 'backward' = 'forward';
currentFocus: HTMLElement | null;
@ -12,6 +13,7 @@ export default class Modal {
this.element = element;
}
/** Activates focus trapping. */
activate() {
activeModals.push(this.element);
document.addEventListener('focusin', this.handleFocusIn);
@ -19,6 +21,7 @@ export default class Modal {
document.addEventListener('keyup', this.handleKeyUp);
}
/** Deactivates focus trapping. */
deactivate() {
activeModals = activeModals.filter(modal => modal !== this.element);
this.currentFocus = null;
@ -27,13 +30,24 @@ export default class Modal {
document.removeEventListener('keyup', this.handleKeyUp);
}
/** Determines if this modal element is currently active or not. */
isActive() {
// The "active" modal is always the most recent one shown
return activeModals[activeModals.length - 1] === this.element;
}
checkFocus() {
if (this.isActive()) {
/** Activates external modal behavior and temporarily disables focus trapping. */
activateExternal() {
this.isExternalActivated = true;
}
/** Deactivates external modal behavior and re-enables focus trapping. */
deactivateExternal() {
this.isExternalActivated = false;
}
private checkFocus() {
if (this.isActive() && !this.isExternalActivated) {
const tabbableElements = getTabbableElements(this.element);
if (!this.element.matches(':focus-within')) {
const start = tabbableElements[0];
@ -56,11 +70,9 @@ export default class Modal {
return getTabbableElements(this.element).findIndex(el => el === this.currentFocus);
}
/**
* Checks if the `startElement` is already focused. This is important if the modal already
* has an existing focus prior to the first tab key.
*/
startElementAlreadyFocused(startElement: HTMLElement) {
// Checks if the `startElement` is already focused. This is important if the modal already has an existing focus prior
// to the first tab key.
private startElementAlreadyFocused(startElement: HTMLElement) {
for (const activeElement of activeElements()) {
if (startElement === activeElement) {
return true;
@ -70,8 +82,8 @@ export default class Modal {
return false;
}
handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
private handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || this.isExternalActivated) return;
if (event.shiftKey) {
this.tabDirection = 'backward';