From 05fcd33af7829c23610760d33a5094c5cc431266 Mon Sep 17 00:00:00 2001
From: Stefan Secrieru
Date: Thu, 1 Oct 2026 17:26:36 +0300
Subject: [PATCH] feat(a11y): make segmented control component fully a11y
compliant
---
.../SegmentedControl.stories.tsx | 2 +
.../SegmentedControl.test.tsx | 216 ++++++++++++++++++
.../SegmentedControl/SegmentedControl.tsx | 92 +++++++-
.../SegmentedControl.test.tsx.snap | 9 +-
4 files changed, 312 insertions(+), 7 deletions(-)
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"
>