Compare commits

...
Author SHA1 Message Date
Ken d97d72a76e 🎉 Add submenu closing 2026-06-01 11:45:05 +02:00
Ken e56d4a98d3 🚚 Rename utility 2026-06-01 11:22:01 +02:00
Ken bd070ae7f5 ♻️ update selector 2026-06-01 10:41:17 +02:00
Ken ec03db9c34 🐛 Rename story 2026-06-01 09:47:52 +02:00
Ken b17104b58e 🎉 Use data- attrb 2026-06-01 09:47:03 +02:00
Ken c8c41fd619 ♻️ Refactor togglegroup descendant use 2026-06-01 08:55:29 +02:00
Ken f28e57d383 🔥 Avoid using descendant context 2026-05-31 20:40:45 +02:00
Ken b50aa783e5 🐛 Properly rename file 2026-05-31 11:16:37 +02:00
Ken e48430d8c7 🎉 Refactor chips 2026-05-31 11:16:25 +02:00
Ken dd9a271a66 🐛 Add note 2026-05-31 11:06:07 +02:00
Ken 6db29cf2bd 🐛 Update skill 2026-05-31 11:05:14 +02:00
Ken 8e061d19be 🎉 Created skill for refactor 2026-05-31 10:59:52 +02:00
18 changed files with 754 additions and 368 deletions

No files matched your search

