Skip to content

Commit b98bc8a

Browse files
committed
fix(accordion): inert on closed content, tests and docstrings
1 parent c7cd9eb commit b98bc8a

3 files changed

Lines changed: 57 additions & 1 deletion

File tree

packages/ui/src/components/Accordion/Accordion.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ describe("Components / Accordion", () => {
283283
const firstContent = content()[0];
284284

285285
expect(firstContent).not.toHaveStyle("transition: max-height 400ms ease-out");
286-
expect(firstContent).not.toHaveAttribute("aria-hidden", "false");
286+
expect(firstContent).not.toHaveAttribute("aria-hidden");
287287
});
288288

289289
it("should use the default `animationDuration` when `animate` is enabled and `animationDuration` is not provided", () => {
@@ -294,6 +294,46 @@ describe("Components / Accordion", () => {
294294
expect(firstContent).toHaveStyle("transition: max-height 300ms ease-out");
295295
expect(firstContent).toHaveAttribute("aria-hidden", "false");
296296
});
297+
298+
it("should set inert on closed animated content to suppress focus", () => {
299+
render(<TestAccordion animate collapseAll />);
300+
301+
const firstContent = content()[0];
302+
303+
expect(firstContent).toHaveAttribute("aria-hidden", "true");
304+
expect(firstContent).toHaveAttribute("inert");
305+
});
306+
307+
it("should set inert when closed and remove when open; focusable descendants reachable when open", async () => {
308+
const user = userEvent.setup();
309+
render(
310+
<Accordion animate>
311+
<AccordionPanel>
312+
<AccordionTitle>First</AccordionTitle>
313+
<AccordionContent>
314+
<a href="#link">Link inside</a>
315+
</AccordionContent>
316+
</AccordionPanel>
317+
<AccordionPanel>
318+
<AccordionTitle>Second</AccordionTitle>
319+
<AccordionContent>
320+
<p>Content</p>
321+
</AccordionContent>
322+
</AccordionPanel>
323+
</Accordion>,
324+
);
325+
326+
const link = screen.getByText("Link inside");
327+
const firstTitle = screen.getByRole("button", { name: "First" });
328+
329+
await user.click(screen.getByRole("button", { name: "Second" }));
330+
expect(content()[0]).toHaveAttribute("inert");
331+
332+
await user.click(firstTitle);
333+
expect(content()[0]).not.toHaveAttribute("inert");
334+
await user.tab();
335+
expect(link).toHaveFocus();
336+
});
297337
});
298338
});
299339

packages/ui/src/components/Accordion/Accordion.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export interface AccordionRootTheme {
2727

2828
export interface AccordionProps extends ComponentProps<"div">, ThemingProps<AccordionRootTheme> {
2929
alwaysOpen?: boolean;
30+
/** Enable smooth open/close animation for panel content. */
3031
animate?: boolean;
32+
/** Duration of the open/close animation in milliseconds. Only used when `animate` is true. Defaults to 300. */
3133
animationDuration?: number;
3234
arrowIcon?: FC<ComponentProps<"svg">>;
3335
children: ReactElement<AccordionPanelProps> | ReactElement<AccordionPanelProps>[];

packages/ui/src/components/Accordion/AccordionContent.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface AccordionContentProps extends ComponentProps<"div">, ThemingPro
2020
export function AccordionContent(props: AccordionContentProps) {
2121
const { isOpen, animate = false, animationDuration = 300 } = useAccordionContext();
2222
const contentRef = useRef<HTMLDivElement>(null);
23+
const wrapperRef = useRef<HTMLDivElement>(null);
2324
const [height, setHeight] = useState<number>(0);
2425

2526
useLayoutEffect(() => {
@@ -28,6 +29,18 @@ export function AccordionContent(props: AccordionContentProps) {
2829
if (el) setHeight(el.scrollHeight);
2930
}, [animate, isOpen]);
3031

32+
/** When closed, set inert on the wrapper so descendants are not focusable. */
33+
useLayoutEffect(() => {
34+
if (!animate) return;
35+
const wrapper = wrapperRef.current;
36+
if (!wrapper) return;
37+
if (isOpen) {
38+
wrapper.removeAttribute("inert");
39+
} else {
40+
wrapper.setAttribute("inert", "");
41+
}
42+
}, [animate, isOpen]);
43+
3144
const provider = useThemeProvider();
3245
const theme = useResolveTheme(
3346
[accordionTheme.content, provider.theme?.accordion?.content, props.theme],
@@ -50,6 +63,7 @@ export function AccordionContent(props: AccordionContentProps) {
5063
/>
5164
) : (
5265
<div
66+
ref={wrapperRef}
5367
data-testid="flowbite-accordion-content"
5468
aria-hidden={!isOpen}
5569
style={{

0 commit comments

Comments
 (0)