From a30d0c906ecf907f6ace54f52df42ce19f36a4be Mon Sep 17 00:00:00 2001 From: Magnus Revheim Martinsen Date: Thu, 28 Sep 2023 13:12:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(ToggleGroup):=20=E2=9C=A8=20New=20`ToggleG?= =?UTF-8?q?roup`=20component=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michael Marszalek Co-authored-by: Michael Marszalek --- packages/Overview.mdx | 2 +- .../components/ToggleGroup/ToggleGroup.mdx | 35 +++++ .../ToggleGroup/ToggleGroup.module.css | 13 ++ .../ToggleGroup/ToggleGroup.stories.tsx | 117 ++++++++++++++++ .../ToggleGroup/ToggleGroup.test.tsx | 131 ++++++++++++++++++ .../components/ToggleGroup/ToggleGroup.tsx | 89 ++++++++++++ .../ToggleGroupItem.module.css | 15 ++ .../ToggleGroupItem/ToggleGroupItem.tsx | 47 +++++++ .../ToggleGroupItem/useToggleGroupitem.ts | 43 ++++++ .../react/src/components/ToggleGroup/index.ts | 17 +++ .../RovingTabIndex/RovingTabindexItem.tsx | 75 ++++++++++ .../RovingTabindexRoot.test.tsx | 83 +++++++++++ .../RovingTabIndex/RovingTabindexRoot.tsx | 113 +++++++++++++++ .../RovingTabIndex/index.ts | 7 + .../RovingTabIndex/useRovingTabindex.ts | 46 ++++++ 15 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroup.mdx create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroup.module.css create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroup.tsx create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx create mode 100644 packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts create mode 100644 packages/react/src/components/ToggleGroup/index.ts create mode 100644 packages/react/src/utility-components/RovingTabIndex/RovingTabindexItem.tsx create mode 100644 packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.test.tsx create mode 100644 packages/react/src/utility-components/RovingTabIndex/RovingTabindexRoot.tsx create mode 100644 packages/react/src/utility-components/RovingTabIndex/index.ts create mode 100644 packages/react/src/utility-components/RovingTabIndex/useRovingTabindex.ts diff --git a/packages/Overview.mdx b/packages/Overview.mdx index 591e72748e..3137f61cf9 100644 --- a/packages/Overview.mdx +++ b/packages/Overview.mdx @@ -42,7 +42,7 @@ V1 er klar når følgende komponenter er markert som "✅ Felles": | [Tag](/docs/felles-tag--docs) | ✅ Felles | [Figma - Tag](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=10185%3A59053&t=7Q2N4sUdQGhFZrPh-1) | [Github - Tag](https://github.com/digdir/designsystem/issues/322) | | [Textarea](/docs/felles-textarea--docs) | ✅ Felles | [Figma - Text area](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=6632%3A21873&t=7Q2N4sUdQGhFZrPh-1) | [Github - Textarea](https://github.com/digdir/designsystem/issues/323) | | [Textfield](/docs/felles-textfield--docs) | ✅ Felles | [Figma - Text Field](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=6632%3A22228&t=7Q2N4sUdQGhFZrPh-1) | [Github - Textfield](https://github.com/digdir/designsystem/issues/92) | -| [ToggleGroup](/docs/altinn-togglebuttongroup--docs) | 🔵 Altinn | Figma - Toggle Group | [Github - ToggleGroup](https://github.com/digdir/designsystem/issues/304) | +| [ToggleGroup](/docs/felles-togglegroup--docs) | ✅ Felles | Figma - Toggle Group | [Github - ToggleGroup](https://github.com/digdir/designsystem/issues/304) | | Tooltip | 🚸 Ikke påbegynt | Figma - Tooltip | [Github - Tooltip](https://github.com/digdir/designsystem/issues/93) | | [Typography](/docs/felles-typography--docs) | ✅ Felles | [Figma - Typography](https://www.figma.com/file/vpM9dqqQPHqU6ogfKp5tlr/Felles-komponenter?node-id=9219%3A49405&t=7Q2N4sUdQGhFZrPh-1) | [Github - Typography](https://github.com/digdir/designsystem/issues/324) | diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.mdx b/packages/react/src/components/ToggleGroup/ToggleGroup.mdx new file mode 100644 index 0000000000..c703aa60aa --- /dev/null +++ b/packages/react/src/components/ToggleGroup/ToggleGroup.mdx @@ -0,0 +1,35 @@ +import { Meta, Canvas, Story, Controls, Primary } from '@storybook/blocks'; +import { Information } from '../../../../../docs-components'; +import * as ToggleGroupStories from './ToggleGroup.stories'; + + + +# ToggleGroup + +Description of the ToggleGroup component. + + + + +## Bruk + + + +```tsx +import '@digdir/design-system-tokens/brand/altinn/tokens.css'; // Importeres kun en gang i appen din. +import { ToggleGroup } from '@digdir/design-system-react'; + + + Option 1 + Option 2 + Option 3 +; +``` + +## Only Icons + + + +## Controlled + + diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.module.css b/packages/react/src/components/ToggleGroup/ToggleGroup.module.css new file mode 100644 index 0000000000..4b87541de4 --- /dev/null +++ b/packages/react/src/components/ToggleGroup/ToggleGroup.module.css @@ -0,0 +1,13 @@ +.toggleGroupContainer { + background-color: var(--fds-semantic-background-default); + border: var(--fds-semantic-border-neutral-default) solid var(--fds-border_width-default); + border-radius: var(--fds-border_radius-medium); +} + +.groupContent { + display: inline-grid; + gap: var(--fds-spacing-1); + grid-auto-columns: 1fr; + grid-auto-flow: column; + padding: var(--fds-spacing-1); +} diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx b/packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx new file mode 100644 index 0000000000..7d41d53754 --- /dev/null +++ b/packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import * as icons from '@navikt/aksel-icons'; + +import { Button } from '../Button'; + +import { ToggleGroup } from '.'; + +const icon = ( + + + +); + +const AkselIcon = icons.AirplaneFillIcon; +const AkselIcon2 = icons.NewspaperFillIcon; +const AkselIcon3 = icons.BrailleIcon; +const AkselIcon4 = icons.BackpackFillIcon; + +export default { + title: 'Felles/ToggleGroup', + component: ToggleGroup, +} as Meta; + +export const Preview: StoryFn = (args) => { + return ( + + Peanut + Walnut + Pistachio 🤤 + + ); +}; + +Preview.args = { + defaultValue: 'Peanut', + size: 'medium', + name: 'toggle-group-nuts', +}; + +export const OnlyIcons: StoryFn = () => { + const handleChange = (value: string) => { + console.log(value); + }; + + return ( + + } + /> + } + /> + } + /> + + ); +}; + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState('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..5dff99b03e --- /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 }; 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>; + getOrderedItems: () => RovingTabindexElement[]; + setFocusableValue: (value: string) => void; + focusableValue: string | null; + onShiftTab: () => void; +}; + +export const RovingTabindexContext = createContext({ + elements: { current: new Map() }, + getOrderedItems: () => [], + setFocusableValue: () => { + /* intentionally empty */ + }, + onShiftTab: () => { + /* intentionally empty */ + }, + focusableValue: null, +}); + +export const RovingTabindexRoot: OverridableComponent< + RovingTabindexRootBaseProps, + HTMLElement +> = forwardRef( + ( + { children, valueId, as: Component = 'div', onBlur, onFocus, ...rest }, + ref, + ) => { + const [focusableValue, setFocusableValue] = useState(null); + const [isShiftTabbing, setIsShiftTabbing] = useState(false); + const elements = useRef(new Map()); + const myRef = useRef(); + + const refs = useMergeRefs([ref, myRef]); + + const getOrderedItems = (): RovingTabindexElement[] => { + if (!myRef.current) return []; + const elementsFromDOM = Array.from( + myRef.current.querySelectorAll( + '[data-roving-tabindex-item]', + ), + ); + + return Array.from(elements.current) + .sort( + (a, b) => + elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), + ) + .map(([value, element]) => ({ value, element })); + }; + + return ( + { + setIsShiftTabbing(true); + }, + }} + > + ) => { + onBlur?.(e); + setIsShiftTabbing(false); + }} + onFocus={(e: React.FocusEvent) => { + onFocus?.(e); + if (e.target !== e.currentTarget) return; + const orderedItems = getOrderedItems(); + if (orderedItems.length === 0) return; + + if (focusableValue != null) { + elements.current.get(focusableValue)?.focus(); + } else if (valueId != null) { + elements.current.get(valueId)?.focus(); + } else { + orderedItems.at(0)?.element.focus(); + } + }} + ref={refs} + > + {children} + + + ); + }, +); diff --git a/packages/react/src/utility-components/RovingTabIndex/index.ts b/packages/react/src/utility-components/RovingTabIndex/index.ts new file mode 100644 index 0000000000..d870acc7a5 --- /dev/null +++ b/packages/react/src/utility-components/RovingTabIndex/index.ts @@ -0,0 +1,7 @@ +export { RovingTabindexRoot } from './RovingTabindexRoot'; +export { useRovingTabindex } from './useRovingTabindex'; +export { + RovingTabindexItem, + getNextFocusableValue, + getPrevFocusableValue, +} from './RovingTabindexItem'; diff --git a/packages/react/src/utility-components/RovingTabIndex/useRovingTabindex.ts b/packages/react/src/utility-components/RovingTabIndex/useRovingTabindex.ts new file mode 100644 index 0000000000..6be4aa1de1 --- /dev/null +++ b/packages/react/src/utility-components/RovingTabIndex/useRovingTabindex.ts @@ -0,0 +1,46 @@ +// 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 type { HTMLAttributes } from 'react'; +import { useContext } from 'react'; + +import { RovingTabindexContext } from './RovingTabindexRoot'; + +/** Handles props for `RovingTabIndex` in context with `RovingTabIndexRoot` */ +export const useRovingTabindex = (value: string) => { + const { + elements, + getOrderedItems, + setFocusableValue, + focusableValue, + onShiftTab, + } = useContext(RovingTabindexContext); + + return { + getOrderedItems, + isFocusable: focusableValue === value, + getRovingProps: (props: HTMLAttributes) => ({ + ...props, + ref: (element: HTMLElement | null) => { + if (element) { + elements.current.set(value, element); + } else { + elements.current.delete(value); + } + }, + onKeyDown: (e: React.KeyboardEvent) => { + props?.onKeyDown?.(e); + if (e.shiftKey && e.key === 'Tab') { + onShiftTab(); + return; + } + }, + onFocus: (e: React.FocusEvent) => { + props?.onFocus?.(e); + setFocusableValue(value); + }, + ['data-roving-tabindex-item']: true, + tabIndex: focusableValue === value ? 0 : -1, + }), + }; +};