iT邦幫忙

2022 iThome 鐵人賽

DAY 20
0
Modern Web

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

如何製作手風琴 accordion 1【 accordion | 我不會寫 React Component 】

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20221003/20107239mlJNqzEplU.jpg

About

手風琴是一排垂直堆疊且用戶可以進行操作的標頭,
標頭內容可以包含像是標題,簡短內容,縮圖 可以用來表示其內容。

用戶可以透過標頭控制哪個段落的內容要顯示或是隱藏。

這個元件通常被使用在該頁面有多個段落,為了減少用戶需要的滑動動作之情況。

手風琴主要包含兩個部分: HeaderPanel

Spec: role="button"

每個手風琴標頭都要包含一個帶有 role="button" 的元素。

it("the title of each accordion header is contained in an element with role button", () => {
  render(
    <Accordion>
      <Accordion.Item>
        <Accordion.Header>Personal Information</Accordion.Header>
      </Accordion.Item>
    </Accordion>
  );

  expect(
    screen.queryByRole("button", { name: "Personal Information" })
  ).toBeInTheDocument();
});

Solution

這邊用到 compound component,
想了解詳細可以看 如何製作月曆 compound components【 calendar | 我不會寫 React Component 】

注意,button 記得標注 type="button"
因為沒有標注 typebuttonform 底下會預設會是 submit

type ItemProps = {
  children?: ReactNode;
};
function Item(props: ItemProps) {
  return <>{props.children}</>;
}

type HeaderProps = {
  children?: ReactNode;
};
function Header(props: HeaderProps) {
  return <button type="button">{props.children}</button>;
}

type AccordionProps = {
  children?: ReactNode;
};
export function Accordion(props: AccordionProps) {
  return <>{props.children}</>;
}

Accordion.Item = Item;
Accordion.Header = Header;

Spec: role="heading"

describe(
  "each accordion header button is wrapped in an element with role heading" +
    "that has a value set for aria-level" +
    "that is appropriate for the information architecture of the page",
  () => {
    it(
      "if the native host language has an element with an implicit heading and aria-level, " +
        "such as an html heading tag, a native host language element may be used",
      () => {
        render(
          <Accordion>
            <Accordion.Item>
              <Accordion.Header as="h2">Personal Information</Accordion.Header>
            </Accordion.Item>
          </Accordion>
        );

        expect(
          screen.queryByRole("heading", {
            level: 2,
            name: "Personal Information",
          })
        ).toBeInTheDocument();
      }
    );

    it(
      "the button element is the only element inside the heading element. " +
        "that is, if there are other visually persistent elements, " +
        "they are not included inside the heading element",
      () => {
        render(
          <Accordion>
            <Accordion.Item>
              <Accordion.Header>Personal Information</Accordion.Header>
            </Accordion.Item>
          </Accordion>
        );

        expect(
          screen.queryByRole("heading", {
            level: 2,
            name: "Personal Information",
          })?.children
        ).toHaveLength(1);

        expect(
          screen.queryByRole("heading", {
            level: 2,
            name: "Personal Information",
          })?.children[0].tagName
        ).toMatch(/button/i);
      }
    );
  }
);

Solution

透過 PCP 讓用戶可以自行決定要用什麼元素,預設為 h2
詳細可以見 如何製作月曆 props【 calendar | 我不會寫 React Component 】

type HeaderProps = PCP<"h2", {}>;
function Header(props: HeaderProps) {
  const Comp = props.as ?? "h2";
  return (
    <Comp>
      <button type="button">{props.children}</button>
    </Comp>
  );
}

Spec: aria-expanded

當手風琴其中一個 panel 被打開時,
對應的 headerbutton 元素需要標注 aria-expanded="true"
如果 panel 隱藏,aria-expanded="false"

it(
  "if the accordion panel associated with an accordion header is visible, " +
    "the header button element has aria-expanded set to true. " +
    "if the panel is not visible, aria-expanded is set to false",
  async () => {
    user.setup();
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );

    expect(
      screen.queryByRole("button", { name: "Personal Information" })
    ).toHaveAttribute("aria-expanded", "true");

    expect(screen.queryByText("test content")).toBeInTheDocument();

    await user.click(
      screen.getByRole("button", { name: "Personal Information" })
    );

    expect(screen.queryByText("test content")).not.toBeInTheDocument();
  }
);

Solution

Context 共享同一個狀態。

interface State {
  open: boolean;
  toggle: () => void;
}
const Context = createContext<State | null>(null);

function useItemContext(error: string) {
  const context = useContext(Context);
  if (!context) {
    throw new Error(error);
  }
  return context;
}

type ItemProps = {
  children?: ReactNode;
};
function Item(props: ItemProps) {
  const [open, setOpen] = useState(true);
  const toggle = () => setOpen(!open);
  return (
    <Context.Provider value={{ open, toggle }}>
      {props.children}
    </Context.Provider>
  );
}
type HeaderProps = PCP<"h2", {}>;
function Header(props: HeaderProps) {
  const context = useItemContext(
    `<Accordion.Header /> cannot be rendered outside <Accordion />`
  );
  const Comp = props.as ?? "h2";
  return (
    <Comp>
      <button
        type="button"
        aria-expanded={context.open}
        onClick={context.toggle}
      >
        {props.children}
      </button>
    </Comp>
  );
}

