@arbor-education/design-system.components 0.23.2 → 0.24.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/components/sidebarNav/SidebarNav.d.ts +46 -0
  3. package/dist/components/sidebarNav/SidebarNav.d.ts.map +1 -0
  4. package/dist/components/sidebarNav/SidebarNav.js +102 -0
  5. package/dist/components/sidebarNav/SidebarNav.js.map +1 -0
  6. package/dist/components/sidebarNav/SidebarNav.stories.d.ts +61 -0
  7. package/dist/components/sidebarNav/SidebarNav.stories.d.ts.map +1 -0
  8. package/dist/components/sidebarNav/SidebarNav.stories.js +253 -0
  9. package/dist/components/sidebarNav/SidebarNav.stories.js.map +1 -0
  10. package/dist/components/sidebarNav/SidebarNav.test.d.ts +2 -0
  11. package/dist/components/sidebarNav/SidebarNav.test.d.ts.map +1 -0
  12. package/dist/components/sidebarNav/SidebarNav.test.js +240 -0
  13. package/dist/components/sidebarNav/SidebarNav.test.js.map +1 -0
  14. package/dist/components/sidebarNav/SidebarNavContext.d.ts +13 -0
  15. package/dist/components/sidebarNav/SidebarNavContext.d.ts.map +1 -0
  16. package/dist/components/sidebarNav/SidebarNavContext.js +15 -0
  17. package/dist/components/sidebarNav/SidebarNavContext.js.map +1 -0
  18. package/dist/components/sidebarNav/SidebarNavGroup.d.ts +10 -0
  19. package/dist/components/sidebarNav/SidebarNavGroup.d.ts.map +1 -0
  20. package/dist/components/sidebarNav/SidebarNavGroup.js +16 -0
  21. package/dist/components/sidebarNav/SidebarNavGroup.js.map +1 -0
  22. package/dist/components/sidebarNav/SidebarNavItem.d.ts +32 -0
  23. package/dist/components/sidebarNav/SidebarNavItem.d.ts.map +1 -0
  24. package/dist/components/sidebarNav/SidebarNavItem.js +43 -0
  25. package/dist/components/sidebarNav/SidebarNavItem.js.map +1 -0
  26. package/dist/components/sidebarNav/SidebarNavItemFavourite.d.ts +8 -0
  27. package/dist/components/sidebarNav/SidebarNavItemFavourite.d.ts.map +1 -0
  28. package/dist/components/sidebarNav/SidebarNavItemFavourite.js +14 -0
  29. package/dist/components/sidebarNav/SidebarNavItemFavourite.js.map +1 -0
  30. package/dist/components/sidebarNav/SidebarNavPanel.d.ts +4 -0
  31. package/dist/components/sidebarNav/SidebarNavPanel.d.ts.map +1 -0
  32. package/dist/components/sidebarNav/SidebarNavPanel.js +9 -0
  33. package/dist/components/sidebarNav/SidebarNavPanel.js.map +1 -0
  34. package/dist/components/sidebarNav/SidebarNavPanelNav.d.ts +10 -0
  35. package/dist/components/sidebarNav/SidebarNavPanelNav.d.ts.map +1 -0
  36. package/dist/components/sidebarNav/SidebarNavPanelNav.js +21 -0
  37. package/dist/components/sidebarNav/SidebarNavPanelNav.js.map +1 -0
  38. package/dist/components/sidebarNav/SidebarNavRail.d.ts +6 -0
  39. package/dist/components/sidebarNav/SidebarNavRail.d.ts.map +1 -0
  40. package/dist/components/sidebarNav/SidebarNavRail.js +7 -0
  41. package/dist/components/sidebarNav/SidebarNavRail.js.map +1 -0
  42. package/dist/components/sidebarNav/SidebarNavRailItem.d.ts +10 -0
  43. package/dist/components/sidebarNav/SidebarNavRailItem.d.ts.map +1 -0
  44. package/dist/components/sidebarNav/SidebarNavRailItem.js +24 -0
  45. package/dist/components/sidebarNav/SidebarNavRailItem.js.map +1 -0
  46. package/dist/components/sidebarNav/SidebarNavRailList.d.ts +4 -0
  47. package/dist/components/sidebarNav/SidebarNavRailList.d.ts.map +1 -0
  48. package/dist/components/sidebarNav/SidebarNavRailList.js +7 -0
  49. package/dist/components/sidebarNav/SidebarNavRailList.js.map +1 -0
  50. package/dist/components/sidebarNav/SidebarNavRailSlot.d.ts +6 -0
  51. package/dist/components/sidebarNav/SidebarNavRailSlot.d.ts.map +1 -0
  52. package/dist/components/sidebarNav/SidebarNavRailSlot.js +7 -0
  53. package/dist/components/sidebarNav/SidebarNavRailSlot.js.map +1 -0
  54. package/dist/components/sidebarNav/SidebarNavSeparator.d.ts +6 -0
  55. package/dist/components/sidebarNav/SidebarNavSeparator.d.ts.map +1 -0
  56. package/dist/components/sidebarNav/SidebarNavSeparator.js +8 -0
  57. package/dist/components/sidebarNav/SidebarNavSeparator.js.map +1 -0
  58. package/dist/components/sidebarNav/SidebarNavTitle.d.ts +4 -0
  59. package/dist/components/sidebarNav/SidebarNavTitle.d.ts.map +1 -0
  60. package/dist/components/sidebarNav/SidebarNavTitle.js +7 -0
  61. package/dist/components/sidebarNav/SidebarNavTitle.js.map +1 -0
  62. package/dist/components/sidebarNav/SidebarNavTooltip.d.ts +7 -0
  63. package/dist/components/sidebarNav/SidebarNavTooltip.d.ts.map +1 -0
  64. package/dist/components/sidebarNav/SidebarNavTooltip.js +9 -0
  65. package/dist/components/sidebarNav/SidebarNavTooltip.js.map +1 -0
  66. package/dist/components/sidebarNav/SidebarNavTrigger.d.ts +8 -0
  67. package/dist/components/sidebarNav/SidebarNavTrigger.d.ts.map +1 -0
  68. package/dist/components/sidebarNav/SidebarNavTrigger.js +15 -0
  69. package/dist/components/sidebarNav/SidebarNavTrigger.js.map +1 -0
  70. package/dist/components/sidebarNav/index.d.ts +4 -0
  71. package/dist/components/sidebarNav/index.d.ts.map +1 -0
  72. package/dist/components/sidebarNav/index.js +3 -0
  73. package/dist/components/sidebarNav/index.js.map +1 -0
  74. package/dist/components/sidebarNav/resolvePanelItemProps.d.ts +4 -0
  75. package/dist/components/sidebarNav/resolvePanelItemProps.d.ts.map +1 -0
  76. package/dist/components/sidebarNav/resolvePanelItemProps.js +43 -0
  77. package/dist/components/sidebarNav/resolvePanelItemProps.js.map +1 -0
  78. package/dist/components/sidebarNav/resolvePanelItemProps.test.d.ts +2 -0
  79. package/dist/components/sidebarNav/resolvePanelItemProps.test.d.ts.map +1 -0
  80. package/dist/components/sidebarNav/resolvePanelItemProps.test.js +52 -0
  81. package/dist/components/sidebarNav/resolvePanelItemProps.test.js.map +1 -0
  82. package/dist/components/sidebarNav/types.d.ts +100 -0
  83. package/dist/components/sidebarNav/types.d.ts.map +1 -0
  84. package/dist/components/sidebarNav/types.js +4 -0
  85. package/dist/components/sidebarNav/types.js.map +1 -0
  86. package/dist/components/sidebarNav/useControllableBoolean.d.ts +9 -0
  87. package/dist/components/sidebarNav/useControllableBoolean.d.ts.map +1 -0
  88. package/dist/components/sidebarNav/useControllableBoolean.js +14 -0
  89. package/dist/components/sidebarNav/useControllableBoolean.js.map +1 -0
  90. package/dist/index.css +275 -0
  91. package/dist/index.css.map +1 -1
  92. package/dist/index.d.ts +3 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +2 -0
  95. package/dist/index.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/components/sidebarNav/SidebarNav.stories.tsx +484 -0
  98. package/src/components/sidebarNav/SidebarNav.test.tsx +611 -0
  99. package/src/components/sidebarNav/SidebarNav.tsx +230 -0
  100. package/src/components/sidebarNav/SidebarNavContext.tsx +28 -0
  101. package/src/components/sidebarNav/SidebarNavGroup.tsx +59 -0
  102. package/src/components/sidebarNav/SidebarNavItem.tsx +160 -0
  103. package/src/components/sidebarNav/SidebarNavItemFavourite.tsx +49 -0
  104. package/src/components/sidebarNav/SidebarNavPanel.tsx +20 -0
  105. package/src/components/sidebarNav/SidebarNavPanelNav.tsx +55 -0
  106. package/src/components/sidebarNav/SidebarNavRail.tsx +20 -0
  107. package/src/components/sidebarNav/SidebarNavRailItem.tsx +84 -0
  108. package/src/components/sidebarNav/SidebarNavRailList.tsx +11 -0
  109. package/src/components/sidebarNav/SidebarNavRailSlot.tsx +15 -0
  110. package/src/components/sidebarNav/SidebarNavSeparator.tsx +19 -0
  111. package/src/components/sidebarNav/SidebarNavTitle.tsx +13 -0
  112. package/src/components/sidebarNav/SidebarNavTooltip.tsx +24 -0
  113. package/src/components/sidebarNav/SidebarNavTrigger.tsx +52 -0
  114. package/src/components/sidebarNav/index.ts +6 -0
  115. package/src/components/sidebarNav/resolvePanelItemProps.test.ts +57 -0
  116. package/src/components/sidebarNav/resolvePanelItemProps.ts +50 -0
  117. package/src/components/sidebarNav/sidebarNav.scss +283 -0
  118. package/src/components/sidebarNav/types.ts +126 -0
  119. package/src/components/sidebarNav/useControllableBoolean.ts +20 -0
  120. package/src/index.scss +1 -0
  121. package/src/index.ts +12 -0
  122. package/src/tokens.scss +14 -0
