手風琴是一排垂直堆疊且用戶可以進行操作的標頭,
標頭內容可以包含像是標題,簡短內容,縮圖 可以用來表示其內容。
用戶可以透過標頭控制哪個段落的內容要顯示或是隱藏。
這個元件通常被使用在該頁面有多個段落,為了減少用戶需要的滑動動作之情況。
手風琴主要包含兩個部分: Header,Panel。
每個手風琴標頭都要包含一個帶有 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();
});
這邊用到 compound component,
想了解詳細可以看 如何製作月曆 compound components【 calendar | 我不會寫 React Component 】。
注意,button
記得標注 type="button"
,
因為沒有標注 type
的 button
在 form
底下會預設會是 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;
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);
}
);
}
);
透過 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>
);
}
當手風琴其中一個 panel
被打開時,
對應的 header
的 button
元素需要標注 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();
}
);
用 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>;
}
手風琴的按鈕要標注 aria-controls
,其數值要對應 panel
的 id
。
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);
}
);
透過 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>
);
}
當前展開的 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();
});
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>
);
}
標注 role="region"
的元素,必須要標注 aria-labelledby
,
aria-labelledby
要對應 button
的 id
。
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
);
});
});
調整一下共享的 id
。
interface State {
open: boolean;
toggle: () => void;
id: {
controls: string;
labelledby: string;
};
}
拆做 controls
跟 labelledby
用的 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 |