type PanelProps = {
  children?: ReactNode;
};
function Panel(props: PanelProps) {
  const context = useItemContext(
    `<Accordion.Panel /> cannot be rendered outside <Accordion />`
  );
  return <div>{context.open && props.children}</div>;
}

Spec: aria-controls

手風琴的按鈕要標注 aria-controls,其數值要對應 panelid

it(
  "the accordion header button element has aria-controls " +
    "set to the id of the element containing the accordion panel content",
  () => {
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel data-testid="panel">test content</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );
    expect(
      screen.getByRole("button", { name: "Personal Information" })
    ).toHaveAttribute("aria-controls", screen.getByTestId("panel").id);
  }
);

Solution

透過 Context 共享同一個 id

interface State {
  open: boolean;
  toggle: () => void;
  id: string;
}
function Item(props: ItemProps) {
  const [open, setOpen] = useState(true);
  const toggle = () => setOpen(!open);
  const id = useId();
  return (
    <Context.Provider value={{ open, toggle, id }}>
      {props.children}
    </Context.Provider>
  );
}
function Header(props: HeaderProps) {
  const context = useItemContext(
    `<Accordion.Header /> cannot be rendered outside <Accordion />`
  );
  const Comp = props.as ?? "h2";
  return (
    <Comp>
      <button
        type="button"
        aria-expanded={context.open}
        aria-controls={context.id}
        onClick={context.toggle}
      >
        {props.children}
      </button>
    </Comp>
  );
}
type PanelProps = PCP<"div", {}>;
function Panel(props: PanelProps) {
  const context = useItemContext(
    `<Accordion.Panel /> cannot be rendered outside <Accordion />`
  );
  return (
    <div {...props} id={context.id}>
      {context.open && props.children}
    </div>
  );
}

Spec: role="region"

當前展開的 panel 需要標注 role="region"
region 用來告訴用戶這個段落有重要的內容。

it("creates a landmark region that contains the currently expanded accordion panel", () => {
  render(
    <Accordion>
      <Accordion.Item>
        <Accordion.Header>Personal Information</Accordion.Header>
        <Accordion.Panel>test content</Accordion.Panel>
      </Accordion.Item>
    </Accordion>
  );

  expect(screen.getByRole("region")).toBeInTheDocument();
});

Solution

function Panel(props: PanelProps) {
  const context = useItemContext(
    `<Accordion.Panel /> cannot be rendered outside <Accordion />`
  );
  const role = context.open ? "region" : undefined;
  return (
    <div {...props} id={context.id} role={role}>
      {context.open && props.children}
    </div>
  );
}

Spec: aria-labelledby

標注 role="region" 的元素,必須要標注 aria-labelledby
aria-labelledby 要對應 buttonid

describe('aria-labelledby="IDREF"', () => {
  it("region elements are required to have an accessible name to be identified as a landmark", () => {
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );

    expect(screen.getByRole("region")).toHaveAccessibleName(
      "Personal Information"
    );
  });

  it("references the accordion header button that expands and collapses the region", () => {
    render(
      <Accordion>
        <Accordion.Item>
          <Accordion.Header>Personal Information</Accordion.Header>
          <Accordion.Panel>test content</Accordion.Panel>
        </Accordion.Item>
      </Accordion>
    );

    expect(screen.getByRole("region")).toHaveAttribute(
      "aria-labelledby",
      screen.getByRole("button", { name: "Personal Information" }).id
    );
  });
});

Solution

調整一下共享的 id

interface State {
  open: boolean;
  toggle: () => void;
  id: {
    controls: string;
    labelledby: string;
  };
}

拆做 controlslabelledby 用的 id

function Item(props: ItemProps) {
  const [open, setOpen] = useState(true);
  const toggle = () => setOpen(!open);
  const _id = useId();
  const id = {
    controls: _id + "controls",
    labelledby: _id + "labelledby",
  };
  return (
    <Context.Provider value={{ open, toggle, id }}>
      {props.children}
    </Context.Provider>
  );
}
function Header(props: HeaderProps) {
  const context = useItemContext(
    `<Accordion.Header /> cannot be rendered outside <Accordion />`
  );
  const Comp = props.as ?? "h2";
  return (
    <Comp>
      <button
        type="button"
        id={context.id.labelledby}
        aria-expanded={context.open}
        aria-controls={context.id.controls}
        onClick={context.toggle}
      >
        {props.children}
      </button>
    </Comp>
  );
}
function Panel(props: PanelProps) {
  const context = useItemContext(
    `<Accordion.Panel /> cannot be rendered outside <Accordion />`
  );
  const role = context.open ? "region" : undefined;
  return (
    <div
      {...props}
      role={role}
      id={context.id.controls}
      aria-labelledby={context.id.labelledby}
    >
      {context.open && props.children}
    </div>
  );
}

名詞對照

中文 英文
手風琴 accordion
標頭 heading

上一篇
如何隱藏元素 【 我不會寫 React Component 】
下一篇
如何製作手風琴 accordion 2【 我不會寫 React Component 】
系列文
【 我不會寫 React Component 】30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言