@startsimpli/ui 0.4.86 → 0.4.87

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.86",
3
+ "version": "0.4.87",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -43,6 +43,20 @@ describe('EntitySwitcher — trigger', () => {
43
43
  expect(trigger).toHaveAttribute('aria-controls', listbox.id)
44
44
  })
45
45
 
46
+ it('takes an explicit accessible name for the trigger', () => {
47
+ // `combobox` is not a name-from-content role, so the trigger's visible
48
+ // "Acme Robotics" does NOT name it — without this prop it has no accessible
49
+ // name at all. startsim-768w.21.4 needs one (OrgSwitcher's caption is
50
+ // aria-hidden), and it has to carry the label AND the current entity.
51
+ setup()
52
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-label')
53
+
54
+ setup({ triggerAriaLabel: 'Organization: Acme Robotics' })
55
+ expect(
56
+ screen.getAllByRole('combobox', { name: 'Organization: Acme Robotics' })
57
+ ).toHaveLength(1)
58
+ })
59
+
46
60
  it('takes a triggerClassName override so it can sit on a dark rail', () => {
47
61
  setup({ triggerClassName: 'my-dark-trigger' })
48
62
  expect(screen.getByRole('combobox')).toHaveClass('my-dark-trigger')
@@ -58,6 +58,14 @@ export interface EntitySwitcherProps {
58
58
  triggerPlaceholder?: string
59
59
  /** Set false to drop the filter input (short lists). */
60
60
  searchable?: boolean
61
+ /**
62
+ * Accessible name for the trigger. Left unset, the trigger is named by its
63
+ * own content — the current item's label — which reads correctly when the
64
+ * surrounding chrome already says WHAT is being switched (the sidebar rail).
65
+ * Set it when the control has to name itself, e.g. a switcher dropped into a
66
+ * settings page whose visible caption is `aria-hidden` (see OrgSwitcher).
67
+ */
68
+ triggerAriaLabel?: string
61
69
  /** Override the trigger chrome — e.g. to sit on a light surface. */
62
70
  triggerClassName?: string
63
71
  /** Override the popover panel chrome (width, padding, colours). */
@@ -79,6 +87,7 @@ export function EntitySwitcher({
79
87
  emptyLabel = 'No matches',
80
88
  triggerPlaceholder = 'Select…',
81
89
  searchable = true,
90
+ triggerAriaLabel,
82
91
  triggerClassName,
83
92
  contentClassName,
84
93
  disabled,
@@ -179,6 +188,7 @@ export function EntitySwitcher({
179
188
  aria-expanded={open}
180
189
  aria-controls={listboxId}
181
190
  aria-haspopup="listbox"
191
+ aria-label={triggerAriaLabel}
182
192
  disabled={disabled}
183
193
  className={cn(
184
194
  'flex w-full items-center justify-between gap-2 rounded-md border border-white/15 bg-white/5 px-3 py-2',
@@ -4,12 +4,20 @@
4
4
  * OrgSwitcher — pick between Companies the current user belongs to. Useful
5
5
  * for accounts that ended up multi-company via cross-domain invitations.
6
6
  *
7
- * Native <select> for now. The classNames-slot API stays the same when we
8
- * upgrade to a dropdown menu. startsim-o7s.
7
+ * A thin adapter over the shared <EntitySwitcher> (startsim-768w.21.4): it maps
8
+ * `CompanyLite` -> `EntitySwitcherItem` and hands the chosen id back. The
9
+ * native <select> this used to render (startsim-o7s, "for now") is gone, so
10
+ * packages/ui ships ONE switcher implementation rather than two.
11
+ *
12
+ * The public props and `classNames` slots are unchanged — `select` now styles
13
+ * the combobox trigger that replaced the <select>, and its defaults are
14
+ * semantic tokens tuned for the LIGHT surfaces this renders on, where
15
+ * EntitySwitcher's own translucent-over-dark rail defaults would wash out.
9
16
  */
10
17
 
11
18
  import * as React from 'react'
12
19
  import type { CompanyLite } from './types'
20
+ import { EntitySwitcher, type EntitySwitcherItem } from '../navigation/entity-switcher'
13
21
  import {
14
22
  ORG_SWITCHER_DEFAULTS,
15
23
  type OrgSwitcherClassNames,
@@ -34,9 +42,20 @@ export function OrgSwitcher({
34
42
  classNames,
35
43
  }: OrgSwitcherProps) {
36
44
  const cls = { ...ORG_SWITCHER_DEFAULTS, ...(classNames ?? {}) }
37
- // If the caller hasn't pinned currentCompanyId, fall back to the first
38
- // entry so the <select>'s value matches an option (otherwise React warns).
39
- const value = currentCompanyId ?? companies[0]?.id ?? ''
45
+
46
+ const items = React.useMemo<EntitySwitcherItem[]>(
47
+ // The slug rides along as the sublabel so it is searchable too — two
48
+ // companies can share a display name, their slugs cannot.
49
+ () => companies.map((c) => ({ id: c.id, label: c.name, sublabel: c.slug || undefined })),
50
+ [companies]
51
+ )
52
+
53
+ // If the caller hasn't pinned currentCompanyId, fall back to the first entry
54
+ // so the control always names a real company — as the <select> did, where a
55
+ // value matching no option made React warn — rather than falling through to
56
+ // a "Select…" placeholder for a list it is already scoped to.
57
+ const currentId = currentCompanyId ?? companies[0]?.id ?? null
58
+ const currentName = companies.find((c) => c.id === currentId)?.name
40
59
 
41
60
  if (companies.length === 0) return null
42
61
 
@@ -47,22 +66,22 @@ export function OrgSwitcher({
47
66
  {label}
48
67
  </span>
49
68
  )}
50
- <label className="sr-only" htmlFor="org-switcher-select">
51
- {label}
52
- </label>
53
- <select
54
- id="org-switcher-select"
55
- value={value}
56
- onChange={(e) => onSwitch(e.target.value)}
57
- className={cls.select}
58
- aria-label={label}
59
- >
60
- {companies.map((c) => (
61
- <option key={c.id} value={c.id}>
62
- {c.name}
63
- </option>
64
- ))}
65
- </select>
69
+ <EntitySwitcher
70
+ items={items}
71
+ currentId={currentId}
72
+ // onSwitch may be async; the <select>'s onChange ignored the promise too.
73
+ onSelect={(id) => {
74
+ void onSwitch(id)
75
+ }}
76
+ // Stands in for the sr-only <label htmlFor> + aria-label the <select>
77
+ // carried. The trigger is a button, so a bare `aria-label={label}`
78
+ // would suppress the company name it renders — the accessible name
79
+ // has to carry both parts.
80
+ triggerAriaLabel={currentName ? `${label}: ${currentName}` : label}
81
+ triggerPlaceholder={`Select ${label.toLowerCase()}…`}
82
+ triggerClassName={cls.select}
83
+ contentClassName={cls.content}
84
+ />
66
85
  </span>
67
86
  )
68
87
  }
@@ -1,4 +1,5 @@
1
- import { render, screen, fireEvent, act } from '@testing-library/react'
1
+ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
2
3
  import {
3
4
  MembersTable,
4
5
  RoleSelector,
@@ -370,20 +371,41 @@ describe('LeaveTeamDialog', () => {
370
371
 
371
372
  // ---------- OrgSwitcher ----------------------------------------------------
372
373
 
374
+ /**
375
+ * Since startsim-768w.21.4 OrgSwitcher is a thin adapter over the shared
376
+ * <EntitySwitcher>: the control is a combobox trigger + popover listbox, not a
377
+ * native <select>, so the assertions read the current company off the trigger
378
+ * and pick from role=option instead of setting `select.value`. The public props
379
+ * and classNames slots are unchanged, and every behaviour the <select> version
380
+ * had is still pinned below.
381
+ */
373
382
  describe('OrgSwitcher', () => {
374
383
  const companies: CompanyLite[] = [
375
384
  { id: 'c1', slug: 'acme', name: 'Acme' },
376
385
  { id: 'c2', slug: 'beta', name: 'Beta' },
377
386
  ]
378
387
 
379
- it('renders an option per company + fires onSwitch on change', () => {
388
+ it('renders an option per company + fires onSwitch on pick', async () => {
389
+ const user = userEvent.setup()
380
390
  const onSwitch = jest.fn()
381
- render(
391
+ const { container } = render(
382
392
  <OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={onSwitch} />
383
393
  )
384
- const select = screen.getByRole('combobox') as HTMLSelectElement
385
- expect(select.value).toBe('c1')
386
- fireEvent.change(select, { target: { value: 'c2' } })
394
+
395
+ // One switcher in packages/ui, not two: no native <select> is left.
396
+ expect(container.querySelector('select')).toBeNull()
397
+
398
+ const trigger = screen.getByRole('combobox')
399
+ expect(trigger).toHaveTextContent('Acme')
400
+
401
+ await user.click(trigger)
402
+ expect(await screen.findAllByRole('option')).toHaveLength(2)
403
+ expect(screen.getByRole('option', { name: /Acme/ })).toHaveAttribute(
404
+ 'aria-selected',
405
+ 'true'
406
+ )
407
+
408
+ await user.click(screen.getByRole('option', { name: /Beta/ }))
387
409
  expect(onSwitch).toHaveBeenCalledWith('c2')
388
410
  })
389
411
 
@@ -393,4 +415,100 @@ describe('OrgSwitcher', () => {
393
415
  )
394
416
  expect(container.firstChild).toBeNull()
395
417
  })
418
+
419
+ it('falls back to the first company when currentCompanyId is not pinned', () => {
420
+ render(<OrgSwitcher companies={companies} onSwitch={() => {}} />)
421
+ // Never a bare "Select organization…" placeholder for a list it is already
422
+ // scoped to — the <select> pinned its value the same way.
423
+ expect(screen.getByRole('combobox')).toHaveTextContent('Acme')
424
+ })
425
+
426
+ it('names the trigger with the label AND the current company', () => {
427
+ render(
428
+ <OrgSwitcher companies={companies} currentCompanyId="c2" onSwitch={() => {}} />
429
+ )
430
+ // Replaces the sr-only <label> + aria-label the <select> had. A button
431
+ // named only "Organization" would hide which org is in scope.
432
+ expect(screen.getByRole('combobox')).toHaveAttribute(
433
+ 'aria-label',
434
+ 'Organization: Beta'
435
+ )
436
+ expect(
437
+ screen.getByRole('combobox', { name: /organization: beta/i })
438
+ ).toBeInTheDocument()
439
+ })
440
+
441
+ it('hides the visible caption with hideLabel but keeps the accessible name', () => {
442
+ const { rerender } = render(
443
+ <OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={() => {}} />
444
+ )
445
+ expect(screen.getByText('Organization')).toBeInTheDocument()
446
+
447
+ rerender(
448
+ <OrgSwitcher
449
+ companies={companies}
450
+ currentCompanyId="c1"
451
+ onSwitch={() => {}}
452
+ hideLabel
453
+ />
454
+ )
455
+ expect(screen.queryByText('Organization')).not.toBeInTheDocument()
456
+ expect(screen.getByRole('combobox')).toHaveAttribute(
457
+ 'aria-label',
458
+ 'Organization: Acme'
459
+ )
460
+ })
461
+
462
+ it('honours a custom label everywhere it surfaces', () => {
463
+ render(
464
+ <OrgSwitcher
465
+ companies={companies}
466
+ currentCompanyId="c1"
467
+ onSwitch={() => {}}
468
+ label="Workspace"
469
+ />
470
+ )
471
+ expect(screen.getByText('Workspace')).toBeInTheDocument()
472
+ expect(screen.getByRole('combobox')).toHaveAttribute(
473
+ 'aria-label',
474
+ 'Workspace: Acme'
475
+ )
476
+ })
477
+
478
+ it('routes the classNames slots at the label and the trigger', () => {
479
+ render(
480
+ <OrgSwitcher
481
+ companies={companies}
482
+ currentCompanyId="c1"
483
+ onSwitch={() => {}}
484
+ classNames={{ label: 'my-label', select: 'my-trigger' }}
485
+ />
486
+ )
487
+ expect(screen.getByText('Organization')).toHaveClass('my-label')
488
+ expect(screen.getByRole('combobox')).toHaveClass('my-trigger')
489
+ })
490
+
491
+ it('filters companies by name or slug — the upgrade the <select> could not do', async () => {
492
+ const user = userEvent.setup()
493
+ render(
494
+ <OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={() => {}} />
495
+ )
496
+ await user.click(screen.getByRole('combobox'))
497
+ // "beta" is Beta's slug as well as its name; either way it is the only hit.
498
+ await user.type(await screen.findByRole('textbox'), 'BET')
499
+
500
+ await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1))
501
+ expect(screen.getByRole('option')).toHaveTextContent('Beta')
502
+ })
503
+
504
+ it('tolerates an async onSwitch', async () => {
505
+ const user = userEvent.setup()
506
+ const onSwitch = jest.fn().mockResolvedValue(undefined)
507
+ render(
508
+ <OrgSwitcher companies={companies} currentCompanyId="c1" onSwitch={onSwitch} />
509
+ )
510
+ await user.click(screen.getByRole('combobox'))
511
+ await user.click(await screen.findByRole('option', { name: /Beta/ }))
512
+ expect(onSwitch).toHaveBeenCalledWith('c2')
513
+ })
396
514
  })
@@ -3,12 +3,30 @@
3
3
  export interface OrgSwitcherClassNames {
4
4
  root?: string
5
5
  label?: string
6
+ /**
7
+ * The switcher control itself. Named `select` since startsim-o7s, when it
8
+ * WAS a native `<select>`; startsim-768w.21.4 swapped the implementation for
9
+ * the shared `<EntitySwitcher>` and this slot now styles its combobox
10
+ * trigger. The name is kept so existing overrides keep landing on the same
11
+ * visual element.
12
+ */
6
13
  select?: string
14
+ /**
15
+ * The popover panel that replaced the browser's native dropdown
16
+ * (startsim-768w.21.4) — width/padding/colour of the list surface.
17
+ */
18
+ content?: string
7
19
  }
8
20
 
9
21
  export const ORG_SWITCHER_DEFAULTS: Required<OrgSwitcherClassNames> = {
10
22
  root: 'inline-flex items-center gap-2',
11
23
  label: 'text-xs font-medium uppercase tracking-wide text-muted-foreground',
24
+ // EntitySwitcher's own trigger defaults are translucent-over-dark, tuned for
25
+ // the `bg-gray-900` sidebar rail. OrgSwitcher renders on LIGHT surfaces
26
+ // (settings pages, headers), so each of those has to be overridden with a
27
+ // semantic token — including the `hover:` / `focus-visible:` variants, which
28
+ // tailwind-merge cannot fold into an unprefixed override.
12
29
  select:
13
- 'rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring',
30
+ 'w-auto min-w-44 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
31
+ content: 'w-64',
14
32
  }