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

feat(input-date-picker): add focus trap support #6816

Merged
Merged
134 changes: 121 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,124 @@ 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;
await page.keyboard.press("Tab");
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;
await page.keyboard.press("Tab");
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;
await page.keyboard.press("Tab");
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");
});
});
});
90 changes: 68 additions & 22 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";

@Component({
tag: "calcite-input-date-picker",
Expand All @@ -79,13 +86,14 @@ import { InputDatePickerMessages } from "./assets/input-date-picker/t9n";
})
export class InputDatePicker
implements
LabelableComponent,
FloatingUIComponent,
FocusTrapComponent,
FormComponent,
InteractiveComponent,
OpenCloseComponent,
FloatingUIComponent,
LocalizedComponent,
LabelableComponent,
LoadableComponent,
LocalizedComponent,
OpenCloseComponent,
T9nComponent
{
//--------------------------------------------------------------------------
Expand All @@ -100,11 +108,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 @@ -462,6 +485,7 @@ export class InputDatePicker
}

disconnectedCallback(): void {
deactivateFocusTrap(this);
disconnectLabel(this);
disconnectForm(this);
disconnectFloatingUI(this, this.referenceEl, this.floatingEl);
Expand Down Expand Up @@ -502,7 +526,6 @@ export class InputDatePicker
}`}
disabled={disabled}
icon="calendar"
label={getLabelText(this)}
number-button-type="none"
numberingSystem={numberingSystem}
onCalciteInputInput={this.calciteInternalInputInputHandler}
Expand Down Expand Up @@ -558,8 +581,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 @@ -636,6 +661,8 @@ export class InputDatePicker

filteredFlipPlacements: EffectivePlacement[];

focusTrap: FocusTrap;

labelEl: HTMLCalciteLabelElement;

formEl: HTMLFormElement;
Expand All @@ -652,8 +679,6 @@ export class InputDatePicker

private lastBlurredInput: "start" | "end" | "none" = "none";

@State() globalAttributes = {};

@State() private localeData: DateLocaleData;

private startInput: HTMLCalciteInputElement;
Expand Down Expand Up @@ -728,6 +753,7 @@ export class InputDatePicker

onOpen(): void {
this.calciteInputDatePickerOpen.emit();
activateFocusTrap(this);
}

onBeforeClose(): void {
Expand All @@ -736,6 +762,8 @@ export class InputDatePicker

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

setStartInput = (el: HTMLCalciteInputElement): void => {
Expand Down Expand Up @@ -803,20 +831,24 @@ 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;
event.preventDefault();
} else if (key === "Escape") {
this.open = false;
event.preventDefault();
this.restoreInputFocus();
}
};

Expand All @@ -835,10 +867,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 @@ -851,6 +881,16 @@ export class InputDatePicker
this.setReferenceEl();
};

setDatePickerRef = (el: HTMLCalciteDatePickerElement): void => {
connectFocusTrap(this, {
focusTrapEl: el,
focusTrapOptions: {
initialFocus: false,
setReturnFocus: false
}
});
};

@Watch("effectiveLocale")
private async loadLocaleData(): Promise<void> {
if (!Build.isBrowser) {
Expand Down Expand Up @@ -879,17 +919,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 @@ -904,13 +945,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