diff --git a/src/components/input-date-picker/input-date-picker.e2e.ts b/src/components/input-date-picker/input-date-picker.e2e.ts
index a67f4d82ca6..f61f4373c1d 100644
--- a/src/components/input-date-picker/input-date-picker.e2e.ts
+++ b/src/components/input-date-picker/input-date-picker.e2e.ts
@@ -12,7 +12,7 @@ import {
import { html } from "../../../support/formatting";
import { CSS } from "./resources";
import { CSS as MONTH_HEADER_CSS } from "../date-picker-month-header/resources";
-import { skipAnimations } from "../../tests/utils";
+import { getFocusedElementProp, skipAnimations } from "../../tests/utils";
const animationDurationInMs = 200;
describe("calcite-input-date-picker", () => {
@@ -495,19 +495,7 @@ describe("calcite-input-date-picker", () => {
await page.waitForChanges();
await page.keyboard.press("Tab");
- await page.waitForChanges();
- await page.keyboard.press("ArrowDown");
- await page.waitForChanges();
await page.keyboard.press("Tab");
- await page.waitForChanges();
- await page.keyboard.press("Tab");
- await page.waitForChanges();
- await page.keyboard.press("Tab");
- await page.waitForChanges();
- await page.keyboard.press("Tab");
- await page.waitForChanges();
- await page.keyboard.press("Tab");
- await page.waitForChanges();
await datePickerEl.type("08/30/2022");
await page.keyboard.press("Enter");
await page.waitForChanges();
@@ -618,4 +606,121 @@ describe("calcite-input-date-picker", () => {
expect(changeEvent).toHaveReceivedEventTimes(1);
expect(await datepickerEl.getProperty("value")).toEqual(["2022-08-15", "2022-08-20"]);
});
+
+ describe("focus trapping", () => {
+ it("traps focus only when open", async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ html`
+
next sibling
`
+ );
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "id")).toBe("next-sibling");
+
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Tab");
+ await page.keyboard.up("Shift");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-INPUT");
+
+ const opening = page.waitForEvent("calciteInputDatePickerOpen");
+ await page.keyboard.press("ArrowDown");
+ await opening;
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Tab");
+ await page.keyboard.up("Shift");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ const closing = page.waitForEvent("calciteInputDatePickerClose");
+ await page.keyboard.press("Escape");
+ await closing;
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-INPUT");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "id")).toBe("next-sibling");
+ });
+
+ it("traps focus only when open (range)", async () => {
+ const page = await newE2EPage();
+ await page.setContent(
+ html`
+ next sibling
`
+ );
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "id")).toBe("next-sibling");
+
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Tab");
+ await page.keyboard.up("Shift");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Tab");
+ await page.keyboard.up("Shift");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-INPUT");
+
+ const startOpening = page.waitForEvent("calciteInputDatePickerOpen");
+ await page.keyboard.press("ArrowDown");
+ await startOpening;
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Tab");
+ await page.keyboard.up("Shift");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ const startClosing = page.waitForEvent("calciteInputDatePickerClose");
+ await page.keyboard.press("Escape");
+ await startClosing;
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-INPUT");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-INPUT");
+
+ const endOpening = page.waitForEvent("calciteInputDatePickerOpen");
+ await page.keyboard.press("ArrowDown");
+ await endOpening;
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ await page.keyboard.down("Shift");
+ await page.keyboard.press("Tab");
+ await page.keyboard.up("Shift");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-DATE-PICKER");
+
+ const endClosing = page.waitForEvent("calciteInputDatePickerClose");
+ await page.keyboard.press("Escape");
+ await endClosing;
+ expect(await getFocusedElementProp(page, "tagName")).toBe("CALCITE-INPUT-DATE-PICKER");
+ expect(await getFocusedElementProp(page, "tagName", { shadow: true })).toBe("CALCITE-INPUT");
+
+ await page.keyboard.press("Tab");
+ expect(await getFocusedElementProp(page, "id")).toBe("next-sibling");
+ });
+ });
});
diff --git a/src/components/input-date-picker/input-date-picker.tsx b/src/components/input-date-picker/input-date-picker.tsx
index 9898bc68038..b4113146979 100644
--- a/src/components/input-date-picker/input-date-picker.tsx
+++ b/src/components/input-date-picker/input-date-picker.tsx
@@ -43,7 +43,7 @@ import {
} from "../../utils/form";
import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive";
import { numberKeys } from "../../utils/key";
-import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label";
+import { connectLabel, disconnectLabel, LabelableComponent } from "../../utils/label";
import {
componentLoaded,
LoadableComponent,
@@ -68,6 +68,13 @@ import { HeadingLevel } from "../functional/Heading";
import { CSS } from "./resources";
import { connectMessages, disconnectMessages, setUpMessages, T9nComponent } from "../../utils/t9n";
import { InputDatePickerMessages } from "./assets/input-date-picker/t9n";
+import {
+ activateFocusTrap,
+ connectFocusTrap,
+ deactivateFocusTrap,
+ FocusTrapComponent
+} from "../../utils/focusTrapComponent";
+import { FocusTrap } from "focus-trap";
import { guid } from "../../utils/guid";
@Component({
@@ -80,13 +87,14 @@ import { guid } from "../../utils/guid";
})
export class InputDatePicker
implements
- LabelableComponent,
+ FloatingUIComponent,
+ FocusTrapComponent,
FormComponent,
InteractiveComponent,
- OpenCloseComponent,
- FloatingUIComponent,
- LocalizedComponent,
+ LabelableComponent,
LoadableComponent,
+ LocalizedComponent,
+ OpenCloseComponent,
T9nComponent
{
//--------------------------------------------------------------------------
@@ -101,11 +109,26 @@ export class InputDatePicker
// Public Properties
//
//--------------------------------------------------------------------------
+
/**
* When `true`, interaction is prevented and the component is displayed with lower opacity.
*/
@Prop({ reflect: true }) disabled = false;
+ /**
+ * When `true`, prevents focus trapping.
+ */
+ @Prop({ reflect: true }) focusTrapDisabled = false;
+
+ @Watch("focusTrapDisabled")
+ handleFocusTrapDisabled(focusTrapDisabled: boolean): void {
+ if (!this.open) {
+ return;
+ }
+
+ focusTrapDisabled ? deactivateFocusTrap(this) : activateFocusTrap(this);
+ }
+
/**
* The ID of the form that will be associated with the component.
*
@@ -463,6 +486,7 @@ export class InputDatePicker
}
disconnectedCallback(): void {
+ deactivateFocusTrap(this);
disconnectLabel(this);
disconnectForm(this);
disconnectFloatingUI(this, this.referenceEl, this.floatingEl);
@@ -503,7 +527,6 @@ export class InputDatePicker
}`}
disabled={disabled}
icon="calendar"
- label={getLabelText(this)}
number-button-type="none"
numberingSystem={numberingSystem}
onCalciteInputInput={this.calciteInternalInputInputHandler}
@@ -559,8 +582,10 @@ export class InputDatePicker
proximitySelectionDisabled={this.proximitySelectionDisabled}
range={this.range}
scale={this.scale}
- tabIndex={0}
+ tabIndex={this.open ? undefined : -1}
valueAsDate={this.valueAsDate}
+ // eslint-disable-next-line react/jsx-sort-props
+ ref={this.setDatePickerRef}
/>
@@ -635,10 +660,16 @@ export class InputDatePicker
//
//--------------------------------------------------------------------------
+ private datePickerEl: HTMLCalciteDatePickerElement;
+
private dialogId = `date-picker-dialog--${guid()}`;
filteredFlipPlacements: EffectivePlacement[];
+ private focusOnOpen = false;
+
+ focusTrap: FocusTrap;
+
labelEl: HTMLCalciteLabelElement;
formEl: HTMLFormElement;
@@ -728,6 +759,14 @@ export class InputDatePicker
}
onOpen(): void {
+ activateFocusTrap(this, {
+ onActivate: () => {
+ if (this.focusOnOpen) {
+ this.datePickerEl.setFocus();
+ this.focusOnOpen = false;
+ }
+ }
+ });
this.calciteInputDatePickerOpen.emit();
}
@@ -737,6 +776,9 @@ export class InputDatePicker
onClose(): void {
this.calciteInputDatePickerClose.emit();
+ deactivateFocusTrap(this);
+ this.restoreInputFocus();
+ this.focusOnOpen = false;
}
setStartInput = (el: HTMLCalciteInputElement): void => {
@@ -804,20 +846,25 @@ export class InputDatePicker
if (key === "Enter") {
this.commitValue();
+
if (this.shouldFocusRangeEnd()) {
this.endInput?.setFocus();
} else if (this.shouldFocusRangeStart()) {
this.startInput?.setFocus();
}
+
if (submitForm(this)) {
event.preventDefault();
+ this.restoreInputFocus();
}
} else if (key === "ArrowDown") {
this.open = true;
+ this.focusOnOpen = true;
event.preventDefault();
} else if (key === "Escape") {
this.open = false;
event.preventDefault();
+ this.restoreInputFocus();
}
};
@@ -836,10 +883,8 @@ export class InputDatePicker
};
setFloatingEl = (el: HTMLDivElement): void => {
- if (el) {
- this.floatingEl = el;
- connectFloatingUI(this, this.referenceEl, this.floatingEl);
- }
+ this.floatingEl = el;
+ connectFloatingUI(this, this.referenceEl, this.floatingEl);
};
setStartWrapper = (el: HTMLDivElement): void => {
@@ -852,6 +897,17 @@ export class InputDatePicker
this.setReferenceEl();
};
+ setDatePickerRef = (el: HTMLCalciteDatePickerElement): void => {
+ this.datePickerEl = el;
+ connectFocusTrap(this, {
+ focusTrapEl: el,
+ focusTrapOptions: {
+ initialFocus: false,
+ setReturnFocus: false
+ }
+ });
+ };
+
@Watch("effectiveLocale")
private async loadLocaleData(): Promise {
if (!Build.isBrowser) {
@@ -880,17 +936,18 @@ export class InputDatePicker
this.setValue((event.target as HTMLCalciteDatePickerElement).valueAsDate as Date);
this.localizeInputValues();
+ this.restoreInputFocus();
};
private shouldFocusRangeStart(): boolean {
- const startValue = this.value[0] || undefined;
- const endValue = this.value[1] || undefined;
+ const startValue = this.value[0];
+ const endValue = this.value[1];
return !!(endValue && !startValue && this.focusedInput === "end" && this.startInput);
}
private shouldFocusRangeEnd(): boolean {
- const startValue = this.value[0] || undefined;
- const endValue = this.value[1] || undefined;
+ const startValue = this.value[0];
+ const endValue = this.value[1];
return !!(startValue && !endValue && this.focusedInput === "start" && this.endInput);
}
@@ -905,13 +962,18 @@ export class InputDatePicker
this.setRangeValue(value);
this.localizeInputValues();
+ this.restoreInputFocus();
+ };
- if (this.shouldFocusRangeEnd()) {
- this.endInput?.setFocus();
- } else if (this.shouldFocusRangeStart()) {
- this.startInput?.setFocus();
+ private restoreInputFocus(): void {
+ if (!this.range) {
+ this.startInput.setFocus();
+ return;
}
- };
+
+ const focusedInput = this.focusedInput === "start" ? this.startInput : this.endInput;
+ focusedInput.setFocus();
+ }
private localizeInputValues(): void {
const date = dateFromRange(
diff --git a/src/tests/commonTests.ts b/src/tests/commonTests.ts
index c63b92fa728..155d7008602 100644
--- a/src/tests/commonTests.ts
+++ b/src/tests/commonTests.ts
@@ -620,7 +620,7 @@ export async function formAssociated(componentTagOrHtml: TagOrHTML, options: For
component: E2EElement,
options: FormAssociatedOptions
): Promise {
- const resettablePropName = await isCheckable(page, component, options) ? "checked" : "value";
+ const resettablePropName = (await isCheckable(page, component, options)) ? "checked" : "value";
const initialValue = await component.getProperty(resettablePropName);
component.setProperty(resettablePropName, options.testValue);
await page.waitForChanges();
diff --git a/src/tests/utils.ts b/src/tests/utils.ts
index dc1c24a0701..76eddb391be 100644
--- a/src/tests/utils.ts
+++ b/src/tests/utils.ts
@@ -303,3 +303,34 @@ export async function isElementFocused(
shadowed
);
}
+
+type GetFocusedElementProp = {
+ /**
+ * Set to true to use the shadow root's active element instead of the light DOM's.
+ */
+ shadow: boolean;
+};
+
+/**
+ * This helps get serializable properties from the focused element.
+ *
+ * @param {E2EPage} page - the E2E test page
+ * @param {string} prop - the property to get from the focused element (note: must be serializable)
+ * @param {GetFocusedElementProp} options – additional configuration options
+ */
+export async function getFocusedElementProp(
+ page: E2EPage,
+ prop: keyof HTMLElement,
+ options?: GetFocusedElementProp
+): Promise> {
+ return await page.evaluate(
+ (by: string, shadow: boolean) => {
+ const { activeElement } = document;
+ const target = shadow ? activeElement?.shadowRoot?.activeElement : activeElement;
+
+ return target?.[by];
+ },
+ prop,
+ options?.shadow
+ );
+}
diff --git a/src/utils/focusTrapComponent.spec.ts b/src/utils/focusTrapComponent.spec.ts
index 267962c33c2..84f37074b55 100644
--- a/src/utils/focusTrapComponent.spec.ts
+++ b/src/utils/focusTrapComponent.spec.ts
@@ -33,4 +33,25 @@ describe("focusTrapComponent", () => {
deactivateFocusTrap(fakeComponent);
expect(deactivateSpy).toHaveBeenCalledTimes(1);
});
+
+ it("supports passing options", () => {
+ const fakeComponent = {} as any;
+ fakeComponent.el = document.createElement("div");
+
+ connectFocusTrap(fakeComponent);
+
+ const activateSpy = jest.fn();
+ fakeComponent.focusTrap.activate = activateSpy;
+
+ const deactivateSpy = jest.fn();
+ fakeComponent.focusTrap.deactivate = deactivateSpy;
+
+ const fakeActivateOptions = {};
+ activateFocusTrap(fakeComponent, fakeActivateOptions);
+ expect(activateSpy).toHaveBeenCalledWith(fakeActivateOptions);
+
+ const fakeDeactivateOptions = {};
+ deactivateFocusTrap(fakeComponent, fakeDeactivateOptions);
+ expect(deactivateSpy).toHaveBeenCalledWith(fakeDeactivateOptions);
+ });
});
diff --git a/src/utils/focusTrapComponent.ts b/src/utils/focusTrapComponent.ts
index 200bf94253d..5b27547432e 100644
--- a/src/utils/focusTrapComponent.ts
+++ b/src/utils/focusTrapComponent.ts
@@ -24,48 +24,71 @@ export interface FocusTrapComponent {
/**
* Method to update the element(s) that are used within the FocusTrap component.
+ *
+ * This should be implemented for components that allow user content and/or have conditionally-rendered focusable elements within the trap.
*/
- updateFocusTrapElements: () => Promise;
+ updateFocusTrapElements?: () => Promise;
}
export type FocusTrap = _FocusTrap;
+interface ConnectFocusTrapOptions {
+ /**
+ * This option allows the focus trap to be created on a different element that's not the host (e.g., a supporting popup component).
+ */
+ focusTrapEl?: HTMLElement;
+
+ /**
+ * This allows specifying overrides to ConnectFocusTrap options.
+ */
+ focusTrapOptions?: Omit;
+}
+
/**
* Helper to set up the FocusTrap component.
*
* @param {FocusTrapComponent} component The FocusTrap component.
+ * @param options
*/
-export function connectFocusTrap(component: FocusTrapComponent): void {
+export function connectFocusTrap(component: FocusTrapComponent, options?: ConnectFocusTrapOptions): void {
const { el } = component;
+ const focusTrapNode = options?.focusTrapEl || el;
- if (!el) {
+ if (!focusTrapNode) {
return;
}
const focusTrapOptions: FocusTrapOptions = {
clickOutsideDeactivates: true,
- document: el.ownerDocument,
escapeDeactivates: false,
- fallbackFocus: el,
+ fallbackFocus: focusTrapNode,
setReturnFocus: (el) => {
focusElement(el as FocusableElement);
return false;
},
+ ...options?.focusTrapOptions,
+
+ // the following options are not overrideable
+ document: el.ownerDocument,
tabbableOptions,
trapStack
};
- component.focusTrap = createFocusTrap(el, focusTrapOptions);
+ component.focusTrap = createFocusTrap(focusTrapNode, focusTrapOptions);
}
/**
* Helper to activate the FocusTrap component.
*
* @param {FocusTrapComponent} component The FocusTrap component.
+ * @param [options] The FocusTrap activate options.
*/
-export function activateFocusTrap(component: FocusTrapComponent): void {
+export function activateFocusTrap(
+ component: FocusTrapComponent,
+ options?: Parameters<_FocusTrap["activate"]>[0]
+): void {
if (!component.focusTrapDisabled) {
- component.focusTrap?.activate();
+ component.focusTrap?.activate(options);
}
}
@@ -73,9 +96,13 @@ export function activateFocusTrap(component: FocusTrapComponent): void {
* Helper to deactivate the FocusTrap component.
*
* @param {FocusTrapComponent} component The FocusTrap component.
+ * @param [options] The FocusTrap deactivate options.
*/
-export function deactivateFocusTrap(component: FocusTrapComponent): void {
- component.focusTrap?.deactivate();
+export function deactivateFocusTrap(
+ component: FocusTrapComponent,
+ options?: Parameters<_FocusTrap["deactivate"]>[0]
+): void {
+ component.focusTrap?.deactivate(options);
}
/**