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); } /**