@arbor-education/design-system.components 0.4.0 → 0.4.2

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 (134) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CLAUDE.md +9 -0
  3. package/dist/components/avatar/Avatar.d.ts +11 -0
  4. package/dist/components/avatar/Avatar.d.ts.map +1 -0
  5. package/dist/components/avatar/Avatar.js +17 -0
  6. package/dist/components/avatar/Avatar.js.map +1 -0
  7. package/dist/components/avatar/Avatar.stories.d.ts +14 -0
  8. package/dist/components/avatar/Avatar.stories.d.ts.map +1 -0
  9. package/dist/components/avatar/Avatar.stories.js +66 -0
  10. package/dist/components/avatar/Avatar.stories.js.map +1 -0
  11. package/dist/components/avatar/Avatar.test.d.ts +2 -0
  12. package/dist/components/avatar/Avatar.test.d.ts.map +1 -0
  13. package/dist/components/avatar/Avatar.test.js +51 -0
  14. package/dist/components/avatar/Avatar.test.js.map +1 -0
  15. package/dist/components/dropdown/Dropdown.d.ts +2 -0
  16. package/dist/components/dropdown/Dropdown.d.ts.map +1 -1
  17. package/dist/components/dropdown/Dropdown.js +5 -1
  18. package/dist/components/dropdown/Dropdown.js.map +1 -1
  19. package/dist/components/dropdown/items/DropdownGroup.d.ts +3 -0
  20. package/dist/components/dropdown/items/DropdownGroup.d.ts.map +1 -0
  21. package/dist/components/dropdown/items/DropdownGroup.js +8 -0
  22. package/dist/components/dropdown/items/DropdownGroup.js.map +1 -0
  23. package/dist/components/dropdown/items/DropdownSeparator.d.ts +3 -0
  24. package/dist/components/dropdown/items/DropdownSeparator.d.ts.map +1 -0
  25. package/dist/components/dropdown/items/DropdownSeparator.js +8 -0
  26. package/dist/components/dropdown/items/DropdownSeparator.js.map +1 -0
  27. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts +2 -0
  28. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
  29. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +2 -2
  30. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  31. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.d.ts +12 -0
  32. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.d.ts.map +1 -1
  33. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.js +13 -0
  34. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.js.map +1 -1
  35. package/dist/components/table/Table.d.ts.map +1 -1
  36. package/dist/components/table/Table.js +0 -2
  37. package/dist/components/table/Table.js.map +1 -1
  38. package/dist/components/table/Table.stories.d.ts.map +1 -1
  39. package/dist/components/table/Table.stories.js +2 -10
  40. package/dist/components/table/Table.stories.js.map +1 -1
  41. package/dist/components/table/Table.test.js +2 -40
  42. package/dist/components/table/Table.test.js.map +1 -1
  43. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.d.ts.map +1 -1
  44. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js +22 -12
  45. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js.map +1 -1
  46. package/dist/components/userDropdown/UserDropdown.d.ts +47 -0
  47. package/dist/components/userDropdown/UserDropdown.d.ts.map +1 -0
  48. package/dist/components/userDropdown/UserDropdown.js +13 -0
  49. package/dist/components/userDropdown/UserDropdown.js.map +1 -0
  50. package/dist/components/userDropdown/UserDropdown.stories.d.ts +12 -0
  51. package/dist/components/userDropdown/UserDropdown.stories.d.ts.map +1 -0
  52. package/dist/components/userDropdown/UserDropdown.stories.js +222 -0
  53. package/dist/components/userDropdown/UserDropdown.stories.js.map +1 -0
  54. package/dist/components/userDropdown/UserDropdown.test.d.ts +2 -0
  55. package/dist/components/userDropdown/UserDropdown.test.d.ts.map +1 -0
  56. package/dist/components/userDropdown/UserDropdown.test.js +197 -0
  57. package/dist/components/userDropdown/UserDropdown.test.js.map +1 -0
  58. package/dist/components/userDropdown/assets/arbor.png +0 -0
  59. package/dist/components/userDropdown/assets/govhub.png +0 -0
  60. package/dist/components/userDropdown/assets/key.png +0 -0
  61. package/dist/components/userDropdown/assets/logos.d.ts +7 -0
  62. package/dist/components/userDropdown/assets/logos.d.ts.map +1 -0
  63. package/dist/components/userDropdown/assets/logos.js +13 -0
  64. package/dist/components/userDropdown/assets/logos.js.map +1 -0
  65. package/dist/components/userDropdown/assets/robin.png +0 -0
  66. package/dist/components/userDropdown/assets/sampeople.png +0 -0
  67. package/dist/components/userDropdown/assets/timetabler.png +0 -0
  68. package/dist/components/userDropdown/internal/UserDropdownAppItem.d.ts +3 -0
  69. package/dist/components/userDropdown/internal/UserDropdownAppItem.d.ts.map +1 -0
  70. package/dist/components/userDropdown/internal/UserDropdownAppItem.js +9 -0
  71. package/dist/components/userDropdown/internal/UserDropdownAppItem.js.map +1 -0
  72. package/dist/components/userDropdown/internal/UserDropdownCollapsibleSection.d.ts +9 -0
  73. package/dist/components/userDropdown/internal/UserDropdownCollapsibleSection.d.ts.map +1 -0
  74. package/dist/components/userDropdown/internal/UserDropdownCollapsibleSection.js +11 -0
  75. package/dist/components/userDropdown/internal/UserDropdownCollapsibleSection.js.map +1 -0
  76. package/dist/components/userDropdown/internal/UserDropdownSignOut.d.ts +7 -0
  77. package/dist/components/userDropdown/internal/UserDropdownSignOut.d.ts.map +1 -0
  78. package/dist/components/userDropdown/internal/UserDropdownSignOut.js +9 -0
  79. package/dist/components/userDropdown/internal/UserDropdownSignOut.js.map +1 -0
  80. package/dist/components/userDropdown/internal/UserDropdownTrigger.d.ts +11 -0
  81. package/dist/components/userDropdown/internal/UserDropdownTrigger.d.ts.map +1 -0
  82. package/dist/components/userDropdown/internal/UserDropdownTrigger.js +10 -0
  83. package/dist/components/userDropdown/internal/UserDropdownTrigger.js.map +1 -0
  84. package/dist/components/userDropdown/internal/UserDropdownUserInfo.d.ts +8 -0
  85. package/dist/components/userDropdown/internal/UserDropdownUserInfo.d.ts.map +1 -0
  86. package/dist/components/userDropdown/internal/UserDropdownUserInfo.js +17 -0
  87. package/dist/components/userDropdown/internal/UserDropdownUserInfo.js.map +1 -0
  88. package/dist/index.css +401 -1
  89. package/dist/index.css.map +1 -1
  90. package/dist/index.d.ts +4 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +3 -0
  93. package/dist/index.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/components/avatar/Avatar.stories.tsx +84 -0
  96. package/src/components/avatar/Avatar.test.tsx +60 -0
  97. package/src/components/avatar/Avatar.tsx +68 -0
  98. package/src/components/avatar/avatar.scss +71 -0
  99. package/src/components/dropdown/Dropdown.tsx +5 -1
  100. package/src/components/dropdown/dropdown.scss +4 -1
  101. package/src/components/dropdown/items/DropdownGroup.tsx +11 -0
  102. package/src/components/dropdown/items/DropdownSeparator.tsx +9 -0
  103. package/src/components/formField/inputs/selectDropdown/SelectDropdown.stories.tsx +15 -0
  104. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +5 -1
  105. package/src/components/table/Table.stories.tsx +2 -10
  106. package/src/components/table/Table.test.tsx +2 -49
  107. package/src/components/table/Table.tsx +0 -2
  108. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.tsx +34 -19
  109. package/src/components/table/table.scss +4 -2
  110. package/src/components/userDropdown/UserDropdown.stories.tsx +237 -0
  111. package/src/components/userDropdown/UserDropdown.test.tsx +349 -0
  112. package/src/components/userDropdown/UserDropdown.tsx +110 -0
  113. package/src/components/userDropdown/assets/arbor.png +0 -0
  114. package/src/components/userDropdown/assets/govhub.png +0 -0
  115. package/src/components/userDropdown/assets/key.png +0 -0
  116. package/src/components/userDropdown/assets/logos.ts +13 -0
  117. package/src/components/userDropdown/assets/robin.png +0 -0
  118. package/src/components/userDropdown/assets/sampeople.png +0 -0
  119. package/src/components/userDropdown/assets/timetabler.png +0 -0
  120. package/src/components/userDropdown/internal/UserDropdownAppItem.tsx +21 -0
  121. package/src/components/userDropdown/internal/UserDropdownCollapsibleSection.tsx +38 -0
  122. package/src/components/userDropdown/internal/UserDropdownSignOut.tsx +19 -0
  123. package/src/components/userDropdown/internal/UserDropdownTrigger.tsx +42 -0
  124. package/src/components/userDropdown/internal/UserDropdownUserInfo.tsx +60 -0
  125. package/src/components/userDropdown/userDropdown.scss +377 -0
  126. package/src/index.scss +2 -0
  127. package/src/index.ts +4 -0
  128. package/tsconfig.json +1 -1
  129. package/vite-env.d.ts +31 -0
  130. package/dist/components/table/cellRenderers/SelectDropdownCellEditor.d.ts +0 -8
  131. package/dist/components/table/cellRenderers/SelectDropdownCellEditor.d.ts.map +0 -1
  132. package/dist/components/table/cellRenderers/SelectDropdownCellEditor.js +0 -19
  133. package/dist/components/table/cellRenderers/SelectDropdownCellEditor.js.map +0 -1
  134. package/src/components/table/cellRenderers/SelectDropdownCellEditor.tsx +0 -43
