diff --git a/src/components/SegmentedControl/SegmentedControl.stories.tsx b/src/components/SegmentedControl/SegmentedControl.stories.tsx index b4f47128c..f0720f2cb 100644 --- a/src/components/SegmentedControl/SegmentedControl.stories.tsx +++ b/src/components/SegmentedControl/SegmentedControl.stories.tsx @@ -46,6 +46,7 @@ export const Default: Story = { ), }, ], + controlLabel: "Segmented Control", }, }; @@ -88,6 +89,7 @@ export const Dense: Story = { ), }, ], + controlLabel: "Segmented Control", }, }; /** diff --git a/src/components/SegmentedControl/SegmentedControl.test.tsx b/src/components/SegmentedControl/SegmentedControl.test.tsx index efee46565..ecdf40d99 100644 --- a/src/components/SegmentedControl/SegmentedControl.test.tsx +++ b/src/components/SegmentedControl/SegmentedControl.test.tsx @@ -61,4 +61,220 @@ describe("SegmentedControl", () => { "content2", ); }); + + it("uses a roving tab index", () => { + render( + content1

, + }, + { + label: "label2", + content:

content2

, + }, + { + label: "label3", + content:

content3

, + }, + ]} + />, + ); + + expect(screen.getByRole("tab", { name: "label1" })).toHaveAttribute( + "tabindex", + "0", + ); + expect(screen.getByRole("tab", { name: "label2" })).toHaveAttribute( + "tabindex", + "-1", + ); + expect(screen.getByRole("tab", { name: "label3" })).toHaveAttribute( + "tabindex", + "-1", + ); + }); + + it("moves focus and selection with arrow keys", async () => { + const user = userEvent.setup(); + + render( + content1

, + }, + { + label: "label2", + content:

content2

, + }, + { + label: "label3", + content:

content3

, + }, + ]} + />, + ); + + const firstTab = screen.getByRole("tab", { name: "label1" }); + const secondTab = screen.getByRole("tab", { name: "label2" }); + const thirdTab = screen.getByRole("tab", { name: "label3" }); + + firstTab.focus(); + await user.keyboard("{ArrowRight}"); + + expect(secondTab).toHaveFocus(); + expect(secondTab).toHaveAttribute("aria-selected", "true"); + expect(secondTab).toHaveAttribute("tabindex", "0"); + expect(firstTab).toHaveAttribute("tabindex", "-1"); + expect(screen.getByRole("tabpanel", { name: "label2" })).toHaveTextContent( + "content2", + ); + + await user.keyboard("{ArrowLeft}"); + + expect(firstTab).toHaveFocus(); + expect(firstTab).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("{ArrowLeft}"); + + expect(thirdTab).toHaveFocus(); + expect(thirdTab).toHaveAttribute("aria-selected", "true"); + }); + + it("moves focus to first and last segment with home and end keys", async () => { + const user = userEvent.setup(); + + render( + content1

, + }, + { + label: "label2", + content:

content2

, + }, + { + label: "label3", + content:

content3

, + }, + ]} + />, + ); + + const firstTab = screen.getByRole("tab", { name: "label1" }); + const thirdTab = screen.getByRole("tab", { name: "label3" }); + + firstTab.focus(); + await user.keyboard("{ArrowRight}"); + await user.keyboard("{End}"); + + expect(thirdTab).toHaveFocus(); + expect(thirdTab).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("{Home}"); + + expect(firstTab).toHaveFocus(); + expect(firstTab).toHaveAttribute("aria-selected", "true"); + }); + + it("moves focus into the active tabpanel on tab", async () => { + const user = userEvent.setup(); + + render( + + + + ), + }, + { + label: "label2", + content: ( +
+ +
+ ), + }, + ]} + />, + ); + + const firstTab = screen.getByRole("tab", { name: "label1" }); + + firstTab.focus(); + await user.keyboard("{ArrowRight}"); + + await user.keyboard("{Tab}"); + + expect(screen.getByRole("tabpanel", { name: "label2" })).toHaveFocus(); + }); + + it("allows tabbing from the active tabpanel into its interactive content", async () => { + const user = userEvent.setup(); + + render( + + + + ), + }, + { + label: "label2", + content: ( +
+ +
+ ), + }, + ]} + />, + ); + + const firstTab = screen.getByRole("tab", { name: "label1" }); + + firstTab.focus(); + await user.keyboard("{ArrowRight}"); + await user.keyboard("{Tab}"); + await user.keyboard("{Tab}"); + + expect( + screen.getByRole("button", { name: "panel action two" }), + ).toHaveFocus(); + }); + + it("focuses the tabpanel when tabbing from a segment", async () => { + const user = userEvent.setup(); + + render( + content1

, + }, + ]} + />, + ); + + const tab = screen.getByRole("tab", { name: "label1" }); + tab.focus(); + + await user.keyboard("{Tab}"); + + expect(screen.getByRole("tabpanel", { name: "label1" })).toHaveFocus(); + }); }); diff --git a/src/components/SegmentedControl/SegmentedControl.tsx b/src/components/SegmentedControl/SegmentedControl.tsx index 59c531406..864712126 100644 --- a/src/components/SegmentedControl/SegmentedControl.tsx +++ b/src/components/SegmentedControl/SegmentedControl.tsx @@ -1,6 +1,12 @@ import classNames from "classnames"; import Icon, { ICONS } from "components/Icon"; -import React, { ReactNode, useState } from "react"; +import React, { + KeyboardEvent, + ReactNode, + useId, + useRef, + useState, +} from "react"; import type { ClassName, ValueOf } from "types"; export type Segments = { @@ -23,11 +29,16 @@ export type Props = { * Optional classes applied to the parent element. */ className?: ClassName; + /** + * Label for the segmented control for accessibility purposes. + */ + controlLabel?: string; /** * List of segments present in the element. */ segments: Segments[]; }; + /** * This is the [React](https://reactjs.org/) component for Vanilla [SegmentedControl](https://vanillaframework.io/docs/patterns/segmented-control). SegmentedControl organises and allows navigation between groups of content that are related and at the same level @@ -36,20 +47,85 @@ of hierarchy. const SegmentedControl = ({ className, segments, + controlLabel = "Segmented Control", }: Props): React.JSX.Element => { const [activeIndex, setActiveIndex] = useState(0); + const tabRefs = useRef>([]); + const panelRef = useRef(null); + const id = useId(); + + const focusSegment = (index: number) => { + setActiveIndex(index); + tabRefs.current[index]?.focus(); + }; + + const focusPanelContent = () => { + const panel = panelRef.current; + if (!panel) { + return; + } + + panel.focus(); + }; + + const handleSegmentKeyDown = ( + event: KeyboardEvent, + index: number, + ) => { + switch (event.key) { + case "ArrowRight": + event.preventDefault(); + focusSegment((index + 1) % segments.length); + break; + case "ArrowLeft": + event.preventDefault(); + focusSegment((index - 1 + segments.length) % segments.length); + break; + case "Home": + event.preventDefault(); + focusSegment(0); + break; + case "End": + event.preventDefault(); + focusSegment(segments.length - 1); + break; + case "Tab": + if (!event.shiftKey) { + event.preventDefault(); + focusPanelContent(); + } + break; + default: + break; + } + }; + + const activeSegment = segments[activeIndex]; + const panelId = `${id}-panel`; + return (
-
+
{segments.map((segment, i) => { + const tabId = `${id}-tab-${i}`; return (
-
- {segments[activeIndex].content} +
+ {activeSegment.content}
); diff --git a/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap b/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap index d508047b3..7ce81fc4d 100644 --- a/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap +++ b/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap @@ -6,21 +6,26 @@ exports[`SegmentedControl renders 1`] = ` class="p-segmented-control" >

content1