@startsimpli/ui 0.4.84 → 0.4.85

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.85",
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/auth": "^0.4.31",
47
+ "@startsimpli/api": "^0.5.51"
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
+ })
@@ -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}
@@ -0,0 +1,293 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * EntitySwitcher — a searchable "which record am I scoped to?" control
5
+ * (startsim-768w.21.1).
6
+ *
7
+ * Deliberately entity-agnostic: it takes `{ id, label, sublabel?, badge? }`
8
+ * items and hands back an id. It knows nothing about companies, foundries,
9
+ * projects or workspaces, so the same control serves all of them — the caller
10
+ * maps its domain objects into `EntitySwitcherItem[]`.
11
+ *
12
+ * Built on the existing Radix popover + Input primitives; no new dependency.
13
+ *
14
+ * Its first consumer is the dark `bg-gray-900` sidebar rail, so the defaults
15
+ * are translucent-over-dark and inherit the surrounding text colour rather
16
+ * than hard-coding a light-only look. Anything that doesn't suit a given
17
+ * surface is overridable via `triggerClassName` / `contentClassName`.
18
+ *
19
+ * a11y follows the WAI-ARIA combobox + listbox pattern:
20
+ * - trigger is `role="combobox"` with `aria-expanded` / `aria-controls`
21
+ * - the list is `role="listbox"`, rows are `role="option"`
22
+ * - `aria-selected` marks the *current* entity (what the app is scoped to)
23
+ * - `aria-activedescendant` (+ `data-highlighted`) tracks the *keyboard*
24
+ * highlight, which moves with ArrowUp/ArrowDown independently of selection
25
+ */
26
+
27
+ import * as React from 'react'
28
+ import { Check, ChevronsUpDown } from 'lucide-react'
29
+ import { cn } from '../../lib/utils'
30
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
31
+ import { Input } from '../ui/input'
32
+
33
+ export interface EntitySwitcherItem {
34
+ /** Stable identifier handed back to `onSelect`. */
35
+ id: string
36
+ /** Primary display text — also the first thing the filter matches on. */
37
+ label: string
38
+ /** Secondary line (slug, org, subtitle). Filtered on as well. */
39
+ sublabel?: string
40
+ /** Optional trailing adornment (status pill, count, icon). */
41
+ badge?: React.ReactNode
42
+ }
43
+
44
+ export interface EntitySwitcherProps {
45
+ /** Everything the user can switch to. */
46
+ items: EntitySwitcherItem[]
47
+ /** The id currently in scope; drives the trigger label + `aria-selected`. */
48
+ currentId?: string | null
49
+ /** Fired with the chosen item's id. Fires even when it re-picks `currentId`. */
50
+ onSelect: (id: string) => void
51
+ /** Filter input placeholder. */
52
+ placeholder?: string
53
+ /** Accessible name for the filter input. */
54
+ searchLabel?: string
55
+ /** Shown instead of the list when the filter matches nothing. */
56
+ emptyLabel?: string
57
+ /** Trigger text when `currentId` matches no item (or there is none yet). */
58
+ triggerPlaceholder?: string
59
+ /** Set false to drop the filter input (short lists). */
60
+ searchable?: boolean
61
+ /** Override the trigger chrome — e.g. to sit on a light surface. */
62
+ triggerClassName?: string
63
+ /** Override the popover panel chrome (width, padding, colours). */
64
+ contentClassName?: string
65
+ /** Disable the whole control. */
66
+ disabled?: boolean
67
+ }
68
+
69
+ function optionDomId(baseId: string, itemId: string) {
70
+ return `${baseId}-opt-${String(itemId).replace(/[^A-Za-z0-9_-]/g, '_')}`
71
+ }
72
+
73
+ export function EntitySwitcher({
74
+ items,
75
+ currentId,
76
+ onSelect,
77
+ placeholder = 'Search…',
78
+ searchLabel = 'Search',
79
+ emptyLabel = 'No matches',
80
+ triggerPlaceholder = 'Select…',
81
+ searchable = true,
82
+ triggerClassName,
83
+ contentClassName,
84
+ disabled,
85
+ }: EntitySwitcherProps) {
86
+ const baseId = React.useId()
87
+ const listboxId = `${baseId}-listbox`
88
+ const inputId = `${baseId}-filter`
89
+
90
+ const [open, setOpen] = React.useState(false)
91
+ const [query, setQuery] = React.useState('')
92
+ const [highlight, setHighlight] = React.useState(0)
93
+
94
+ const inputRef = React.useRef<HTMLInputElement>(null)
95
+ const listRef = React.useRef<HTMLUListElement>(null)
96
+
97
+ const current = items.find((i) => i.id === currentId)
98
+
99
+ const filtered = React.useMemo(() => {
100
+ const q = query.trim().toLowerCase()
101
+ if (!q) return items
102
+ return items.filter(
103
+ (i) =>
104
+ i.label.toLowerCase().includes(q) ||
105
+ (i.sublabel ? i.sublabel.toLowerCase().includes(q) : false)
106
+ )
107
+ }, [items, query])
108
+
109
+ // Keep the highlight inside the (possibly just-filtered) list.
110
+ React.useEffect(() => {
111
+ setHighlight((h) => (filtered.length === 0 ? 0 : Math.min(h, filtered.length - 1)))
112
+ }, [filtered.length])
113
+
114
+ const handleOpenChange = (next: boolean) => {
115
+ setOpen(next)
116
+ if (next) {
117
+ setQuery('')
118
+ // Open on whatever is currently in scope, so Enter is a no-op re-pick
119
+ // rather than a surprise jump to the first row.
120
+ const idx = items.findIndex((i) => i.id === currentId)
121
+ setHighlight(idx >= 0 ? idx : 0)
122
+ }
123
+ }
124
+
125
+ const choose = (id: string) => {
126
+ onSelect(id)
127
+ setOpen(false)
128
+ }
129
+
130
+ const handleKeyDown = (event: React.KeyboardEvent) => {
131
+ if (event.key === 'ArrowDown') {
132
+ event.preventDefault()
133
+ if (filtered.length === 0) return
134
+ setHighlight((h) => (h + 1) % filtered.length)
135
+ return
136
+ }
137
+ if (event.key === 'ArrowUp') {
138
+ event.preventDefault()
139
+ if (filtered.length === 0) return
140
+ setHighlight((h) => (h - 1 + filtered.length) % filtered.length)
141
+ return
142
+ }
143
+ if (event.key === 'Home') {
144
+ event.preventDefault()
145
+ setHighlight(0)
146
+ return
147
+ }
148
+ if (event.key === 'End') {
149
+ event.preventDefault()
150
+ setHighlight(Math.max(filtered.length - 1, 0))
151
+ return
152
+ }
153
+ if (event.key === 'Enter') {
154
+ const item = filtered[highlight]
155
+ if (!item) return
156
+ event.preventDefault()
157
+ choose(item.id)
158
+ }
159
+ // Escape is left to the popover's dismiss layer, which also restores
160
+ // focus to the trigger.
161
+ }
162
+
163
+ const activeItem = filtered[highlight]
164
+ const activeDescendant = activeItem ? optionDomId(baseId, activeItem.id) : undefined
165
+
166
+ // Keep the highlighted row in view on long lists.
167
+ React.useEffect(() => {
168
+ if (!open || !activeDescendant) return
169
+ const el = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(activeDescendant)}`)
170
+ el?.scrollIntoView?.({ block: 'nearest' })
171
+ }, [open, activeDescendant])
172
+
173
+ return (
174
+ <Popover open={open} onOpenChange={handleOpenChange}>
175
+ <PopoverTrigger asChild>
176
+ <button
177
+ type="button"
178
+ role="combobox"
179
+ aria-expanded={open}
180
+ aria-controls={listboxId}
181
+ aria-haspopup="listbox"
182
+ disabled={disabled}
183
+ className={cn(
184
+ 'flex w-full items-center justify-between gap-2 rounded-md border border-white/15 bg-white/5 px-3 py-2',
185
+ 'text-left text-sm font-medium text-inherit transition-colors',
186
+ 'hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40',
187
+ 'disabled:cursor-not-allowed disabled:opacity-50',
188
+ triggerClassName
189
+ )}
190
+ >
191
+ <span className="min-w-0 flex-1 truncate">{current ? current.label : triggerPlaceholder}</span>
192
+ <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-60" aria-hidden="true" />
193
+ </button>
194
+ </PopoverTrigger>
195
+
196
+ <PopoverContent
197
+ align="start"
198
+ className={cn('w-64 p-0', contentClassName)}
199
+ onOpenAutoFocus={(event) => {
200
+ // Focus the filter (or the list) rather than the panel itself, so the
201
+ // very first ArrowDown moves the highlight.
202
+ event.preventDefault()
203
+ if (searchable) inputRef.current?.focus()
204
+ else listRef.current?.focus()
205
+ }}
206
+ onKeyDown={handleKeyDown}
207
+ >
208
+ {searchable && (
209
+ <div className="border-b p-2">
210
+ <label className="sr-only" htmlFor={inputId}>
211
+ {searchLabel}
212
+ </label>
213
+ <Input
214
+ id={inputId}
215
+ ref={inputRef}
216
+ type="text"
217
+ value={query}
218
+ onChange={(e) => setQuery(e.target.value)}
219
+ placeholder={placeholder}
220
+ autoComplete="off"
221
+ aria-autocomplete="list"
222
+ aria-controls={listboxId}
223
+ aria-activedescendant={activeDescendant}
224
+ className="h-8"
225
+ />
226
+ </div>
227
+ )}
228
+
229
+ <ul
230
+ ref={listRef}
231
+ id={listboxId}
232
+ role="listbox"
233
+ tabIndex={searchable ? -1 : 0}
234
+ aria-label={searchLabel}
235
+ aria-activedescendant={searchable ? undefined : activeDescendant}
236
+ className="max-h-64 overflow-y-auto p-1 outline-none"
237
+ >
238
+ {filtered.map((item, index) => {
239
+ const isCurrent = item.id === currentId
240
+ const isHighlighted = index === highlight
241
+ return (
242
+ <li
243
+ key={item.id}
244
+ id={optionDomId(baseId, item.id)}
245
+ role="option"
246
+ aria-selected={isCurrent}
247
+ data-highlighted={isHighlighted}
248
+ onClick={() => choose(item.id)}
249
+ onMouseEnter={() => setHighlight(index)}
250
+ className={cn(
251
+ 'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
252
+ isHighlighted && 'bg-accent text-accent-foreground'
253
+ )}
254
+ >
255
+ <Check
256
+ className={cn('h-4 w-4 flex-shrink-0', isCurrent ? 'opacity-100' : 'opacity-0')}
257
+ aria-hidden="true"
258
+ />
259
+ <span className="min-w-0 flex-1">
260
+ <span className="block truncate">{item.label}</span>
261
+ {item.sublabel && (
262
+ <span
263
+ className={cn(
264
+ 'block truncate text-xs',
265
+ // The sublabel has to follow the row's foreground, not
266
+ // the panel's. `text-muted-foreground` is picked to sit
267
+ // on `bg-popover`; left alone it stays that grey when
268
+ // the row flips to `bg-accent`, and on a saturated
269
+ // accent that lands at ~1.1:1 — browser-verified as
270
+ // washed out to the point of unreadable
271
+ // (startsim-768w.21.1.3). Dimming the accent foreground
272
+ // instead keeps the primary/secondary hierarchy while
273
+ // staying legible on whatever accent the theme sets.
274
+ isHighlighted ? 'text-accent-foreground/80' : 'text-muted-foreground'
275
+ )}
276
+ >
277
+ {item.sublabel}
278
+ </span>
279
+ )}
280
+ </span>
281
+ {item.badge ? <span className="flex-shrink-0">{item.badge}</span> : null}
282
+ </li>
283
+ )
284
+ })}
285
+ </ul>
286
+
287
+ {filtered.length === 0 && (
288
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyLabel}</div>
289
+ )}
290
+ </PopoverContent>
291
+ </Popover>
292
+ )
293
+ }
@@ -0,0 +1,6 @@
1
+ // Navigation chrome: the app rail, the shell that assembles it, grouped nav
2
+ // sections and the generic entity switcher that scopes the rail.
3
+ export * from './sidebar'
4
+ export * from './app-shell'
5
+ export * from './grouped-nav'
6
+ export * from './entity-switcher'
@@ -27,6 +27,13 @@ export interface SidebarProps {
27
27
  * Navigation sections
28
28
  */
29
29
  sections: SidebarSection[];
30
+ /**
31
+ * Contextual switcher rendered directly under the logo, above the nav —
32
+ * "which record is this rail scoped to?" (e.g. an <EntitySwitcher> over the
33
+ * user's foundries). Hidden while collapsed: a 5rem rail can only show a
34
+ * truncated combobox, which is noise rather than navigation.
35
+ */
36
+ switcher?: ReactNode;
30
37
  /**
31
38
  * User dropdown content (rendered at bottom of sidebar)
32
39
  */
@@ -56,6 +63,7 @@ export interface SidebarProps {
56
63
  export function Sidebar({
57
64
  appName,
58
65
  sections,
66
+ switcher,
59
67
  userDropdown,
60
68
  className,
61
69
  collapsible = true,
@@ -99,6 +107,11 @@ export function Sidebar({
99
107
  </Link>
100
108
  </div>
101
109
 
110
+ {/* Contextual switcher (scope of this rail) */}
111
+ {switcher && !collapsed && (
112
+ <div className="px-4 pt-4">{switcher}</div>
113
+ )}
114
+
102
115
  {/* Navigation */}
103
116
  <nav className="flex-1 overflow-y-auto p-4 space-y-6">
104
117
  {sections.map((section, idx) => (