@startsimpli/ui 0.4.86 → 0.4.88
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/navigation/__tests__/entity-switcher.test.tsx +14 -0
- package/src/components/navigation/entity-switcher.tsx +10 -0
- package/src/components/team/OrgSwitcher.tsx +40 -21
- package/src/components/team/__tests__/team-components.test.tsx +124 -6
- package/src/components/team/org-switcher-default-class-names.ts +19 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.88",
|
|
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/
|
|
47
|
-
"@startsimpli/
|
|
46
|
+
"@startsimpli/auth": "^0.4.32",
|
|
47
|
+
"@startsimpli/api": "^0.5.54"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"@startsimpli/api": {
|
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
<
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
id
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
aria-label={label}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
}
|