@@ -0,0 +1,221 @@
---
name: ds-component-restructure
description: "Restructure a ds-react component to follow canonical folder/file conventions. Use when: restructuring a component, reorganizing component files, migrating component structure, refactoring component layout, splitting monolithic component files, renaming component files to match domain."
argument-hint: "Path to component folder, e.g. @navikt/core/react/src/accordion"
---
# ds-component-restructure
Restructure a `ds-react` component folder to follow canonical conventions — without breaking the public API.
## Canonical Structure
```
<component>/
├── index.ts # "use client" + re-exports from root
├── <Component>.stories.tsx # Storybook stories
├── <Component>.tests.stories.tsx # Storybook play-tests (only if play tests exist)
├── root/
│ ├── <Component>Root.tsx # Main compound component + sub-component namespace
│ ├── <Component>Root.context.ts # Context (if shared across sub-components)
│ └── <Component>Root.test.ts # Unit tests for root (if needed)
├── <subcomponent>/ # One dir per sub-component
│ ├── <Component><Subcomponent>.tsx
│ └── <Component><Subcomponent>.test.ts # Unit tests for sub-component (if needed)
├── helpers/
│ ├── <helper-name>.ts
│ └── <helper-name>.test.ts # Unit tests co-located with helper
└── <Component>.types.ts # Shared types (if needed across multiple files)
```
### Naming Rules
| What | Pattern | Example |
| ---------------------- | --------------------------------------- | ----------------------------- |
| Root component file | `<Component>Root.tsx` | `AccordionRoot.tsx` |
| Sub-component file | `<Component><Subcomponent>.tsx` | `AccordionItem.tsx` |
| Internal sub-component | `<Component><Subcomponent>Internal.tsx` | `DialogBackdropInternal.tsx` |
| Sub-component dir | lowercase sub-component name | `item/` |
| Context file | `<Component>Root.context.ts` | `AccordionRoot.context.ts` |
| Shared types file | `<Component>.types.ts` | `Accordion.types.ts` |
| Stories | `<Component>.stories.tsx` | `Accordion.stories.tsx` |
| Play-test stories | `<Component>.tests.stories.tsx` | `Accordion.tests.stories.tsx` |
| Unit test file | `<filename>.test.ts(x)` | `useAccordion.test.ts` |
| `index.ts` | always `index.ts` | `index.ts` |
## Procedure
### 1. Read the Component
Read every file in the component folder:
- `index.ts` — current exports
- All `.tsx` / `.ts` source files
- Stories and test stories
- Note what is currently exported publicly
- Note: Ignore inconsistensies in file naming and structure — the goal is to fix those, not preserve them. Focus on understanding the component's public API and internal structure.
### 2. Map Files to Target Structure
Build this table before touching any files:
| Old path | New path | Renamed? | Publicly exported? | Importers to update |
| ------------------- | --------------------------- | -------- | ------------------ | --------------------- |
| `DialogTrigger.tsx` | `trigger/DialogTrigger.tsx` | No | Yes | `root/DialogRoot.tsx` |
| ... | | | | |
For each file:
- Determine new path and whether it moves/renames
- Check whether content needs splitting (e.g. types → `<Component>.types.ts`)
- List every file that imports it (grep for the filename or exported symbol)
- Flag circular dep risks: context importing sub-components, helpers importing component files, types importing non-types
Resolve circular deps before proceeding: move shared pieces to a lower-level file (types → `.types.ts`, utilities → `helpers/`).
### 3. Create New Files
For each row in the mapping table where the path changes:
1. Create the file at the new path with all import paths updated to reflect new locations
2. Update every file in the "Importers to update" column — use the mapping table, don't rely on memory
3. Do NOT delete old files yet
### 4. Update `index.ts`
The repo uses a mix of default and named exports because `react-docgen-typescript` (used by `scripts/docgen.ts`) has inconsistent pickup depending on export style. Follow the existing pattern in the component's original `index.ts` — do NOT change export style during restructure.
Typical pattern (main component as default re-export, sub-components as named):
```ts
"use client";
export {
default as <Component>, // main component — default export from Root file
<Component>Trigger, // sub-components — named exports
// ...
} from "./root/<Component>Root";
export type {
<Component>Props,
<Component>TriggerProps,
// ...
} from "./root/<Component>Root";
```
Some components export everything as named (no `default`). If the original had no default export, keep it that way. If types are in `<Component>.types.ts`, re-export them from there directly or via the root.
### 5. Remove Old Files
Only after verifying new files are correct and `index.ts` exports match the original, delete the old files.
### 6. Validate No Breaking Changes
**Step 1 — Export diff.** Before deleting old files, extract all exported names from the original `index.ts` (recorded in step 1) and compare against the new `index.ts`. Every name must be present:
```bash
# Exports in new index.ts that are missing from original → regression
# Exports in original that are missing from new index.ts → missing export (breaking change)
```
Concretely: grep for `export` lines in both versions and diff them. All component names and type names must match exactly.
**Step 2 — Build.** Run `yarn workspace @navikt/ds-react build` — must succeed with no TypeScript errors.
**Step 3 — Package root.** Verify `@navikt/core/react/src/index.ts` still re-exports the component. No changes needed there unless the component's `index.ts` path changed (it shouldn't).
### 7. Update Stories
Move stories to component root dir. If stories import from internal paths, update those paths.
**Exception:** A `stories/` subdirectory is acceptable if it already exists — do not flatten it.
## Unit Tests
Unit tests live next to the file they test — same directory, same base name with `.test.ts` / `.test.tsx` suffix.
| What to test | File location |
| ---------------------- | --------------------------------------------------- |
| Helper utility | `helpers/<helper-name>.test.ts` |
| Sub-component logic | `<subcomponent>/<Component><Subcomponent>.test.tsx` |
| Root component / hooks | `root/<Component>Root.test.tsx` |
| Context helpers | `root/<Component>Root.context.test.ts` |
Do NOT move existing test files unless their source file moves. When moving a source file, move its test file with it and update import paths.
## Edge Cases
### Flat components
Leave flat components flat — do NOT introduce subdirs unless the component contains internal utility-components (files that render JSX but are not public sub-components). If such a utility-component exists, move it to its own subdir following the canonical naming.
Example: `button/Button.tsx` has no other sub-components, so should stay in root.
```
### `parts/` directory
If the component uses a `parts/` dir instead of `root/`, rename it to `root/` during restructure. Update all relative imports.
### Public hooks
Hooks that are part of the public API (e.g. `useTabs`, `useFormField`) live in `root/` alongside the root component:
```
root/
├── <Component>Root.tsx
├── <Component>Root.context.ts
└── use<Hook>.ts # public hook
````
Export from `index.ts` the same way as components:
```ts
export { use<Hook> } from "./root/use<Hook>";
export type { Use<Hook>Return } from "./root/use<Hook>";
````
### Internal sub-components
Sub-components that are implementation details (not exported publicly) follow the same dir convention as public ones, but the filename gets an `Internal` suffix:
```
backdrop/
└── DialogBackdropInternal.tsx ← internal, NOT exported from index.ts
```
- **Dir name**: same pattern as public sub-components
- **File name**: `<Component><Subcomponent>Internal.tsx`
- **`index.ts`**: do NOT export it
### Nested subcomponents
Do NOT nest subcomponent dirs inside other subcomponent dirs. All sub-component dirs must be direct children of the component root:
```
# Wrong
checkbox/
└── input/
└── CheckboxInput.tsx
# Correct
checkbox/
└── checkbox-input/
└── CheckboxInput.tsx
```
## Constraints
- **No breaking changes.** Public export names and prop shapes must be identical after restructuring.
- **React 17 compatible.** No React 18/19-only APIs. Import React explicitly in `.tsx` files.
- `index.ts` must start with `"use client"`.
- Use tokens, not hardcoded values.
- Preserve `forwardRef`, `className`, `...rest`, `as`/`OverridableComponent` patterns.
- Keep JSDoc on public props and components.
## Reference Files
- Canonical example: `@navikt/core/react/src/dialog/`
- Package public API: `@navikt/core/react/src/index.ts`
- Component `index.ts` pattern: `@navikt/core/react/src/dialog/index.ts`
- Context pattern: `@navikt/core/react/src/dialog/root/DialogRoot.context.ts`
@@ -0,0 +1,205 @@
import { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { Chips } from ".";
import { VStack } from "../primitives/stack";
import { renderStoriesForChromatic } from "../utils/renderStoriesForChromatic";
const meta: Meta<typeof Chips> = {
title: "ds-react/Chips",
component: Chips,
parameters: {
chromatic: { disable: true },
},
};
export default meta;
const options = ["Norsk", "Dansk", "Svensk", "Tysk", "Spansk"];
export const Default = (props) => {
const [selected, setSelected] = useState(["Dansk", "Svensk"]);
const [filter, setFilter] = useState(options);
if (props.type === "toggle") {
return (
<Chips size={props.size ?? "medium"}>
{options.map((c) => (
<Chips.Toggle
selected={selected.includes(c)}
key={c}
onClick={() =>
setSelected(
selected.includes(c)
? selected.filter((x) => x !== c)
: [...selected, c],
)
}
>
{c}
</Chips.Toggle>
))}
</Chips>
);
}
return (
<Chips size={props.size ?? "medium"}>
{filter.map((c) => (
<Chips.Removable
key={c}
onClick={() => setFilter((x) => x.filter((y) => y !== c))}
>
{c}
</Chips.Removable>
))}
</Chips>
);
};
Default.args = {
type: "toggle",
};
Default.argTypes = {
type: {
control: { type: "radio" },
options: ["toggle", "removable"],
},
size: {
control: { type: "radio" },
options: ["medium", "small"],
},
};
export const Toggle = () => {
const [selected, setSelected] = useState<number[]>([2, 4]);
return (
<Chips>
{options.map((c, y) => (
<Chips.Toggle
selected={selected.includes(y)}
onClick={() =>
setSelected(
selected.includes(y)
? selected.filter((x) => x !== y)
: [...selected, y],
)
}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
);
};
export const ToggleNoCheckmark = () => {
const [selected, setSelected] = useState<number>(2);
return (
<Chips>
{options.map((c, y) => (
<Chips.Toggle
selected={selected === y}
checkmark={false}
onClick={() => setSelected(y)}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
);
};
export const Removable = () => {
return (
<Chips>
{options.map((c, y) => (
<Chips.Removable key={y}>{c}</Chips.Removable>
))}
</Chips>
);
};
export const Medium = (props: any) => {
const [selected, setSelected] = useState<number[]>([2]);
return (
<VStack gap="space-16">
<Chips>
{options.map((c, y) => (
<Chips.Removable key={y} {...props}>
{c}
</Chips.Removable>
))}
</Chips>
<Chips>
{options.map((c, y) => (
<Chips.Toggle
{...props}
selected={selected.includes(y)}
onClick={() =>
setSelected(
selected.includes(y)
? selected.filter((x) => x !== y)
: [...selected, y],
)
}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
</VStack>
);
};
export const Small = () => {
const [selected, setSelected] = useState<number[]>([2, 4]);
return (
<div className="colgap">
<Chips size="small">
{options.map((c, y) => (
<Chips.Removable key={y}>{c}</Chips.Removable>
))}
</Chips>
<Chips size="small">
{options.map((c, y) => (
<Chips.Toggle
selected={selected.includes(y)}
onClick={() =>
setSelected(
selected.includes(y)
? selected.filter((x) => x !== y)
: [...selected, y],
)
}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
</div>
);
};
export const ColorRole = () => (
<VStack gap="space-24">
<div>
<Medium />
</div>
<div data-color="brand-magenta">
<Medium />
</div>
<div>
<Medium data-color="brand-beige" />
</div>
</VStack>
);
export const Chromatic = renderStoriesForChromatic({
Medium,
Small,
ColorRole,
ToggleNoCheckmark,
});
+6 -2
View File
@@ -1,7 +1,11 @@
"use client";
export { default as Chips, ChipsRemovable, ChipsToggle } from "./Chips";
export {
default as Chips,
ChipsRemovable,
ChipsToggle,
} from "./root/ChipsRoot";
export type {
ChipsProps,
ChipsRemovableProps,
ChipsToggleProps,
} from "./Chips";
} from "./root/ChipsRoot";
@@ -1,8 +1,8 @@
import React, { forwardRef } from "react";
import { XMarkIcon } from "@navikt/aksel-icons";
import { AkselColor } from "../types";
import { cl, composeEventHandlers } from "../utils/helpers";
import { useI18n } from "../utils/i18n/i18n.hooks";
import { AkselColor } from "../../types";
import { cl, composeEventHandlers } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
@@ -1,7 +1,9 @@
import React, { HTMLAttributes, forwardRef } from "react";
import { cl } from "../utils/helpers";
import RemovableChips, { ChipsRemovableProps } from "./Removable";
import ToggleChips, { ChipsToggleProps } from "./Toggle";
import { cl } from "../../utils/helpers";
import RemovableChips, {
ChipsRemovableProps,
} from "../removable/ChipsRemovable";
import ToggleChips, { ChipsToggleProps } from "../toggle/ChipsToggle";
interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
children: React.ReactNode;
@@ -1,7 +1,7 @@
import React, { forwardRef } from "react";
import { AkselColor } from "../types";
import type { OverridableComponent } from "../utils-external";
import { cl } from "../utils/helpers";
import { AkselColor } from "../../types";
import type { OverridableComponent } from "../../utils-external";
import { cl } from "../../utils/helpers";
export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
@@ -1,15 +1,7 @@
import { createStrictContext } from "../utils/helpers";
import { createDescendantContext } from "../utils/hooks";
import { TabsProps } from "./Tabs.types";
import { useTabs } from "./useTabs";
export const [
TabsDescendantsProvider,
useTabsDescendantsContext,
useTabsDescendants,
useTabsDescendant,
] = createDescendantContext<HTMLButtonElement, { value: string }>();
type TabsProviderProps = ReturnType<typeof useTabs> &
Pick<
TabsProps,
+11 -19
View File
@@ -1,10 +1,6 @@
import React, { forwardRef } from "react";
import { cl } from "../utils/helpers";
import {
TabsDescendantsProvider,
TabsProvider,
useTabsDescendants,
} from "./Tabs.context";
import { TabsProvider } from "./Tabs.context";
import { TabsProps } from "./Tabs.types";
import Tab, { TabProps } from "./parts/tab/Tab";
import TabList, { TabListProps } from "./parts/tablist/TabList";
@@ -73,8 +69,6 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
},
ref,
) => {
const descendants = useTabsDescendants();
const tabsContext = useTabs({ defaultValue, value, onChange, id });
/**
@@ -90,18 +84,16 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
};
return (
<TabsDescendantsProvider value={descendants}>
<TabsProvider {...context}>
<div
ref={ref}
{...rest}
id={id}
className={cl("aksel-tabs", className, `aksel-tabs--${size}`)}
>
{children}
</div>
</TabsProvider>
</TabsDescendantsProvider>
<TabsProvider {...context}>
<div
ref={ref}
{...rest}
id={id}
className={cl("aksel-tabs", className, `aksel-tabs--${size}`)}
>
{children}
</div>
</TabsProvider>
);
},
) as TabsComponent;
+13 -11
View File
@@ -43,11 +43,20 @@ export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
onFocus,
disabled,
id,
"aria-controls": ariaControls,
...rest
},
ref: React.ForwardedRef<HTMLButtonElement>,
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) => {
const tabCtx = useTab({ value, onClick, onFocus, disabled }, ref);
const tabProps = useTab({
id,
value,
onClick,
onFocus,
disabled,
ariaControls,
});
const ctx = useTabsContext();
if (!label && !icon) {
@@ -57,7 +66,7 @@ export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
return (
<Component
ref={tabCtx.ref}
ref={forwardedRef}
{...rest}
className={cl(
"aksel-tabs__tab",
@@ -69,15 +78,8 @@ export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
"aksel-tabs__tab--fill": ctx.fill,
},
)}
role="tab"
type="button"
aria-selected={tabCtx.isSelected}
data-state={tabCtx.isSelected ? "active" : "inactive"}
tabIndex={tabCtx.isFocused ? 0 : -1}
aria-controls={rest["aria-controls"] ?? tabCtx.controlsId}
id={id ?? tabCtx.id}
onFocus={tabCtx.onFocus}
onClick={tabCtx.onClick}
{...tabProps}
>
<BodyShort
as="span"
+23 -19
View File
@@ -1,8 +1,8 @@
import { composeEventHandlers } from "../../../utils/helpers";
import { useMergeRefs } from "../../../utils/hooks";
import { useTabsContext, useTabsDescendant } from "../../Tabs.context";
import { useTabsContext } from "../../Tabs.context";
export interface UseTabProps {
id?: string;
/**
* If `true`, the `Tab` won't be toggleable
* @default false
@@ -11,14 +11,19 @@ export interface UseTabProps {
onClick?: React.MouseEventHandler;
onFocus?: React.FocusEventHandler;
value: string;
ariaControls?: string;
}
export function useTab<P extends UseTabProps>(
{ value, disabled = false, onFocus: _onFocus, onClick }: P,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
function useTab({
id,
value,
disabled = false,
onFocus: _onFocus,
onClick,
ariaControls,
}: UseTabProps) {
const {
id,
id: contextId,
setSelectedValue,
selectionFollowsFocus,
focusedValue,
@@ -28,11 +33,6 @@ export function useTab<P extends UseTabProps>(
makeTabPanelId,
} = useTabsContext();
const { register } = useTabsDescendant({
disabled,
value,
});
const isSelected = value === selectedValue;
const onFocus = () => {
@@ -40,15 +40,19 @@ export function useTab<P extends UseTabProps>(
selectionFollowsFocus && setSelectedValue(value);
};
const refs = useMergeRefs(register, ref);
return {
ref: refs,
isSelected,
isFocused: focusedValue === value,
id: makeTabId(id, value),
controlsId: makeTabPanelId(id, value),
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
id: id ?? makeTabId(contextId, value),
"aria-controls": ariaControls ?? makeTabPanelId(contextId, value),
tabIndex: focusedValue === value ? 0 : -1,
"aria-selected": isSelected,
"data-state": isSelected ? "active" : "inactive",
role: "tab",
"data-aksel-tab": "",
disabled,
"data-disabled": disabled ? "" : undefined,
};
}
export { useTab };
@@ -1,51 +1,33 @@
import { useCallback } from "react";
import { useTabsContext, useTabsDescendantsContext } from "../../Tabs.context";
import { ownerDocument } from "../../../utils/helpers";
import { rowingFocus } from "../../../utils/helpers/rowing-focus";
import { useTabsContext } from "../../Tabs.context";
const TAB_SELECTOR = "[data-aksel-tab]:not([data-disabled])";
/**
* TabList hook to manage multiple tab buttons,
* and ensures only one tab is selected at a time.
*/
export function useTabList() {
const { focusedValue, loop, selectedValue, setFocusedValue } =
useTabsContext();
const descendants = useTabsDescendantsContext();
const { loop, selectedValue, setFocusedValue } = useTabsContext();
/**
* Implements rowing-tabindex for horizontal tabs
* Implements roving-tabindex for horizontal tabs.
*/
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
/**
* Tabs.Tab is registered with its prop 'value'.
* We can then use it to find the current focuses descendant
*/
const idx = descendants
.values()
.findIndex((x) => x.value === focusedValue);
const container = event.currentTarget as HTMLElement;
const current = ownerDocument(container)
.activeElement as HTMLElement | null;
const nextTab = () => {
const next = descendants.nextEnabled(idx, loop);
next?.node?.focus();
};
const prevTab = () => {
const prev = descendants.prevEnabled(idx, loop);
prev?.node?.focus();
};
const firstTab = () => {
const first = descendants.firstEnabled();
first?.node?.focus();
};
const lastTab = () => {
const last = descendants.lastEnabled();
last?.node?.focus();
};
const keyMap: Record<string, React.KeyboardEventHandler> = {
ArrowLeft: prevTab,
ArrowRight: nextTab,
Home: firstTab,
End: lastTab,
const keyMap: Record<string, () => void> = {
ArrowLeft: () =>
rowingFocus(TAB_SELECTOR, container, "prev", current, loop),
ArrowRight: () =>
rowingFocus(TAB_SELECTOR, container, "next", current, loop),
Home: () => rowingFocus(TAB_SELECTOR, container, "first"),
End: () => rowingFocus(TAB_SELECTOR, container, "last"),
};
const hasModifiers =
@@ -55,7 +37,7 @@ export function useTabList() {
if (action && !hasModifiers) {
event.preventDefault();
action(event);
action();
} else if (event.key === "Tab") {
/**
* Imperative focus during keydown is risky so we prevent React's batching updates
@@ -64,7 +46,7 @@ export function useTabList() {
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
}
},
[descendants, focusedValue, loop, selectedValue, setFocusedValue],
[loop, selectedValue, setFocusedValue],
);
return { onKeyDown };
@@ -1,6 +1,5 @@
import { createContext as ReactCreateContext } from "react";
import { createStrictContext } from "../utils/helpers";
import { createDescendantContext } from "../utils/hooks";
import { ToggleGroupProps } from "./ToggleGroup.types";
import { useToggleGroup } from "./useToggleGroup";
@@ -11,14 +10,6 @@ interface ToggleContextProps {
export const ToggleGroupContext = ReactCreateContext<ToggleContextProps | null>(
null,
);
export const [
ToggleGroupDescendantsProvider,
useToggleGroupDescendantsContext,
useToggleGroupDescendants,
useToggleGroupDescendant,
] = createDescendantContext<HTMLButtonElement, { value: string }>();
type ToggleGroupProviderProps = ReturnType<typeof useToggleGroup> &
Pick<ToggleGroupProps, "size">;
@@ -3,11 +3,7 @@ import { AkselColor } from "../types";
import { Label } from "../typography";
import { useId } from "../utils-external";
import { cl } from "../utils/helpers";
import {
ToggleGroupDescendantsProvider,
ToggleGroupProvider,
useToggleGroupDescendants,
} from "./ToggleGroup.context";
import { ToggleGroupProvider } from "./ToggleGroup.context";
import { ToggleGroupProps } from "./ToggleGroup.types";
import ToggleItem, { ToggleGroupItemProps } from "./parts/ToggleItem";
import { useToggleGroup } from "./useToggleGroup";
@@ -53,8 +49,6 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
},
ref,
) => {
const descendants = useToggleGroupDescendants();
const toggleGroupContext = useToggleGroup({
defaultValue,
value,
@@ -80,39 +74,35 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
}
return (
<ToggleGroupDescendantsProvider value={descendants}>
<ToggleGroupProvider {...context}>
<div
className={cl("aksel-toggle-group__wrapper", className, {
"aksel-toggle-group__wrapper--fill": fill,
})}
data-color={color ?? variantToColor(variant)}
>
{label && (
<Label
as="div"
size={size}
className="aksel-toggle-group__label"
id={labelId}
>
{label}
</Label>
)}
<div
aria-labelledby={label ? labelId : undefined}
{...rest}
ref={ref}
className={cl(
"aksel-toggle-group",
`aksel-toggle-group--${size}`,
)}
role="radiogroup"
<ToggleGroupProvider {...context}>
<div
className={cl("aksel-toggle-group__wrapper", className, {
"aksel-toggle-group__wrapper--fill": fill,
})}
data-color={color ?? variantToColor(variant)}
>
{label && (
<Label
as="div"
size={size}
className="aksel-toggle-group__label"
id={labelId}
>
{children}
</div>
{label}
</Label>
)}
<div
aria-labelledby={label ? labelId : undefined}
{...rest}
ref={ref}
className={cl("aksel-toggle-group", `aksel-toggle-group--${size}`)}
role="radiogroup"
data-aksel-toggle-group
>
{children}
</div>
</ToggleGroupProvider>
</ToggleGroupDescendantsProvider>
</div>
</ToggleGroupProvider>
);
},
) as ToggleGroupComponent;
@@ -4,7 +4,10 @@ import { cl } from "../../utils/helpers";
import { useToggleGroupContext } from "../ToggleGroup.context";
import { useToggleItem } from "./useToggleItem";
type BaseProps = Omit<React.HTMLAttributes<HTMLButtonElement>, "children"> & {
type BaseProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"children"
> & {
/**
* Value for state-handling.
*/
@@ -58,29 +61,27 @@ const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
onClick,
onFocus,
onKeyDown,
disabled,
...rest
},
forwardedRef,
) => {
const itemCtx = useToggleItem(
{ value, onClick, onFocus, disabled: false, onKeyDown },
forwardedRef,
);
const toggleItemProps = useToggleItem({
value,
onClick,
onFocus,
disabled,
onKeyDown,
});
const ctx = useToggleGroupContext();
return (
<button
{...rest}
ref={itemCtx.ref}
ref={forwardedRef}
className={cl("aksel-toggle-group__button", className)}
type="button"
role="radio"
aria-checked={itemCtx.isSelected}
data-selected={itemCtx.isSelected}
tabIndex={itemCtx.isFocused ? 0 : -1}
onClick={itemCtx.onClick}
onFocus={itemCtx.onFocus}
onKeyDown={itemCtx.onKeyDown}
{...toggleItemProps}
>
<BodyShort
as="span"
@@ -1,10 +1,9 @@
import { useCallback } from "react";
import { composeEventHandlers } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
import {
useToggleGroupContext,
useToggleGroupDescendant,
} from "../ToggleGroup.context";
import { composeEventHandlers, ownerDocument } from "../../utils/helpers";
import { rowingFocus } from "../../utils/helpers/rowing-focus";
import { useToggleGroupContext } from "../ToggleGroup.context";
const TOGGLE_ITEM_SELECTOR = "[data-aksel-toggle-item]:not([data-disabled])";
export interface UseToggleItemProps {
/**
@@ -18,63 +17,43 @@ export interface UseToggleItemProps {
value: string;
}
export function useToggleItem<P extends UseToggleItemProps>(
{
value,
disabled = false,
onFocus: _onFocus,
onClick,
onKeyDown: _onKeyDown,
}: P,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
export function useToggleItem<P extends UseToggleItemProps>({
value,
disabled = false,
onFocus: _onFocus,
onClick,
onKeyDown: _onKeyDown,
}: P) {
const { setSelectedValue, setFocusedValue, selectedValue, focusedValue } =
useToggleGroupContext();
const { register, descendants } = useToggleGroupDescendant({
disabled,
value,
});
const isSelected = value === selectedValue;
const onFocus = () => setFocusedValue(value);
/**
* Implements roving-tabindex for horizontal tabs
* Implements roving-tabindex.
*/
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
/**
* ToggleGroup.Item is registered with its prop 'value'.
* We can then use it to find the current focuses descendant
*/
const idx = descendants
.values()
.findIndex((x) => x.value === focusedValue);
const container = (event.currentTarget as HTMLElement).closest(
"[data-aksel-toggle-group]",
) as HTMLElement | null;
const nextTab = () => {
const next = descendants.nextEnabled(idx, false);
next?.node?.focus();
};
const prevTab = () => {
const prev = descendants.prevEnabled(idx, false);
prev?.node?.focus();
};
const firstTab = () => {
const first = descendants.firstEnabled();
first?.node?.focus();
};
const lastTab = () => {
const last = descendants.lastEnabled();
last?.node?.focus();
};
if (!container) {
return;
}
const keyMap: Record<string, React.KeyboardEventHandler> = {
ArrowLeft: prevTab,
ArrowRight: nextTab,
Home: firstTab,
End: lastTab,
const current = ownerDocument(container)
.activeElement as HTMLElement | null;
const keyMap: Record<string, () => void> = {
ArrowLeft: () =>
rowingFocus(TOGGLE_ITEM_SELECTOR, container, "prev", current, false),
ArrowRight: () =>
rowingFocus(TOGGLE_ITEM_SELECTOR, container, "next", current, false),
Home: () => rowingFocus(TOGGLE_ITEM_SELECTOR, container, "first"),
End: () => rowingFocus(TOGGLE_ITEM_SELECTOR, container, "last"),
};
const hasModifiers =
@@ -84,7 +63,7 @@ export function useToggleItem<P extends UseToggleItemProps>(
if (action && !hasModifiers) {
event.preventDefault();
action(event);
action();
} else if (event.key === "Tab") {
/**
* Imperative focus during keydown is risky so we prevent React's batching updates
@@ -93,20 +72,22 @@ export function useToggleItem<P extends UseToggleItemProps>(
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
}
},
[descendants, focusedValue, selectedValue, setFocusedValue],
[selectedValue, setFocusedValue],
);
const refs = useMergeRefs(register, ref);
return {
ref: refs,
isSelected,
isFocused: focusedValue === value,
onClick: composeEventHandlers(
onClick,
() => selectedValue !== value && setSelectedValue(value),
() => !isSelected && setSelectedValue(value),
),
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
onKeyDown: composeEventHandlers(_onKeyDown, onKeyDown),
tabIndex: focusedValue === value ? 0 : -1,
"aria-checked": isSelected,
"data-selected": isSelected,
"data-aksel-toggle-item": "",
role: "radio",
disabled,
"data-disabled": disabled ? "" : undefined,
};
}
@@ -3,11 +3,7 @@ import ReactDOM from "react-dom";
import { Portal } from "../../../portal";
import { useId } from "../../../utils-external";
import { composeEventHandlers, createStrictContext } from "../../helpers";
import {
createDescendantContext,
useEventCallback,
useMergeRefs,
} from "../../hooks";
import { useEventCallback, useMergeRefs } from "../../hooks";
import { DismissableLayer } from "../dismissablelayer/DismissableLayer";
import { Floating, MENU_COLLISION_AVOIDANCE } from "../floating/Floating";
import { FocusBoundary } from "../focus-boundary/FocusBoundary";
@@ -24,6 +20,7 @@ import {
const FIRST_KEYS = ["ArrowDown", "PageUp", "Home"];
const LAST_KEYS = ["ArrowUp", "PageDown", "End"];
const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
const CLOSE_SUBMENU_EVENT = "menu.closeSubmenu";
type CheckedState = boolean | "indeterminate";
@@ -53,18 +50,6 @@ interface MenuComponent extends React.FC<MenuProps> {
ItemIndicator: typeof MenuItemIndicator;
}
const [
MenuDescendantsProvider,
useMenuDescendantsContext,
useMenuDescendants,
useMenuDescendant,
] = createDescendantContext<
SlottedDivElementRef,
{
closeMenu: () => void;
}
>();
type MenuContentElementRef = React.ElementRef<typeof Floating.Content>;
type MenuContextValue = {
@@ -181,17 +166,12 @@ const MenuContent = React.forwardRef<
MenuContentInternalElement,
MenuContentProps
>((props: MenuContentProps, ref) => {
const descendants = useMenuDescendants();
const rootContext = useMenuRootContext();
return (
<MenuDescendantsProvider value={descendants}>
{rootContext.modal ? (
<MenuRootContentModal {...props} ref={ref} />
) : (
<MenuRootContentNonModal {...props} ref={ref} />
)}
</MenuDescendantsProvider>
return rootContext.modal ? (
<MenuRootContentModal {...props} ref={ref} />
) : (
<MenuRootContentNonModal {...props} ref={ref} />
);
});
@@ -286,8 +266,6 @@ const MenuContentInternal = forwardRef<
}: MenuContentInternalProps,
forwardedRef,
) => {
const descendants = useMenuDescendantsContext();
const context = useMenuContext();
const contentRef = useRef<HTMLDivElement>(null);
@@ -317,7 +295,6 @@ const MenuContentInternal = forwardRef<
>
<RovingFocus
asChild
descendants={descendants}
onEntryFocus={composeEventHandlers(onEntryFocus, (event) => {
event.preventDefault();
})}
@@ -347,15 +324,27 @@ const MenuContentInternal = forwardRef<
// focus first/last item based on key pressed
const content = contentRef.current;
if (event.target !== content) return;
if (!FIRST_LAST_KEYS.includes(event.key)) return;
event.preventDefault();
if (LAST_KEYS.includes(event.key)) {
descendants.lastEnabled()?.node?.focus();
if (event.target !== content) {
return;
}
descendants.firstEnabled()?.node?.focus();
if (!FIRST_LAST_KEYS.includes(event.key)) {
return;
}
event.preventDefault();
const MENU_ITEM_SELECTOR =
"[data-aksel-menu-item]:not([data-disabled])";
const items = Array.from(
content.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
);
if (LAST_KEYS.includes(event.key)) {
items[items.length - 1]?.focus();
return;
}
items[0]?.focus();
})}
/>
</RovingFocus>
@@ -502,23 +491,28 @@ const MenuItemInternal = forwardRef<
forwardedRef,
) => {
const context = useMenuContext();
const { register } = useMenuDescendant({
disabled,
closeMenu: () => {
rest["data-submenu-trigger"] &&
context.open &&
context.onOpenChange(false);
},
});
const ref = useRef<HTMLDivElement>(null);
const composedRefs = useMergeRefs(forwardedRef, ref, register);
const composedRefs = useMergeRefs(forwardedRef, ref);
/* If this item is a submenu trigger, listen for the close event dispatched by siblings */
useEffect(() => {
const node = ref.current;
if (!node || !rest["data-submenu-trigger"]) {
return;
}
const handler = () => {
context.open && context.onOpenChange(false);
};
node.addEventListener(CLOSE_SUBMENU_EVENT, handler);
return () => node.removeEventListener(CLOSE_SUBMENU_EVENT, handler);
});
return (
<SlottedDivElement
role="menuitem"
aria-disabled={disabled || undefined}
data-disabled={disabled ? "" : undefined}
data-aksel-menu-item
tabIndex={-1}
{...rest}
style={{ userSelect: "none", ...rest?.style }}
@@ -764,8 +758,6 @@ const MenuSub: React.FC<MenuSubProps> = ({
}: MenuSubProps) => {
const parentMenuContext = useMenuContext();
const { values } = useMenuDescendantsContext();
const [trigger, setTrigger] = useState<MenuItemElement | null>(null);
const [content, setContent] = useState<MenuContentInternalElement | null>(
null,
@@ -787,12 +779,17 @@ const MenuSub: React.FC<MenuSubProps> = ({
onOpenChange={(_open) => {
handleOpenChange(_open);
if (_open) {
/* Makes sure to close all adjacent submenus if they are open */
values().forEach((descendant) => {
if (descendant.node !== trigger) {
descendant.closeMenu();
}
});
/*
* Makes sure to close all adjacent submenus if they are open
* This makes sure only one submenu can be open at a time and prevents the edgecase of multiple open submenus when the user quickly moves through multiple submenu triggers.
*/
parentMenuContext.content
?.querySelectorAll("[data-submenu-trigger]")
.forEach((node) => {
if (node !== trigger) {
node.dispatchEvent(new Event(CLOSE_SUBMENU_EVENT));
}
});
}
}}
content={content}
@@ -891,8 +888,6 @@ const MenuSubContent = forwardRef<
MenuContentInternalElement,
MenuSubContentProps
>((props: MenuSubContentProps, forwardedRef) => {
const descendants = useMenuDescendants();
const context = useMenuContext();
const rootContext = useMenuRootContext();
const subContext = useMenuSubContext();
@@ -900,53 +895,48 @@ const MenuSubContent = forwardRef<
const composedRefs = useMergeRefs(forwardedRef, ref);
return (
<MenuDescendantsProvider value={descendants}>
<MenuContentInternal
id={subContext.contentId}
aria-labelledby={subContext.triggerId}
{...props}
ref={composedRefs}
align="start"
side="right"
disableOutsidePointerEvents={false}
initialFocus={() => {
if (rootContext.isUsingKeyboardRef.current) {
return ref.current;
}
return false;
}}
/* Since we manually focus Subtrigger, we prevent use of auto-focus */
returnFocus={false}
onEscapeKeyDown={composeEventHandlers(
props.onEscapeKeyDown,
(event) => {
rootContext.onClose();
// Ensure pressing escape in submenu doesn't escape full screen mode
event.preventDefault();
},
)}
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
// Submenu key events bubble through portals. We only care about keys in this menu.
const isKeyDownInside = event.currentTarget.contains(
event.target as HTMLElement,
);
let isCloseKey = event.key === "ArrowLeft";
<MenuContentInternal
id={subContext.contentId}
aria-labelledby={subContext.triggerId}
{...props}
ref={composedRefs}
align="start"
side="right"
disableOutsidePointerEvents={false}
initialFocus={() => {
if (rootContext.isUsingKeyboardRef.current) {
return ref.current;
}
return false;
}}
/* Since we manually focus Subtrigger, we prevent use of auto-focus */
returnFocus={false}
onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, (event) => {
rootContext.onClose();
// Ensure pressing escape in submenu doesn't escape full screen mode
event.preventDefault();
})}
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
// Submenu key events bubble through portals. We only care about keys in this menu.
const isKeyDownInside = event.currentTarget.contains(
event.target as HTMLElement,
);
let isCloseKey = event.key === "ArrowLeft";
/* When submenu opens to the left, we allow closing it with ArrowRight */
if (context.content?.dataset.side === "left") {
isCloseKey = isCloseKey || event.key === "ArrowRight";
}
/* When submenu opens to the left, we allow closing it with ArrowRight */
if (context.content?.dataset.side === "left") {
isCloseKey = isCloseKey || event.key === "ArrowRight";
}
if (isKeyDownInside && isCloseKey) {
context.onOpenChange(false);
// We focus manually because we prevented it in `onCloseAutoFocus`
subContext.trigger?.focus();
// Prevent window from scrolling
event.preventDefault();
}
})}
/>
</MenuDescendantsProvider>
if (isKeyDownInside && isCloseKey) {
context.onOpenChange(false);
// We focus manually because we prevented it in `onCloseAutoFocus`
subContext.trigger?.focus();
// Prevent window from scrolling
event.preventDefault();
}
})}
/>
);
});
@@ -1,15 +1,16 @@
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
import { composeEventHandlers, ownerDocument } from "../../../helpers";
import type { DescendantsManager } from "../../../hooks";
import { rowingFocus } from "../../../helpers/rowing-focus";
import { useEventCallback, useMergeRefs } from "../../../hooks";
import { Slot } from "../../slot/Slot";
const MENU_ITEM_SELECTOR = "[data-aksel-menu-item]:not([data-disabled])";
interface RovingFocusProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"tabIndex"
> {
asChild?: boolean;
descendants: DescendantsManager<HTMLDivElement, any>;
onEntryFocus?: (event: Event) => void;
}
@@ -21,7 +22,6 @@ const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
{
children,
asChild,
descendants,
onKeyDown,
onEntryFocus,
onMouseDown,
@@ -44,49 +44,31 @@ const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
}
}, [handleEntryFocus]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const loop = false;
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
const container = _ref.current;
if (!container) {
return;
}
const ownerDoc = ownerDocument(_ref?.current);
const ownerDoc = ownerDocument(container);
const current = ownerDoc.activeElement as HTMLElement | null;
const idx = descendants
.values()
.findIndex((x) => x.node.isSameNode(ownerDoc.activeElement));
const keyMap: Record<string, () => void> = {
ArrowUp: () =>
rowingFocus(MENU_ITEM_SELECTOR, container, "prev", current, false),
ArrowDown: () =>
rowingFocus(MENU_ITEM_SELECTOR, container, "next", current, false),
Home: () => rowingFocus(MENU_ITEM_SELECTOR, container, "first"),
End: () => rowingFocus(MENU_ITEM_SELECTOR, container, "last"),
};
const nextItem = () => {
const next = descendants.nextEnabled(idx, loop);
next?.node?.focus();
};
const prevItem = () => {
const prev = descendants.prevEnabled(idx, loop);
prev?.node?.focus();
};
const firstItem = () => {
const first = descendants.firstEnabled();
first?.node?.focus();
};
const lastItem = () => {
const last = descendants.lastEnabled();
last?.node?.focus();
};
const action = keyMap[event.key];
const keyMap: Record<string, React.KeyboardEventHandler> = {
ArrowUp: prevItem,
ArrowDown: nextItem,
Home: firstItem,
End: lastItem,
};
const action = keyMap[event.key];
if (action) {
event.preventDefault();
action(event);
}
},
[descendants],
);
if (action) {
event.preventDefault();
action();
}
}, []);
const Comp = asChild ? Slot : "div";
@@ -94,7 +76,7 @@ const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
<Comp
ref={composedRefs}
{...rest}
tabIndex={descendants.enabledCount() === 0 ? -1 : 0}
tabIndex={0}
style={{ outline: "none", ...rest.style }}
onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
onMouseDown={composeEventHandlers(onMouseDown, () => {
@@ -106,7 +88,8 @@ const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
event.currentTarget.dispatchEvent(entryFocusEvent);
if (!entryFocusEvent.defaultPrevented) {
descendants.firstEnabled()?.node.focus({ preventScroll: true });
const container = _ref.current;
container && rowingFocus(MENU_ITEM_SELECTOR, container, "first");
}
}
@@ -0,0 +1,46 @@
type FocusDirection = "first" | "last" | "next" | "prev";
/**
* Focuses an item in a list based on direction.
* - first/last: focus first or last item
* - next/prev: focus relative to `current`, with optional looping
*/
function rowingFocus(
selector: string,
container: HTMLElement,
direction: FocusDirection,
current?: HTMLElement | null,
loop = true,
) {
const items = Array.from(container.querySelectorAll<HTMLElement>(selector));
if (items.length === 0) {
return;
}
if (direction === "first") {
items[0].focus();
return;
}
if (direction === "last") {
items[items.length - 1].focus();
return;
}
const currentIndex = current ? items.indexOf(current) : -1;
if (direction === "next") {
const nextIndex = loop
? (currentIndex + 1) % items.length
: Math.min(currentIndex + 1, items.length - 1);
items[nextIndex]?.focus();
} else {
const prevIndex = loop
? (currentIndex - 1 + items.length) % items.length
: Math.max(currentIndex - 1, 0);
items[prevIndex]?.focus();
}
}
export { rowingFocus };