('peanut');
+ return (
+ <>
+
+
+
+
+
+ }
+ >
+ Pistachio
+
+
+ Peanut
+
+ }
+ >
+ Walnut
+
+
+
+ You have chosen: {value}
+ >
+ );
+};
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx b/packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx
new file mode 100644
index 0000000000..d06936c25c
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ToggleGroup } from '.';
+
+const user = userEvent.setup();
+
+describe('ToggleGroup', () => {
+ test('has generated name for ToggleGroupItem children', () => {
+ render(
+
+ test
+ ,
+ );
+
+ const item = screen.getByRole('radio');
+ expect(item).toHaveAttribute('name');
+ });
+
+ test('has passed name to ToggleGroupItem children', (): void => {
+ render(
+
+ test
+ ,
+ );
+
+ const item = screen.getByRole('radio');
+ expect(item.name).toEqual('my name');
+ });
+ test('has passed size to ToggleGroupItem children', (): void => {
+ render(
+
+ test
+ ,
+ );
+
+ const item = screen.getByRole('radio');
+ expect(item).toHaveClass('medium');
+ });
+ test('can navigate with tab and arrow keys', async () => {
+ render(
+
+ test
+ test2
+ test3
+ ,
+ );
+
+ const item1 = screen.getByRole('radio', {
+ name: 'test',
+ });
+ const item2 = screen.getByRole('radio', {
+ name: 'test2',
+ });
+ const item3 = screen.getByRole('radio', {
+ name: 'test3',
+ });
+ await user.tab();
+ expect(item1).toHaveFocus();
+ await user.type(item1, '{arrowright}');
+ expect(item2).toHaveFocus();
+ await user.type(item2, '{arrowright}');
+ expect(item3).toHaveFocus();
+ await user.type(item3, '{arrowleft}');
+ expect(item2).toHaveFocus();
+ });
+ test('has correct ToggleGroupItem defaultChecked & checked when defaultValue is used', () => {
+ render(
+
+ test1
+ test2
+ test3
+ ,
+ );
+
+ const item = screen.getByRole('radio', {
+ name: 'test2',
+ });
+ expect(item).toHaveAttribute('aria-checked', 'true');
+ });
+ test('has passed clicked ToggleGroupItem element to onChange', async () => {
+ let onChangeValue = '';
+
+ render(
+ (onChangeValue = value)}>
+ test1
+ test2
+ ,
+ );
+
+ const item = screen.getByRole('radio', {
+ name: 'test2',
+ });
+
+ expect(item).toHaveAttribute('aria-checked', 'false');
+
+ await user.click(item);
+
+ expect(onChangeValue).toEqual('test2');
+ expect(item).toHaveAttribute('aria-checked', 'true');
+ });
+ test('has passed clicked ToggleGroupItem element to onChange when defaultValue is used', async () => {
+ let onChangeValue = '';
+
+ render(
+ (onChangeValue = value)}
+ >
+ test1
+ test2
+ ,
+ );
+
+ const item1 = screen.getByRole('radio', {
+ name: 'test1',
+ });
+ const item2 = screen.getByRole('radio', {
+ name: 'test2',
+ });
+
+ expect(item1).toHaveAttribute('aria-checked', 'true');
+ expect(item2).toHaveAttribute('aria-checked', 'false');
+
+ await user.click(item2);
+
+ expect(onChangeValue).toEqual('test2');
+ expect(item2).toHaveAttribute('aria-checked', 'true');
+ });
+});
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.tsx b/packages/react/src/components/ToggleGroup/ToggleGroup.tsx
new file mode 100644
index 0000000000..9f01c90805
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.tsx
@@ -0,0 +1,89 @@
+import type { HTMLAttributes } from 'react';
+import React, { createContext, forwardRef, useId, useState } from 'react';
+import cn from 'classnames';
+
+import { RovingTabindexRoot } from '../../utility-components/RovingTabIndex';
+
+import classes from './ToggleGroup.module.css';
+
+export type ToggleGroupContextProps = {
+ value?: string;
+ defaultValue?: string;
+ onChange?: (value: string) => void;
+ name?: string;
+ size?: 'small' | 'medium' | 'large';
+};
+
+export const ToggleGroupContext = createContext({});
+
+export type ToggleGroupProps = {
+ /** Controlled state for `ToggleGroup` component. */
+ value?: string;
+ /** Default value. */
+ defaultValue?: string;
+ /** Callback with selected `ToggleGroupItem` `value` */
+ onChange?: (value: string) => void;
+ /** Form element name */
+ name?: string;
+ /** Changes items size and paddings */
+ size?: 'small' | 'medium' | 'large';
+} & Omit, 'value' | 'onChange'>;
+
+/** `ToggleGroup` component.
+ * @example
+ * ```tsx
+ * console.log(value)}>
+ * Toggle 1
+ * Toggle 2
+ * Toggle 3
+ *
+ * ```
+ */
+export const ToggleGroup = forwardRef(
+ (
+ { children, value, defaultValue, onChange, size = 'medium', name, ...rest },
+ ref,
+ ) => {
+ const nameId = useId();
+ const isControlled = value !== undefined;
+ const [uncontrolledValue, setUncontrolledValue] = useState<
+ string | undefined
+ >(defaultValue);
+
+ let onValueChange = onChange;
+ if (!isControlled) {
+ onValueChange = (newValue: string) => {
+ setUncontrolledValue(newValue);
+ onChange?.(newValue);
+ };
+ value = uncontrolledValue;
+ }
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ },
+);
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css
new file mode 100644
index 0000000000..ff805e0589
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css
@@ -0,0 +1,15 @@
+.toggleGroupItem.small {
+ padding: var(--fds-spacing-1) var(--fds-spacing-2);
+}
+
+.toggleGroupItem.medium {
+ padding: var(--fds-spacing-2) var(--fds-spacing-3);
+}
+
+.toggleGroupItem.large {
+ padding: var(--fds-spacing-2) var(--fds-spacing-3);
+}
+
+.toggleGroupItem:focus-visible {
+ z-index: 1;
+}
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx
new file mode 100644
index 0000000000..ced224c6d6
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx
@@ -0,0 +1,47 @@
+import type { ButtonHTMLAttributes } from 'react';
+import React, { forwardRef } from 'react';
+import cn from 'classnames';
+
+import { Button } from '../../Button';
+import { RovingTabindexItem } from '../../../utility-components/RovingTabIndex';
+
+import classes from './ToggleGroupItem.module.css';
+import { useToggleGroupItem } from './useToggleGroupitem';
+
+export type ToggleGroupItemProps = {
+ /** The value of the ToggleGroupItem. If not set, the string value of the items children will be used. */
+ value?: string;
+ /** Icon to be displayed on the ToggleGroupItem */
+ icon?: React.ReactNode;
+ /** The text to be displayed on the ToggleGroupItem */
+ children?: string;
+} & Omit, 'value' | 'children'>;
+
+export const ToggleGroupItem = forwardRef<
+ HTMLButtonElement,
+ ToggleGroupItemProps
+>((props, ref) => {
+ const { children, icon, ...rest } = props;
+ const { active, size = 'medium', buttonProps } = useToggleGroupItem(props);
+ return (
+
+ {children}
+
+ );
+});
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts b/packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts
new file mode 100644
index 0000000000..cc247c279b
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts
@@ -0,0 +1,43 @@
+import { useContext, useId } from 'react';
+
+import { ToggleGroupContext } from '../ToggleGroup';
+import type { ButtonProps } from '../../Button';
+
+import type { ToggleGroupItemProps } from './ToggleGroupItem';
+
+type UseToggleGroupItem = (props: ToggleGroupItemProps) => {
+ active: boolean;
+ size?: 'small' | 'medium' | 'large';
+ buttonProps?: Pick<
+ ButtonProps,
+ 'id' | 'onClick' | 'role' | 'aria-checked' | 'aria-current' | 'name'
+ >;
+};
+
+/** Handles props for `ToggleGroup.Item` in context with `ToggleGroup` and `RovingTabIndex` */
+export const useToggleGroupItem: UseToggleGroupItem = (
+ props: ToggleGroupItemProps,
+) => {
+ const { ...rest } = props;
+ const toggleGroup = useContext(ToggleGroupContext);
+ const itemValue =
+ props.value ?? (typeof props.children === 'string' ? props.children : '');
+ const active = toggleGroup.value == itemValue;
+ const buttonId = `togglegroup-item-${useId()}`;
+
+ return {
+ ...rest,
+ active: active,
+ size: toggleGroup?.size,
+ buttonProps: {
+ id: buttonId,
+ 'aria-checked': active,
+ 'aria-current': active,
+ role: 'radio',
+ name: toggleGroup.name,
+ onClick: () => {
+ toggleGroup.onChange?.(itemValue);
+ },
+ },
+ };
+};
diff --git a/packages/react/src/components/ToggleGroup/index.ts b/packages/react/src/components/ToggleGroup/index.ts
new file mode 100644
index 0000000000..5aedd6a78f
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/index.ts
@@ -0,0 +1,17 @@
+import { ToggleGroup as ToggleGroupRoot } from './ToggleGroup';
+import { ToggleGroupItem } from './ToggleGroupItem/ToggleGroupItem';
+
+export type { ToggleGroupProps } from './ToggleGroup';
+export type { ToggleGroupItemProps } from './ToggleGroupItem/ToggleGroupItem';
+
+type ToggleGroupComponent = typeof ToggleGroupRoot & {
+ Item: typeof ToggleGroupItem;
+};
+
+const ToggleGroup = ToggleGroupRoot as ToggleGroupComponent;
+
+ToggleGroup.Item = ToggleGroupItem;
+
+ToggleGroup.Item.displayName = 'ToggleGroup.Item';
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/packages/react/src/components/form/Checkbox/index.ts b/packages/react/src/components/form/Checkbox/index.ts
index 985e7afe60..8069d15009 100644
--- a/packages/react/src/components/form/Checkbox/index.ts
+++ b/packages/react/src/components/form/Checkbox/index.ts
@@ -26,4 +26,4 @@ Checkbox.Group.displayName = 'Checkbox.Group';
export type { CheckboxProps, CheckboxGroupProps };
-export { Checkbox };
+export { Checkbox, CheckboxGroup };
diff --git a/packages/react/src/components/form/Radio/index.ts b/packages/react/src/components/form/Radio/index.ts
index f0de6d81f7..33a118a8dc 100644
--- a/packages/react/src/components/form/Radio/index.ts
+++ b/packages/react/src/components/form/Radio/index.ts
@@ -24,4 +24,4 @@ Radio.Group.displayName = 'Radio.Group';
export type { RadioProps, RadioGroupProps };
-export { Radio };
+export { Radio, RadioGroup };
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index e34167bf29..cf1f72bbec 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -5,6 +5,7 @@ export * from './legacy/LegacyRadioButton';
export * from './legacy/LegacyRadioGroup';
export * from './legacy/LegacyTextField';
export * from './legacy/LegacyTextArea';
+export * from './legacy/LegacyToggleButtonGroup';
export * from './Button';
export * from './Tabs';
@@ -15,7 +16,6 @@ export * from './Spinner';
export * from './Link';
export * from './List';
export * from './Table';
-export * from './ToggleButtonGroup';
export * from './Typography/';
export * from './Accordion';
export * from './NativeSelect';
diff --git a/packages/react/src/components/legacy/LegacyTextField/TextField.tsx b/packages/react/src/components/legacy/LegacyTextField/TextField.tsx
index 1ec52b5b6f..4d76c23bd3 100644
--- a/packages/react/src/components/legacy/LegacyTextField/TextField.tsx
+++ b/packages/react/src/components/legacy/LegacyTextField/TextField.tsx
@@ -127,6 +127,7 @@ export const LegacyTextField = forwardRef<
values: NumberFormatValues,
sourceInfo: SourceInfo,
): void => {
+ /* eslint-disable-next-line */
if (sourceInfo.source === 'event' && onChange) {
const parsedEvent = replaceTargetValueWithUnformattedValue({
values,
diff --git a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.module.css
similarity index 100%
rename from packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css
rename to packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.module.css
diff --git a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.mdx b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.stories.mdx
similarity index 69%
rename from packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.mdx
rename to packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.stories.mdx
index 24cf054b89..a6cb625106 100644
--- a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.mdx
+++ b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.stories.mdx
@@ -1,10 +1,10 @@
import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
-import { TokensTable, Information } from '../../../../../docs-components';
-import { ToggleButtonGroup } from './ToggleButtonGroup';
+import { TokensTable, Information } from '../../../../../../docs-components';
+import { LegacyToggleButtonGroup } from '.';
export const defaultArgs = {
@@ -26,10 +26,10 @@ export const defaultArgs = {
};
export const Template = (args = {}) => (
-
+
);
-# ToggleButtonGroup
+# LegacyToggleButtonGroup
## Variants
diff --git a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.test.tsx b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.test.tsx
similarity index 90%
rename from packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.test.tsx
rename to packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.test.tsx
index 0e4d7eb6d6..ad77d7e438 100644
--- a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.test.tsx
+++ b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.test.tsx
@@ -2,25 +2,22 @@ import React from 'react';
import { render as renderRtl, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { ToggleButtonGroup } from './ToggleButtonGroup';
-import type {
- ToggleButtonGroupProps,
- ToggleButtonProps,
-} from './ToggleButtonGroup';
+import { LegacyToggleButtonGroup } from '.';
+import type { LegacyToggleButtonGroupProps, LegacyToggleButtonProps } from '.';
const user = userEvent.setup();
// Test data:
-const items: ToggleButtonProps[] = [
+const items: LegacyToggleButtonProps[] = [
{ value: 'value 1', label: 'Label 1' },
{ value: 'value 2', label: 'Label 2' },
{ value: 'value 3', label: 'Label 3' },
];
-const defaultProps: ToggleButtonGroupProps = {
+const defaultProps: LegacyToggleButtonGroupProps = {
items,
};
-describe('ToggleButtonGroup', () => {
+describe('LegacyToggleButtonGroup', () => {
it('Renders all items', () => {
render();
expect(getAllButtons()).toHaveLength(items.length);
@@ -46,7 +43,7 @@ describe('ToggleButtonGroup', () => {
const index = 1;
const value = items[index].value;
rerender(
- ,
@@ -121,9 +118,9 @@ describe('ToggleButtonGroup', () => {
};
});
-const render = (props: Partial = {}) =>
+const render = (props: Partial = {}) =>
renderRtl(
- ,
diff --git a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.tsx
similarity index 89%
rename from packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx
rename to packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.tsx
index b9016d68e5..60bf7423ec 100644
--- a/packages/react/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx
+++ b/packages/react/src/components/legacy/LegacyToggleButtonGroup/ToggleButtonGroup.tsx
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import cn from 'classnames';
-import { areItemsUnique } from '../../utils';
+import { areItemsUnique } from '../../../utils';
import classes from './ToggleButtonGroup.module.css';
-export interface ToggleButtonProps {
+export interface LegacyToggleButtonProps {
/**
* The value of the toggle button. The onChange function will be called with this value when the button is selected.
*/
@@ -18,11 +18,11 @@ export interface ToggleButtonProps {
label: ReactNode;
}
-export interface ToggleButtonGroupProps {
+export interface LegacyToggleButtonGroupProps {
/**
* The list of toggle buttons to render.
*/
- items: ToggleButtonProps[];
+ items: LegacyToggleButtonProps[];
/**
* Optional function to be called when the selected value changes.
@@ -40,11 +40,11 @@ export interface ToggleButtonGroupProps {
/**
* Component for rendering a group of toggle buttons. At any given time, only one button in the group can be selected.
*/
-export const ToggleButtonGroup = ({
+export const LegacyToggleButtonGroup = ({
onChange,
items,
selectedValue,
-}: ToggleButtonGroupProps) => {
+}: LegacyToggleButtonGroupProps) => {
const initiallySelected = selectedValue ?? items[0].value;
const [selected, setSelected] = useState(initiallySelected);
diff --git a/packages/react/src/components/legacy/LegacyToggleButtonGroup/index.ts b/packages/react/src/components/legacy/LegacyToggleButtonGroup/index.ts
new file mode 100644
index 0000000000..454d8b5b11
--- /dev/null
+++ b/packages/react/src/components/legacy/LegacyToggleButtonGroup/index.ts
@@ -0,0 +1,5 @@
+export { LegacyToggleButtonGroup } from './ToggleButtonGroup';
+export type {
+ LegacyToggleButtonGroupProps,
+ LegacyToggleButtonProps,
+} from './ToggleButtonGroup';
diff --git a/packages/react/src/utility-components/AnimateHeight/AnimateHeight.test.tsx b/packages/react/src/utility-components/AnimateHeight/AnimateHeight.test.tsx
index 4ca2cbfe32..e434cbc32f 100644
--- a/packages/react/src/utility-components/AnimateHeight/AnimateHeight.test.tsx
+++ b/packages/react/src/utility-components/AnimateHeight/AnimateHeight.test.tsx
@@ -59,7 +59,7 @@ describe('AnimateHeight', () => {
const { container, rerender } = render({ open: false });
rerender();
expect(container.firstChild).toHaveClass('openingOrClosing');
- await act(jest.runAllTimers);
+ await act(() => jest.runAllTimers);
await waitFor(() => {
expect(container.firstChild).not.toHaveClass('openingOrClosing');
});
@@ -70,7 +70,7 @@ describe('AnimateHeight', () => {
const { container, rerender } = render({ open: true });
rerender();
expect(container.firstChild).toHaveClass('openingOrClosing');
- await act(jest.runAllTimers);
+ await act(() => jest.runAllTimers);
await waitFor(() => {
expect(container.firstChild).not.toHaveClass('openingOrClosing');
});
diff --git a/packages/react/src/utility-components/RovingTabIndex/RovingTabindexItem.tsx b/packages/react/src/utility-components/RovingTabIndex/RovingTabindexItem.tsx
new file mode 100644
index 0000000000..6ba5b4d674
--- /dev/null
+++ b/packages/react/src/utility-components/RovingTabIndex/RovingTabindexItem.tsx
@@ -0,0 +1,75 @@
+// Logic from: https://www.joshuawootonn.com/react-roving-tabindex
+// Inspired by: https://github.com/radix-ui/primitives/tree/main/packages/react/roving-focus/src
+
+import React, { forwardRef } from 'react';
+import type { HTMLAttributes } from 'react';
+import { useMergeRefs } from '@floating-ui/react';
+
+import type { OverridableComponent } from '../../types/OverridableComponent';
+
+import type { RovingTabindexElement } from './RovingTabindexRoot';
+
+import { useRovingTabindex } from '.';
+
+type RovingTabindexItemProps = {
+ /** The value of the `RovingTabindexItem` used to determine which item should have focus. */
+ value?: string;
+} & HTMLAttributes;
+
+/** Get the next focusable RovingTabindexItem */
+export function getNextFocusableValue(
+ items: RovingTabindexElement[],
+ value: string,
+): RovingTabindexElement | undefined {
+ const currIndex = items.findIndex((item) => item.value === value);
+ return items.at(currIndex === items.length - 1 ? 0 : currIndex + 1);
+}
+
+/** Get the previous focusable RovingTabindexItem */
+export function getPrevFocusableValue(
+ items: RovingTabindexElement[],
+ value: string,
+): RovingTabindexElement | undefined {
+ const currIndex = items.findIndex((item) => item.value === value);
+ return items.at(currIndex === 0 ? -1 : currIndex - 1);
+}
+
+export const RovingTabindexItem: OverridableComponent<
+ RovingTabindexItemProps,
+ HTMLElement
+> = forwardRef(({ value, as: Component = 'div', ...rest }, ref) => {
+ const focusValue =
+ value ?? (typeof rest.children == 'string' ? rest.children : '');
+
+ const { getOrderedItems, getRovingProps } = useRovingTabindex(focusValue);
+
+ const rovingProps = getRovingProps({
+ onKeyDown: (e) => {
+ rest?.onKeyDown?.(e);
+ const items = getOrderedItems();
+ let nextItem: RovingTabindexElement | undefined;
+
+ if (e.key === 'ArrowRight') {
+ nextItem = getNextFocusableValue(items, focusValue);
+ }
+
+ if (e.key === 'ArrowLeft') {
+ nextItem = getPrevFocusableValue(items, focusValue);
+ }
+
+ nextItem?.element.focus();
+ },
+ });
+
+ const mergedRefs = useMergeRefs([ref, rovingProps.ref]);
+
+ return (
+
+ {rest.children}
+
+ );
+});
diff --git a/packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.test.tsx b/packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.test.tsx
new file mode 100644
index 0000000000..d4b4e12d62
--- /dev/null
+++ b/packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.test.tsx
@@ -0,0 +1,83 @@
+// tests for the RovingTabindexRoot component
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { RovingTabindexRoot } from './RovingTabindexRoot';
+import { RovingTabindexItem } from './RovingTabindexItem';
+
+const user = userEvent.setup();
+
+describe('RovingTabindexRoot', () => {
+ test('can navigate with tab and arrow keys', async () => {
+ render(
+
+ test
+ test2
+ test3
+ ,
+ );
+
+ const item1 = screen.getByText('test');
+ const item2 = screen.getByText('test2');
+ const item3 = screen.getByText('test3');
+ await user.tab();
+ expect(item1).toHaveFocus();
+ await user.type(item1, '{arrowright}');
+ expect(item2).toHaveFocus();
+ await user.type(item2, '{arrowright}');
+ expect(item3).toHaveFocus();
+ await user.type(item3, '{arrowright}');
+ expect(item1).toHaveFocus();
+ await user.type(item1, '{arrowleft}');
+ expect(item3).toHaveFocus();
+ await user.type(item3, '{arrowleft}');
+ expect(item2).toHaveFocus();
+ await user.type(item2, '{arrowleft}');
+ expect(item1).toHaveFocus();
+ });
+
+ test('can navigate with tab and arrow keys with custom value', async () => {
+ render(
+
+
+ test
+
+
+ test
+
+
+ test
+
+ ,
+ );
+
+ const item1 = screen.getByTestId('id1');
+ const item2 = screen.getByTestId('id2');
+ const item3 = screen.getByTestId('id3');
+ await user.tab();
+ expect(item1).toHaveFocus();
+ await user.type(item1, '{arrowright}');
+ expect(item2).toHaveFocus();
+ await user.type(item2, '{arrowright}');
+ expect(item3).toHaveFocus();
+ await user.type(item3, '{arrowright}');
+ expect(item1).toHaveFocus();
+ await user.type(item1, '{arrowleft}');
+ expect(item3).toHaveFocus();
+ await user.type(item3, '{arrowleft}');
+ expect(item2).toHaveFocus();
+ await user.type(item2, '{arrowleft}');
+ expect(item1).toHaveFocus();
+ });
+});
diff --git a/packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.tsx b/packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.tsx
new file mode 100644
index 0000000000..3f90fa9337
--- /dev/null
+++ b/packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.tsx
@@ -0,0 +1,113 @@
+// Logic from: https://www.joshuawootonn.com/react-roving-tabindex
+// Inspired by: https://github.com/radix-ui/primitives/tree/main/packages/react/roving-focus/src
+
+import React, { createContext, useRef, useState, forwardRef } from 'react';
+import type { MutableRefObject, ReactNode, HTMLAttributes } from 'react';
+import { useMergeRefs } from '@floating-ui/react';
+
+import type { OverridableComponent } from '../../types/OverridableComponent';
+
+type RovingTabindexRootBaseProps = {
+ /** The children of the `RovingTabindexRoot`. The children should get their roving-relevant props from the `useRovingTabIndex` hook. */
+ children: ReactNode;
+ /** The value of the element that should be focused when the `RovingTabindexRoot` receives focus. */
+ valueId?: string;
+} & HTMLAttributes;
+
+export type RovingTabindexElement = {
+ value: string;
+ element: HTMLElement;
+};
+
+export type RovingTabindexProps = {
+ elements: MutableRefObject