@@ -0,0 +1,237 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { UserDropdown } from './UserDropdown';
3
+ import { ArborLogo, GovhubLogo, KeyLogo, RobinLogo, SampeopleLogo, TimetablerLogo } from './assets/logos';
4
+
5
+ const meta: Meta<typeof UserDropdown> = {
6
+ title: 'Components/UserDropdown',
7
+ component: UserDropdown,
8
+ tags: ['autodocs'],
9
+ };
10
+
11
+ type Story = StoryObj<typeof UserDropdown>;
12
+
13
+ // Mock avatar image (placeholder)
14
+ const avatarSrc = 'data:image/svg+xml,%3Csvg width="32" height="32" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="16" cy="16" r="16" fill="%233cad51"/%3E%3Ctext x="16" y="21" font-family="Arial" font-size="14" font-weight="bold" fill="white" text-anchor="middle"%3ECM%3C/text%3E%3C/svg%3E';
15
+
16
+ // Import real logos from assets
17
+ const logoArborWorkflows = ArborLogo;
18
+ const logoGovernorHub = GovhubLogo;
19
+ const logoTheKey = KeyLogo;
20
+ const logoRobin = RobinLogo;
21
+ const logoSamPeople = SampeopleLogo;
22
+ const logoTimetabler = TimetablerLogo;
23
+
24
+ const AppLogo = ({ src, alt }: { src: string; alt: string }) => (
25
+ <img src={src} alt={alt} width={24} height={24} style={{ objectFit: 'contain' }} />
26
+ );
27
+
28
+ // Mock company logo SVG
29
+ const mockLogoSrc = 'data:image/svg+xml,%3Csvg width="50" height="30" xmlns="http://www.w3.org/2000/svg"%3E%3Crect width="50" height="30" rx="4" fill="%233cad51"/%3E%3Ctext x="25" y="20" font-family="Arial" font-size="14" font-weight="bold" fill="white" text-anchor="middle"%3ELogo%3C/text%3E%3C/svg%3E';
30
+
31
+ export const Default: Story = {
32
+ args: {
33
+ user: {
34
+ name: 'Christine Montgomery-Smith',
35
+ subtitle: 'Business Manager',
36
+ avatarSrc,
37
+ avatarAlt: 'Christine Montgomery-Smith',
38
+ },
39
+ logoSrc: mockLogoSrc,
40
+ logoAlt: 'Arbor',
41
+ sections: [
42
+ {
43
+ label: 'My Apps',
44
+ apps: [
45
+ {
46
+ logo: <AppLogo src={logoArborWorkflows} alt="Arbor Workflows" />,
47
+ name: 'Arbor Workflows',
48
+ onClick: () => { console.log('Open Arbor Workflows'); },
49
+ },
50
+ {
51
+ logo: <AppLogo src={logoGovernorHub} alt="GovernorHub" />,
52
+ name: 'GovernorHub',
53
+ onClick: () => { console.log('Open GovernorHub'); },
54
+ },
55
+ {
56
+ logo: <AppLogo src={logoTheKey} alt="The Key" />,
57
+ name: 'The Key',
58
+ onClick: () => { console.log('Open The Key'); },
59
+ },
60
+ ],
61
+ },
62
+ ],
63
+ discoverSection: {
64
+ label: 'Discover the Arbor suite',
65
+ defaultOpen: true,
66
+ apps: [
67
+ {
68
+ logo: <AppLogo src={logoArborWorkflows} alt="Arbor Finance" />,
69
+ name: 'Arbor Finance',
70
+ description: 'Cloud-based accounting',
71
+ onClick: () => { console.log('Open Arbor Finance'); },
72
+ },
73
+ {
74
+ logo: <AppLogo src={logoRobin} alt="Robin" />,
75
+ name: 'Robin',
76
+ description: 'AI website compliance checks',
77
+ onClick: () => { console.log('Open Robin'); },
78
+ },
79
+ {
80
+ logo: <AppLogo src={logoSamPeople} alt="SAMPeople" />,
81
+ name: 'SAMPeople',
82
+ description: 'HR and recruitment',
83
+ onClick: () => { console.log('Open SAMPeople'); },
84
+ },
85
+ {
86
+ logo: <AppLogo src={logoTimetabler} alt="Timetabler" />,
87
+ name: 'Timetabler',
88
+ description: 'Schedule timetables and more',
89
+ onClick: () => { console.log('Open Timetabler'); },
90
+ },
91
+ ],
92
+ },
93
+ userInfoAction: { type: 'link', onClick: () => { console.log('View profile'); } },
94
+ onSignOut: () => { console.log('Sign out'); },
95
+ signOutLabel: 'Sign out of Arbor',
96
+ },
97
+ };
98
+
99
+ export const WithApps: Story = {
100
+ args: {
101
+ user: {
102
+ name: 'Christine Montgomery-Smith',
103
+ subtitle: 'Business Manager',
104
+ avatarInitials: 'CM',
105
+ },
106
+ sections: [
107
+ {
108
+ label: 'Quick Actions',
109
+ apps: [
110
+ { name: 'Settings', onClick: () => { console.log('Settings'); } },
111
+ { name: 'Help', onClick: () => { console.log('Help'); } },
112
+ ],
113
+ },
114
+ ],
115
+ onSignOut: () => { console.log('Sign out'); },
116
+ },
117
+ };
118
+
119
+ export const Minimal: Story = {
120
+ args: {
121
+ user: {
122
+ name: 'Christine Montgomery',
123
+ avatarInitials: 'CM',
124
+ avatarAlt: 'Christine Montgomery',
125
+ },
126
+ onSignOut: () => { console.log('Sign out'); },
127
+ },
128
+ };
129
+
130
+ export const WithMenu: Story = {
131
+ args: {
132
+ user: {
133
+ name: 'Christine Montgomery-Smith',
134
+ subtitle: 'Business Manager',
135
+ avatarSrc,
136
+ avatarAlt: 'Christine Montgomery-Smith',
137
+ },
138
+ logoSrc: mockLogoSrc,
139
+ logoAlt: 'Arbor',
140
+ sections: [
141
+ {
142
+ label: 'My Apps',
143
+ apps: [
144
+ {
145
+ logo: <AppLogo src={logoArborWorkflows} alt="Arbor Workflows" />,
146
+ name: 'Arbor Workflows',
147
+ onClick: () => { console.log('Open Arbor Workflows'); },
148
+ },
149
+ ],
150
+ },
151
+ ],
152
+ userInfoAction: {
153
+ type: 'menu',
154
+ items: [
155
+ { label: 'Help centre', onClick: () => { console.log('Help centre'); } },
156
+ { label: 'About', onClick: () => { console.log('About'); } },
157
+ { label: 'Leave feedback', onClick: () => { console.log('Leave feedback'); } },
158
+ ],
159
+ },
160
+ onSignOut: () => { console.log('Sign out'); },
161
+ signOutLabel: 'Sign out of Arbor',
162
+ },
163
+ };
164
+
165
+ export const DarkVariant: Story = {
166
+ args: {
167
+ user: {
168
+ name: 'Christine Montgomery-Smith',
169
+ subtitle: 'Business Manager',
170
+ avatarSrc,
171
+ avatarAlt: 'Christine Montgomery-Smith',
172
+ },
173
+ logoSrc: mockLogoSrc,
174
+ logoAlt: 'Arbor',
175
+ variant: 'dark',
176
+ sections: [
177
+ {
178
+ label: 'My Apps',
179
+ apps: [
180
+ {
181
+ logo: <AppLogo src={logoArborWorkflows} alt="Arbor Workflows" />,
182
+ name: 'Arbor Workflows',
183
+ onClick: () => { console.log('Open Arbor Workflows'); },
184
+ },
185
+ ],
186
+ },
187
+ ],
188
+ onSignOut: () => { console.log('Sign out'); },
189
+ signOutLabel: 'Sign out of Arbor',
190
+ },
191
+ parameters: {
192
+ backgrounds: {
193
+ default: 'dark',
194
+ values: [
195
+ { name: 'dark', value: '#2f2f2f' },
196
+ ],
197
+ },
198
+ },
199
+ };
200
+
201
+ export const LightVariant: Story = {
202
+ args: {
203
+ user: {
204
+ name: 'Christine Montgomery-Smith',
205
+ subtitle: 'Business Manager',
206
+ avatarSrc,
207
+ avatarAlt: 'Christine Montgomery-Smith',
208
+ },
209
+ logoSrc: mockLogoSrc,
210
+ logoAlt: 'Arbor',
211
+ variant: 'light',
212
+ sections: [
213
+ {
214
+ label: 'My Apps',
215
+ apps: [
216
+ {
217
+ logo: <AppLogo src={logoArborWorkflows} alt="Arbor Workflows" />,
218
+ name: 'Arbor Workflows',
219
+ onClick: () => { console.log('Open Arbor Workflows'); },
220
+ },
221
+ ],
222
+ },
223
+ ],
224
+ onSignOut: () => { console.log('Sign out'); },
225
+ signOutLabel: 'Sign out of Arbor',
226
+ },
227
+ parameters: {
228
+ backgrounds: {
229
+ default: 'light',
230
+ values: [
231
+ { name: 'light', value: '#ffffff' },
232
+ ],
233
+ },
234
+ },
235
+ };
236
+
237
+ export default meta;
@@ -0,0 +1,349 @@
1
+ import { expect, test, describe, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { UserDropdown } from './UserDropdown';
5
+ import '@testing-library/jest-dom/vitest';
6
+
7
+ describe('UserDropdown', () => {
8
+ const mockUser = {
9
+ name: 'John Doe',
10
+ subtitle: 'Software Engineer',
11
+ avatarInitials: 'JD',
12
+ };
13
+
14
+ const mockOnSignOut = vi.fn();
15
+
16
+ const clickTrigger = async (user: ReturnType<typeof userEvent.setup>) => {
17
+ const trigger = screen.getByRole('button', { expanded: false });
18
+ await user.click(trigger);
19
+ };
20
+
21
+ test('renders with minimal props', async () => {
22
+ const user = userEvent.setup();
23
+
24
+ render(
25
+ <UserDropdown
26
+ user={mockUser}
27
+ onSignOut={mockOnSignOut}
28
+ />,
29
+ );
30
+
31
+ await clickTrigger(user);
32
+
33
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
34
+ expect(screen.getByText('Software Engineer')).toBeInTheDocument();
35
+ expect(screen.getByText('Sign out')).toBeInTheDocument();
36
+ });
37
+
38
+ test('renders user info with avatar image', async () => {
39
+ const user = userEvent.setup();
40
+
41
+ render(
42
+ <UserDropdown
43
+ user={{
44
+ ...mockUser,
45
+ avatarSrc: 'https://example.com/avatar.jpg',
46
+ avatarAlt: 'Test User',
47
+ }}
48
+ onSignOut={mockOnSignOut}
49
+ />,
50
+ );
51
+
52
+ await clickTrigger(user);
53
+
54
+ const images = screen.getAllByRole('img', { hidden: true });
55
+ const avatar = images.find(img => img.getAttribute('src') === 'https://example.com/avatar.jpg');
56
+ expect(avatar).toBeDefined();
57
+ expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg');
58
+ });
59
+
60
+ test('renders with logo', () => {
61
+ render(
62
+ <UserDropdown
63
+ user={mockUser}
64
+ logoSrc="https://example.com/logo.png"
65
+ logoAlt="Company Logo"
66
+ onSignOut={mockOnSignOut}
67
+ />,
68
+ );
69
+
70
+ const logo = screen.getByAltText('Company Logo');
71
+ expect(logo).toBeInTheDocument();
72
+ expect(logo).toHaveAttribute('src', 'https://example.com/logo.png');
73
+ });
74
+
75
+ test('renders without role', async () => {
76
+ const user = userEvent.setup();
77
+
78
+ render(
79
+ <UserDropdown
80
+ user={{ name: 'John Doe', avatarInitials: 'JD' }}
81
+ onSignOut={mockOnSignOut}
82
+ />,
83
+ );
84
+
85
+ await clickTrigger(user);
86
+
87
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
88
+ expect(screen.queryByText('Software Engineer')).not.toBeInTheDocument();
89
+ });
90
+
91
+ test('renders app sections', async () => {
92
+ const user = userEvent.setup();
93
+
94
+ render(
95
+ <UserDropdown
96
+ user={mockUser}
97
+ sections={[
98
+ {
99
+ label: 'My Apps',
100
+ apps: [
101
+ { name: 'App 1', onClick: vi.fn() },
102
+ { name: 'App 2', onClick: vi.fn() },
103
+ ],
104
+ },
105
+ ]}
106
+ onSignOut={mockOnSignOut}
107
+ />,
108
+ );
109
+
110
+ await clickTrigger(user);
111
+
112
+ expect(screen.getByText('My Apps')).toBeInTheDocument();
113
+ expect(screen.getByText('App 1')).toBeInTheDocument();
114
+ expect(screen.getByText('App 2')).toBeInTheDocument();
115
+ });
116
+
117
+ test('renders app item with description', async () => {
118
+ const user = userEvent.setup();
119
+
120
+ render(
121
+ <UserDropdown
122
+ user={mockUser}
123
+ sections={[
124
+ {
125
+ apps: [
126
+ { name: 'My App', description: 'A great app', onClick: vi.fn() },
127
+ ],
128
+ },
129
+ ]}
130
+ onSignOut={mockOnSignOut}
131
+ />,
132
+ );
133
+
134
+ await clickTrigger(user);
135
+
136
+ expect(screen.getByText('My App')).toBeInTheDocument();
137
+ expect(screen.getByText('A great app')).toBeInTheDocument();
138
+ });
139
+
140
+ test('renders app item with logo', async () => {
141
+ const user = userEvent.setup();
142
+
143
+ render(
144
+ <UserDropdown
145
+ user={mockUser}
146
+ sections={[
147
+ {
148
+ apps: [
149
+ {
150
+ name: 'My App',
151
+ logo: <img src="logo.png" alt="Logo" />,
152
+ onClick: vi.fn(),
153
+ },
154
+ ],
155
+ },
156
+ ]}
157
+ onSignOut={mockOnSignOut}
158
+ />,
159
+ );
160
+
161
+ await clickTrigger(user);
162
+
163
+ expect(screen.getByAltText('Logo')).toBeInTheDocument();
164
+ });
165
+
166
+ test('renders discover section collapsed by default', async () => {
167
+ const user = userEvent.setup();
168
+
169
+ render(
170
+ <UserDropdown
171
+ user={mockUser}
172
+ discoverSection={{
173
+ label: 'Discover',
174
+ apps: [
175
+ { name: 'Hidden App', onClick: vi.fn() },
176
+ ],
177
+ }}
178
+ onSignOut={mockOnSignOut}
179
+ />,
180
+ );
181
+
182
+ await clickTrigger(user);
183
+
184
+ expect(screen.getByText('Discover')).toBeInTheDocument();
185
+ expect(screen.queryByText('Hidden App')).not.toBeInTheDocument();
186
+ });
187
+
188
+ test('renders discover section open when defaultOpen is true', async () => {
189
+ const user = userEvent.setup();
190
+
191
+ render(
192
+ <UserDropdown
193
+ user={mockUser}
194
+ discoverSection={{
195
+ label: 'Discover',
196
+ defaultOpen: true,
197
+ apps: [
198
+ { name: 'Visible App', onClick: vi.fn() },
199
+ ],
200
+ }}
201
+ onSignOut={mockOnSignOut}
202
+ />,
203
+ );
204
+
205
+ await clickTrigger(user);
206
+
207
+ expect(screen.getByText('Discover')).toBeInTheDocument();
208
+ expect(screen.getByText('Visible App')).toBeInTheDocument();
209
+ });
210
+
211
+ test('toggles discover section when clicked', async () => {
212
+ const user = userEvent.setup();
213
+
214
+ render(
215
+ <UserDropdown
216
+ user={mockUser}
217
+ discoverSection={{
218
+ label: 'Discover',
219
+ apps: [
220
+ { name: 'Toggle App', onClick: vi.fn() },
221
+ ],
222
+ }}
223
+ onSignOut={mockOnSignOut}
224
+ />,
225
+ );
226
+
227
+ await clickTrigger(user);
228
+
229
+ expect(screen.queryByText('Toggle App')).not.toBeInTheDocument();
230
+
231
+ await user.click(screen.getByText('Discover'));
232
+ expect(screen.getByText('Toggle App')).toBeInTheDocument();
233
+
234
+ await user.click(screen.getByText('Discover'));
235
+ expect(screen.queryByText('Toggle App')).not.toBeInTheDocument();
236
+ });
237
+
238
+ test('calls userInfoAction onClick when user info is clicked with link action', async () => {
239
+ const user = userEvent.setup();
240
+ const handleClick = vi.fn();
241
+
242
+ render(
243
+ <UserDropdown
244
+ user={mockUser}
245
+ userInfoAction={{ type: 'link', onClick: handleClick }}
246
+ onSignOut={mockOnSignOut}
247
+ />,
248
+ );
249
+
250
+ await clickTrigger(user);
251
+
252
+ await user.click(screen.getByText('John Doe'));
253
+ expect(handleClick).toHaveBeenCalledTimes(1);
254
+ });
255
+
256
+ test('calls app onClick when app item is clicked', async () => {
257
+ const user = userEvent.setup();
258
+ const handleClick = vi.fn();
259
+
260
+ render(
261
+ <UserDropdown
262
+ user={mockUser}
263
+ sections={[
264
+ {
265
+ apps: [
266
+ { name: 'My App', onClick: handleClick },
267
+ ],
268
+ },
269
+ ]}
270
+ onSignOut={mockOnSignOut}
271
+ />,
272
+ );
273
+
274
+ await clickTrigger(user);
275
+
276
+ await user.click(screen.getByText('My App'));
277
+ expect(handleClick).toHaveBeenCalledTimes(1);
278
+ });
279
+
280
+ test('calls onSignOut when sign out is clicked', async () => {
281
+ const user = userEvent.setup();
282
+ const handleSignOut = vi.fn();
283
+
284
+ render(
285
+ <UserDropdown
286
+ user={mockUser}
287
+ onSignOut={handleSignOut}
288
+ />,
289
+ );
290
+
291
+ await clickTrigger(user);
292
+
293
+ await user.click(screen.getByText('Sign out'));
294
+ expect(handleSignOut).toHaveBeenCalledTimes(1);
295
+ });
296
+
297
+ test('renders custom sign out label', async () => {
298
+ const user = userEvent.setup();
299
+
300
+ render(
301
+ <UserDropdown
302
+ user={mockUser}
303
+ onSignOut={mockOnSignOut}
304
+ signOutLabel="Log out"
305
+ />,
306
+ );
307
+
308
+ await clickTrigger(user);
309
+
310
+ expect(screen.getByText('Log out')).toBeInTheDocument();
311
+ expect(screen.queryByText('Sign out')).not.toBeInTheDocument();
312
+ });
313
+
314
+ test('renders complete dropdown structure', async () => {
315
+ const user = userEvent.setup();
316
+
317
+ render(
318
+ <UserDropdown
319
+ user={mockUser}
320
+ logoSrc="https://example.com/logo.png"
321
+ sections={[
322
+ {
323
+ label: 'Apps',
324
+ apps: [
325
+ { name: 'App 1', onClick: vi.fn() },
326
+ ],
327
+ },
328
+ ]}
329
+ discoverSection={{
330
+ label: 'Discover',
331
+ apps: [
332
+ { name: 'App 2', onClick: vi.fn() },
333
+ ],
334
+ }}
335
+ userInfoAction={{ type: 'link', onClick: vi.fn() }}
336
+ onSignOut={mockOnSignOut}
337
+ signOutLabel="Sign out of Arbor"
338
+ />,
339
+ );
340
+
341
+ await clickTrigger(user);
342
+
343
+ expect(screen.getByRole('menu')).toBeInTheDocument();
344
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
345
+ expect(screen.getByText('Apps')).toBeInTheDocument();
346
+ expect(screen.getByText('App 1')).toBeInTheDocument();
347
+ expect(screen.getByText('Sign out of Arbor')).toBeInTheDocument();
348
+ });
349
+ });
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import { Dropdown } from 'Components/dropdown/Dropdown';
3
+ import { UserDropdownTrigger } from './internal/UserDropdownTrigger';
4
+ import { UserDropdownUserInfo } from './internal/UserDropdownUserInfo';
5
+ import { UserDropdownAppItem } from './internal/UserDropdownAppItem';
6
+ import { UserDropdownCollapsibleSection } from './internal/UserDropdownCollapsibleSection';
7
+ import { UserDropdownSignOut } from './internal/UserDropdownSignOut';
8
+
9
+ export type UserDropdownUser = {
10
+ name: string;
11
+ subtitle?: string;
12
+ avatarSrc?: string;
13
+ avatarAlt?: string;
14
+ avatarInitials?: string;
15
+ };
16
+
17
+ export type UserDropdownApp = {
18
+ logo?: React.ReactNode;
19
+ name: string;
20
+ description?: string;
21
+ onClick?: () => void;
22
+ };
23
+
24
+ export type UserDropdownSection = {
25
+ label?: string;
26
+ apps: UserDropdownApp[];
27
+ };
28
+
29
+ export type UserDropdownUserInfoAction
30
+ = | { type: 'link'; onClick: () => void }
31
+ | { type: 'menu'; items: Array<{ label: string; onClick: () => void }> };
32
+
33
+ export type UserDropdownProps = {
34
+ user: UserDropdownUser;
35
+ logoSrc?: string;
36
+ logoAlt?: string;
37
+ sections?: UserDropdownSection[];
38
+ discoverSection?: {
39
+ label: string;
40
+ apps: UserDropdownApp[];
41
+ defaultOpen?: boolean;
42
+ };
43
+ userInfoAction?: UserDropdownUserInfoAction;
44
+ onSignOut: () => void;
45
+ signOutLabel?: string;
46
+ open?: boolean;
47
+ onOpenChange?: (open: boolean) => void;
48
+ variant?: 'dark' | 'light';
49
+ };
50
+
51
+ export const UserDropdown = (props: UserDropdownProps) => {
52
+ const {
53
+ user,
54
+ logoSrc,
55
+ logoAlt,
56
+ sections = [],
57
+ discoverSection,
58
+ userInfoAction,
59
+ onSignOut,
60
+ signOutLabel,
61
+ open,
62
+ onOpenChange,
63
+ variant = 'dark',
64
+ } = props;
65
+
66
+ return (
67
+ <Dropdown open={open} onOpenChange={onOpenChange}>
68
+ <UserDropdownTrigger
69
+ avatarSrc={user.avatarSrc}
70
+ avatarAlt={user.avatarAlt}
71
+ avatarInitials={user.avatarInitials}
72
+ logoSrc={logoSrc}
73
+ logoAlt={logoAlt}
74
+ variant={variant}
75
+ />
76
+
77
+ <Dropdown.Content className="ds-user-dropdown__content" contentProps={{ sideOffset: 12 }}>
78
+ <UserDropdownUserInfo user={user} action={userInfoAction} />
79
+
80
+ {sections.length > 0 && (
81
+ <>
82
+ <Dropdown.Separator className="ds-user-dropdown__divider" />
83
+ {sections.map((section, index) => (
84
+ <React.Fragment key={index}>
85
+ {section.label && <div className="ds-user-dropdown__section-label">{section.label}</div>}
86
+ <Dropdown.Group className="ds-user-dropdown__section">
87
+ {section.apps.map((app, appIndex) => (
88
+ <UserDropdownAppItem key={appIndex} {...app} />
89
+ ))}
90
+ </Dropdown.Group>
91
+ </React.Fragment>
92
+ ))}
93
+ </>
94
+ )}
95
+
96
+ {discoverSection && (
97
+ <UserDropdownCollapsibleSection
98
+ label={discoverSection.label}
99
+ apps={discoverSection.apps}
100
+ defaultOpen={discoverSection.defaultOpen}
101
+ />
102
+ )}
103
+
104
+ <Dropdown.Separator className="ds-user-dropdown__divider" />
105
+
106
+ <UserDropdownSignOut onClick={onSignOut} label={signOutLabel} />
107
+ </Dropdown.Content>
108
+ </Dropdown>
109
+ );
110
+ };