Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for external modals #1575

Merged
merged 1 commit into from
Sep 26, 2023
Merged
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
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