@@ -0,0 +1,611 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { Icon } from 'Components/icon/Icon';
5
+ import { describe, expect, test, vi } from 'vitest';
6
+ import { SidebarNav } from './SidebarNav.js';
7
+
8
+ describe('SidebarNav', () => {
9
+ test('toggles panel visibility via trigger (uncontrolled)', async () => {
10
+ render(
11
+ <SidebarNav defaultExpanded={false}>
12
+ <SidebarNav.Rail>
13
+ <SidebarNav.Trigger>
14
+ <Icon name="menu" size={24} />
15
+ </SidebarNav.Trigger>
16
+ </SidebarNav.Rail>
17
+ <SidebarNav.Panel>
18
+ <SidebarNav.Title>Attendance</SidebarNav.Title>
19
+ <SidebarNav.PanelNav items={[]} />
20
+ </SidebarNav.Panel>
21
+ </SidebarNav>,
22
+ );
23
+
24
+ const panel = screen.getByTestId('sidebar-nav-panel');
25
+ expect(panel).toHaveAttribute('aria-hidden', 'true');
26
+
27
+ const trigger = screen.getByRole('button', { name: 'Show/hide sub navigation' });
28
+ await userEvent.click(trigger);
29
+ expect(panel).toHaveAttribute('aria-hidden', 'false');
30
+ expect(screen.getByText('Attendance')).toBeInTheDocument();
31
+
32
+ await userEvent.click(trigger);
33
+ expect(panel).toHaveAttribute('aria-hidden', 'true');
34
+ });
35
+
36
+ test('expands and collapses groups in place', async () => {
37
+ render(
38
+ <SidebarNav>
39
+ <SidebarNav.Rail>
40
+ <SidebarNav.Trigger>
41
+ <Icon name="menu" size={24} />
42
+ </SidebarNav.Trigger>
43
+ </SidebarNav.Rail>
44
+ <SidebarNav.Panel>
45
+ <SidebarNav.PanelNav
46
+ items={[
47
+ {
48
+ type: 'group',
49
+ id: 'registers',
50
+ label: 'Registers',
51
+ children: [
52
+ { type: 'item', id: 'daily', label: 'Daily attendance', href: '/daily' },
53
+ ],
54
+ },
55
+ ]}
56
+ />
57
+ </SidebarNav.Panel>
58
+ </SidebarNav>,
59
+ );
60
+
61
+ const registersTrigger = screen.getByRole('button', { name: 'Registers' });
62
+ const groupChildren = document.getElementById(registersTrigger.getAttribute('aria-controls')!);
63
+ expect(groupChildren).toHaveAttribute('aria-hidden', 'true');
64
+
65
+ await userEvent.click(registersTrigger);
66
+ expect(groupChildren).toHaveAttribute('aria-hidden', 'false');
67
+ expect(screen.getByText('Daily attendance')).toBeInTheDocument();
68
+
69
+ await userEvent.click(registersTrigger);
70
+ expect(groupChildren).toHaveAttribute('aria-hidden', 'true');
71
+ });
72
+
73
+ test('renders group row as a button with aria-expanded', () => {
74
+ render(
75
+ <SidebarNav>
76
+ <SidebarNav.Rail>
77
+ <SidebarNav.Trigger>
78
+ <Icon name="menu" size={24} />
79
+ </SidebarNav.Trigger>
80
+ </SidebarNav.Rail>
81
+ <SidebarNav.Panel>
82
+ <SidebarNav.PanelNav
83
+ items={[
84
+ {
85
+ type: 'group',
86
+ id: 'registers',
87
+ label: 'Registers',
88
+ children: [],
89
+ },
90
+ ]}
91
+ />
92
+ </SidebarNav.Panel>
93
+ </SidebarNav>,
94
+ );
95
+
96
+ const registers = screen.getByRole('button', { name: 'Registers' });
97
+ expect(registers).toHaveAttribute('aria-expanded', 'false');
98
+ expect(registers.tagName).toBe('BUTTON');
99
+ });
100
+
101
+ test('supports nested groups (sub-sub menus)', async () => {
102
+ render(
103
+ <SidebarNav>
104
+ <SidebarNav.Rail>
105
+ <SidebarNav.Trigger>
106
+ <Icon name="menu" size={24} />
107
+ </SidebarNav.Trigger>
108
+ </SidebarNav.Rail>
109
+ <SidebarNav.Panel>
110
+ <SidebarNav.PanelNav
111
+ items={[
112
+ {
113
+ type: 'group',
114
+ id: 'a',
115
+ label: 'A',
116
+ children: [
117
+ {
118
+ type: 'group',
119
+ id: 'b',
120
+ label: 'B',
121
+ children: [{ type: 'item', id: 'c', label: 'C', href: '/c' }],
122
+ },
123
+ ],
124
+ },
125
+ ]}
126
+ />
127
+ </SidebarNav.Panel>
128
+ </SidebarNav>,
129
+ );
130
+
131
+ const triggerA = screen.getByRole('button', { name: 'A' });
132
+ const groupAChildren = document.getElementById(triggerA.getAttribute('aria-controls')!);
133
+ const triggerB = screen.getByRole('button', { name: 'B', hidden: true });
134
+ const groupC = document.getElementById(triggerB.getAttribute('aria-controls')!);
135
+
136
+ expect(groupC).toHaveAttribute('aria-hidden', 'true');
137
+ await userEvent.click(triggerA);
138
+ expect(groupAChildren).toHaveAttribute('aria-hidden', 'false');
139
+ expect(groupC).toHaveAttribute('aria-hidden', 'true');
140
+ await userEvent.click(screen.getByRole('button', { name: 'B' }));
141
+ expect(groupC).toHaveAttribute('aria-hidden', 'false');
142
+ expect(screen.getByText('C')).toBeInTheDocument();
143
+ });
144
+
145
+ test('uses renderLink for leaf items', () => {
146
+ const renderLink = vi.fn(({
147
+ href,
148
+ className,
149
+ children,
150
+ ariaCurrent,
151
+ onClick,
152
+ linkProps,
153
+ }) => (
154
+ <a
155
+ href={href}
156
+ className={className}
157
+ data-custom-link="true"
158
+ {...(ariaCurrent ? { 'aria-current': ariaCurrent } : {})}
159
+ {...linkProps}
160
+ onClick={onClick}
161
+ >
162
+ {children}
163
+ </a>
164
+ ));
165
+
166
+ render(
167
+ <SidebarNav renderLink={renderLink}>
168
+ <SidebarNav.Rail>
169
+ <SidebarNav.Trigger>
170
+ <Icon name="menu" size={24} />
171
+ </SidebarNav.Trigger>
172
+ </SidebarNav.Rail>
173
+ <SidebarNav.Panel>
174
+ <SidebarNav.PanelNav
175
+ items={[
176
+ { type: 'item', id: 'home', label: 'Home', href: '/' },
177
+ ]}
178
+ />
179
+ </SidebarNav.Panel>
180
+ </SidebarNav>,
181
+ );
182
+
183
+ expect(screen.getByText('Home').closest('a')).toHaveAttribute('data-custom-link', 'true');
184
+ expect(renderLink).toHaveBeenCalledWith(
185
+ expect.objectContaining({
186
+ linkProps: undefined,
187
+ }),
188
+ );
189
+ });
190
+
191
+ test('passes linkProps through renderLink for panel items', () => {
192
+ const renderLink = vi.fn(({ href, className, children, linkProps }) => (
193
+ <a href={href} className={className} data-custom-link="true" {...linkProps}>
194
+ {children}
195
+ </a>
196
+ ));
197
+
198
+ render(
199
+ <SidebarNav renderLink={renderLink}>
200
+ <SidebarNav.Rail>
201
+ <SidebarNav.Trigger>
202
+ <Icon name="menu" size={24} />
203
+ </SidebarNav.Trigger>
204
+ </SidebarNav.Rail>
205
+ <SidebarNav.Panel>
206
+ <SidebarNav.PanelNav>
207
+ <SidebarNav.Item itemId="daily" href="/daily" linkProps={{ 'data-track': 'daily' }}>
208
+ Daily Attendance
209
+ </SidebarNav.Item>
210
+ </SidebarNav.PanelNav>
211
+ </SidebarNav.Panel>
212
+ </SidebarNav>,
213
+ );
214
+
215
+ expect(screen.getByRole('link', { name: 'Daily Attendance' })).toHaveAttribute('data-track', 'daily');
216
+ expect(renderLink).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ linkProps: { 'data-track': 'daily' },
219
+ }),
220
+ );
221
+ });
222
+
223
+ test('passes linkElementProps through renderLink for panel items', () => {
224
+ const renderLink = vi.fn(({ href, className, children, linkProps, linkElementProps }) => (
225
+ <a
226
+ href={href}
227
+ className={className}
228
+ data-custom-link="true"
229
+ {...linkProps}
230
+ {...linkElementProps}
231
+ >
232
+ {children}
233
+ </a>
234
+ ));
235
+
236
+ render(
237
+ <SidebarNav renderLink={renderLink}>
238
+ <SidebarNav.Rail>
239
+ <SidebarNav.Trigger>
240
+ <Icon name="menu" size={24} />
241
+ </SidebarNav.Trigger>
242
+ </SidebarNav.Rail>
243
+ <SidebarNav.Panel>
244
+ <SidebarNav.PanelNav>
245
+ <SidebarNav.Item
246
+ itemId="daily"
247
+ href="/daily"
248
+ linkElementProps={{ 'aria-describedby': 'daily-desc' }}
249
+ >
250
+ Daily Attendance
251
+ </SidebarNav.Item>
252
+ </SidebarNav.PanelNav>
253
+ </SidebarNav.Panel>
254
+ </SidebarNav>,
255
+ );
256
+
257
+ expect(screen.getByRole('link', { name: 'Daily Attendance' })).toHaveAttribute(
258
+ 'aria-describedby',
259
+ 'daily-desc',
260
+ );
261
+ expect(renderLink).toHaveBeenCalledWith(
262
+ expect.objectContaining({
263
+ linkElementProps: { 'aria-describedby': 'daily-desc' },
264
+ }),
265
+ );
266
+ });
267
+
268
+ test('passes ariaLabel through renderLink for rail items', () => {
269
+ const renderLink = vi.fn(({ href, className, children, ariaLabel, onClick }) => (
270
+ <a
271
+ href={href}
272
+ className={className}
273
+ aria-label={ariaLabel}
274
+ data-custom-link="true"
275
+ onClick={onClick}
276
+ >
277
+ {children}
278
+ </a>
279
+ ));
280
+
281
+ render(
282
+ <SidebarNav renderLink={renderLink}>
283
+ <SidebarNav.Rail>
284
+ <SidebarNav.RailList>
285
+ <SidebarNav.RailItem href="/favourites" aria-label="Favourites">
286
+ <Icon name="favourite-outline" size={24} />
287
+ </SidebarNav.RailItem>
288
+ </SidebarNav.RailList>
289
+ </SidebarNav.Rail>
290
+ </SidebarNav>,
291
+ );
292
+
293
+ expect(screen.getByRole('link', { name: 'Favourites' })).toHaveAttribute('data-custom-link', 'true');
294
+ expect(renderLink).toHaveBeenCalledWith(
295
+ expect.objectContaining({
296
+ ariaLabel: 'Favourites',
297
+ }),
298
+ );
299
+ });
300
+
301
+ test('renders onClick-only panel items as buttons', async () => {
302
+ const onClick = vi.fn();
303
+
304
+ render(
305
+ <SidebarNav>
306
+ <SidebarNav.Rail>
307
+ <SidebarNav.Trigger>
308
+ <Icon name="menu" size={24} />
309
+ </SidebarNav.Trigger>
310
+ </SidebarNav.Rail>
311
+ <SidebarNav.Panel>
312
+ <SidebarNav.PanelNav
313
+ items={[
314
+ { type: 'item', id: 'action', label: 'Run action', onClick },
315
+ ]}
316
+ />
317
+ </SidebarNav.Panel>
318
+ </SidebarNav>,
319
+ );
320
+
321
+ const action = screen.getByRole('button', { name: 'Run action' });
322
+ expect(action.tagName).toBe('BUTTON');
323
+ await userEvent.click(action);
324
+ expect(onClick).toHaveBeenCalled();
325
+ });
326
+
327
+ test('renders composable button items via linkElement', async () => {
328
+ const onClick = vi.fn();
329
+
330
+ render(
331
+ <SidebarNav>
332
+ <SidebarNav.Rail>
333
+ <SidebarNav.Trigger>
334
+ <Icon name="menu" size={24} />
335
+ </SidebarNav.Trigger>
336
+ </SidebarNav.Rail>
337
+ <SidebarNav.Panel>
338
+ <SidebarNav.PanelNav>
339
+ <SidebarNav.Item
340
+ itemId="action"
341
+ linkElement="button"
342
+ linkElementProps={{ onClick }}
343
+ >
344
+ Run action
345
+ </SidebarNav.Item>
346
+ </SidebarNav.PanelNav>
347
+ </SidebarNav.Panel>
348
+ </SidebarNav>,
349
+ );
350
+
351
+ await userEvent.click(screen.getByRole('button', { name: 'Run action' }));
352
+ expect(onClick).toHaveBeenCalled();
353
+ });
354
+
355
+ test('prefers top-level onClick over linkElementProps.onClick when both are provided', async () => {
356
+ const topLevelOnClick = vi.fn();
357
+ const linkElementPropsOnClick = vi.fn();
358
+
359
+ render(
360
+ <SidebarNav>
361
+ <SidebarNav.Rail>
362
+ <SidebarNav.Trigger>
363
+ <Icon name="menu" size={24} />
364
+ </SidebarNav.Trigger>
365
+ </SidebarNav.Rail>
366
+ <SidebarNav.Panel>
367
+ <SidebarNav.PanelNav>
368
+ <SidebarNav.Item
369
+ itemId="action"
370
+ linkElement="button"
371
+ onClick={topLevelOnClick}
372
+ linkElementProps={{ onClick: linkElementPropsOnClick }}
373
+ >
374
+ Run action
375
+ </SidebarNav.Item>
376
+ </SidebarNav.PanelNav>
377
+ </SidebarNav.Panel>
378
+ </SidebarNav>,
379
+ );
380
+
381
+ await userEvent.click(screen.getByRole('button', { name: 'Run action' }));
382
+ expect(topLevelOnClick).toHaveBeenCalledTimes(1);
383
+ expect(linkElementPropsOnClick).not.toHaveBeenCalled();
384
+ });
385
+
386
+ test('assigns distinct test ids to composable items via required itemId', () => {
387
+ render(
388
+ <SidebarNav>
389
+ <SidebarNav.Rail>
390
+ <SidebarNav.Trigger>
391
+ <Icon name="menu" size={24} />
392
+ </SidebarNav.Trigger>
393
+ </SidebarNav.Rail>
394
+ <SidebarNav.Panel>
395
+ <SidebarNav.PanelNav>
396
+ <SidebarNav.Item itemId="first" linkElement="button" linkElementProps={{ onClick: vi.fn() }}>
397
+ First action
398
+ </SidebarNav.Item>
399
+ <SidebarNav.Item itemId="second" linkElement="button" linkElementProps={{ onClick: vi.fn() }}>
400
+ Second action
401
+ </SidebarNav.Item>
402
+ </SidebarNav.PanelNav>
403
+ </SidebarNav.Panel>
404
+ </SidebarNav>,
405
+ );
406
+
407
+ expect(screen.getByTestId('sidebar-nav-panel-item-first')).toBeInTheDocument();
408
+ expect(screen.getByTestId('sidebar-nav-panel-item-second')).toBeInTheDocument();
409
+ });
410
+
411
+ test('warns when composable Rail is wrapped and hybrid panel mode is used', () => {
412
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
413
+
414
+ render(
415
+ <SidebarNav panelTitle="Attendance" panelNavItems={[]}>
416
+ <>
417
+ <SidebarNav.Rail>
418
+ <SidebarNav.Trigger>
419
+ <Icon name="menu" size={24} />
420
+ </SidebarNav.Trigger>
421
+ </SidebarNav.Rail>
422
+ </>
423
+ </SidebarNav>,
424
+ );
425
+
426
+ expect(warnSpy).toHaveBeenCalledWith(
427
+ expect.stringContaining('SidebarNav.Rail must be a direct child of SidebarNav'),
428
+ );
429
+
430
+ warnSpy.mockRestore();
431
+ });
432
+
433
+ test('does not warn for rail-only data-driven sidebar', () => {
434
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
435
+
436
+ render(
437
+ <SidebarNav
438
+ railItems={[
439
+ {
440
+ id: 'burger',
441
+ label: 'Show/hide sub navigation',
442
+ iconName: 'menu',
443
+ opensPanel: true,
444
+ },
445
+ ]}
446
+ />,
447
+ );
448
+
449
+ expect(warnSpy).not.toHaveBeenCalled();
450
+
451
+ warnSpy.mockRestore();
452
+ });
453
+
454
+ test('defaults rail nav aria-label to Sidebar rail', () => {
455
+ render(
456
+ <SidebarNav defaultExpanded={false}>
457
+ <SidebarNav.Rail>
458
+ <SidebarNav.Trigger>
459
+ <Icon name="menu" size={24} />
460
+ </SidebarNav.Trigger>
461
+ </SidebarNav.Rail>
462
+ </SidebarNav>,
463
+ );
464
+
465
+ expect(screen.getByRole('navigation', { name: 'Sidebar rail' })).toBeInTheDocument();
466
+ });
467
+
468
+ test('allows overriding rail nav aria-label', () => {
469
+ render(
470
+ <SidebarNav defaultExpanded={false}>
471
+ <SidebarNav.Rail ariaLabel="App shortcuts">
472
+ <SidebarNav.Trigger>
473
+ <Icon name="menu" size={24} />
474
+ </SidebarNav.Trigger>
475
+ </SidebarNav.Rail>
476
+ </SidebarNav>,
477
+ );
478
+
479
+ expect(screen.getByRole('navigation', { name: 'App shortcuts' })).toBeInTheDocument();
480
+ });
481
+
482
+ test('uses data-driven railItems when an empty array is passed', () => {
483
+ render(
484
+ <SidebarNav railItems={[]} panelTitle="Attendance" panelNavItems={[]} />,
485
+ );
486
+
487
+ expect(screen.getByTestId('sidebar-nav-rail')).toBeInTheDocument();
488
+ expect(screen.queryByTestId('sidebar-nav-rail-item-burger')).not.toBeInTheDocument();
489
+ expect(screen.getByText('Attendance')).toBeInTheDocument();
490
+ });
491
+
492
+ test('prefixes data-driven trigger test id with entry id', () => {
493
+ render(
494
+ <SidebarNav
495
+ railItems={[
496
+ {
497
+ id: 'burger',
498
+ label: 'Show/hide sub navigation',
499
+ iconName: 'menu',
500
+ opensPanel: true,
501
+ },
502
+ ]}
503
+ panelNavItems={[]}
504
+ />,
505
+ );
506
+
507
+ expect(screen.getByTestId('sidebar-nav-panel-trigger-burger')).toBeInTheDocument();
508
+ });
509
+
510
+ test('renders favourite button outside the item link with aria-pressed', async () => {
511
+ const onFavouriteClick = vi.fn();
512
+
513
+ render(
514
+ <SidebarNav>
515
+ <SidebarNav.Rail>
516
+ <SidebarNav.Trigger>
517
+ <Icon name="menu" size={24} />
518
+ </SidebarNav.Trigger>
519
+ </SidebarNav.Rail>
520
+ <SidebarNav.Panel>
521
+ <SidebarNav.PanelNav
522
+ items={[
523
+ {
524
+ type: 'item',
525
+ id: 'daily',
526
+ label: 'Daily Attendance',
527
+ href: '/daily',
528
+ canFavourite: true,
529
+ isPressed: true,
530
+ favouriteTooltip: 'Remove from favourites',
531
+ onFavouriteClick,
532
+ },
533
+ ]}
534
+ />
535
+ </SidebarNav.Panel>
536
+ </SidebarNav>,
537
+ );
538
+
539
+ const listItem = screen.getByTestId('sidebar-nav-panel-item-daily');
540
+ const link = screen.getByRole('link', { name: 'Daily Attendance' });
541
+ const favourite = screen.getByTestId('sidebar-nav-panel-item-favourite-daily');
542
+
543
+ expect(listItem).toContainElement(link);
544
+ expect(listItem).toContainElement(favourite);
545
+ expect(favourite).not.toContainElement(link);
546
+ expect(link).not.toContainElement(favourite);
547
+ expect(favourite).toHaveAttribute('aria-pressed', 'true');
548
+
549
+ await userEvent.click(favourite);
550
+ expect(onFavouriteClick).toHaveBeenCalled();
551
+ });
552
+
553
+ test('uses favouriteTooltip as aria-label on favourite button', () => {
554
+ render(
555
+ <SidebarNav>
556
+ <SidebarNav.Rail>
557
+ <SidebarNav.Trigger>
558
+ <Icon name="menu" size={24} />
559
+ </SidebarNav.Trigger>
560
+ </SidebarNav.Rail>
561
+ <SidebarNav.Panel>
562
+ <SidebarNav.PanelNav
563
+ items={[
564
+ {
565
+ type: 'item',
566
+ id: 'daily',
567
+ label: 'Daily Attendance',
568
+ href: '/daily',
569
+ canFavourite: true,
570
+ isPressed: false,
571
+ favouriteTooltip: 'Add to favourites',
572
+ },
573
+ ]}
574
+ />
575
+ </SidebarNav.Panel>
576
+ </SidebarNav>,
577
+ );
578
+
579
+ expect(screen.getByTestId('sidebar-nav-panel-item-favourite-daily')).toHaveAttribute(
580
+ 'aria-label',
581
+ 'Add to favourites',
582
+ );
583
+ });
584
+
585
+ test('does not render favourite button when canFavourite is false', () => {
586
+ render(
587
+ <SidebarNav>
588
+ <SidebarNav.Rail>
589
+ <SidebarNav.Trigger>
590
+ <Icon name="menu" size={24} />
591
+ </SidebarNav.Trigger>
592
+ </SidebarNav.Rail>
593
+ <SidebarNav.Panel>
594
+ <SidebarNav.PanelNav
595
+ items={[
596
+ {
597
+ type: 'item',
598
+ id: 'daily',
599
+ label: 'Daily Attendance',
600
+ href: '/daily',
601
+ canFavourite: false,
602
+ },
603
+ ]}
604
+ />
605
+ </SidebarNav.Panel>
606
+ </SidebarNav>,
607
+ );
608
+
609
+ expect(screen.queryByTestId('sidebar-nav-panel-item-favourite-daily')).not.toBeInTheDocument();
610
+ });
611
+ });