iT邦幫忙

2022 iThome 鐵人賽

DAY 16
0
Modern Web

【 我不會寫 React Component 】系列 第 16

如何製作日期選擇 Date Picker 4【 date picker | 我不會寫 React Component 】

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20220929/20107239AixABcGUu7.jpg

hashtags: #react, #components, #accessibility, #datepicker

本篇接續前篇 如何製作日期選擇 Date Picker 3【 我不會寫 React Component 】
可以先看完上一篇再接續此篇。

Spec: Roving tabindex Navigation

我們在 如何製作月曆 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);
});

Spec: Focus Trap

因為我們在 如何製作對話視窗 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();
  });
});

Spec: Control Month/Year Button

這邊有個不容易被注意到的 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");
    });
  });
});

Solution

注意到 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

上一篇
如何製作日期選擇 Date Picker 3【 date picker | 我不會寫 React Component 】
下一篇
如何製作日期選擇 Date Picker 5【 date picker | 我不會寫 React Component 】
系列文
【 我不會寫 React Component 】30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言