@startsimpli/ui 0.4.84 → 0.4.86
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/package.json +3 -3
- package/src/components/index.ts +2 -4
- package/src/components/navigation/__tests__/entity-switcher.test.tsx +258 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +103 -1
- package/src/components/navigation/__tests__/switcher-slot.test.tsx +102 -0
- package/src/components/navigation/app-shell.tsx +8 -0
- package/src/components/navigation/entity-switcher.tsx +293 -0
- package/src/components/navigation/index.ts +6 -0
- package/src/components/navigation/sidebar.tsx +45 -10
- package/src/components/team/MembersSection.tsx +56 -1
- package/src/components/team/MembersTable.tsx +49 -1
- package/src/components/team/__tests__/member-provenance.test.tsx +132 -0
- package/src/components/team/index.ts +4 -1
- package/src/components/team/members-section-default-class-names.ts +8 -0
- package/src/components/team/members-table-default-class-names.ts +13 -0
- package/src/components/team/types.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.86",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
44
44
|
"react": "^18.0.0 || ^19.0.0",
|
|
45
45
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
46
|
-
"@startsimpli/api": "^0.5.
|
|
47
|
-
"@startsimpli/auth": "^0.4.
|
|
46
|
+
"@startsimpli/api": "^0.5.53",
|
|
47
|
+
"@startsimpli/auth": "^0.4.32"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
package/src/components/index.ts
CHANGED
|
@@ -50,10 +50,8 @@ export type {
|
|
|
50
50
|
// UnifiedTable utils
|
|
51
51
|
export * from './unified-table/utils'
|
|
52
52
|
|
|
53
|
-
// Export Navigation
|
|
54
|
-
export * from './navigation
|
|
55
|
-
export * from './navigation/app-shell'
|
|
56
|
-
export * from './navigation/grouped-nav'
|
|
53
|
+
// Export Navigation (Sidebar, AppShell, GroupedNav, EntitySwitcher)
|
|
54
|
+
export * from './navigation'
|
|
57
55
|
|
|
58
56
|
// Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
|
|
59
57
|
export * from './workspace'
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntitySwitcher — generic searchable "which record am I looking at?" control
|
|
3
|
+
* (startsim-768w.21.1).
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately entity-agnostic: items are `{ id, label, sublabel?, badge? }`,
|
|
6
|
+
* so the same control switches foundries, companies, projects or workspaces.
|
|
7
|
+
* These tests pin the contract the rail (and later OrgSwitcher, .21.4) relies on.
|
|
8
|
+
*/
|
|
9
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { EntitySwitcher, type EntitySwitcherItem } from '../entity-switcher'
|
|
12
|
+
|
|
13
|
+
const items: EntitySwitcherItem[] = [
|
|
14
|
+
{ id: 'acme', label: 'Acme Robotics', sublabel: 'acme' },
|
|
15
|
+
{ id: 'globex', label: 'Globex', sublabel: 'globex-industries' },
|
|
16
|
+
{ id: 'initech', label: 'Initech', sublabel: 'initech', badge: <span data-testid="badge-initech">live</span> },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function setup(props: Partial<React.ComponentProps<typeof EntitySwitcher>> = {}) {
|
|
20
|
+
const onSelect = jest.fn()
|
|
21
|
+
const utils = render(
|
|
22
|
+
<EntitySwitcher items={items} currentId="acme" onSelect={onSelect} {...props} />
|
|
23
|
+
)
|
|
24
|
+
return { onSelect, ...utils }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('EntitySwitcher — trigger', () => {
|
|
28
|
+
it("renders the current item's label on the trigger", () => {
|
|
29
|
+
setup()
|
|
30
|
+
expect(screen.getByRole('combobox')).toHaveTextContent('Acme Robotics')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('exposes combobox a11y wiring on the trigger', async () => {
|
|
34
|
+
const user = userEvent.setup()
|
|
35
|
+
setup()
|
|
36
|
+
const trigger = screen.getByRole('combobox')
|
|
37
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
38
|
+
|
|
39
|
+
await user.click(trigger)
|
|
40
|
+
|
|
41
|
+
const listbox = await screen.findByRole('listbox')
|
|
42
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
43
|
+
expect(trigger).toHaveAttribute('aria-controls', listbox.id)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('takes a triggerClassName override so it can sit on a dark rail', () => {
|
|
47
|
+
setup({ triggerClassName: 'my-dark-trigger' })
|
|
48
|
+
expect(screen.getByRole('combobox')).toHaveClass('my-dark-trigger')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('EntitySwitcher — opening and filtering', () => {
|
|
53
|
+
it('opening the trigger reveals a labelled filter input and every item', async () => {
|
|
54
|
+
const user = userEvent.setup()
|
|
55
|
+
setup()
|
|
56
|
+
|
|
57
|
+
await user.click(screen.getByRole('combobox'))
|
|
58
|
+
|
|
59
|
+
// Labelled (accessible name), not a bare unlabelled textbox.
|
|
60
|
+
const filter = await screen.findByRole('textbox', { name: /search|filter/i })
|
|
61
|
+
expect(filter).toBeInTheDocument()
|
|
62
|
+
|
|
63
|
+
const options = screen.getAllByRole('option')
|
|
64
|
+
expect(options).toHaveLength(3)
|
|
65
|
+
expect(options.map((o) => o.textContent)).toEqual(
|
|
66
|
+
expect.arrayContaining([expect.stringContaining('Acme Robotics')])
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('filters on the label, case-insensitively', async () => {
|
|
71
|
+
const user = userEvent.setup()
|
|
72
|
+
setup()
|
|
73
|
+
await user.click(screen.getByRole('combobox'))
|
|
74
|
+
await user.type(await screen.findByRole('textbox', { name: /search|filter/i }), 'GLOB')
|
|
75
|
+
|
|
76
|
+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1))
|
|
77
|
+
expect(screen.getByRole('option')).toHaveTextContent('Globex')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('filters on the sublabel too, case-insensitively', async () => {
|
|
81
|
+
const user = userEvent.setup()
|
|
82
|
+
setup()
|
|
83
|
+
await user.click(screen.getByRole('combobox'))
|
|
84
|
+
// "industries" only appears in Globex's sublabel, never in a label.
|
|
85
|
+
await user.type(await screen.findByRole('textbox', { name: /search|filter/i }), 'INDUSTRIES')
|
|
86
|
+
|
|
87
|
+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1))
|
|
88
|
+
expect(screen.getByRole('option')).toHaveTextContent('Globex')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders an explicit empty state when nothing matches, not a blank popover', async () => {
|
|
92
|
+
const user = userEvent.setup()
|
|
93
|
+
setup({ emptyLabel: 'No foundries match' })
|
|
94
|
+
await user.click(screen.getByRole('combobox'))
|
|
95
|
+
await user.type(await screen.findByRole('textbox', { name: /search|filter/i }), 'zzzz')
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(screen.queryAllByRole('option')).toHaveLength(0))
|
|
98
|
+
expect(screen.getByText('No foundries match')).toBeInTheDocument()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('hides the filter input when searchable={false}', async () => {
|
|
102
|
+
const user = userEvent.setup()
|
|
103
|
+
setup({ searchable: false })
|
|
104
|
+
await user.click(screen.getByRole('combobox'))
|
|
105
|
+
|
|
106
|
+
await screen.findByRole('listbox')
|
|
107
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
|
108
|
+
expect(screen.getAllByRole('option')).toHaveLength(3)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('renders a per-item badge when supplied', async () => {
|
|
112
|
+
const user = userEvent.setup()
|
|
113
|
+
setup()
|
|
114
|
+
await user.click(screen.getByRole('combobox'))
|
|
115
|
+
expect(await screen.findByTestId('badge-initech')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Regression (startsim-768w.21.1.3): the sublabel used to be hard-wired to
|
|
119
|
+
// `text-muted-foreground`, a colour chosen for `bg-popover`. On the
|
|
120
|
+
// highlighted row — which paints `bg-accent` — that grey-on-accent measured
|
|
121
|
+
// 1.1:1 in the browser, i.e. effectively invisible. The sublabel must track
|
|
122
|
+
// the row's own foreground.
|
|
123
|
+
it('switches the sublabel to the accent foreground on the highlighted row', async () => {
|
|
124
|
+
const user = userEvent.setup()
|
|
125
|
+
setup()
|
|
126
|
+
await user.click(screen.getByRole('combobox'))
|
|
127
|
+
await screen.findByRole('listbox')
|
|
128
|
+
|
|
129
|
+
const sublabelOf = (name: RegExp) =>
|
|
130
|
+
screen.getByRole('option', { name }).querySelector('span > span.text-xs')!
|
|
131
|
+
|
|
132
|
+
// Opens highlighted on the current item (Acme Robotics).
|
|
133
|
+
await waitFor(() =>
|
|
134
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
135
|
+
'data-highlighted',
|
|
136
|
+
'true'
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
expect(sublabelOf(/Acme Robotics/)).toHaveClass('text-accent-foreground/80')
|
|
140
|
+
expect(sublabelOf(/Acme Robotics/)).not.toHaveClass('text-muted-foreground')
|
|
141
|
+
|
|
142
|
+
// ...and the un-highlighted rows keep the muted treatment.
|
|
143
|
+
expect(sublabelOf(/Globex/)).toHaveClass('text-muted-foreground')
|
|
144
|
+
expect(sublabelOf(/Globex/)).not.toHaveClass('text-accent-foreground/80')
|
|
145
|
+
|
|
146
|
+
// The treatment follows the highlight rather than the selection.
|
|
147
|
+
await user.keyboard('{ArrowDown}')
|
|
148
|
+
await waitFor(() =>
|
|
149
|
+
expect(sublabelOf(/Globex/)).toHaveClass('text-accent-foreground/80')
|
|
150
|
+
)
|
|
151
|
+
expect(sublabelOf(/Acme Robotics/)).toHaveClass('text-muted-foreground')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('EntitySwitcher — a11y of the list', () => {
|
|
156
|
+
it('renders role=listbox with role=option children and marks the current one aria-selected', async () => {
|
|
157
|
+
const user = userEvent.setup()
|
|
158
|
+
setup()
|
|
159
|
+
await user.click(screen.getByRole('combobox'))
|
|
160
|
+
|
|
161
|
+
const listbox = await screen.findByRole('listbox')
|
|
162
|
+
expect(listbox).toBeInTheDocument()
|
|
163
|
+
|
|
164
|
+
const options = screen.getAllByRole('option')
|
|
165
|
+
expect(options).toHaveLength(3)
|
|
166
|
+
const current = options.find((o) => o.textContent?.includes('Acme Robotics'))!
|
|
167
|
+
expect(current).toHaveAttribute('aria-selected', 'true')
|
|
168
|
+
const other = options.find((o) => o.textContent?.includes('Globex'))!
|
|
169
|
+
expect(other).toHaveAttribute('aria-selected', 'false')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('EntitySwitcher — keyboard', () => {
|
|
174
|
+
it('ArrowDown/ArrowUp move the highlighted option', async () => {
|
|
175
|
+
const user = userEvent.setup()
|
|
176
|
+
setup()
|
|
177
|
+
await user.click(screen.getByRole('combobox'))
|
|
178
|
+
const filter = await screen.findByRole('textbox', { name: /search|filter/i })
|
|
179
|
+
|
|
180
|
+
// Opens highlighted on the current item (index 0).
|
|
181
|
+
await waitFor(() =>
|
|
182
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
183
|
+
'data-highlighted',
|
|
184
|
+
'true'
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
expect(filter).toHaveAttribute(
|
|
188
|
+
'aria-activedescendant',
|
|
189
|
+
screen.getByRole('option', { name: /Acme Robotics/ }).id
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
await user.keyboard('{ArrowDown}')
|
|
193
|
+
await waitFor(() =>
|
|
194
|
+
expect(screen.getByRole('option', { name: /Globex/ })).toHaveAttribute('data-highlighted', 'true')
|
|
195
|
+
)
|
|
196
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
197
|
+
'data-highlighted',
|
|
198
|
+
'false'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
await user.keyboard('{ArrowUp}')
|
|
202
|
+
await waitFor(() =>
|
|
203
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
204
|
+
'data-highlighted',
|
|
205
|
+
'true'
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('Enter selects the highlighted option', async () => {
|
|
211
|
+
const user = userEvent.setup()
|
|
212
|
+
const { onSelect } = setup()
|
|
213
|
+
await user.click(screen.getByRole('combobox'))
|
|
214
|
+
await screen.findByRole('listbox')
|
|
215
|
+
|
|
216
|
+
await user.keyboard('{ArrowDown}')
|
|
217
|
+
await user.keyboard('{Enter}')
|
|
218
|
+
|
|
219
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
220
|
+
expect(onSelect).toHaveBeenCalledWith('globex')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('Escape closes the popover and restores focus to the trigger', async () => {
|
|
224
|
+
const user = userEvent.setup()
|
|
225
|
+
const { onSelect } = setup()
|
|
226
|
+
const trigger = screen.getByRole('combobox')
|
|
227
|
+
await user.click(trigger)
|
|
228
|
+
await screen.findByRole('listbox')
|
|
229
|
+
|
|
230
|
+
await user.keyboard('{Escape}')
|
|
231
|
+
|
|
232
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
|
233
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
234
|
+
await waitFor(() => expect(trigger).toHaveFocus())
|
|
235
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('EntitySwitcher — selection', () => {
|
|
240
|
+
it("clicking an item calls onSelect with that item's id and closes", async () => {
|
|
241
|
+
const user = userEvent.setup()
|
|
242
|
+
const { onSelect } = setup()
|
|
243
|
+
await user.click(screen.getByRole('combobox'))
|
|
244
|
+
|
|
245
|
+
await user.click(await screen.findByRole('option', { name: /Initech/ }))
|
|
246
|
+
|
|
247
|
+
expect(onSelect).toHaveBeenCalledWith('initech')
|
|
248
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('selecting the already-current item still reports it (caller decides to no-op)', async () => {
|
|
252
|
+
const user = userEvent.setup()
|
|
253
|
+
const { onSelect } = setup()
|
|
254
|
+
await user.click(screen.getByRole('combobox'))
|
|
255
|
+
await user.click(await screen.findByRole('option', { name: /Acme Robotics/ }))
|
|
256
|
+
expect(onSelect).toHaveBeenCalledWith('acme')
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
1
|
+
import { render, screen, fireEvent, within } from '@testing-library/react'
|
|
2
|
+
import { usePathname } from 'next/navigation'
|
|
2
3
|
import { Sidebar, SidebarLayout } from '../sidebar'
|
|
3
4
|
|
|
5
|
+
const mockPathname = usePathname as unknown as jest.Mock
|
|
6
|
+
|
|
4
7
|
// next/link and next/navigation are mocked via jest.config.js moduleNameMapper
|
|
5
8
|
|
|
6
9
|
const sections = [
|
|
@@ -161,6 +164,105 @@ describe('Sidebar', () => {
|
|
|
161
164
|
})
|
|
162
165
|
})
|
|
163
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// The rail as PRIMARY nav (startsim-768w.21.3)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A control plane puts its whole section list in the rail — a foundry
|
|
173
|
+
* contributes nine scoped links under a titled group. That promotes the rail
|
|
174
|
+
* from "app shortcuts" to the thing you navigate with, and a tab strip used to
|
|
175
|
+
* provide two things for free that the rail owes back:
|
|
176
|
+
*
|
|
177
|
+
* • the current item ANNOUNCED, not just tinted. Colour is not an accessible
|
|
178
|
+
* name; `aria-current="page"` is the standard for "you are here".
|
|
179
|
+
* • "current" decided by path SEGMENT, not by string prefix.
|
|
180
|
+
* '/foundry/x/builds'.startsWith('/foundry/x/build') is true, so a prefix
|
|
181
|
+
* test lights up two peer surfaces at once — and the nine foundry sections
|
|
182
|
+
* include exactly that pair.
|
|
183
|
+
*
|
|
184
|
+
* Plus a section addressable by its title, so a consumer can assert "these
|
|
185
|
+
* links, under this group" without walking up from a heading — and a pathname
|
|
186
|
+
* that may legitimately be null (the hook returns null outside an app-router
|
|
187
|
+
* provider) instead of throwing.
|
|
188
|
+
*/
|
|
189
|
+
describe('Sidebar — which link is current (768w.21.3)', () => {
|
|
190
|
+
const railLinks = [
|
|
191
|
+
{ href: '/', label: 'Foundries' },
|
|
192
|
+
{ href: '/foundry/mcr/build', label: 'Build with AI' },
|
|
193
|
+
{ href: '/foundry/mcr/builds', label: 'Builds' },
|
|
194
|
+
{ href: '/foundry/mcr/members', label: 'Members' },
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
const renderAt = (pathname: string | null) => {
|
|
198
|
+
mockPathname.mockReturnValue(pathname)
|
|
199
|
+
return render(<Sidebar appName="Foundry" sections={[{ links: railLinks }]} />)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const current = () =>
|
|
203
|
+
screen
|
|
204
|
+
.getAllByRole('link')
|
|
205
|
+
.filter((a) => a.getAttribute('aria-current') === 'page')
|
|
206
|
+
.map((a) => a.getAttribute('href'))
|
|
207
|
+
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
mockPathname.mockReturnValue('/test-path')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('announces the current link with aria-current="page"', () => {
|
|
213
|
+
renderAt('/foundry/mcr/members')
|
|
214
|
+
expect(current()).toEqual(['/foundry/mcr/members'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('does not let a prefix sibling light up too', () => {
|
|
218
|
+
renderAt('/foundry/mcr/builds')
|
|
219
|
+
expect(current()).toEqual(['/foundry/mcr/builds'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('keeps a link current on a deeper URL under it', () => {
|
|
223
|
+
renderAt('/foundry/mcr/members/u-42')
|
|
224
|
+
expect(current()).toEqual(['/foundry/mcr/members'])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('marks the root link current only at the root', () => {
|
|
228
|
+
renderAt('/')
|
|
229
|
+
expect(current()).toEqual(['/'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('renders with no current link rather than throwing on a null pathname', () => {
|
|
233
|
+
// usePathname() is null outside an app-router provider — shared chrome gets
|
|
234
|
+
// rendered in harnesses and tests that have no router.
|
|
235
|
+
expect(() => renderAt(null)).not.toThrow()
|
|
236
|
+
expect(current()).toEqual([])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('still tints the current link, so the marker and the pill agree', () => {
|
|
240
|
+
renderAt('/foundry/mcr/builds')
|
|
241
|
+
expect(screen.getByText('Builds').closest('a')).toHaveClass('bg-primary')
|
|
242
|
+
expect(screen.getByText('Build with AI').closest('a')).not.toHaveClass('bg-primary')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('Sidebar — titled groups (768w.21.3)', () => {
|
|
247
|
+
const sectionsWithTitle = [
|
|
248
|
+
{ links: [{ href: '/', label: 'Foundries' }] },
|
|
249
|
+
{ title: 'My Copyright', links: [{ href: '/foundry/mcr/members', label: 'Members' }] },
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
it('exposes a titled section as a group carrying that name', () => {
|
|
253
|
+
render(<Sidebar appName="Foundry" sections={sectionsWithTitle} />)
|
|
254
|
+
const group = screen.getByRole('group', { name: 'My Copyright' })
|
|
255
|
+
expect(within(group).getByRole('link', { name: 'Members' })).toBeInTheDocument()
|
|
256
|
+
expect(within(group).queryByRole('link', { name: 'Foundries' })).toBeNull()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('keeps the group name while collapsed, when the heading is hidden', () => {
|
|
260
|
+
render(<Sidebar appName="Foundry" sections={sectionsWithTitle} defaultCollapsed />)
|
|
261
|
+
expect(screen.queryByText('My Copyright')).toBeNull()
|
|
262
|
+
expect(screen.getByRole('group', { name: 'My Copyright' })).toBeInTheDocument()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
164
266
|
// ---------------------------------------------------------------------------
|
|
165
267
|
// SidebarLayout
|
|
166
268
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sidebar's contextual `switcher` slot (startsim-768w.21.1).
|
|
3
|
+
*
|
|
4
|
+
* Control-plane apps need a "which record am I scoped to?" control mounted in
|
|
5
|
+
* the rail itself — directly under the brand, above the nav sections — instead
|
|
6
|
+
* of round-tripping through an index page. The slot is a plain ReactNode so the
|
|
7
|
+
* rail stays generic; the foundry app passes an <EntitySwitcher>.
|
|
8
|
+
*/
|
|
9
|
+
import { render, screen } from '@testing-library/react'
|
|
10
|
+
import { Sidebar } from '../sidebar'
|
|
11
|
+
import { AppShell } from '../app-shell'
|
|
12
|
+
|
|
13
|
+
jest.mock('@startsimpli/auth', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
useAuth: () => ({
|
|
16
|
+
user: { id: 'u1', email: 'q@x.com' },
|
|
17
|
+
isLoading: false,
|
|
18
|
+
logout: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
}),
|
|
20
|
+
buildCentralAuthUrl: (flow: string, opts: { app: string }) =>
|
|
21
|
+
`https://auth.startsimpli.com/${flow}?app=${opts.app}`,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const sections = [
|
|
25
|
+
{ title: 'Main', links: [{ href: '/', label: 'Dashboard' }] },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** True when `a` appears before `b` in document order. */
|
|
29
|
+
function precedes(a: Element, b: Element) {
|
|
30
|
+
// eslint-disable-next-line no-bitwise
|
|
31
|
+
return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('Sidebar switcher slot', () => {
|
|
35
|
+
it('renders the switcher between the logo and the nav', () => {
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<Sidebar
|
|
38
|
+
appName="Foundry"
|
|
39
|
+
sections={sections}
|
|
40
|
+
switcher={<div data-testid="sw">Switcher</div>}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const switcher = screen.getByTestId('sw')
|
|
45
|
+
const logo = screen.getByText('Foundry')
|
|
46
|
+
const nav = container.querySelector('nav')!
|
|
47
|
+
|
|
48
|
+
expect(switcher).toBeInTheDocument()
|
|
49
|
+
expect(precedes(logo, switcher)).toBe(true)
|
|
50
|
+
expect(precedes(switcher, nav)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders nothing when no switcher is supplied (every existing caller is unaffected)', () => {
|
|
54
|
+
render(<Sidebar appName="Foundry" sections={sections} />)
|
|
55
|
+
expect(screen.queryByTestId('sw')).not.toBeInTheDocument()
|
|
56
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('hides the switcher when the rail is collapsed', () => {
|
|
60
|
+
render(
|
|
61
|
+
<Sidebar
|
|
62
|
+
appName="Foundry"
|
|
63
|
+
sections={sections}
|
|
64
|
+
defaultCollapsed
|
|
65
|
+
switcher={<div data-testid="sw">Switcher</div>}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
expect(screen.queryByTestId('sw')).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('AppShell switcher forwarding', () => {
|
|
73
|
+
it('forwards the switcher through to the Sidebar rail', () => {
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<AppShell
|
|
76
|
+
app="foundry"
|
|
77
|
+
appName="Foundry"
|
|
78
|
+
sections={sections}
|
|
79
|
+
switcher={<div data-testid="sw">Switcher</div>}
|
|
80
|
+
>
|
|
81
|
+
<p>Page content</p>
|
|
82
|
+
</AppShell>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const switcher = screen.getByTestId('sw')
|
|
86
|
+
const nav = container.querySelector('nav')!
|
|
87
|
+
expect(switcher).toBeInTheDocument()
|
|
88
|
+
expect(precedes(screen.getByText('Foundry'), switcher)).toBe(true)
|
|
89
|
+
expect(precedes(switcher, nav)).toBe(true)
|
|
90
|
+
expect(screen.getByText('Page content')).toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders without a switcher (prop is optional)', () => {
|
|
94
|
+
render(
|
|
95
|
+
<AppShell app="foundry" appName="Foundry" sections={sections}>
|
|
96
|
+
<p>Page content</p>
|
|
97
|
+
</AppShell>
|
|
98
|
+
)
|
|
99
|
+
expect(screen.queryByTestId('sw')).not.toBeInTheDocument()
|
|
100
|
+
expect(screen.getByText('Page content')).toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -36,6 +36,12 @@ export interface AppShellProps {
|
|
|
36
36
|
appName: string;
|
|
37
37
|
/** Sidebar navigation sections (label + href + optional icon). */
|
|
38
38
|
sections: SidebarSection[];
|
|
39
|
+
/**
|
|
40
|
+
* Contextual switcher for the rail, rendered under the brand and above the
|
|
41
|
+
* nav — e.g. an <EntitySwitcher> over the foundries the user can see.
|
|
42
|
+
* Optional, so every existing caller is unaffected.
|
|
43
|
+
*/
|
|
44
|
+
switcher?: ReactNode;
|
|
39
45
|
/** Extra items rendered above "Log out" in the account menu (optional). */
|
|
40
46
|
accountMenuExtra?: ReactNode;
|
|
41
47
|
/** Loading label while the session bootstraps (default "Loading…"). */
|
|
@@ -47,6 +53,7 @@ export function AppShell({
|
|
|
47
53
|
app,
|
|
48
54
|
appName,
|
|
49
55
|
sections,
|
|
56
|
+
switcher,
|
|
50
57
|
accountMenuExtra,
|
|
51
58
|
loadingLabel = 'Loading…',
|
|
52
59
|
children,
|
|
@@ -118,6 +125,7 @@ export function AppShell({
|
|
|
118
125
|
<Sidebar
|
|
119
126
|
appName={appName}
|
|
120
127
|
sections={sections}
|
|
128
|
+
switcher={switcher}
|
|
121
129
|
userDropdown={userDropdown}
|
|
122
130
|
collapsible
|
|
123
131
|
collapsed={collapsed}
|