mirror of
https://github.com/navikt/aksel.git
synced 2026-10-14 09:42:02 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d97d72a76e | ||
|
|
e56d4a98d3 | ||
|
|
bd070ae7f5 | ||
|
|
ec03db9c34 | ||
|
|
b17104b58e | ||
|
|
c8c41fd619 | ||
|
|
f28e57d383 | ||
|
|
b50aa783e5 | ||
|
|
e48430d8c7 | ||
|
|
dd9a271a66 | ||
|
|
6db29cf2bd | ||
|
|
8e061d19be |
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,
|
||||
});
|
||||
@@ -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";
|
||||
+3
-3
@@ -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;
|
||||
+5
-3
@@ -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;
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user