@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.84",
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.52",
47
- "@startsimpli/auth": "^0.4.31"
46
+ "@startsimpli/api": "^0.5.53",
47
+ "@startsimpli/auth": "^0.4.32"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@startsimpli/api": {
@@ -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/sidebar'
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}