Skip to content

Commit 31dae60

Browse files
authored
feat(dialog): add focusTrapDisabled property for non-modal dialogs (#11362)
**Related Issue:** #10685 ## Summary Add `focusTrapDisabled` attribute to for non-`modal` `dialog`s.
1 parent 2c5caa4 commit 31dae60

File tree

5 files changed

+185
-4
lines changed

5 files changed

+185
-4
lines changed

packages/calcite-components/src/components/dialog/dialog.e2e.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @ts-strict-ignore
22
import { newE2EPage, E2EPage } from "@arcgis/lumina-compiler/puppeteerTesting";
3-
import { describe, expect, it, vi } from "vitest";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
44
import {
55
accessible,
66
defaults,
@@ -1160,4 +1160,126 @@ describe("calcite-dialog", () => {
11601160
expect(await dialog.getProperty("open")).toBe(true);
11611161
});
11621162
});
1163+
1164+
describe("focusTrap behavior for modal dialogs", () => {
1165+
let page: E2EPage;
1166+
1167+
beforeEach(async () => {
1168+
page = await newE2EPage();
1169+
await page.setContent(html`
1170+
<calcite-dialog modal width-scale="s" open closable><button id="insideEl">inside</button></calcite-dialog>
1171+
<button id="outsideEl">outside</button>
1172+
`);
1173+
1174+
await skipAnimations(page);
1175+
await page.waitForChanges();
1176+
});
1177+
1178+
it("cannot tab out of dialog when modal=true and focusTrapDisabled=true", async () => {
1179+
const dialog = await page.find("calcite-dialog");
1180+
const insideEl = await page.find("#insideEl");
1181+
1182+
dialog.setProperty("focusTrapDisabled", true);
1183+
1184+
await page.waitForChanges();
1185+
1186+
expect(await dialog.isVisible()).toBe(true);
1187+
1188+
await page.keyboard.press("Tab");
1189+
await page.waitForChanges();
1190+
await page.keyboard.press("Tab");
1191+
await page.waitForChanges();
1192+
1193+
const activeElementId = await page.evaluate(() => document.activeElement.id);
1194+
expect(activeElementId).toBe(await insideEl.getProperty("id"));
1195+
});
1196+
1197+
it("cannot tab out of dialog when modal=true and focusTrapDisabled=false", async () => {
1198+
const dialog = await page.find("calcite-dialog");
1199+
const action = await page.find("calcite-dialog >>> calcite-action");
1200+
const insideEl = await page.find("#insideEl");
1201+
1202+
dialog.setProperty("focusTrapDisabled", false);
1203+
1204+
await page.waitForChanges();
1205+
1206+
expect(await dialog.isVisible()).toBe(true);
1207+
1208+
await action.callMethod("setFocus");
1209+
await page.waitForChanges();
1210+
1211+
await page.keyboard.press("Tab");
1212+
await page.waitForChanges();
1213+
await page.keyboard.press("Tab");
1214+
await page.waitForChanges();
1215+
await page.keyboard.press("Tab");
1216+
await page.waitForChanges();
1217+
1218+
const activeElementId = await page.evaluate(() => document.activeElement.id);
1219+
expect(activeElementId).toBe(await insideEl.getProperty("id"));
1220+
});
1221+
});
1222+
1223+
describe("focusTrap behavior for non-modal dialogs", () => {
1224+
let page: E2EPage;
1225+
1226+
beforeEach(async () => {
1227+
page = await newE2EPage();
1228+
await page.setContent(html`
1229+
<calcite-dialog width-scale="s" open closable><button id="insideEl">inside</button></calcite-dialog>
1230+
<button id="outsideEl">outside</button>
1231+
`);
1232+
1233+
await skipAnimations(page);
1234+
await page.waitForChanges();
1235+
});
1236+
1237+
it("can tab out of non-modal dialog when focusTrapDisabled=true", async () => {
1238+
const dialog = await page.find("calcite-dialog");
1239+
const action = await page.find("calcite-dialog >>> calcite-action");
1240+
const outsideEl = await page.find("#outsideEl");
1241+
1242+
dialog.setProperty("focusTrapDisabled", true);
1243+
1244+
await page.waitForChanges();
1245+
1246+
expect(await dialog.isVisible()).toBe(true);
1247+
1248+
await action.callMethod("setFocus");
1249+
await page.waitForChanges();
1250+
1251+
await page.keyboard.press("Tab");
1252+
await page.waitForChanges();
1253+
await page.keyboard.press("Tab");
1254+
await page.waitForChanges();
1255+
1256+
const activeElementId = await page.evaluate(() => document.activeElement.id);
1257+
expect(activeElementId).toBe(await outsideEl.getProperty("id"));
1258+
});
1259+
1260+
it("cannot tab out of non-modal dialog when focusTrapDisabled=false", async () => {
1261+
const dialog = await page.find("calcite-dialog");
1262+
const action = await page.find("calcite-dialog >>> calcite-action");
1263+
const insideEl = await page.find("#insideEl");
1264+
1265+
dialog.setProperty("focusTrapDisabled", false);
1266+
1267+
await page.waitForChanges();
1268+
1269+
expect(await dialog.isVisible()).toBe(true);
1270+
1271+
await action.callMethod("setFocus");
1272+
await page.waitForChanges();
1273+
1274+
await page.keyboard.press("Tab");
1275+
await page.waitForChanges();
1276+
await page.keyboard.press("Tab");
1277+
await page.waitForChanges();
1278+
await page.keyboard.press("Tab");
1279+
await page.waitForChanges();
1280+
1281+
const activeElementId = await page.evaluate(() => document.activeElement.id);
1282+
expect(activeElementId).toBe(await insideEl.getProperty("id"));
1283+
});
1284+
});
11631285
});

