@proteos/ui 0.31.0
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/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { BadgeMultiSelect, type BadgeMultiSelectOption } from './badge-multi-select'
|
|
7
|
+
|
|
8
|
+
const statuses: BadgeMultiSelectOption<string>[] = [
|
|
9
|
+
{ id: 'in-transit', label: 'in transit', variant: 'active' },
|
|
10
|
+
{ id: 'quoted', label: 'quoted' },
|
|
11
|
+
{ id: 'reconciled', label: 'reconciled', variant: 'success' },
|
|
12
|
+
{ id: 'past-due', label: 'past due', variant: 'warning' },
|
|
13
|
+
{ id: 'exception', label: 'exception', variant: 'danger' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function Harness({
|
|
17
|
+
onSelectionChange,
|
|
18
|
+
initial = new Set<string>(),
|
|
19
|
+
}: {
|
|
20
|
+
onSelectionChange?: (keys: Set<string>) => void
|
|
21
|
+
initial?: Set<string>
|
|
22
|
+
}) {
|
|
23
|
+
const [selected, setSelected] = useState<Set<string>>(initial)
|
|
24
|
+
return (
|
|
25
|
+
<BadgeMultiSelect<string>
|
|
26
|
+
label="Status"
|
|
27
|
+
placeholder="Any status"
|
|
28
|
+
options={statuses}
|
|
29
|
+
selectedKeys={selected}
|
|
30
|
+
onSelectionChange={(keys) => {
|
|
31
|
+
setSelected(keys)
|
|
32
|
+
onSelectionChange?.(keys)
|
|
33
|
+
}}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('BadgeMultiSelect', () => {
|
|
39
|
+
it('renders placeholder when nothing is selected', () => {
|
|
40
|
+
render(<Harness />)
|
|
41
|
+
expect(screen.getByText('Any status')).toBeInTheDocument()
|
|
42
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('opens the listbox on trigger click', async () => {
|
|
46
|
+
const user = userEvent.setup()
|
|
47
|
+
render(<Harness />)
|
|
48
|
+
await user.click(screen.getByRole('combobox'))
|
|
49
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
50
|
+
expect(screen.getAllByRole('option')).toHaveLength(statuses.length)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('selects multiple options and renders them as removable badges', async () => {
|
|
54
|
+
const user = userEvent.setup()
|
|
55
|
+
const onChange = vi.fn()
|
|
56
|
+
render(<Harness onSelectionChange={onChange} />)
|
|
57
|
+
|
|
58
|
+
await user.click(screen.getByRole('combobox'))
|
|
59
|
+
await user.click(screen.getByRole('option', { name: 'in transit' }))
|
|
60
|
+
await user.click(screen.getByRole('option', { name: 'past due' }))
|
|
61
|
+
|
|
62
|
+
const lastCall = onChange.mock.calls.at(-1)?.[0] as Set<string> | undefined
|
|
63
|
+
expect(lastCall?.has('in-transit')).toBe(true)
|
|
64
|
+
expect(lastCall?.has('past-due')).toBe(true)
|
|
65
|
+
expect(lastCall?.size).toBe(2)
|
|
66
|
+
|
|
67
|
+
// Two selections = two × remove buttons inside the trigger.
|
|
68
|
+
expect(screen.getAllByRole('button', { name: 'Remove', hidden: true })).toHaveLength(2)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('removes a selection via the × inside the badge', async () => {
|
|
72
|
+
const user = userEvent.setup()
|
|
73
|
+
const onChange = vi.fn()
|
|
74
|
+
render(<Harness onSelectionChange={onChange} initial={new Set(['in-transit', 'reconciled'])} />)
|
|
75
|
+
|
|
76
|
+
const [firstRemove, ...rest] = screen.getAllByRole('button', { name: 'Remove' })
|
|
77
|
+
expect(rest).toHaveLength(1)
|
|
78
|
+
if (!firstRemove) throw new Error('expected a remove button')
|
|
79
|
+
await user.click(firstRemove)
|
|
80
|
+
|
|
81
|
+
expect(onChange).toHaveBeenCalledWith(new Set(['reconciled']))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('hides selected options from the dropdown when hideSelectedOptions is set', async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
render(
|
|
87
|
+
<BadgeMultiSelect<string>
|
|
88
|
+
label="Status"
|
|
89
|
+
options={statuses}
|
|
90
|
+
selectedKeys={new Set<string>(['in-transit'])}
|
|
91
|
+
onSelectionChange={() => {}}
|
|
92
|
+
hideSelectedOptions
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
await user.click(screen.getByRole('combobox'))
|
|
97
|
+
await screen.findByRole('listbox')
|
|
98
|
+
|
|
99
|
+
// "in transit" is already selected — it should NOT be in the listbox.
|
|
100
|
+
expect(screen.queryByRole('option', { name: 'in transit' })).not.toBeInTheDocument()
|
|
101
|
+
// The other five are still available.
|
|
102
|
+
expect(screen.getAllByRole('option')).toHaveLength(statuses.length - 1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('shows "All options selected" when every option is picked and hidden', async () => {
|
|
106
|
+
const user = userEvent.setup()
|
|
107
|
+
render(
|
|
108
|
+
<BadgeMultiSelect<string>
|
|
109
|
+
label="Status"
|
|
110
|
+
options={statuses}
|
|
111
|
+
selectedKeys={new Set(statuses.map((s) => s.id))}
|
|
112
|
+
onSelectionChange={() => {}}
|
|
113
|
+
hideSelectedOptions
|
|
114
|
+
/>,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await user.click(screen.getByRole('combobox'))
|
|
118
|
+
// RAC's renderEmptyState renders inside the listbox; the copy landing
|
|
119
|
+
// is enough proof — we don't poke at internal listbox semantics.
|
|
120
|
+
expect(await screen.findByText('All options selected')).toBeInTheDocument()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not open when disabled', async () => {
|
|
124
|
+
const user = userEvent.setup()
|
|
125
|
+
render(
|
|
126
|
+
<BadgeMultiSelect
|
|
127
|
+
label="Status"
|
|
128
|
+
options={statuses}
|
|
129
|
+
selectedKeys={new Set<string>()}
|
|
130
|
+
onSelectionChange={() => {}}
|
|
131
|
+
isDisabled
|
|
132
|
+
/>,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await user.click(screen.getByRole('combobox'))
|
|
136
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders description and error slots', () => {
|
|
140
|
+
render(
|
|
141
|
+
<BadgeMultiSelect
|
|
142
|
+
label="Status"
|
|
143
|
+
description="Filter the board."
|
|
144
|
+
errorMessage="Pick at least one."
|
|
145
|
+
isInvalid
|
|
146
|
+
options={statuses}
|
|
147
|
+
selectedKeys={new Set<string>()}
|
|
148
|
+
onSelectionChange={() => {}}
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByText('Filter the board.')).toBeInTheDocument()
|
|
152
|
+
expect(screen.getByText('Pick at least one.')).toBeInTheDocument()
|
|
153
|
+
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { type Key, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
import { Badge, type BadgeVariants } from '../badge/badge'
|
|
5
|
+
import {
|
|
6
|
+
type FieldAppearance,
|
|
7
|
+
FieldCaret,
|
|
8
|
+
fieldChipPadding,
|
|
9
|
+
fieldControlStyles,
|
|
10
|
+
fieldErrorStyles,
|
|
11
|
+
fieldHintStyles,
|
|
12
|
+
fieldLabelStyles,
|
|
13
|
+
fieldMinHeight,
|
|
14
|
+
type FieldSize,
|
|
15
|
+
fieldTextStyles,
|
|
16
|
+
} from '../field/field'
|
|
17
|
+
|
|
18
|
+
/* =========================================================================
|
|
19
|
+
BadgeMultiSelect — a MultiSelect specialized for picking status-like
|
|
20
|
+
values. Each option is a <Badge/> — both in the dropdown and as the
|
|
21
|
+
selected pill. The × remove button lives INSIDE each selected badge
|
|
22
|
+
(no extra chip chrome), so the color coding from the Badge variant is
|
|
23
|
+
what you see, cleanly.
|
|
24
|
+
========================================================================= */
|
|
25
|
+
|
|
26
|
+
export type BadgeMultiSelectOption<T extends Key = Key> = {
|
|
27
|
+
id: T
|
|
28
|
+
/** Text shown inside the badge. Used as the accessible name too. */
|
|
29
|
+
label: string
|
|
30
|
+
/** Badge color register. Defaults to `default`. */
|
|
31
|
+
variant?: BadgeVariants['variant']
|
|
32
|
+
isDisabled?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type BadgeMultiSelectProps<T extends Key = Key> = {
|
|
36
|
+
label?: ReactNode
|
|
37
|
+
description?: ReactNode
|
|
38
|
+
errorMessage?: ReactNode
|
|
39
|
+
placeholder?: string
|
|
40
|
+
options: ReadonlyArray<BadgeMultiSelectOption<T>>
|
|
41
|
+
/** Controlled selection. Pass a `Set<T>` or any `Iterable<T>`. */
|
|
42
|
+
selectedKeys: Iterable<T>
|
|
43
|
+
onSelectionChange: (keys: Set<T>) => void
|
|
44
|
+
isDisabled?: boolean
|
|
45
|
+
isInvalid?: boolean
|
|
46
|
+
/** Hide already-selected options from the dropdown. Removal then only
|
|
47
|
+
* happens via the chip × in the trigger. Default `false` (selected
|
|
48
|
+
* options stay visible and can be toggled from the listbox). */
|
|
49
|
+
hideSelectedOptions?: boolean
|
|
50
|
+
/** Field size. Defaults to `md`. */
|
|
51
|
+
size?: FieldSize
|
|
52
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
53
|
+
appearance?: FieldAppearance
|
|
54
|
+
/** Focus the trigger on mount. Mirrors RAC's `autoFocus` prop on native
|
|
55
|
+
* inputs; useful for keyboard chains where the trigger should take focus
|
|
56
|
+
* when its surrounding context becomes ready. */
|
|
57
|
+
autoFocus?: boolean
|
|
58
|
+
className?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function BadgeMultiSelect<T extends Key>({
|
|
62
|
+
label,
|
|
63
|
+
description,
|
|
64
|
+
errorMessage,
|
|
65
|
+
placeholder = 'Select…',
|
|
66
|
+
options,
|
|
67
|
+
selectedKeys,
|
|
68
|
+
onSelectionChange,
|
|
69
|
+
isDisabled = false,
|
|
70
|
+
isInvalid = false,
|
|
71
|
+
hideSelectedOptions = false,
|
|
72
|
+
size = 'md',
|
|
73
|
+
appearance,
|
|
74
|
+
autoFocus = false,
|
|
75
|
+
className,
|
|
76
|
+
}: BadgeMultiSelectProps<T>) {
|
|
77
|
+
const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
|
|
78
|
+
const focusWithin =
|
|
79
|
+
appearance === 'cell'
|
|
80
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
81
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
82
|
+
const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
|
|
83
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
84
|
+
const triggerRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (autoFocus) triggerRef.current?.focus()
|
|
88
|
+
// mount-only — autoFocus is a one-shot signal, not sustained.
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
const selectedSet = new Set(selectedKeys)
|
|
93
|
+
const selectedOptions = options.filter((o) => selectedSet.has(o.id))
|
|
94
|
+
const listboxOptions = hideSelectedOptions
|
|
95
|
+
? options.filter((o) => !selectedSet.has(o.id))
|
|
96
|
+
: options
|
|
97
|
+
|
|
98
|
+
const remove = (id: T) => {
|
|
99
|
+
const next = new Set(selectedSet)
|
|
100
|
+
next.delete(id)
|
|
101
|
+
onSelectionChange(next)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const onTriggerKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
105
|
+
if (isDisabled) return
|
|
106
|
+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
setIsOpen(true)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
114
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
115
|
+
|
|
116
|
+
<div
|
|
117
|
+
ref={triggerRef}
|
|
118
|
+
role="combobox"
|
|
119
|
+
aria-expanded={isOpen}
|
|
120
|
+
aria-haspopup="listbox"
|
|
121
|
+
aria-disabled={isDisabled || undefined}
|
|
122
|
+
aria-invalid={isInvalid || undefined}
|
|
123
|
+
tabIndex={isDisabled ? -1 : 0}
|
|
124
|
+
onClick={() => !isDisabled && setIsOpen((v) => !v)}
|
|
125
|
+
onKeyDown={onTriggerKeyDown}
|
|
126
|
+
data-invalid={isInvalid || undefined}
|
|
127
|
+
data-disabled={isDisabled || undefined}
|
|
128
|
+
className={cn(
|
|
129
|
+
styles.box(),
|
|
130
|
+
'flex flex-wrap items-center gap-1.5 cursor-pointer',
|
|
131
|
+
minHeight,
|
|
132
|
+
fieldChipPadding[size],
|
|
133
|
+
'pr-2',
|
|
134
|
+
focusWithin,
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
{selectedOptions.length === 0 ? (
|
|
138
|
+
// No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
|
|
139
|
+
// overall height so it matches the Input/Select trigger at the same
|
|
140
|
+
// size.
|
|
141
|
+
<span className={cn('text-ink-4 font-sans px-1', fieldTextStyles[size])}>
|
|
142
|
+
{placeholder}
|
|
143
|
+
</span>
|
|
144
|
+
) : (
|
|
145
|
+
selectedOptions.map((o) => (
|
|
146
|
+
<RemovableBadge
|
|
147
|
+
key={o.id}
|
|
148
|
+
variant={o.variant}
|
|
149
|
+
onRemove={() => remove(o.id)}
|
|
150
|
+
isDisabled={isDisabled}
|
|
151
|
+
>
|
|
152
|
+
{o.label}
|
|
153
|
+
</RemovableBadge>
|
|
154
|
+
))
|
|
155
|
+
)}
|
|
156
|
+
<span className="ml-auto pr-1">
|
|
157
|
+
<FieldCaret />
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{description ? <p className={fieldHintStyles}>{description}</p> : null}
|
|
162
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
163
|
+
<p className={fieldErrorStyles}>{errorMessage}</p>
|
|
164
|
+
) : null}
|
|
165
|
+
|
|
166
|
+
<Popover
|
|
167
|
+
isOpen={isOpen}
|
|
168
|
+
onOpenChange={setIsOpen}
|
|
169
|
+
triggerRef={triggerRef}
|
|
170
|
+
offset={6}
|
|
171
|
+
placement="bottom start"
|
|
172
|
+
className={cn(
|
|
173
|
+
'w-[var(--trigger-width)]',
|
|
174
|
+
'rounded-md border border-rule bg-surface p-1',
|
|
175
|
+
'shadow-[var(--shadow-raised)]',
|
|
176
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
177
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<ListBox
|
|
181
|
+
aria-label={typeof label === 'string' ? label : 'Options'}
|
|
182
|
+
selectionMode="multiple"
|
|
183
|
+
selectedKeys={selectedSet}
|
|
184
|
+
onSelectionChange={(keys) => {
|
|
185
|
+
if (keys === 'all') {
|
|
186
|
+
onSelectionChange(new Set(options.map((o) => o.id)))
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
onSelectionChange(keys as Set<T>)
|
|
190
|
+
}}
|
|
191
|
+
className="outline-none max-h-72 overflow-auto"
|
|
192
|
+
renderEmptyState={() => (
|
|
193
|
+
<div className={cn('px-3 py-2', fieldHintStyles)}>All options selected</div>
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
196
|
+
{listboxOptions.map((option) => (
|
|
197
|
+
<ListBoxItem
|
|
198
|
+
key={option.id}
|
|
199
|
+
id={option.id}
|
|
200
|
+
textValue={option.label}
|
|
201
|
+
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
202
|
+
className={cn(
|
|
203
|
+
'group flex items-center',
|
|
204
|
+
'rounded-[6px] px-3 py-2',
|
|
205
|
+
'cursor-pointer outline-none select-none',
|
|
206
|
+
'data-[hovered]:bg-surface-2',
|
|
207
|
+
'data-[focused]:bg-surface-2',
|
|
208
|
+
'data-[selected]:bg-surface-3',
|
|
209
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
<Badge variant={option.variant}>{option.label}</Badge>
|
|
213
|
+
</ListBoxItem>
|
|
214
|
+
))}
|
|
215
|
+
</ListBox>
|
|
216
|
+
</Popover>
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* =========================================================================
|
|
222
|
+
Internal: a Badge with a trailing × button baked into the pill.
|
|
223
|
+
The × inherits the badge's text color via `currentColor`, so it reads
|
|
224
|
+
correctly against every variant's fill.
|
|
225
|
+
========================================================================= */
|
|
226
|
+
|
|
227
|
+
function RemovableBadge({
|
|
228
|
+
children,
|
|
229
|
+
variant,
|
|
230
|
+
onRemove,
|
|
231
|
+
isDisabled,
|
|
232
|
+
}: {
|
|
233
|
+
children: ReactNode
|
|
234
|
+
variant?: BadgeVariants['variant']
|
|
235
|
+
onRemove: () => void
|
|
236
|
+
isDisabled: boolean
|
|
237
|
+
}) {
|
|
238
|
+
return (
|
|
239
|
+
<Badge variant={variant}>
|
|
240
|
+
<span>{children}</span>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
aria-label="Remove"
|
|
244
|
+
disabled={isDisabled}
|
|
245
|
+
onClick={(e) => {
|
|
246
|
+
e.stopPropagation()
|
|
247
|
+
onRemove()
|
|
248
|
+
}}
|
|
249
|
+
className={cn(
|
|
250
|
+
'inline-flex items-center justify-center shrink-0',
|
|
251
|
+
'size-3.5 rounded-full',
|
|
252
|
+
'text-current/70 hover:text-current hover:bg-current/10',
|
|
253
|
+
'outline-none cursor-pointer',
|
|
254
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
255
|
+
'disabled:opacity-45 disabled:cursor-not-allowed',
|
|
256
|
+
// Pull the button flush with the right edge so the pill still feels
|
|
257
|
+
// symmetric around the leading dot.
|
|
258
|
+
'-mr-1',
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
<svg aria-hidden viewBox="0 0 10 10" className="size-2.5" fill="none">
|
|
262
|
+
<path
|
|
263
|
+
d="M2 2l6 6M8 2l-6 6"
|
|
264
|
+
stroke="currentColor"
|
|
265
|
+
strokeWidth="1.4"
|
|
266
|
+
strokeLinecap="round"
|
|
267
|
+
/>
|
|
268
|
+
</svg>
|
|
269
|
+
</button>
|
|
270
|
+
</Badge>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Breadcrumbs } from './breadcrumbs'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Breadcrumbs> = {
|
|
6
|
+
title: 'Components/Breadcrumbs',
|
|
7
|
+
component: Breadcrumbs,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: { layout: 'padded' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof Breadcrumbs>
|
|
14
|
+
|
|
15
|
+
export const Playground: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
items: [
|
|
18
|
+
{ id: 1, label: 'Operations', href: '/operations' },
|
|
19
|
+
{ id: 2, label: 'Shipments', href: '/operations/shipments' },
|
|
20
|
+
{ id: 3, label: 'Open' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const TwoLevels: Story = {
|
|
26
|
+
args: {
|
|
27
|
+
items: [
|
|
28
|
+
{ id: 1, label: 'Workspace', href: '/' },
|
|
29
|
+
{ id: 2, label: 'Home' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const WithChevronSeparator: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
separator: '›',
|
|
37
|
+
items: [
|
|
38
|
+
{ id: 1, label: 'Finance', href: '/finance' },
|
|
39
|
+
{ id: 2, label: 'Reconciliation', href: '/finance/reconciliation' },
|
|
40
|
+
{ id: 3, label: 'Disputes' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Breadcrumb as AriaBreadcrumb,
|
|
4
|
+
Breadcrumbs as AriaBreadcrumbs,
|
|
5
|
+
type BreadcrumbsProps as AriaBreadcrumbsProps,
|
|
6
|
+
Link as AriaLink,
|
|
7
|
+
} from 'react-aria-components'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../../lib/cn'
|
|
10
|
+
|
|
11
|
+
/* =========================================================================
|
|
12
|
+
Breadcrumbs — RAC Breadcrumbs/Breadcrumb under the hood. Last item is
|
|
13
|
+
rendered as a non-link span with `aria-current="page"`; everything before
|
|
14
|
+
it is a RAC Link with hover + focus-visible chrome.
|
|
15
|
+
========================================================================= */
|
|
16
|
+
|
|
17
|
+
export type BreadcrumbItem = {
|
|
18
|
+
/** Stable key for the row. */
|
|
19
|
+
id: string | number
|
|
20
|
+
/** Display label. */
|
|
21
|
+
label: ReactNode
|
|
22
|
+
/** Optional href. Omit (or leave the last item without one) to render the
|
|
23
|
+
* current page as plain text. */
|
|
24
|
+
href?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BreadcrumbsProps = Omit<AriaBreadcrumbsProps<never>, 'children' | 'items'> & {
|
|
28
|
+
items: ReadonlyArray<BreadcrumbItem>
|
|
29
|
+
/** Glyph between crumbs. Default `/`. */
|
|
30
|
+
separator?: ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Breadcrumbs({ items, separator = '/', className, ...props }: BreadcrumbsProps) {
|
|
34
|
+
return (
|
|
35
|
+
<AriaBreadcrumbs
|
|
36
|
+
{...props}
|
|
37
|
+
className={cn(
|
|
38
|
+
'flex items-center gap-2',
|
|
39
|
+
'font-mono text-[12px] text-ink-3',
|
|
40
|
+
// RAC renders Breadcrumbs as <ol> — neutralise list defaults.
|
|
41
|
+
'list-none m-0 p-0',
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{items.map((item, i) => {
|
|
46
|
+
const isLast = i === items.length - 1
|
|
47
|
+
return (
|
|
48
|
+
<AriaBreadcrumb
|
|
49
|
+
key={item.id}
|
|
50
|
+
className="flex items-center gap-2"
|
|
51
|
+
// The last crumb is the current page. Hides the trailing
|
|
52
|
+
// separator and skips the link affordance.
|
|
53
|
+
data-current={isLast || undefined}
|
|
54
|
+
>
|
|
55
|
+
{item.href !== undefined && !isLast ? (
|
|
56
|
+
<AriaLink
|
|
57
|
+
href={item.href}
|
|
58
|
+
className={cn(
|
|
59
|
+
'rounded-sm outline-none cursor-pointer',
|
|
60
|
+
'transition-colors duration-150',
|
|
61
|
+
'hover:text-ink',
|
|
62
|
+
'data-[hovered]:text-ink',
|
|
63
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{item.label}
|
|
67
|
+
</AriaLink>
|
|
68
|
+
) : (
|
|
69
|
+
<span
|
|
70
|
+
className={cn(isLast && 'text-ink font-semibold')}
|
|
71
|
+
aria-current={isLast ? 'page' : undefined}
|
|
72
|
+
>
|
|
73
|
+
{item.label}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
{!isLast ? (
|
|
77
|
+
<span aria-hidden className="text-ink-4 select-none">
|
|
78
|
+
{separator}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</AriaBreadcrumb>
|
|
82
|
+
)
|
|
83
|
+
})}
|
|
84
|
+
</AriaBreadcrumbs>
|
|
85
|
+
)
|
|
86
|
+
}
|