@arbor-education/design-system.components 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/alpha-build.yml +3 -3
- package/.github/workflows/chromatic.yml +3 -1
- package/.github/workflows/design-system-pr-checks.yml +3 -3
- package/.github/workflows/fail-on-no-changeset.yml +3 -3
- package/.github/workflows/release.yml +4 -3
- package/.yarnrc.yml +3 -0
- package/CHANGELOG.md +12 -0
- package/component-library.md +6 -1
- package/dist/components/breadcrumbs/BreadcrumbDropdown.d.ts.map +1 -1
- package/dist/components/breadcrumbs/BreadcrumbDropdown.js +12 -1
- package/dist/components/breadcrumbs/BreadcrumbDropdown.js.map +1 -1
- package/dist/components/breadcrumbs/Breadcrumbs.stories.d.ts +1 -0
- package/dist/components/breadcrumbs/Breadcrumbs.stories.d.ts.map +1 -1
- package/dist/components/breadcrumbs/Breadcrumbs.stories.js +59 -1
- package/dist/components/breadcrumbs/Breadcrumbs.stories.js.map +1 -1
- package/dist/components/breadcrumbs/Breadcrumbs.test.d.ts.map +1 -1
- package/dist/components/breadcrumbs/Breadcrumbs.test.js +63 -0
- package/dist/components/breadcrumbs/Breadcrumbs.test.js.map +1 -1
- package/dist/components/dropdown/Dropdown.d.ts +9 -0
- package/dist/components/dropdown/Dropdown.d.ts.map +1 -1
- package/dist/components/dropdown/Dropdown.js +6 -0
- package/dist/components/dropdown/Dropdown.js.map +1 -1
- package/dist/components/dropdown/Dropdown.stories.d.ts +4 -0
- package/dist/components/dropdown/Dropdown.stories.d.ts.map +1 -1
- package/dist/components/dropdown/Dropdown.stories.js +71 -2
- package/dist/components/dropdown/Dropdown.stories.js.map +1 -1
- package/dist/components/dropdown/Dropdown.test.js +23 -0
- package/dist/components/dropdown/Dropdown.test.js.map +1 -1
- package/dist/components/dropdown/DropdownSub.d.ts +4 -0
- package/dist/components/dropdown/DropdownSub.d.ts.map +1 -0
- package/dist/components/dropdown/DropdownSub.js +4 -0
- package/dist/components/dropdown/DropdownSub.js.map +1 -0
- package/dist/components/dropdown/DropdownSubContent.d.ts +9 -0
- package/dist/components/dropdown/DropdownSubContent.d.ts.map +1 -0
- package/dist/components/dropdown/DropdownSubContent.js +11 -0
- package/dist/components/dropdown/DropdownSubContent.js.map +1 -0
- package/dist/components/dropdown/items/DropdownSubTrigger.d.ts +4 -0
- package/dist/components/dropdown/items/DropdownSubTrigger.d.ts.map +1 -0
- package/dist/components/dropdown/items/DropdownSubTrigger.js +9 -0
- package/dist/components/dropdown/items/DropdownSubTrigger.js.map +1 -0
- package/dist/index.css +11 -0
- package/dist/index.css.map +1 -1
- package/package.json +4 -3
- package/src/components/breadcrumbs/BreadcrumbDropdown.tsx +32 -8
- package/src/components/breadcrumbs/Breadcrumbs.stories.tsx +68 -1
- package/src/components/breadcrumbs/Breadcrumbs.test.tsx +77 -0
- package/src/components/dropdown/Dropdown.stories.tsx +102 -1
- package/src/components/dropdown/Dropdown.test.tsx +47 -0
- package/src/components/dropdown/Dropdown.tsx +9 -0
- package/src/components/dropdown/DropdownSub.tsx +5 -0
- package/src/components/dropdown/DropdownSubContent.tsx +24 -0
- package/src/components/dropdown/dropdown.scss +15 -0
- package/src/components/dropdown/items/DropdownSubTrigger.tsx +18 -0
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { Dropdown } from 'Components/dropdown/Dropdown';
|
|
2
|
-
import type { BreadcrumbDropdownProps } from './types.js';
|
|
2
|
+
import type { BreadcrumbDropdownProps, BreadcrumbItem } from './types.js';
|
|
3
3
|
import { BreadcrumbLink } from './BreadcrumbLink.js';
|
|
4
4
|
|
|
5
|
+
const renderOption = (option: BreadcrumbItem, index: number) => {
|
|
6
|
+
const key = `${option.label}-${index}`;
|
|
7
|
+
const hasNestedOptions = Boolean(option.dropdownOptions?.length);
|
|
8
|
+
|
|
9
|
+
if (hasNestedOptions) {
|
|
10
|
+
if (process.env.NODE_ENV !== 'production' && (option.href || option.onClick)) {
|
|
11
|
+
console.warn(
|
|
12
|
+
`BreadcrumbDropdown: option "${option.label}" has dropdownOptions as well as an href/onClick. It will render as a submenu trigger only — its own href/onClick will not be used.`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Dropdown.Sub key={key}>
|
|
18
|
+
<Dropdown.SubTrigger>{option.label}</Dropdown.SubTrigger>
|
|
19
|
+
<Dropdown.SubContent>
|
|
20
|
+
{option.dropdownOptions?.map(renderOption)}
|
|
21
|
+
</Dropdown.SubContent>
|
|
22
|
+
</Dropdown.Sub>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Dropdown.Item asChild key={key}>
|
|
28
|
+
<a href={option.href} onClick={() => option.onClick?.()}>
|
|
29
|
+
{option.label}
|
|
30
|
+
</a>
|
|
31
|
+
</Dropdown.Item>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
5
35
|
export const BreadcrumbDropdown = (props: BreadcrumbDropdownProps) => {
|
|
6
36
|
const { item } = props;
|
|
7
37
|
return (
|
|
@@ -14,13 +44,7 @@ export const BreadcrumbDropdown = (props: BreadcrumbDropdownProps) => {
|
|
|
14
44
|
/>
|
|
15
45
|
</Dropdown.Trigger>
|
|
16
46
|
<Dropdown.Content>
|
|
17
|
-
{item.dropdownOptions?.map(
|
|
18
|
-
<Dropdown.Item asChild key={`${option.label}-${index}`}>
|
|
19
|
-
<a href={option.href} onClick={() => option.onClick?.()}>
|
|
20
|
-
{option.label}
|
|
21
|
-
</a>
|
|
22
|
-
</Dropdown.Item>
|
|
23
|
-
))}
|
|
47
|
+
{item.dropdownOptions?.map(renderOption)}
|
|
24
48
|
</Dropdown.Content>
|
|
25
49
|
</Dropdown>
|
|
26
50
|
);
|
|
@@ -150,7 +150,7 @@ const meta = {
|
|
|
150
150
|
'Array of `BreadcrumbItem` objects that define the trail.',
|
|
151
151
|
'Each item has: `label` (string, required — also used as the clipboard copy text),',
|
|
152
152
|
'`href` (string, optional — used for all items except the last, which is always non-navigable regardless of whether `href` is set),',
|
|
153
|
-
'`dropdownOptions` (array of `BreadcrumbItem`, optional — renders a dropdown trigger; items may themselves have `dropdownOptions` for nested menus),',
|
|
153
|
+
'`dropdownOptions` (array of `BreadcrumbItem`, optional — renders a dropdown trigger; items may themselves have `dropdownOptions` for nested menus). If an item has `dropdownOptions`, its own `href`/`onClick` are not used — it renders as a submenu trigger only, not a link,',
|
|
154
154
|
'`isEllipsis` (boolean, optional — renders an icon-only ellipsis button),',
|
|
155
155
|
'`onClick` (function, optional — forwarded to the ellipsis button; use to expand a collapsed trail).',
|
|
156
156
|
].join(' '),
|
|
@@ -516,6 +516,73 @@ export default WithDropdownBreadcrumb;
|
|
|
516
516
|
].join(' '),
|
|
517
517
|
);
|
|
518
518
|
|
|
519
|
+
export const NestedDropdownOptions: Story = withDescription(
|
|
520
|
+
{
|
|
521
|
+
render: () => (
|
|
522
|
+
<Breadcrumbs
|
|
523
|
+
breadcrumbLabel="Breadcrumb"
|
|
524
|
+
items={[
|
|
525
|
+
{ label: 'Home', href: '/' },
|
|
526
|
+
{
|
|
527
|
+
label: 'Students',
|
|
528
|
+
href: '/students',
|
|
529
|
+
dropdownOptions: [
|
|
530
|
+
{ label: 'All students', href: '/students' },
|
|
531
|
+
{
|
|
532
|
+
label: 'Tutor groups',
|
|
533
|
+
dropdownOptions: [
|
|
534
|
+
{ label: 'Group A', href: '/students/tutor-group/a' },
|
|
535
|
+
{ label: 'Group B', href: '/students/tutor-group/b' },
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
},
|
|
540
|
+
{ label: 'Current page', href: '/current' },
|
|
541
|
+
]}
|
|
542
|
+
/>
|
|
543
|
+
),
|
|
544
|
+
parameters: {
|
|
545
|
+
controls: { disable: true },
|
|
546
|
+
docs: {
|
|
547
|
+
source: {
|
|
548
|
+
language: 'tsx',
|
|
549
|
+
code: `
|
|
550
|
+
import { Breadcrumbs } from '@arbor-education/design-system.components';
|
|
551
|
+
|
|
552
|
+
function NestedDropdownOptionsBreadcrumb() {
|
|
553
|
+
return (
|
|
554
|
+
<Breadcrumbs
|
|
555
|
+
breadcrumbLabel="Breadcrumb"
|
|
556
|
+
items={[
|
|
557
|
+
{ label: 'Home', href: '/' },
|
|
558
|
+
{
|
|
559
|
+
label: 'Students',
|
|
560
|
+
href: '/students',
|
|
561
|
+
dropdownOptions: [
|
|
562
|
+
{ label: 'All students', href: '/students' },
|
|
563
|
+
{
|
|
564
|
+
label: 'Tutor groups',
|
|
565
|
+
dropdownOptions: [
|
|
566
|
+
{ label: 'Group A', href: '/students/tutor-group/a' },
|
|
567
|
+
{ label: 'Group B', href: '/students/tutor-group/b' },
|
|
568
|
+
],
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
{ label: 'Current page', href: '/current' },
|
|
573
|
+
]}
|
|
574
|
+
/>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
export default NestedDropdownOptionsBreadcrumb;
|
|
578
|
+
`.trim(),
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
'Any `dropdownOptions` entry can itself have `dropdownOptions` — here "Tutor groups" renders as a submenu trigger that cascades into a nested popover on hover or click. A nested entry\'s own `href`/`onClick` are not used — it is a pure submenu trigger, not a link.',
|
|
584
|
+
);
|
|
585
|
+
|
|
519
586
|
export const MultipleDropdowns: Story = withDescription(
|
|
520
587
|
{
|
|
521
588
|
render: () => (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
3
4
|
import '@testing-library/jest-dom/vitest';
|
|
4
5
|
import { Breadcrumbs } from './Breadcrumbs.js';
|
|
5
6
|
|
|
@@ -182,4 +183,80 @@ describe('Breadcrumbs', () => {
|
|
|
182
183
|
expect(screen.getByRole('button', { name: 'Students' })).toHaveAttribute('tabindex', '0');
|
|
183
184
|
});
|
|
184
185
|
});
|
|
186
|
+
|
|
187
|
+
describe('nested dropdown item', () => {
|
|
188
|
+
const itemsWithNestedDropdown = [
|
|
189
|
+
{ label: 'Home', href: '/' },
|
|
190
|
+
{
|
|
191
|
+
label: 'Students',
|
|
192
|
+
href: '/students',
|
|
193
|
+
dropdownOptions: [
|
|
194
|
+
{ label: 'All students', href: '/students' },
|
|
195
|
+
{
|
|
196
|
+
label: 'Tutor groups',
|
|
197
|
+
dropdownOptions: [
|
|
198
|
+
{ label: 'Group A', href: '/students/tutor-group/a' },
|
|
199
|
+
{ label: 'Group B', href: '/students/tutor-group/b' },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
{ label: 'Current page', href: '/current' },
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
test('renders a nested option as a submenu trigger, not a link', async () => {
|
|
208
|
+
render(<Breadcrumbs items={itemsWithNestedDropdown} breadcrumbLabel="Breadcrumb" />);
|
|
209
|
+
|
|
210
|
+
await userEvent.click(screen.getByRole('button', { name: 'Students' }));
|
|
211
|
+
|
|
212
|
+
const subTrigger = await screen.findByText('Tutor groups');
|
|
213
|
+
expect(subTrigger.closest('a')).not.toBeInTheDocument();
|
|
214
|
+
expect(subTrigger.closest('[role="menuitem"]')).toHaveAttribute('aria-haspopup', 'menu');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('reveals second-level options when the nested trigger is clicked', async () => {
|
|
218
|
+
render(<Breadcrumbs items={itemsWithNestedDropdown} breadcrumbLabel="Breadcrumb" />);
|
|
219
|
+
|
|
220
|
+
await userEvent.click(screen.getByRole('button', { name: 'Students' }));
|
|
221
|
+
await userEvent.click(await screen.findByText('Tutor groups'));
|
|
222
|
+
|
|
223
|
+
expect(await screen.findByRole('menuitem', { name: 'Group A' })).toHaveAttribute('href', '/students/tutor-group/a');
|
|
224
|
+
expect(screen.getByRole('menuitem', { name: 'Group B' })).toHaveAttribute('href', '/students/tutor-group/b');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('leaf-level dropdown options without nested dropdownOptions still render as links', async () => {
|
|
228
|
+
render(<Breadcrumbs items={itemsWithNestedDropdown} breadcrumbLabel="Breadcrumb" />);
|
|
229
|
+
|
|
230
|
+
await userEvent.click(screen.getByRole('button', { name: 'Students' }));
|
|
231
|
+
|
|
232
|
+
expect(await screen.findByRole('menuitem', { name: 'All students' })).toHaveAttribute('href', '/students');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('warns in development when a nested option also has an href', async () => {
|
|
236
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
237
|
+
const itemsWithHrefAndNestedOptions = [
|
|
238
|
+
{ label: 'Home', href: '/' },
|
|
239
|
+
{
|
|
240
|
+
label: 'Students',
|
|
241
|
+
href: '/students',
|
|
242
|
+
dropdownOptions: [
|
|
243
|
+
{
|
|
244
|
+
label: 'Tutor groups',
|
|
245
|
+
href: '/tutor-groups',
|
|
246
|
+
dropdownOptions: [
|
|
247
|
+
{ label: 'Group A', href: '/students/tutor-group/a' },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
render(<Breadcrumbs items={itemsWithHrefAndNestedOptions} breadcrumbLabel="Breadcrumb" />);
|
|
255
|
+
await userEvent.click(screen.getByRole('button', { name: 'Students' }));
|
|
256
|
+
|
|
257
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Tutor groups'));
|
|
258
|
+
|
|
259
|
+
warnSpy.mockRestore();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
185
262
|
});
|
|
@@ -20,7 +20,7 @@ const TRIGGER_PROPS = `
|
|
|
20
20
|
const CONTENT_PROPS = `
|
|
21
21
|
| Prop | Type | Default | Description |
|
|
22
22
|
|---|---|---|---|
|
|
23
|
-
| \`children\` | \`React.ReactNode\` | — | The menu content — \`Dropdown.Item\`, \`Dropdown.SelectItem\`, \`Dropdown.Separator\`, and \`Dropdown.
|
|
23
|
+
| \`children\` | \`React.ReactNode\` | — | The menu content — \`Dropdown.Item\`, \`Dropdown.SelectItem\`, \`Dropdown.Separator\`, \`Dropdown.Group\`, and \`Dropdown.Sub\` elements. |
|
|
24
24
|
| \`contentProps\` | \`DropdownMenuContentProps\` | — | Passed to the Radix \`DropdownMenu.Content\`. Key values: \`align\` (\`'start' \\| 'center' \\| 'end'\`, default \`'start'\`), \`side\` (\`'top' \\| 'right' \\| 'bottom' \\| 'left'\`, default \`'bottom'\`), \`sideOffset\` (gap in px), \`avoidCollisions\` (auto-flip when clipped, default \`true\`). |
|
|
25
25
|
| \`portalProps\` | \`DropdownMenuPortalProps\` | — | Passed to the Radix \`DropdownMenu.Portal\`. Rarely needed — content already renders via \`PopupParentContext\`. |
|
|
26
26
|
| \`className\` | \`string\` | \`''\` | Additional CSS class for the content panel. |
|
|
@@ -60,6 +60,35 @@ const SEPARATOR_PROPS = `
|
|
|
60
60
|
| \`className\` | \`string\` | \`''\` | Additional CSS class. The separator renders as a zero-config horizontal rule with no configurable content. |
|
|
61
61
|
`.trim();
|
|
62
62
|
|
|
63
|
+
const SUB_PROPS = `
|
|
64
|
+
| Prop | Type | Default | Description |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| \`children\` | \`React.ReactNode\` | — | The submenu tree — a \`Dropdown.SubTrigger\` and a \`Dropdown.SubContent\`. |
|
|
67
|
+
| \`open\` | \`boolean\` | — | Controlled open state for the submenu. Pair with \`onOpenChange\` to drive it externally. |
|
|
68
|
+
| \`onOpenChange\` | \`(open: boolean) => void\` | — | Callback fired when the submenu's open state changes. |
|
|
69
|
+
| \`defaultOpen\` | \`boolean\` | \`false\` | Initial open state for uncontrolled mode. |
|
|
70
|
+
|
|
71
|
+
\`Dropdown.Sub\` accepts the standard Radix \`DropdownMenuSubProps\` — it has no custom props of its own and wraps its \`children\` in a Radix \`DropdownMenu.Sub\`.
|
|
72
|
+
`.trim();
|
|
73
|
+
|
|
74
|
+
const SUB_TRIGGER_PROPS = `
|
|
75
|
+
| Prop | Type | Default | Description |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| \`children\` | \`React.ReactNode\` | — | Submenu trigger label. A trailing chevron-right icon is always rendered automatically — there is no separate icon prop. |
|
|
78
|
+
| \`disabled\` | \`boolean\` | \`false\` | Prevents the submenu from opening. |
|
|
79
|
+
|
|
80
|
+
Accepts the standard Radix \`DropdownMenuSubTriggerProps\`.
|
|
81
|
+
`.trim();
|
|
82
|
+
|
|
83
|
+
const SUB_CONTENT_PROPS = `
|
|
84
|
+
| Prop | Type | Default | Description |
|
|
85
|
+
|---|---|---|---|
|
|
86
|
+
| \`children\` | \`React.ReactNode\` | — | **Required.** The submenu content — \`Dropdown.Item\`, \`Dropdown.SelectItem\`, \`Dropdown.Separator\`, \`Dropdown.Group\`, and further nested \`Dropdown.Sub\` elements. |
|
|
87
|
+
| \`contentProps\` | \`DropdownMenuSubContentProps\` | — | Passed to the Radix \`DropdownMenu.SubContent\`. |
|
|
88
|
+
| \`portalProps\` | \`DropdownMenuPortalProps\` | — | Passed to the Radix \`DropdownMenu.Portal\`. Rarely needed — content already renders via \`PopupParentContext\`. |
|
|
89
|
+
| \`className\` | \`string\` | \`''\` | Additional CSS class for the submenu panel. |
|
|
90
|
+
`.trim();
|
|
91
|
+
|
|
63
92
|
const DESCRIPTION_INTRO = [
|
|
64
93
|
'A **non-blocking popup menu** built on [Radix UI\'s DropdownMenu](https://www.radix-ui.com/primitives/docs/components/dropdown-menu) primitive.',
|
|
65
94
|
'`modal={false}` is hardcoded — the background is never blocked, letting users continue interacting with the rest of the page while the menu is open.',
|
|
@@ -100,6 +129,12 @@ const USAGE_GUIDANCE = [
|
|
|
100
129
|
' Year 7',
|
|
101
130
|
' </Dropdown.SelectItem>',
|
|
102
131
|
' </Dropdown.Group>',
|
|
132
|
+
' <Dropdown.Sub>',
|
|
133
|
+
' <Dropdown.SubTrigger>More options</Dropdown.SubTrigger>',
|
|
134
|
+
' <Dropdown.SubContent>',
|
|
135
|
+
' <Dropdown.Item onSelect={fn}>Settings</Dropdown.Item>',
|
|
136
|
+
' </Dropdown.SubContent>',
|
|
137
|
+
' </Dropdown.Sub>',
|
|
103
138
|
' </Dropdown.Content>',
|
|
104
139
|
'</Dropdown>',
|
|
105
140
|
'```',
|
|
@@ -134,6 +169,9 @@ const DEVELOPER_NOTES = [
|
|
|
134
169
|
'- **Trigger**: Radix sets `aria-expanded` on the trigger automatically.',
|
|
135
170
|
'- **Disabled items**: rendered with `aria-disabled="true"` and `pointer-events: none` — still readable by',
|
|
136
171
|
' screen readers but cannot be activated.',
|
|
172
|
+
'- **Submenus**: `Dropdown.SubTrigger` carries `aria-haspopup="menu"` and `aria-expanded`, and opens on',
|
|
173
|
+
' click or hover. When focused, `ArrowRight` opens the submenu and `ArrowLeft` closes it and returns',
|
|
174
|
+
' focus to the trigger — standard Radix `DropdownMenu.Sub` behaviour.',
|
|
137
175
|
].join('\n');
|
|
138
176
|
|
|
139
177
|
const RELATED_COMPONENTS = [
|
|
@@ -166,6 +204,12 @@ function DropdownDocsPage() {
|
|
|
166
204
|
<Markdown>{GROUP_PROPS}</Markdown>
|
|
167
205
|
<Subheading>Dropdown.Separator props</Subheading>
|
|
168
206
|
<Markdown>{SEPARATOR_PROPS}</Markdown>
|
|
207
|
+
<Subheading>Dropdown.Sub props</Subheading>
|
|
208
|
+
<Markdown>{SUB_PROPS}</Markdown>
|
|
209
|
+
<Subheading>Dropdown.SubTrigger props</Subheading>
|
|
210
|
+
<Markdown>{SUB_TRIGGER_PROPS}</Markdown>
|
|
211
|
+
<Subheading>Dropdown.SubContent props</Subheading>
|
|
212
|
+
<Markdown>{SUB_CONTENT_PROPS}</Markdown>
|
|
169
213
|
<Heading>Usage guidance</Heading>
|
|
170
214
|
<Markdown>{USAGE_GUIDANCE}</Markdown>
|
|
171
215
|
<Heading>Developer notes</Heading>
|
|
@@ -875,6 +919,63 @@ export default RecordOptionsDropdown;`,
|
|
|
875
919
|
'You can mix `Dropdown.Item` (one-shot actions) and `Dropdown.SelectItem` (toggles) in the same content panel, separated by `Dropdown.Separator`. Here the top section has navigational actions, the middle has export format toggles (multi-select, stays open), and the bottom has a final action. This mirrors real Arbor pages where you might want both "go somewhere" and "configure how you export" in one menu.',
|
|
876
920
|
);
|
|
877
921
|
|
|
922
|
+
export const NestedDropdowns: Story = withDescription(
|
|
923
|
+
{
|
|
924
|
+
render: () => (
|
|
925
|
+
<div style={{ padding: 'var(--spacing-xlarge)' }}>
|
|
926
|
+
<Dropdown>
|
|
927
|
+
<Dropdown.Trigger>
|
|
928
|
+
<Button variant="secondary">Open menu</Button>
|
|
929
|
+
</Dropdown.Trigger>
|
|
930
|
+
<Dropdown.Content>
|
|
931
|
+
<Dropdown.Item onSelect={() => console.log('profile')}>Profile</Dropdown.Item>
|
|
932
|
+
<Dropdown.Sub>
|
|
933
|
+
<Dropdown.SubTrigger>More options</Dropdown.SubTrigger>
|
|
934
|
+
<Dropdown.SubContent>
|
|
935
|
+
<Dropdown.Item onSelect={() => console.log('settings')}>Settings</Dropdown.Item>
|
|
936
|
+
<Dropdown.Item onSelect={() => console.log('help')}>Help</Dropdown.Item>
|
|
937
|
+
</Dropdown.SubContent>
|
|
938
|
+
</Dropdown.Sub>
|
|
939
|
+
<Dropdown.Item onSelect={() => console.log('logout')}>Log out</Dropdown.Item>
|
|
940
|
+
</Dropdown.Content>
|
|
941
|
+
</Dropdown>
|
|
942
|
+
</div>
|
|
943
|
+
),
|
|
944
|
+
parameters: {
|
|
945
|
+
docs: {
|
|
946
|
+
source: {
|
|
947
|
+
language: 'tsx',
|
|
948
|
+
code: `import { Dropdown, Button } from '@arbor-education/design-system.components';
|
|
949
|
+
|
|
950
|
+
function NestedOptionsDropdown() {
|
|
951
|
+
return (
|
|
952
|
+
<Dropdown>
|
|
953
|
+
<Dropdown.Trigger>
|
|
954
|
+
<Button variant="secondary">Open menu</Button>
|
|
955
|
+
</Dropdown.Trigger>
|
|
956
|
+
<Dropdown.Content>
|
|
957
|
+
<Dropdown.Item onSelect={() => console.log('profile')}>Profile</Dropdown.Item>
|
|
958
|
+
<Dropdown.Sub>
|
|
959
|
+
<Dropdown.SubTrigger>More options</Dropdown.SubTrigger>
|
|
960
|
+
<Dropdown.SubContent>
|
|
961
|
+
<Dropdown.Item onSelect={() => console.log('settings')}>Settings</Dropdown.Item>
|
|
962
|
+
<Dropdown.Item onSelect={() => console.log('help')}>Help</Dropdown.Item>
|
|
963
|
+
</Dropdown.SubContent>
|
|
964
|
+
</Dropdown.Sub>
|
|
965
|
+
<Dropdown.Item onSelect={() => console.log('logout')}>Log out</Dropdown.Item>
|
|
966
|
+
</Dropdown.Content>
|
|
967
|
+
</Dropdown>
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export default NestedOptionsDropdown;`,
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
'`Dropdown.Sub`, `Dropdown.SubTrigger`, and `Dropdown.SubContent` render a cascading submenu — hover or focus "More options" to open a second-level menu without leaving the parent. Use nested submenus sparingly, for secondary actions that would otherwise clutter the top-level menu (e.g. burying rarely-used settings behind a "More options" item).',
|
|
977
|
+
);
|
|
978
|
+
|
|
878
979
|
export const WithIcons: Story = withDescription(
|
|
879
980
|
{
|
|
880
981
|
render: () => (
|
|
@@ -365,4 +365,51 @@ describe('Dropdown component', () => {
|
|
|
365
365
|
expect(item).toBeInTheDocument();
|
|
366
366
|
});
|
|
367
367
|
});
|
|
368
|
+
|
|
369
|
+
describe('nested dropdown (Sub)', () => {
|
|
370
|
+
const renderNestedDropdown = (itemProps: Partial<Dropdown.ItemProps> = {}) => render(
|
|
371
|
+
<Dropdown>
|
|
372
|
+
<Dropdown.Trigger>
|
|
373
|
+
<Button>Open Menu</Button>
|
|
374
|
+
</Dropdown.Trigger>
|
|
375
|
+
<Dropdown.Content>
|
|
376
|
+
<Dropdown.Sub>
|
|
377
|
+
<Dropdown.SubTrigger>More options</Dropdown.SubTrigger>
|
|
378
|
+
<Dropdown.SubContent>
|
|
379
|
+
<Dropdown.Item {...itemProps}>Nested item</Dropdown.Item>
|
|
380
|
+
</Dropdown.SubContent>
|
|
381
|
+
</Dropdown.Sub>
|
|
382
|
+
</Dropdown.Content>
|
|
383
|
+
</Dropdown>,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
test('renders sub trigger with a chevron-right icon', async () => {
|
|
387
|
+
renderNestedDropdown();
|
|
388
|
+
|
|
389
|
+
await userEvent.click(screen.getByText('Open Menu'));
|
|
390
|
+
|
|
391
|
+
const subTrigger = await screen.findByText('More options');
|
|
392
|
+
expect(subTrigger.closest('[role="menuitem"]')?.querySelector('.ds-icon-chevron-right')).toBeInTheDocument();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('opens sub content when the sub trigger is clicked', async () => {
|
|
396
|
+
renderNestedDropdown();
|
|
397
|
+
|
|
398
|
+
await userEvent.click(screen.getByText('Open Menu'));
|
|
399
|
+
await userEvent.click(await screen.findByText('More options'));
|
|
400
|
+
|
|
401
|
+
expect(await screen.findByText('Nested item')).toBeInTheDocument();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('fires click handler on a nested item inside sub content', async () => {
|
|
405
|
+
const handleClick = vi.fn();
|
|
406
|
+
renderNestedDropdown({ onClick: handleClick });
|
|
407
|
+
|
|
408
|
+
await userEvent.click(screen.getByText('Open Menu'));
|
|
409
|
+
await userEvent.click(await screen.findByText('More options'));
|
|
410
|
+
await userEvent.click(await screen.findByText('Nested item'));
|
|
411
|
+
|
|
412
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
368
415
|
});
|
|
@@ -5,6 +5,9 @@ import { DropdownItem } from './items/DropdownItem.js';
|
|
|
5
5
|
import { DropdownSelectItem, type DropdowSelectItemProps } from './items/DropdownSelectItem.js';
|
|
6
6
|
import { DropdownSeparator } from './items/DropdownSeparator.js';
|
|
7
7
|
import { DropdownGroup } from './items/DropdownGroup.js';
|
|
8
|
+
import { DropdownSub, type DropdownSubProps } from './DropdownSub.js';
|
|
9
|
+
import { DropdownSubTrigger, type DropdownSubTriggerProps } from './items/DropdownSubTrigger.js';
|
|
10
|
+
import { DropdownSubContent, type DropdownSubContentProps } from './DropdownSubContent.js';
|
|
8
11
|
|
|
9
12
|
export const Dropdown = (props: DropdownMenu.DropdownMenuProps) => {
|
|
10
13
|
const { children, ...rest } = props;
|
|
@@ -21,6 +24,9 @@ Dropdown.Item = DropdownItem;
|
|
|
21
24
|
Dropdown.SelectItem = DropdownSelectItem;
|
|
22
25
|
Dropdown.Separator = DropdownSeparator;
|
|
23
26
|
Dropdown.Group = DropdownGroup;
|
|
27
|
+
Dropdown.Sub = DropdownSub;
|
|
28
|
+
Dropdown.SubTrigger = DropdownSubTrigger;
|
|
29
|
+
Dropdown.SubContent = DropdownSubContent;
|
|
24
30
|
|
|
25
31
|
export namespace Dropdown {
|
|
26
32
|
export type Props = DropdownMenu.DropdownMenuProps;
|
|
@@ -30,4 +36,7 @@ export namespace Dropdown {
|
|
|
30
36
|
export type SelectItemProps = DropdowSelectItemProps;
|
|
31
37
|
export type SeparatorProps = DropdownMenu.DropdownMenuSeparatorProps;
|
|
32
38
|
export type GroupProps = DropdownMenu.DropdownMenuGroupProps;
|
|
39
|
+
export type SubProps = DropdownSubProps;
|
|
40
|
+
export type SubTriggerProps = DropdownSubTriggerProps;
|
|
41
|
+
export type SubContentProps = DropdownSubContentProps;
|
|
33
42
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { DropdownMenu } from 'radix-ui';
|
|
3
|
+
import { useContext } from 'react';
|
|
4
|
+
import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
|
|
5
|
+
|
|
6
|
+
export type DropdownSubContentProps = {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
portalProps?: DropdownMenu.DropdownMenuPortalProps;
|
|
9
|
+
contentProps?: DropdownMenu.DropdownMenuSubContentProps;
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const DropdownSubContent = (props: DropdownSubContentProps) => {
|
|
14
|
+
const { children, portalProps, contentProps, className = '' } = props;
|
|
15
|
+
const popupParentRef = useContext(PopupParentContext);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<DropdownMenu.Portal {...portalProps} container={popupParentRef.current}>
|
|
19
|
+
<DropdownMenu.SubContent {...contentProps} className={classNames('ds-dropdown__content', className)}>
|
|
20
|
+
{children}
|
|
21
|
+
</DropdownMenu.SubContent>
|
|
22
|
+
</DropdownMenu.Portal>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
@@ -53,3 +53,18 @@
|
|
|
53
53
|
width: 100%;
|
|
54
54
|
background-color: var(--color-grey-200);
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
.ds-dropdown__sub-trigger {
|
|
58
|
+
&[data-state="open"] {
|
|
59
|
+
background: var(--form-dropdown-form-drop-item-hover-color-background);
|
|
60
|
+
|
|
61
|
+
&:focus-visible {
|
|
62
|
+
background: var(--form-dropdown-form-drop-item-hover-color-background);
|
|
63
|
+
outline: none;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ds-dropdown__sub-trigger-icon {
|
|
68
|
+
margin-left: auto;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { DropdownMenu } from 'radix-ui';
|
|
3
|
+
import { Icon } from 'Components/icon/Icon';
|
|
4
|
+
|
|
5
|
+
export type DropdownSubTriggerProps = DropdownMenu.DropdownMenuSubTriggerProps;
|
|
6
|
+
|
|
7
|
+
export const DropdownSubTrigger = (props: DropdownSubTriggerProps) => {
|
|
8
|
+
const { children, className = '', ...rest } = props;
|
|
9
|
+
return (
|
|
10
|
+
<DropdownMenu.SubTrigger
|
|
11
|
+
className={classNames('ds-dropdown__item ds-dropdown__sub-trigger', className)}
|
|
12
|
+
{...rest}
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
<Icon className="ds-dropdown__sub-trigger-icon" name="chevron-right" size={16} />
|
|
16
|
+
</DropdownMenu.SubTrigger>
|
|
17
|
+
);
|
|
18
|
+
};
|