Skip to content

Commit

Permalink
add support for external modals; fixes #1571 (#1575)
Browse files Browse the repository at this point in the history
  • Loading branch information
claviska authored Sep 26, 2023
1 parent 9b96933 commit cbd4336
Show file tree
Hide file tree
Showing 4 changed files with 32 additions and 11 deletions.
1 change: 1 addition & 0 deletions docs/pages/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion src/components/dialog/dialog.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/components/drawer/drawer.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,19 @@ 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;
static dependencies = { 'sl-icon-button': SlIconButton };

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;
Expand Down
30 changes: 21 additions & 9 deletions src/internal/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ let activeModals: HTMLElement[] = [];

export default class Modal {
element: HTMLElement;
isExternalActivated: boolean;
tabDirection: 'forward' | 'backward' = 'forward';
currentFocus: HTMLElement | null;

constructor(element: HTMLElement) {
this.element = element;
}

/** Activates focus trapping. */
activate() {
activeModals.push(this.element);
document.addEventListener('focusin', this.handleFocusIn);
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
}

/** Deactivates focus trapping. */
deactivate() {
activeModals = activeModals.filter(modal => modal !== this.element);
this.currentFocus = null;
Expand All @@ -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];
Expand All @@ -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;
Expand All @@ -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';
Expand Down

0 comments on commit cbd4336

Please sign in to comment.