packages/calcite-components/src/components/dialog/dialog.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo
192192
/** When `true`, displays a scrim blocking interaction underneath the component. */
193193
@property({ reflect: true }) modal = false;
194194

195+
/** When `true` and `modal` is `false`, prevents focus trapping. */
196+
@property({ reflect: true }) focusTrapDisabled = false;
197+
195198
/** When `true`, displays and positions the component. */
196199
@property({ reflect: true })
197200
get open(): boolean {
@@ -275,6 +278,11 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo
275278
this.focusTrap.updateContainerElements();
276279
}
277280

281+
/** When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */
282+
focusTrapDisabledOverride(): boolean {
283+
return !this.modal && this.focusTrapDisabled;
284+
}
285+
278286
// #endregion
279287

280288
// #region Events
@@ -352,6 +360,7 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo
352360
// #endregion
353361

354362
// #region Private Methods
363+
355364
private updateAssistiveText(): void {
356365
const { messages } = this;
357366
this.assistiveText =

packages/calcite-components/src/controllers/useFocusTrap.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,26 @@ interface UseFocusTrapOptions<T extends LitElement = LitElement> {
4343
focusTrapOptions?: FocusTrapOptions;
4444
}
4545

46+
interface FocusTrapComponent extends LitElement {
47+
/**
48+
* When `true` prevents focus trapping.
49+
*/
50+
focusTrapDisabled?: boolean;
51+
52+
/**
53+
* When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping.
54+
*/
55+
focusTrapDisabledOverride?: () => boolean;
56+
}
57+
4658
/**
4759
* A controller for managing focus traps.
4860
*
4961
* Note: traps will be deactivated automatically when the component is disconnected.
5062
*
5163
* @param options
5264
*/
53-
export const useFocusTrap = <T extends LitElement>(
65+
export const useFocusTrap = <T extends FocusTrapComponent>(
5466
options: UseFocusTrapOptions<T>,
5567
): ReturnType<typeof makeGenericController<UseFocusTrap, T>> => {
5668
return makeGenericController<UseFocusTrap, T>((component, controller) => {
@@ -77,7 +89,13 @@ export const useFocusTrap = <T extends LitElement>(
7789
focusTrap = createFocusTrap(targetEl, createFocusTrapOptions(targetEl, focusTrapOptions));
7890
}
7991

80-
focusTrap.activate(options);
92+
if (
93+
typeof component.focusTrapDisabledOverride === "function"
94+
? !component.focusTrapDisabledOverride()
95+
: !component.focusTrapDisabled
96+
) {
97+
focusTrap.activate(options);
98+
}
8199
},
82100
deactivate: (options?: Parameters<FocusTrap["deactivate"]>[0]) => focusTrap?.deactivate(options),
83101
overrideFocusTrapEl: (el: HTMLElement) => {

packages/calcite-components/src/utils/focusTrapComponent.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,33 @@ describe("focusTrapComponent", () => {
112112
expect(customFocusTrapStack).toHaveLength(1);
113113
});
114114
});
115+
describe("focusTrapDisabledOverride", () => {
116+
const fakeComponent = {} as FocusTrapComponent;
117+
let activateSpy: ReturnType<typeof vi.fn>;
118+
119+
beforeEach(() => {
120+
fakeComponent.el = document.createElement("div");
121+
122+
connectFocusTrap(fakeComponent);
123+
124+
activateSpy = vi.fn();
125+
fakeComponent.focusTrap.activate = activateSpy;
126+
});
127+
128+
it("should activate focus trap when focusTrapDisabledOverride returns false", () => {
129+
fakeComponent.focusTrapDisabledOverride = () => false;
130+
131+
activateFocusTrap(fakeComponent);
132+
133+
expect(activateSpy).toHaveBeenCalledTimes(1);
134+
});
135+
136+
it("should not activate focus trap when focusTrapDisabledOverride returns true", () => {
137+
fakeComponent.focusTrapDisabledOverride = () => true;
138+
139+
activateFocusTrap(fakeComponent);
140+
141+
expect(activateSpy).toHaveBeenCalledTimes(0);
142+
});
143+
});
115144
});

packages/calcite-components/src/utils/focusTrapComponent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export interface FocusTrapComponent {
1010
/** When `true`, prevents focus trapping. */
1111
focusTrapDisabled?: boolean;
1212

13+
/** When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */
14+
focusTrapDisabledOverride?: () => boolean;
15+
1316
/** The focus trap instance. */
1417
focusTrap: FocusTrap;
1518

@@ -83,7 +86,7 @@ export function activateFocusTrap(
8386
component: FocusTrapComponent,
8487
options?: Parameters<_FocusTrap["activate"]>[0],
8588
): void {
86-
if (!component.focusTrapDisabled) {
89+
if (component.focusTrapDisabledOverride ? !component.focusTrapDisabledOverride() : !component.focusTrapDisabled) {
8790
component.focusTrap?.activate(options);
8891
}
8992
}

0 commit comments

Comments
 (0)