Skip to content

Commit

Permalink
feat(input-date-picker): add focus trap support (#6816)
Browse files Browse the repository at this point in the history
**Related Issue:** #6668 

## Summary

Adds focus-trap to date-picker dialog to improve a11y.

**Note:** This also adds a util to help retrieve props from the
currently focused element.

## Notable `focusTrapComponent` changes

* `connectFocusTrap` now supports passing a different focus-trap target
element (for setting the focus trap on a component's subtree) and
overrides to certain options from `focus-trap`'s `focusTrap()`.
* `activateFocusTrap`/`deactivateFocusTrap` now allow passing options to
`focus-trap`'s `activate`/`deactivate` methods.
* `updateFocusTrapElements` is now optional.
  • Loading branch information
jcfranco authored Apr 22, 2023
1 parent fbcb273 commit 0d9ddc9
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 44 deletions.
131 changes: 118 additions & 13 deletions src/components/input-date-picker/input-date-picker.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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` <calcite-input-date-picker></calcite-input-date-picker>
<div id="next-sibling" tabindex="0">next sibling</div>`
);

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`<calcite-input-date-picker range></calcite-input-date-picker>
<div id="next-sibling" tabindex="0">next sibling</div>`
);

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");
});
});
});
102 changes: 82 additions & 20 deletions src/components/input-date-picker/input-date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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
{
//--------------------------------------------------------------------------
Expand All @@ -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.
*
Expand Down Expand Up @@ -463,6 +486,7 @@ export class InputDatePicker
}

disconnectedCallback(): void {
deactivateFocusTrap(this);
disconnectLabel(this);
disconnectForm(this);
disconnectFloatingUI(this, this.referenceEl, this.floatingEl);
Expand Down Expand Up @@ -503,7 +527,6 @@ export class InputDatePicker
}`}
disabled={disabled}
icon="calendar"
label={getLabelText(this)}
number-button-type="none"
numberingSystem={numberingSystem}
onCalciteInputInput={this.calciteInternalInputInputHandler}
Expand Down Expand Up @@ -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}
/>
</div>
</div>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -728,6 +759,14 @@ export class InputDatePicker
}

onOpen(): void {
activateFocusTrap(this, {
onActivate: () => {
if (this.focusOnOpen) {
this.datePickerEl.setFocus();
this.focusOnOpen = false;
}
}
});
this.calciteInputDatePickerOpen.emit();
}

Expand All @@ -737,6 +776,9 @@ export class InputDatePicker

onClose(): void {
this.calciteInputDatePickerClose.emit();
deactivateFocusTrap(this);
this.restoreInputFocus();
this.focusOnOpen = false;
}

setStartInput = (el: HTMLCalciteInputElement): void => {
Expand Down Expand Up @@ -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();
}
};

Expand All @@ -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 => {
Expand All @@ -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<void> {
if (!Build.isBrowser) {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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(
Expand Down
Loading

0 comments on commit 0d9ddc9

Please sign in to comment.