@discourser/design-system 0.15.1 → 0.17.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/dist/{chunk-UNWXE6UB.cjs → chunk-2P7Z5PVP.cjs} +817 -16
- package/dist/chunk-2P7Z5PVP.cjs.map +1 -0
- package/dist/{chunk-ABC7N32K.cjs → chunk-PFWU7QSM.cjs} +464 -8
- package/dist/chunk-PFWU7QSM.cjs.map +1 -0
- package/dist/{chunk-GD6Q2FUE.js → chunk-QC7LGFM3.js} +808 -18
- package/dist/chunk-QC7LGFM3.js.map +1 -0
- package/dist/{chunk-SBKRSXSZ.js → chunk-SNUJBT5R.js} +464 -8
- package/dist/chunk-SNUJBT5R.js.map +1 -0
- package/dist/components/Accordion.figma.d.ts +2 -0
- package/dist/components/Accordion.figma.d.ts.map +1 -0
- package/dist/components/Breadcrumb.d.ts +2 -0
- package/dist/components/Breadcrumb.d.ts.map +1 -1
- package/dist/components/Breadcrumb.figma.d.ts +2 -0
- package/dist/components/Breadcrumb.figma.d.ts.map +1 -0
- package/dist/components/ContentCard/ContentCard.d.ts +13 -0
- package/dist/components/ContentCard/ContentCard.d.ts.map +1 -0
- package/dist/components/ContentCard/ContentCard.figma.d.ts +2 -0
- package/dist/components/ContentCard/ContentCard.figma.d.ts.map +1 -0
- package/dist/components/ContentCard/index.d.ts +2 -0
- package/dist/components/ContentCard/index.d.ts.map +1 -0
- package/dist/components/{Heading.d.ts → Header.d.ts} +3 -3
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.figma.d.ts +2 -0
- package/dist/components/Header.figma.d.ts.map +1 -0
- package/dist/components/Icons/AccountIcon.d.ts +6 -0
- package/dist/components/Icons/AccountIcon.d.ts.map +1 -0
- package/dist/components/Icons/ChevronUpIcon.d.ts +6 -0
- package/dist/components/Icons/ChevronUpIcon.d.ts.map +1 -0
- package/dist/components/Icons/ClockIcon.d.ts.map +1 -1
- package/dist/components/Icons/DashboardIcon.d.ts +6 -0
- package/dist/components/Icons/DashboardIcon.d.ts.map +1 -0
- package/dist/components/Icons/DiscourserLogo.d.ts +6 -0
- package/dist/components/Icons/DiscourserLogo.d.ts.map +1 -0
- package/dist/components/Icons/DiscourserLogo.figma.d.ts +2 -0
- package/dist/components/Icons/DiscourserLogo.figma.d.ts.map +1 -0
- package/dist/components/Icons/GripDotsVerticalIcon.d.ts.map +1 -1
- package/dist/components/Icons/HelpIcon.d.ts +6 -0
- package/dist/components/Icons/HelpIcon.d.ts.map +1 -0
- package/dist/components/Icons/NotebookIcon.d.ts +6 -0
- package/dist/components/Icons/NotebookIcon.d.ts.map +1 -0
- package/dist/components/Icons/RightArrowIcon.d.ts +6 -0
- package/dist/components/Icons/RightArrowIcon.d.ts.map +1 -0
- package/dist/components/Icons/ScenarioIcon.d.ts +6 -0
- package/dist/components/Icons/ScenarioIcon.d.ts.map +1 -0
- package/dist/components/Icons/index.d.ts +9 -1
- package/dist/components/Icons/index.d.ts.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.d.ts +3 -0
- package/dist/components/NavigationMenu/NavigationMenu.d.ts.map +1 -0
- package/dist/components/NavigationMenu/NavigationMenu.figma.d.ts +2 -0
- package/dist/components/NavigationMenu/NavigationMenu.figma.d.ts.map +1 -0
- package/dist/components/NavigationMenu/index.d.ts +3 -0
- package/dist/components/NavigationMenu/index.d.ts.map +1 -0
- package/dist/components/NavigationMenu/types.d.ts +25 -0
- package/dist/components/NavigationMenu/types.d.ts.map +1 -0
- package/dist/components/QuickStartPage/QuickStartPage.d.ts +21 -0
- package/dist/components/QuickStartPage/QuickStartPage.d.ts.map +1 -0
- package/dist/components/QuickStartPage/index.d.ts +3 -0
- package/dist/components/QuickStartPage/index.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/ScenarioQueue.figma.d.ts +2 -0
- package/dist/components/ScenarioQueue/ScenarioQueue.figma.d.ts.map +1 -0
- package/dist/components/ScenarioSettings/ScenarioSettings.d.ts +3 -0
- package/dist/components/ScenarioSettings/ScenarioSettings.d.ts.map +1 -0
- package/dist/components/ScenarioSettings/ScenarioSettings.figma.d.ts +2 -0
- package/dist/components/ScenarioSettings/ScenarioSettings.figma.d.ts.map +1 -0
- package/dist/components/ScenarioSettings/index.d.ts +3 -0
- package/dist/components/ScenarioSettings/index.d.ts.map +1 -0
- package/dist/components/ScenarioSettings/types.d.ts +54 -0
- package/dist/components/ScenarioSettings/types.d.ts.map +1 -0
- package/dist/components/index.cjs +86 -42
- package/dist/components/index.d.ts +14 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/figma-codex/config.d.ts +8 -0
- package/dist/figma-codex/config.d.ts.map +1 -0
- package/dist/figma-codex/fixtures/CompoundComponent/CompoundComponent.d.ts +6 -0
- package/dist/figma-codex/fixtures/CompoundComponent/CompoundComponent.d.ts.map +1 -0
- package/dist/figma-codex/fixtures/CompoundComponent/index.d.ts +2 -0
- package/dist/figma-codex/fixtures/CompoundComponent/index.d.ts.map +1 -0
- package/dist/figma-codex/fixtures/CompoundComponent.figma.d.ts +2 -0
- package/dist/figma-codex/fixtures/CompoundComponent.figma.d.ts.map +1 -0
- package/dist/figma-codex/fixtures/SimpleComponent.d.ts +8 -0
- package/dist/figma-codex/fixtures/SimpleComponent.d.ts.map +1 -0
- package/dist/figma-codex/fixtures/SimpleComponent.figma.d.ts +2 -0
- package/dist/figma-codex/fixtures/SimpleComponent.figma.d.ts.map +1 -0
- package/dist/figma-codex/generate.d.ts +6 -0
- package/dist/figma-codex/generate.d.ts.map +1 -0
- package/dist/figma-codex/parser.d.ts +18 -0
- package/dist/figma-codex/parser.d.ts.map +1 -0
- package/dist/figma-codex/resolver.d.ts +5 -0
- package/dist/figma-codex/resolver.d.ts.map +1 -0
- package/dist/figma-codex/schema.d.ts +60 -0
- package/dist/figma-codex/schema.d.ts.map +1 -0
- package/dist/figma-codex/writer.d.ts +8 -0
- package/dist/figma-codex/writer.d.ts.map +1 -0
- package/dist/figma-codex.json +373 -0
- package/dist/index.cjs +90 -46
- package/dist/index.js +2 -2
- package/dist/preset/index.cjs +2 -2
- package/dist/preset/index.d.ts.map +1 -1
- package/dist/preset/index.js +1 -1
- package/dist/preset/recipes/accordion.d.ts.map +1 -1
- package/dist/preset/recipes/breadcrumb.d.ts.map +1 -1
- package/dist/preset/recipes/content-card.d.ts +2 -0
- package/dist/preset/recipes/content-card.d.ts.map +1 -0
- package/dist/preset/recipes/index.d.ts +4 -0
- package/dist/preset/recipes/index.d.ts.map +1 -1
- package/dist/preset/recipes/navigation-menu.d.ts +2 -0
- package/dist/preset/recipes/navigation-menu.d.ts.map +1 -0
- package/dist/preset/recipes/scenario-settings.d.ts +2 -0
- package/dist/preset/recipes/scenario-settings.d.ts.map +1 -0
- package/package.json +26 -2
- package/src/components/Accordion.figma.tsx +20 -0
- package/src/components/Breadcrumb.figma.tsx +18 -0
- package/src/components/Breadcrumb.tsx +33 -15
- package/src/components/ContentCard/ContentCard.figma.tsx +21 -0
- package/src/components/ContentCard/ContentCard.test.tsx +197 -0
- package/src/components/ContentCard/ContentCard.tsx +19 -0
- package/src/components/ContentCard/index.ts +13 -0
- package/src/components/Header.figma.tsx +25 -0
- package/src/components/{Heading.tsx → Header.tsx} +2 -2
- package/src/components/Icons/AccountIcon.tsx +26 -0
- package/src/components/Icons/ChevronUpIcon.tsx +24 -0
- package/src/components/Icons/ClockIcon.tsx +6 -6
- package/src/components/Icons/DashboardIcon.tsx +47 -0
- package/src/components/Icons/Discourser-Logo.svg +14 -0
- package/src/components/Icons/DiscourserLogo.figma.tsx +10 -0
- package/src/components/Icons/DiscourserLogo.tsx +72 -0
- package/src/components/Icons/GripDotsVerticalIcon.tsx +6 -6
- package/src/components/Icons/HelpIcon.tsx +26 -0
- package/src/components/Icons/NotebookIcon.tsx +26 -0
- package/src/components/Icons/RightArrowIcon.tsx +23 -0
- package/src/components/Icons/ScenarioIcon.tsx +26 -0
- package/src/components/Icons/index.ts +13 -2
- package/src/components/NavigationMenu/NavigationMenu.figma.tsx +26 -0
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +524 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +102 -0
- package/src/components/NavigationMenu/index.ts +2 -0
- package/src/components/NavigationMenu/types.ts +27 -0
- package/src/components/QuickStartPage/QuickStartPage.tsx +627 -0
- package/src/components/QuickStartPage/index.ts +2 -0
- package/src/components/ScenarioQueue/ScenarioQueue.figma.tsx +37 -0
- package/src/components/ScenarioSettings/ScenarioSettings.figma.tsx +12 -0
- package/src/components/ScenarioSettings/ScenarioSettings.test.tsx +406 -0
- package/src/components/ScenarioSettings/ScenarioSettings.tsx +386 -0
- package/src/components/ScenarioSettings/index.ts +11 -0
- package/src/components/ScenarioSettings/types.ts +70 -0
- package/src/components/__tests__/Breadcrumb.test.tsx +94 -0
- package/src/components/index.ts +38 -4
- package/src/figma-codex/README.md +186 -0
- package/src/figma-codex/__tests__/config.test.ts +63 -0
- package/src/figma-codex/__tests__/generate.test.ts +78 -0
- package/src/figma-codex/__tests__/parser.test.ts +138 -0
- package/src/figma-codex/__tests__/resolver.test.ts +196 -0
- package/src/figma-codex/__tests__/writer.test.ts +111 -0
- package/src/figma-codex/config.ts +42 -0
- package/src/figma-codex/fixtures/CompoundComponent/CompoundComponent.tsx +17 -0
- package/src/figma-codex/fixtures/CompoundComponent/index.ts +1 -0
- package/src/figma-codex/fixtures/CompoundComponent.figma.tsx +14 -0
- package/src/figma-codex/fixtures/SimpleComponent.figma.tsx +10 -0
- package/src/figma-codex/fixtures/SimpleComponent.tsx +10 -0
- package/src/figma-codex/fixtures/expected-output.json +78 -0
- package/src/figma-codex/generate.ts +106 -0
- package/src/figma-codex/parser.ts +138 -0
- package/src/figma-codex/resolver.ts +280 -0
- package/src/figma-codex/schema.ts +79 -0
- package/src/figma-codex/writer.ts +54 -0
- package/src/preset/index.ts +6 -0
- package/src/preset/recipes/accordion.ts +8 -5
- package/src/preset/recipes/breadcrumb.ts +34 -2
- package/src/preset/recipes/content-card.ts +124 -0
- package/src/preset/recipes/index.ts +4 -0
- package/src/preset/recipes/navigation-menu.ts +97 -0
- package/src/preset/recipes/scenario-settings.ts +182 -0
- package/src/test/setup.ts +12 -9
- package/dist/chunk-ABC7N32K.cjs.map +0 -1
- package/dist/chunk-GD6Q2FUE.js.map +0 -1
- package/dist/chunk-SBKRSXSZ.js.map +0 -1
- package/dist/chunk-UNWXE6UB.cjs.map +0 -1
- package/dist/components/Heading.d.ts.map +0 -1
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/* global describe, it, expect, vi */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { axe } from 'jest-axe';
|
|
6
|
+
import { NavigationMenu } from './NavigationMenu';
|
|
7
|
+
import type { NavSection } from './types';
|
|
8
|
+
|
|
9
|
+
// ── Mock icon ─────────────────────────────────────────────────────────────────
|
|
10
|
+
// Avoids importing real DS icon SVGs — keeps tests free of styled-system deps.
|
|
11
|
+
|
|
12
|
+
const MockIcon = () =>
|
|
13
|
+
React.createElement('svg', { 'data-testid': 'section-icon' });
|
|
14
|
+
|
|
15
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
16
|
+
// Mirrors the mock data in NavigationMenu.stories.tsx
|
|
17
|
+
|
|
18
|
+
const MOCK_SECTIONS: NavSection[] = [
|
|
19
|
+
{
|
|
20
|
+
value: 'dashboard',
|
|
21
|
+
title: 'Dashboard',
|
|
22
|
+
icon: React.createElement(MockIcon),
|
|
23
|
+
items: [
|
|
24
|
+
{ label: 'Quick Start', href: '/dashboard/quick-start' },
|
|
25
|
+
{ label: 'Resume Session', href: '/dashboard/resume-session' },
|
|
26
|
+
{ label: 'Progress', href: '/dashboard/progress' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
value: 'scenarios',
|
|
31
|
+
title: 'Scenarios',
|
|
32
|
+
icon: React.createElement(MockIcon),
|
|
33
|
+
items: [
|
|
34
|
+
{ label: 'MyQueue', href: '/scenarios/my-queue' },
|
|
35
|
+
{ label: 'Conversation Studio', href: '/scenarios/conversation-studio' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
value: 'help',
|
|
40
|
+
title: 'Help',
|
|
41
|
+
icon: React.createElement(MockIcon),
|
|
42
|
+
items: [
|
|
43
|
+
{ label: 'How it Works', href: '/help/how-it-works' },
|
|
44
|
+
{ label: 'Contact Support', href: '/help/contact-support' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe('NavigationMenu', () => {
|
|
52
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe('Rendering', () => {
|
|
55
|
+
it('renders a <nav> with the provided aria-label', () => {
|
|
56
|
+
render(
|
|
57
|
+
<NavigationMenu
|
|
58
|
+
sections={MOCK_SECTIONS}
|
|
59
|
+
ariaLabel="Dashboard navigation"
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
screen.getByRole('navigation', { name: 'Dashboard navigation' }),
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('defaults ariaLabel to "Navigation" when not provided', () => {
|
|
69
|
+
render(<NavigationMenu sections={MOCK_SECTIONS} />);
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
screen.getByRole('navigation', { name: 'Navigation' }),
|
|
73
|
+
).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders one trigger button per section', () => {
|
|
77
|
+
render(<NavigationMenu sections={MOCK_SECTIONS} />);
|
|
78
|
+
|
|
79
|
+
expect(
|
|
80
|
+
screen.getByRole('button', { name: /Dashboard/i }),
|
|
81
|
+
).toBeInTheDocument();
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByRole('button', { name: /Scenarios/i }),
|
|
84
|
+
).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByRole('button', { name: /Help/i })).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders icons for each section', () => {
|
|
89
|
+
render(<NavigationMenu sections={MOCK_SECTIONS} />);
|
|
90
|
+
|
|
91
|
+
expect(screen.getAllByTestId('section-icon')).toHaveLength(
|
|
92
|
+
MOCK_SECTIONS.length,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renders nav items for sections that are open by default', () => {
|
|
97
|
+
render(
|
|
98
|
+
<NavigationMenu
|
|
99
|
+
sections={MOCK_SECTIONS}
|
|
100
|
+
defaultOpenSections={['dashboard']}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(
|
|
105
|
+
screen.getByRole('link', { name: 'Quick Start' }),
|
|
106
|
+
).toBeInTheDocument();
|
|
107
|
+
expect(
|
|
108
|
+
screen.getByRole('link', { name: 'Resume Session' }),
|
|
109
|
+
).toBeInTheDocument();
|
|
110
|
+
expect(
|
|
111
|
+
screen.getByRole('link', { name: 'Progress' }),
|
|
112
|
+
).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not render items for sections that are closed by default', () => {
|
|
116
|
+
render(
|
|
117
|
+
<NavigationMenu
|
|
118
|
+
sections={MOCK_SECTIONS}
|
|
119
|
+
defaultOpenSections={['dashboard']}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Scenarios section is closed — its items are not in the DOM
|
|
124
|
+
expect(
|
|
125
|
+
screen.queryByRole('link', { name: 'MyQueue' }),
|
|
126
|
+
).not.toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── Expand / Collapse ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('Expand / Collapse', () => {
|
|
133
|
+
it('closed section trigger has aria-expanded="false"', () => {
|
|
134
|
+
render(
|
|
135
|
+
<NavigationMenu sections={MOCK_SECTIONS} defaultOpenSections={[]} />,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const trigger = screen.getByRole('button', { name: /Dashboard/i });
|
|
139
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('open section trigger has aria-expanded="true"', () => {
|
|
143
|
+
render(
|
|
144
|
+
<NavigationMenu
|
|
145
|
+
sections={MOCK_SECTIONS}
|
|
146
|
+
defaultOpenSections={['dashboard']}
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const trigger = screen.getByRole('button', { name: /Dashboard/i });
|
|
151
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('clicking a closed section opens it and shows its items', async () => {
|
|
155
|
+
const user = userEvent.setup();
|
|
156
|
+
render(
|
|
157
|
+
<NavigationMenu sections={MOCK_SECTIONS} defaultOpenSections={[]} />,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const dashboardTrigger = screen.getByRole('button', {
|
|
161
|
+
name: /Dashboard/i,
|
|
162
|
+
});
|
|
163
|
+
await user.click(dashboardTrigger);
|
|
164
|
+
|
|
165
|
+
expect(dashboardTrigger).toHaveAttribute('aria-expanded', 'true');
|
|
166
|
+
expect(
|
|
167
|
+
screen.getByRole('link', { name: 'Quick Start' }),
|
|
168
|
+
).toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('clicking an open section closes it', async () => {
|
|
172
|
+
const user = userEvent.setup();
|
|
173
|
+
render(
|
|
174
|
+
<NavigationMenu
|
|
175
|
+
sections={MOCK_SECTIONS}
|
|
176
|
+
defaultOpenSections={['dashboard']}
|
|
177
|
+
/>,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const dashboardTrigger = screen.getByRole('button', {
|
|
181
|
+
name: /Dashboard/i,
|
|
182
|
+
});
|
|
183
|
+
await user.click(dashboardTrigger);
|
|
184
|
+
|
|
185
|
+
expect(dashboardTrigger).toHaveAttribute('aria-expanded', 'false');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('multiple sections can be open simultaneously', async () => {
|
|
189
|
+
const user = userEvent.setup();
|
|
190
|
+
render(
|
|
191
|
+
<NavigationMenu
|
|
192
|
+
sections={MOCK_SECTIONS}
|
|
193
|
+
defaultOpenSections={['dashboard']}
|
|
194
|
+
/>,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Open Scenarios section too
|
|
198
|
+
await user.click(screen.getByRole('button', { name: /Scenarios/i }));
|
|
199
|
+
|
|
200
|
+
expect(
|
|
201
|
+
screen.getByRole('button', { name: /Dashboard/i }),
|
|
202
|
+
).toHaveAttribute('aria-expanded', 'true');
|
|
203
|
+
expect(
|
|
204
|
+
screen.getByRole('button', { name: /Scenarios/i }),
|
|
205
|
+
).toHaveAttribute('aria-expanded', 'true');
|
|
206
|
+
expect(
|
|
207
|
+
screen.getByRole('link', { name: 'Quick Start' }),
|
|
208
|
+
).toBeInTheDocument();
|
|
209
|
+
expect(screen.getByRole('link', { name: 'MyQueue' })).toBeInTheDocument();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('all sections expand correctly when defaultOpenSections covers all values', () => {
|
|
213
|
+
render(
|
|
214
|
+
<NavigationMenu
|
|
215
|
+
sections={MOCK_SECTIONS}
|
|
216
|
+
defaultOpenSections={['dashboard', 'scenarios', 'help']}
|
|
217
|
+
/>,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(
|
|
221
|
+
screen.getByRole('link', { name: 'Quick Start' }),
|
|
222
|
+
).toBeInTheDocument();
|
|
223
|
+
expect(screen.getByRole('link', { name: 'MyQueue' })).toBeInTheDocument();
|
|
224
|
+
expect(
|
|
225
|
+
screen.getByRole('link', { name: 'How it Works' }),
|
|
226
|
+
).toBeInTheDocument();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── Active Item ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
describe('Active Item', () => {
|
|
233
|
+
it('active link has aria-current="page"', () => {
|
|
234
|
+
render(
|
|
235
|
+
<NavigationMenu
|
|
236
|
+
sections={MOCK_SECTIONS}
|
|
237
|
+
defaultOpenSections={['dashboard']}
|
|
238
|
+
activeHref="/dashboard/quick-start"
|
|
239
|
+
/>,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const activeLink = screen.getByRole('link', { name: 'Quick Start' });
|
|
243
|
+
expect(activeLink).toHaveAttribute('aria-current', 'page');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('active link has data-active="true"', () => {
|
|
247
|
+
render(
|
|
248
|
+
<NavigationMenu
|
|
249
|
+
sections={MOCK_SECTIONS}
|
|
250
|
+
defaultOpenSections={['dashboard']}
|
|
251
|
+
activeHref="/dashboard/quick-start"
|
|
252
|
+
/>,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const activeLink = screen.getByRole('link', { name: 'Quick Start' });
|
|
256
|
+
expect(activeLink).toHaveAttribute('data-active', 'true');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('non-active links do not have aria-current', () => {
|
|
260
|
+
render(
|
|
261
|
+
<NavigationMenu
|
|
262
|
+
sections={MOCK_SECTIONS}
|
|
263
|
+
defaultOpenSections={['dashboard']}
|
|
264
|
+
activeHref="/dashboard/quick-start"
|
|
265
|
+
/>,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const inactiveLink = screen.getByRole('link', { name: 'Resume Session' });
|
|
269
|
+
expect(inactiveLink).not.toHaveAttribute('aria-current');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('non-active links do not have data-active', () => {
|
|
273
|
+
render(
|
|
274
|
+
<NavigationMenu
|
|
275
|
+
sections={MOCK_SECTIONS}
|
|
276
|
+
defaultOpenSections={['dashboard']}
|
|
277
|
+
activeHref="/dashboard/quick-start"
|
|
278
|
+
/>,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const inactiveLink = screen.getByRole('link', { name: 'Resume Session' });
|
|
282
|
+
expect(inactiveLink).not.toHaveAttribute('data-active');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('only the matching href is marked active', () => {
|
|
286
|
+
render(
|
|
287
|
+
<NavigationMenu
|
|
288
|
+
sections={MOCK_SECTIONS}
|
|
289
|
+
defaultOpenSections={['dashboard', 'scenarios']}
|
|
290
|
+
activeHref="/dashboard/progress"
|
|
291
|
+
/>,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const links = screen.getAllByRole('link');
|
|
295
|
+
const activeLinks = links.filter((l) => l.hasAttribute('aria-current'));
|
|
296
|
+
expect(activeLinks).toHaveLength(1);
|
|
297
|
+
expect(activeLinks[0]).toHaveAccessibleName('Progress');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('no link is marked active when activeHref does not match any item', () => {
|
|
301
|
+
render(
|
|
302
|
+
<NavigationMenu
|
|
303
|
+
sections={MOCK_SECTIONS}
|
|
304
|
+
defaultOpenSections={['dashboard']}
|
|
305
|
+
activeHref="/unmatched/path"
|
|
306
|
+
/>,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const links = screen.getAllByRole('link');
|
|
310
|
+
for (const link of links) {
|
|
311
|
+
expect(link).not.toHaveAttribute('aria-current');
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── Navigation (onNavigate) ─────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe('Navigation', () => {
|
|
319
|
+
it('clicking a nav item calls onNavigate with the correct href', async () => {
|
|
320
|
+
const user = userEvent.setup();
|
|
321
|
+
const onNavigate = vi.fn();
|
|
322
|
+
|
|
323
|
+
render(
|
|
324
|
+
<NavigationMenu
|
|
325
|
+
sections={MOCK_SECTIONS}
|
|
326
|
+
defaultOpenSections={['dashboard']}
|
|
327
|
+
onNavigate={onNavigate}
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await user.click(screen.getByRole('link', { name: 'Quick Start' }));
|
|
332
|
+
|
|
333
|
+
expect(onNavigate).toHaveBeenCalledWith('/dashboard/quick-start');
|
|
334
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("clicking different items calls onNavigate with each item's href", async () => {
|
|
338
|
+
const user = userEvent.setup();
|
|
339
|
+
const onNavigate = vi.fn();
|
|
340
|
+
|
|
341
|
+
render(
|
|
342
|
+
<NavigationMenu
|
|
343
|
+
sections={MOCK_SECTIONS}
|
|
344
|
+
defaultOpenSections={['dashboard']}
|
|
345
|
+
onNavigate={onNavigate}
|
|
346
|
+
/>,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await user.click(screen.getByRole('link', { name: 'Quick Start' }));
|
|
350
|
+
await user.click(screen.getByRole('link', { name: 'Resume Session' }));
|
|
351
|
+
|
|
352
|
+
expect(onNavigate).toHaveBeenNthCalledWith(1, '/dashboard/quick-start');
|
|
353
|
+
expect(onNavigate).toHaveBeenNthCalledWith(
|
|
354
|
+
2,
|
|
355
|
+
'/dashboard/resume-session',
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('default link href attribute matches the nav item href', () => {
|
|
360
|
+
render(
|
|
361
|
+
<NavigationMenu
|
|
362
|
+
sections={MOCK_SECTIONS}
|
|
363
|
+
defaultOpenSections={['dashboard']}
|
|
364
|
+
/>,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const link = screen.getByRole('link', { name: 'Quick Start' });
|
|
368
|
+
expect(link).toHaveAttribute('href', '/dashboard/quick-start');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── Custom Link Renderer ────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
describe('Custom Link Renderer', () => {
|
|
375
|
+
it('renders the element returned by renderLink', () => {
|
|
376
|
+
render(
|
|
377
|
+
<NavigationMenu
|
|
378
|
+
sections={MOCK_SECTIONS}
|
|
379
|
+
defaultOpenSections={['dashboard']}
|
|
380
|
+
renderLink={({ href, children, className }) =>
|
|
381
|
+
React.createElement(
|
|
382
|
+
'a',
|
|
383
|
+
{ href, className, 'data-custom': 'true' },
|
|
384
|
+
children,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
/>,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const link = screen.getByRole('link', { name: 'Quick Start' });
|
|
391
|
+
expect(link).toHaveAttribute('data-custom', 'true');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('passes isActive=true to renderLink for the matching href', () => {
|
|
395
|
+
const renderLink = vi.fn(
|
|
396
|
+
({
|
|
397
|
+
href,
|
|
398
|
+
children,
|
|
399
|
+
}: {
|
|
400
|
+
href: string;
|
|
401
|
+
children: React.ReactNode;
|
|
402
|
+
isActive: boolean;
|
|
403
|
+
className: string;
|
|
404
|
+
}) => React.createElement('a', { href }, children),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
render(
|
|
408
|
+
<NavigationMenu
|
|
409
|
+
sections={MOCK_SECTIONS}
|
|
410
|
+
defaultOpenSections={['dashboard']}
|
|
411
|
+
activeHref="/dashboard/quick-start"
|
|
412
|
+
renderLink={renderLink}
|
|
413
|
+
/>,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const quickStartCall = renderLink.mock.calls.find(
|
|
417
|
+
([props]) => props.href === '/dashboard/quick-start',
|
|
418
|
+
);
|
|
419
|
+
expect(quickStartCall?.[0].isActive).toBe(true);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('passes isActive=false to renderLink for non-matching hrefs', () => {
|
|
423
|
+
const renderLink = vi.fn(
|
|
424
|
+
({
|
|
425
|
+
href,
|
|
426
|
+
children,
|
|
427
|
+
}: {
|
|
428
|
+
href: string;
|
|
429
|
+
children: React.ReactNode;
|
|
430
|
+
isActive: boolean;
|
|
431
|
+
className: string;
|
|
432
|
+
}) => React.createElement('a', { href }, children),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
render(
|
|
436
|
+
<NavigationMenu
|
|
437
|
+
sections={MOCK_SECTIONS}
|
|
438
|
+
defaultOpenSections={['dashboard']}
|
|
439
|
+
activeHref="/dashboard/quick-start"
|
|
440
|
+
renderLink={renderLink}
|
|
441
|
+
/>,
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const resumeCall = renderLink.mock.calls.find(
|
|
445
|
+
([props]) => props.href === '/dashboard/resume-session',
|
|
446
|
+
);
|
|
447
|
+
expect(resumeCall?.[0].isActive).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('passes the recipe className string to renderLink', () => {
|
|
451
|
+
let receivedClassName = '';
|
|
452
|
+
|
|
453
|
+
render(
|
|
454
|
+
<NavigationMenu
|
|
455
|
+
sections={MOCK_SECTIONS}
|
|
456
|
+
defaultOpenSections={['dashboard']}
|
|
457
|
+
renderLink={({ href, children, className }) => {
|
|
458
|
+
receivedClassName = className;
|
|
459
|
+
return React.createElement('a', { href }, children);
|
|
460
|
+
}}
|
|
461
|
+
/>,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(typeof receivedClassName).toBe('string');
|
|
465
|
+
expect(receivedClassName.length).toBeGreaterThan(0);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ── Accessibility ───────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
describe('Accessibility', () => {
|
|
472
|
+
it('passes axe audit with all sections open', async () => {
|
|
473
|
+
const { container } = render(
|
|
474
|
+
<NavigationMenu
|
|
475
|
+
sections={MOCK_SECTIONS}
|
|
476
|
+
defaultOpenSections={['dashboard', 'scenarios', 'help']}
|
|
477
|
+
activeHref="/dashboard/quick-start"
|
|
478
|
+
ariaLabel="Dashboard navigation"
|
|
479
|
+
/>,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const results = await axe(container);
|
|
483
|
+
expect(results).toHaveNoViolations();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('passes axe audit with all sections closed', async () => {
|
|
487
|
+
const { container } = render(
|
|
488
|
+
<NavigationMenu
|
|
489
|
+
sections={MOCK_SECTIONS}
|
|
490
|
+
defaultOpenSections={[]}
|
|
491
|
+
ariaLabel="Dashboard navigation"
|
|
492
|
+
/>,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const results = await axe(container);
|
|
496
|
+
expect(results).toHaveNoViolations();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('section triggers are keyboard-focusable buttons', () => {
|
|
500
|
+
render(<NavigationMenu sections={MOCK_SECTIONS} />);
|
|
501
|
+
|
|
502
|
+
const buttons = screen.getAllByRole('button');
|
|
503
|
+
expect(buttons).toHaveLength(MOCK_SECTIONS.length);
|
|
504
|
+
for (const btn of buttons) {
|
|
505
|
+
expect(btn.tagName).toBe('BUTTON');
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('nav items are rendered as links', () => {
|
|
510
|
+
render(
|
|
511
|
+
<NavigationMenu
|
|
512
|
+
sections={MOCK_SECTIONS}
|
|
513
|
+
defaultOpenSections={['dashboard']}
|
|
514
|
+
/>,
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const links = screen.getAllByRole('link');
|
|
518
|
+
expect(links.length).toBeGreaterThan(0);
|
|
519
|
+
for (const link of links) {
|
|
520
|
+
expect(link.tagName).toBe('A');
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Fragment } from 'react';
|
|
4
|
+
import * as Accordion from '../Accordion';
|
|
5
|
+
import { HStack } from 'styled-system/jsx';
|
|
6
|
+
import { navigationMenu } from 'styled-system/recipes';
|
|
7
|
+
import type { NavigationMenuProps } from './types';
|
|
8
|
+
|
|
9
|
+
export function NavigationMenu({
|
|
10
|
+
sections,
|
|
11
|
+
activeHref,
|
|
12
|
+
defaultOpenSections,
|
|
13
|
+
onNavigate,
|
|
14
|
+
renderLink,
|
|
15
|
+
ariaLabel = 'Navigation',
|
|
16
|
+
}: NavigationMenuProps) {
|
|
17
|
+
const styles = navigationMenu();
|
|
18
|
+
|
|
19
|
+
const defaultRenderLink: NavigationMenuProps['renderLink'] = ({
|
|
20
|
+
href,
|
|
21
|
+
children,
|
|
22
|
+
isActive,
|
|
23
|
+
className,
|
|
24
|
+
}) => (
|
|
25
|
+
<a
|
|
26
|
+
href={href}
|
|
27
|
+
aria-current={isActive ? 'page' : undefined}
|
|
28
|
+
data-active={isActive || undefined}
|
|
29
|
+
className={className}
|
|
30
|
+
onClick={(e) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
onNavigate?.(href);
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const resolvedRenderLink = renderLink ?? defaultRenderLink;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<nav className={styles.root} aria-label={ariaLabel}>
|
|
43
|
+
<Accordion.Root
|
|
44
|
+
defaultValue={defaultOpenSections}
|
|
45
|
+
multiple
|
|
46
|
+
variant="plain"
|
|
47
|
+
>
|
|
48
|
+
{sections.map((section) => (
|
|
49
|
+
<Accordion.Item
|
|
50
|
+
key={section.value}
|
|
51
|
+
value={section.value}
|
|
52
|
+
className={styles.section}
|
|
53
|
+
>
|
|
54
|
+
<Accordion.ItemTrigger
|
|
55
|
+
className={styles.sectionTrigger}
|
|
56
|
+
css={{
|
|
57
|
+
// These override accordion recipe via utilities layer (same pattern as ScenarioCard)
|
|
58
|
+
fontSize: 'lg', // 18px — Figma spec (accordion forces 'md'/16px)
|
|
59
|
+
fontWeight: 'medium', // 500 — Figma spec (accordion forces semibold/600)
|
|
60
|
+
borderRadius: 'l3', // 8px — Figma spec (accordion forces l2/6px)
|
|
61
|
+
p: '2', // 8px — Figma: 5px, spacing.2 is closest
|
|
62
|
+
bg: 'surface.container', // Figma: #eeefe3 section header bg
|
|
63
|
+
color: 'onSurface', // Figma: #363636 (accordion forces fg.default)
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<HStack gap="2">
|
|
67
|
+
<span className={styles.sectionIcon}>{section.icon}</span>
|
|
68
|
+
<span className={styles.sectionTitle}>{section.title}</span>
|
|
69
|
+
</HStack>
|
|
70
|
+
<Accordion.ItemIndicator
|
|
71
|
+
className={styles.sectionIndicator}
|
|
72
|
+
css={{
|
|
73
|
+
color: 'primary.50', // olive green chevrons — Figma: #518500
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</Accordion.ItemTrigger>
|
|
77
|
+
|
|
78
|
+
<Accordion.ItemContent className={styles.sectionContent}>
|
|
79
|
+
<div className={styles.itemList}>
|
|
80
|
+
{section.items.map((item) => {
|
|
81
|
+
const isActive = activeHref === item.href;
|
|
82
|
+
return (
|
|
83
|
+
<Fragment key={item.href}>
|
|
84
|
+
<div className={styles.item}>
|
|
85
|
+
{resolvedRenderLink({
|
|
86
|
+
href: item.href,
|
|
87
|
+
children: item.label,
|
|
88
|
+
isActive,
|
|
89
|
+
className: styles.itemLink,
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
</Fragment>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
</Accordion.ItemContent>
|
|
97
|
+
</Accordion.Item>
|
|
98
|
+
))}
|
|
99
|
+
</Accordion.Root>
|
|
100
|
+
</nav>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface NavItem {
|
|
4
|
+
label: string;
|
|
5
|
+
href: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NavSection {
|
|
9
|
+
value: string;
|
|
10
|
+
title: string;
|
|
11
|
+
icon: React.ReactNode;
|
|
12
|
+
items: NavItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NavigationMenuProps {
|
|
16
|
+
sections: NavSection[];
|
|
17
|
+
activeHref?: string;
|
|
18
|
+
defaultOpenSections?: string[];
|
|
19
|
+
onNavigate?: (href: string) => void;
|
|
20
|
+
renderLink?: (props: {
|
|
21
|
+
href: string;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
isActive: boolean;
|
|
24
|
+
className: string;
|
|
25
|
+
}) => React.ReactNode;
|
|
26
|
+
ariaLabel?: string;
|
|
27
|
+
}
|