hashtags: #react
, #components
, #accessibility
, #datepicker
本篇接續前篇 如何製作日期選擇 Date Picker 3【 我不會寫 React Component 】
可以先看完上一篇再接續此篇。
我們在 如何製作月曆 integration【 calendar | 我不會寫 React Component 】 實作了 Roving tabindex Navigation,
這邊就不用在實作一遍,以下提供測試。
it("only one button in the calendar grid is in the tab sequence", async () => {
setup(new Date(0));
await user.click(screen.getByRole("button"));
expect(
Array.from(document.querySelectorAll("table button[tabindex]")).filter(
(button) => Number(button.getAttribute("tabindex")) >= 0
).length
).toEqual(1);
});
因為我們在 如何製作對話視窗 interaction【 dialog | 我不會寫 React Component 】 實作過 Focus Trap,
所以這邊就不用在實作一遍,以下提供測試。
describe("tab", () => {
it("moves focus to next element in the dialog tab sequence", async () => {
setup(new Date(0));
await user.click(screen.getByRole("button"));
expect(screen.getByText("01")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByLabelText("previous month")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByLabelText("next month")).toHaveFocus();
});
it("if focus is on the last element, moves focus to the first element", async () => {
setup(new Date(0));
await user.click(screen.getByRole("button"));
expect(screen.getByText("01")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByLabelText("previous month")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByLabelText("next month")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByLabelText("previous year")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByLabelText("next year")).toHaveFocus();
await user.keyboard("{Tab}");
expect(screen.getByText("01")).toHaveFocus();
});
});
describe("shift + tab", () => {
it("moves focus to previous element in the dialog tab sequence.", async () => {
setup(new Date(0));
await user.click(screen.getByRole("button"));
expect(screen.getByText("01")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByLabelText("next year")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByLabelText("previous year")).toHaveFocus();
});
it("if focus is on the first element, moves focus to the last element", async () => {
setup(new Date(0));
await user.click(screen.getByRole("button"));
expect(screen.getByText("01")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByLabelText("next year")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByLabelText("previous year")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByLabelText("next month")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByLabelText("previous month")).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(screen.getByText("01")).toHaveFocus();
});
});
這邊有個不容易被注意到的 bug,
寫單元測試可以在早期階段就抓出這些不容易被注意到的問題。
describe("date picker dialog: month/year buttons", () => {
describe("space, enter", () => {
it("change the month and/or year displayed in the calendar grid", async () => {
setup(new Date(0));
await user.click(screen.getByRole("button"));
await user.keyboard("{Tab}");
expect(screen.getByLabelText("previous month")).toHaveFocus();
await user.keyboard("{Space}");
expect(screen.getByRole("grid")).toHaveAccessibleName("December 1969");
await user.keyboard("{Tab}");
expect(screen.getByLabelText("next month")).toHaveFocus();
await user.keyboard("{Space}");
expect(screen.getByRole("grid")).toHaveAccessibleName("January 1970");
await user.keyboard("{Tab}");
expect(screen.getByLabelText("previous year")).toHaveFocus();
await user.keyboard("{Space}");
expect(screen.getByRole("grid")).toHaveAccessibleName("January 1969");
await user.keyboard("{Tab}");
expect(screen.getByLabelText("next year")).toHaveFocus();
await user.keyboard("{Space}");
expect(screen.getByRole("grid")).toHaveAccessibleName("January 1970");
});
});
});
注意到 TestCalendar
這邊,我們拋入了 as={Dialog}
來組合這兩個元件。
function TestCalendar() {
//...
return (
<Calendar
as={Dialog}
>
因為 Calendar
內部有用於追蹤對焦日期的狀態 focus
,
當用戶按下切換月份或年份時會處發 rerender
。
export function Calendar() {
// ...
const [focus, dispatch] = useReducer(reducer, value ?? new Date());
因為 as={Dialog}
,Dialog
被用作這個元件的 children
。
const Comp = as ?? "div";
//...
return (
<Context.Provider value={context}>
<Comp {...rest} />
</Context.Provider>
);
Calendar
rerender
也影響到了 Dialog
rerender
,
導致 Dialog
內部的 useEffect
重新被執行。
useEffect(() => {
const element = ref.current;
if (!element) return;
const tabbables = tabbable(element, {
displayCheck: IS_TEST_ENV,
}) as HTMLElement[];
focus(initialFocusRef?.current ?? tabbables.at(0));
因為上面的 focus
邏輯,
每次重新渲染會改變焦點到 initialFocusRef
,
這不是我們希望的。
if (!element.contains(document.activeElement)) {
focus(initialFocusRef?.current ?? tabbables.at(0));
}
透過上面的邏輯,確認當前焦點是否已經對焦在元件內,如果沒有才去執行對焦。
(暫時我只想得到這個解法,如果有其他解法歡迎在下方留言討論)。
中文 | 英文 |
---|---|
日期選擇 | date picker |
對話視窗 | dialog |