@proteos/ui 0.46.0 → 0.48.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/dist/index.cjs +154 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +152 -16
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/channel-icon/channel-icon.tsx +2 -0
- package/src/components/checkbox/checkbox.tsx +8 -1
- package/src/components/data-table/data-table.test.tsx +112 -0
- package/src/components/data-table/data-table.tsx +135 -11
- package/src/components/data-table/index.ts +1 -1
- package/src/components/orb/index.ts +2 -0
- package/src/components/orb/orb.stories.tsx +25 -0
- package/src/components/orb/orb.tsx +57 -0
- package/src/components/record-filter/chip-value.tsx +17 -2
- package/src/components/record-filter/field-label.ts +29 -0
- package/src/components/record-filter/index.ts +1 -0
- package/src/components/record-filter/record-filter-chips.tsx +7 -1
- package/src/components/record-filter/record-filter.stories.tsx +3 -3
- package/src/components/record-filter/record-filter.test.tsx +86 -0
- package/src/components/record-filter/types.ts +2 -1
- package/src/index.ts +1 -0
- package/src/styles.css +1 -0
- package/src/test/setup.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"description": "Proteos React component library (React Aria Components + Tailwind v4).",
|
|
6
6
|
"repository": {
|
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
"tailwind-merge": "^3.5.0",
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
|
-
"
|
|
58
|
+
"thinking-orbs": "^0.3.1",
|
|
59
|
+
"@proteos/sdk": "0.48.0"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
62
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
MessageCircle,
|
|
10
10
|
MessagesSquare,
|
|
11
11
|
Mic,
|
|
12
|
+
Phone,
|
|
12
13
|
Send,
|
|
13
14
|
Video,
|
|
14
15
|
} from 'lucide-react'
|
|
@@ -41,6 +42,7 @@ const CHANNEL_ICONS: Readonly<Record<string, LucideIcon>> = {
|
|
|
41
42
|
'teams-meeting': Video,
|
|
42
43
|
'webex-meeting': Video,
|
|
43
44
|
adhoc: AudioLines,
|
|
45
|
+
phone: Phone,
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** The channel's glyph component. Unknown channels get the generic one. */
|
|
@@ -16,7 +16,14 @@ export function Checkbox({ children, className, ...props }: CheckboxProps) {
|
|
|
16
16
|
{...props}
|
|
17
17
|
className={(renderProps) =>
|
|
18
18
|
cn(
|
|
19
|
-
'
|
|
19
|
+
// `relative` so RAC's visually-hidden <input> — which is absolutely
|
|
20
|
+
// positioned and is the element that actually receives focus — is
|
|
21
|
+
// laid out against THIS checkbox. Without a positioned ancestor here
|
|
22
|
+
// it resolves against a far-away container, ends up outside any
|
|
23
|
+
// scroll clipping, and focusing it makes the browser scroll a
|
|
24
|
+
// container the user cannot scroll back (the roles permission
|
|
25
|
+
// matrix, where toggling a row threw the page off-screen).
|
|
26
|
+
'group relative inline-flex items-center gap-2.5 cursor-pointer',
|
|
20
27
|
'font-sans text-[14px] text-ink-2 select-none',
|
|
21
28
|
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
22
29
|
typeof className === 'function' ? className(renderProps) : className,
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { DataTable, type InfiniteQueryLike } from './data-table'
|
|
7
|
+
|
|
8
|
+
type Row = { id: string; name: string }
|
|
9
|
+
|
|
10
|
+
const ROWS: Row[] = [
|
|
11
|
+
{ id: 'r-1', name: 'Alpha' },
|
|
12
|
+
{ id: 'r-2', name: 'Beta' },
|
|
13
|
+
{ id: 'r-3', name: 'Gamma' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function query(rows: Row[]): InfiniteQueryLike<Row> {
|
|
17
|
+
return {
|
|
18
|
+
data: { pages: [{ data: rows, meta: { items_total: rows.length } }] },
|
|
19
|
+
isLoading: false,
|
|
20
|
+
isFetchingNextPage: false,
|
|
21
|
+
hasNextPage: false,
|
|
22
|
+
error: null,
|
|
23
|
+
fetchNextPage: vi.fn(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const columns = [{ id: 'name', accessorKey: 'name', header: 'Name', size: 160 }]
|
|
28
|
+
|
|
29
|
+
function Harness({ onChange }: { onChange?: (ids: Set<string>) => void }) {
|
|
30
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
31
|
+
return (
|
|
32
|
+
<DataTable<Row>
|
|
33
|
+
query={query(ROWS)}
|
|
34
|
+
columns={columns}
|
|
35
|
+
ariaLabel="Records"
|
|
36
|
+
selectionMode="multiple"
|
|
37
|
+
selectedIds={selected}
|
|
38
|
+
onSelectionChange={(ids) => {
|
|
39
|
+
setSelected(ids)
|
|
40
|
+
onChange?.(ids)
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The body is virtualized against a real scroll box, which jsdom cannot
|
|
47
|
+
// measure (every rect is 0×0, so the virtualizer yields no items and no row
|
|
48
|
+
// ever renders). Swap the virtualizer for a pass-through that emits every
|
|
49
|
+
// row — the windowing itself is TanStack's to test, not ours.
|
|
50
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
51
|
+
useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: () => number }) => {
|
|
52
|
+
const size = estimateSize()
|
|
53
|
+
return {
|
|
54
|
+
getTotalSize: () => count * size,
|
|
55
|
+
getVirtualItems: () =>
|
|
56
|
+
Array.from({ length: count }, (_unused, index) => ({
|
|
57
|
+
index,
|
|
58
|
+
key: index,
|
|
59
|
+
start: index * size,
|
|
60
|
+
size,
|
|
61
|
+
})),
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
describe('DataTable selection', () => {
|
|
67
|
+
it('renders no checkboxes when selection is off', () => {
|
|
68
|
+
render(<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" />)
|
|
69
|
+
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('select-all toggles every loaded row, then clears', async () => {
|
|
73
|
+
const user = userEvent.setup()
|
|
74
|
+
const onChange = vi.fn()
|
|
75
|
+
render(<Harness onChange={onChange} />)
|
|
76
|
+
|
|
77
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
|
|
78
|
+
expect(onChange).toHaveBeenLastCalledWith(new Set(['r-1', 'r-2', 'r-3']))
|
|
79
|
+
|
|
80
|
+
await user.click(screen.getByRole('checkbox', { name: 'Deselect all rows' }))
|
|
81
|
+
expect(onChange).toHaveBeenLastCalledWith(new Set())
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('selecting one row leaves select-all indeterminate', async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
render(<Harness />)
|
|
87
|
+
|
|
88
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select row 1' }))
|
|
89
|
+
|
|
90
|
+
const selectAll = screen.getByRole('checkbox', { name: 'Select all rows' })
|
|
91
|
+
expect(selectAll).toBePartiallyChecked()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not open a row when its checkbox is clicked', async () => {
|
|
95
|
+
const user = userEvent.setup()
|
|
96
|
+
const onRowClick = vi.fn()
|
|
97
|
+
render(
|
|
98
|
+
<DataTable<Row>
|
|
99
|
+
query={query(ROWS)}
|
|
100
|
+
columns={columns}
|
|
101
|
+
ariaLabel="Records"
|
|
102
|
+
selectionMode="multiple"
|
|
103
|
+
selectedIds={new Set()}
|
|
104
|
+
onSelectionChange={vi.fn()}
|
|
105
|
+
onRowClick={onRowClick}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select row 1' }))
|
|
110
|
+
expect(onRowClick).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from 'react'
|
|
25
25
|
|
|
26
26
|
import { cn } from '../../lib/cn'
|
|
27
|
+
import { Checkbox } from '../checkbox'
|
|
27
28
|
import {
|
|
28
29
|
isColumnDragData,
|
|
29
30
|
type SortDirection,
|
|
@@ -138,6 +139,20 @@ export type DataTableProps<TRow> = {
|
|
|
138
139
|
* at slot='end'. Sits inside the scrollable area below the rows.
|
|
139
140
|
*/
|
|
140
141
|
bodyAfter?: ReactNode
|
|
142
|
+
/**
|
|
143
|
+
* Row selection. `none` (default) renders no checkbox column; `multiple`
|
|
144
|
+
* prepends a sticky leading checkbox column and turns the header checkbox
|
|
145
|
+
* into a tri-state select-all.
|
|
146
|
+
*
|
|
147
|
+
* Selection is CONTROLLED — the owner holds the ids because it is the one
|
|
148
|
+
* that acts on them (running a batch action, clearing after a save). The
|
|
149
|
+
* header checkbox covers the rows currently LOADED: an infinite list can't
|
|
150
|
+
* honestly claim to select rows it has never fetched, so "select everything
|
|
151
|
+
* matching the filter" belongs to the owner's toolbar, not here.
|
|
152
|
+
*/
|
|
153
|
+
selectionMode?: 'none' | 'multiple'
|
|
154
|
+
selectedIds?: Set<string>
|
|
155
|
+
onSelectionChange?: (ids: Set<string>) => void
|
|
141
156
|
/**
|
|
142
157
|
* Draw vertical gridlines between body cells. The header always has them;
|
|
143
158
|
* the body opts in (e.g. inline-edit mode wants the full Excel-style grid).
|
|
@@ -148,6 +163,9 @@ export type DataTableProps<TRow> = {
|
|
|
148
163
|
|
|
149
164
|
const DEFAULT_ROW_HEIGHT = 32
|
|
150
165
|
|
|
166
|
+
/** Column id of the leading checkbox column added by `selectionMode`. */
|
|
167
|
+
export const SELECTION_COLUMN_ID = '__select__'
|
|
168
|
+
|
|
151
169
|
export function DataTable<TRow>({
|
|
152
170
|
query,
|
|
153
171
|
columns,
|
|
@@ -172,6 +190,9 @@ export function DataTable<TRow>({
|
|
|
172
190
|
extraRows,
|
|
173
191
|
bodyBefore,
|
|
174
192
|
bodyAfter,
|
|
193
|
+
selectionMode = 'none',
|
|
194
|
+
selectedIds,
|
|
195
|
+
onSelectionChange,
|
|
175
196
|
columnBorders = false,
|
|
176
197
|
}: DataTableProps<TRow>) {
|
|
177
198
|
const rows = useMemo<TRow[]>(() => {
|
|
@@ -183,21 +204,124 @@ export function DataTable<TRow>({
|
|
|
183
204
|
return [...startExtras, ...dataRows, ...endExtras]
|
|
184
205
|
}, [query.data, extraRows])
|
|
185
206
|
|
|
207
|
+
const resolveRowId = useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
getRowId ??
|
|
210
|
+
((row: TRow, index: number) => {
|
|
211
|
+
const r = row as Record<string, unknown>
|
|
212
|
+
if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
|
|
213
|
+
if (typeof r.slug === 'string') return r.slug
|
|
214
|
+
return String(index)
|
|
215
|
+
}),
|
|
216
|
+
[getRowId],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const selectionEnabled = selectionMode === 'multiple'
|
|
220
|
+
const selected = useMemo(() => selectedIds ?? new Set<string>(), [selectedIds])
|
|
221
|
+
// Ids of the rows currently LOADED — what the header checkbox operates on.
|
|
222
|
+
const loadedIds = useMemo(
|
|
223
|
+
() => (selectionEnabled ? rows.map((row, index) => resolveRowId(row, index)) : []),
|
|
224
|
+
[selectionEnabled, rows, resolveRowId],
|
|
225
|
+
)
|
|
226
|
+
// Anchor for shift-click range selection, in loaded-row index space.
|
|
227
|
+
const lastToggledIndexRef = useRef<number | null>(null)
|
|
228
|
+
|
|
229
|
+
const toggleRow = (id: string, index: number, isRange: boolean) => {
|
|
230
|
+
if (!onSelectionChange) return
|
|
231
|
+
const next = new Set(selected)
|
|
232
|
+
const anchor = lastToggledIndexRef.current
|
|
233
|
+
if (isRange && anchor !== null) {
|
|
234
|
+
const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]
|
|
235
|
+
// A range drag always ADDS — matching how file managers and mail
|
|
236
|
+
// clients behave; use individual clicks to take rows back out.
|
|
237
|
+
for (let i = from; i <= to; i += 1) {
|
|
238
|
+
const rangeId = loadedIds[i]
|
|
239
|
+
if (rangeId !== undefined) next.add(rangeId)
|
|
240
|
+
}
|
|
241
|
+
} else if (next.has(id)) {
|
|
242
|
+
next.delete(id)
|
|
243
|
+
} else {
|
|
244
|
+
next.add(id)
|
|
245
|
+
}
|
|
246
|
+
lastToggledIndexRef.current = index
|
|
247
|
+
onSelectionChange(next)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const loadedSelectedCount = loadedIds.reduce((count, id) => count + (selected.has(id) ? 1 : 0), 0)
|
|
251
|
+
const allLoadedSelected = loadedIds.length > 0 && loadedSelectedCount === loadedIds.length
|
|
252
|
+
|
|
253
|
+
const toggleAllLoaded = () => {
|
|
254
|
+
if (!onSelectionChange) return
|
|
255
|
+
const next = new Set(selected)
|
|
256
|
+
if (allLoadedSelected) {
|
|
257
|
+
for (const id of loadedIds) next.delete(id)
|
|
258
|
+
} else {
|
|
259
|
+
for (const id of loadedIds) next.add(id)
|
|
260
|
+
}
|
|
261
|
+
lastToggledIndexRef.current = null
|
|
262
|
+
onSelectionChange(next)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// RAC's Checkbox onChange carries no modifier keys, so the shift state is
|
|
266
|
+
// captured on the preceding pointer event.
|
|
267
|
+
const rangeModifierRef = useRef(false)
|
|
268
|
+
|
|
269
|
+
const selectionColumn = useMemo<ColumnDef<TRow>>(
|
|
270
|
+
() => ({
|
|
271
|
+
id: SELECTION_COLUMN_ID,
|
|
272
|
+
size: 36,
|
|
273
|
+
enableSorting: false,
|
|
274
|
+
meta: { align: 'center', sticky: 'left', cellClassName: 'px-0 justify-center' },
|
|
275
|
+
header: () => (
|
|
276
|
+
<span className="inline-flex items-center justify-center w-full">
|
|
277
|
+
<Checkbox
|
|
278
|
+
aria-label={allLoadedSelected ? 'Deselect all rows' : 'Select all rows'}
|
|
279
|
+
isSelected={allLoadedSelected}
|
|
280
|
+
isIndeterminate={loadedSelectedCount > 0 && !allLoadedSelected}
|
|
281
|
+
isDisabled={loadedIds.length === 0}
|
|
282
|
+
onChange={toggleAllLoaded}
|
|
283
|
+
/>
|
|
284
|
+
</span>
|
|
285
|
+
),
|
|
286
|
+
cell: ({ row }) => (
|
|
287
|
+
// Stop the click here: selecting a row must never also open it.
|
|
288
|
+
<span
|
|
289
|
+
className="inline-flex items-center justify-center w-full"
|
|
290
|
+
onClick={(event) => event.stopPropagation()}
|
|
291
|
+
onKeyDown={(event) => {
|
|
292
|
+
event.stopPropagation()
|
|
293
|
+
rangeModifierRef.current = event.shiftKey
|
|
294
|
+
}}
|
|
295
|
+
onPointerDownCapture={(event) => {
|
|
296
|
+
rangeModifierRef.current = event.shiftKey
|
|
297
|
+
}}
|
|
298
|
+
role="presentation"
|
|
299
|
+
>
|
|
300
|
+
<Checkbox
|
|
301
|
+
aria-label={`Select row ${row.index + 1}`}
|
|
302
|
+
isSelected={selected.has(row.id)}
|
|
303
|
+
onChange={() => toggleRow(row.id, row.index, rangeModifierRef.current)}
|
|
304
|
+
/>
|
|
305
|
+
</span>
|
|
306
|
+
),
|
|
307
|
+
}),
|
|
308
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: handlers read the latest selection via closure recreated on these inputs
|
|
309
|
+
[selected, loadedIds, allLoadedSelected, loadedSelectedCount],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const tableColumns = useMemo(
|
|
313
|
+
() => (selectionEnabled ? [selectionColumn, ...columns] : columns),
|
|
314
|
+
[selectionEnabled, selectionColumn, columns],
|
|
315
|
+
)
|
|
316
|
+
|
|
186
317
|
const table = useReactTable({
|
|
187
318
|
data: rows,
|
|
188
|
-
columns,
|
|
319
|
+
columns: tableColumns,
|
|
189
320
|
state: { sorting: sorting ?? [] },
|
|
190
321
|
...(onSortingChange ? { onSortingChange } : {}),
|
|
191
322
|
manualSorting: true,
|
|
192
323
|
getCoreRowModel: getCoreRowModel(),
|
|
193
|
-
getRowId:
|
|
194
|
-
? (row, index) => getRowId(row, index)
|
|
195
|
-
: (row, index) => {
|
|
196
|
-
const r = row as Record<string, unknown>
|
|
197
|
-
if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
|
|
198
|
-
if (typeof r.slug === 'string') return r.slug
|
|
199
|
-
return String(index)
|
|
200
|
-
},
|
|
324
|
+
getRowId: (row, index) => resolveRowId(row, index),
|
|
201
325
|
})
|
|
202
326
|
|
|
203
327
|
const tableRows = table.getRowModel().rows
|
|
@@ -243,8 +367,8 @@ export function DataTable<TRow>({
|
|
|
243
367
|
})
|
|
244
368
|
|
|
245
369
|
const totalColumnWidth = useMemo(
|
|
246
|
-
() =>
|
|
247
|
-
[
|
|
370
|
+
() => tableColumns.reduce((sum, c) => sum + (c.size ?? 140), 0),
|
|
371
|
+
[tableColumns],
|
|
248
372
|
)
|
|
249
373
|
|
|
250
374
|
// Single global pragmatic-dnd monitor for column reorder. We mirror the
|
|
@@ -7,7 +7,7 @@ export type {
|
|
|
7
7
|
} from './cells'
|
|
8
8
|
export { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
|
|
9
9
|
export type { DataTableProps, InfiniteQueryLike } from './data-table'
|
|
10
|
-
export { DataTable } from './data-table'
|
|
10
|
+
export { DataTable, SELECTION_COLUMN_ID } from './data-table'
|
|
11
11
|
|
|
12
12
|
export type { ActionsColumnOptions } from './make-actions-column'
|
|
13
13
|
export { makeActionsColumn } from './make-actions-column'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Orb } from './orb'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Orb> = {
|
|
6
|
+
title: 'Components/Orb',
|
|
7
|
+
component: Orb,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: { layout: 'padded' },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof Orb>
|
|
14
|
+
|
|
15
|
+
export const Sizes: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div className="flex items-center gap-4">
|
|
18
|
+
<Orb size="xs" label="Agent working" />
|
|
19
|
+
<Orb size="sm" label="Agent working" />
|
|
20
|
+
<Orb size="md" label="Agent working" />
|
|
21
|
+
<Orb size="lg" label="Agent working" />
|
|
22
|
+
<span className="text-[13px] text-ink-3">Agent is composing…</span>
|
|
23
|
+
</div>
|
|
24
|
+
),
|
|
25
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type OrbState, type OrbTheme, ThinkingOrb } from 'thinking-orbs'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
|
|
5
|
+
/* =========================================================================
|
|
6
|
+
Orb — the "agent is working" indicator, backed by the `thinking-orbs`
|
|
7
|
+
package (https://orbs.jakubantalik.com): a canvas-rendered dotted orb
|
|
8
|
+
(state: solving), monochrome ink that auto-resolves against `data-theme`
|
|
9
|
+
/ OS scheme, with a reduced-motion static frame built in.
|
|
10
|
+
|
|
11
|
+
The package ships two hand-tuned designs — 20px (inline) and 64px
|
|
12
|
+
(avatar). `sm` and `lg` render them at native size; `xs` gently
|
|
13
|
+
downscales the 20px design, `md` downscales the 64px one (downscaling
|
|
14
|
+
stays crisp — the canvas buffer is the larger design).
|
|
15
|
+
|
|
16
|
+
Purely decorative by default — pass `label` to expose it as an img with
|
|
17
|
+
an accessible name (e.g. "Agent working").
|
|
18
|
+
========================================================================= */
|
|
19
|
+
|
|
20
|
+
export type OrbSizeToken = 'xs' | 'sm' | 'md' | 'lg'
|
|
21
|
+
|
|
22
|
+
/** CSS box per token. sm/lg match the native designs exactly (no scaling). */
|
|
23
|
+
const SIZE_PX: Record<OrbSizeToken, number> = { xs: 12, sm: 20, md: 40, lg: 64 }
|
|
24
|
+
|
|
25
|
+
/** Which native design each token rides. */
|
|
26
|
+
const DESIGN: Record<OrbSizeToken, 20 | 64> = { xs: 20, sm: 20, md: 64, lg: 64 }
|
|
27
|
+
|
|
28
|
+
export interface OrbProps {
|
|
29
|
+
/** xs 12 · sm 20 (native inline) · md 40 · lg 64 (native avatar). Default `sm`. */
|
|
30
|
+
size?: OrbSizeToken
|
|
31
|
+
/** Animation state; defaults to `solving` (the working indicator). */
|
|
32
|
+
state?: OrbState
|
|
33
|
+
/** Speed multiplier (1 = tuned speed); e.g. 0.2 for a slow idle shimmer. */
|
|
34
|
+
speed?: number
|
|
35
|
+
/** Pin the ink instead of auto-detecting — `dark` renders light dots (for
|
|
36
|
+
* a dark chip like the launcher's black circle). */
|
|
37
|
+
theme?: OrbTheme
|
|
38
|
+
className?: string
|
|
39
|
+
/** Accessible name; omitting it hides the orb from assistive tech. */
|
|
40
|
+
label?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Orb({ size = 'sm', state = 'solving', speed, theme, className, label }: OrbProps) {
|
|
44
|
+
const px = SIZE_PX[size]
|
|
45
|
+
const design = DESIGN[size]
|
|
46
|
+
return (
|
|
47
|
+
<ThinkingOrb
|
|
48
|
+
state={state}
|
|
49
|
+
size={design}
|
|
50
|
+
{...(speed !== undefined ? { speed } : {})}
|
|
51
|
+
{...(theme !== undefined ? { theme } : {})}
|
|
52
|
+
className={cn('inline-block shrink-0', className)}
|
|
53
|
+
{...(px !== design ? { style: { width: px, height: px } } : {})}
|
|
54
|
+
{...(label ? { 'aria-label': label } : { 'aria-hidden': true })}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -124,7 +124,7 @@ function ChipItem({
|
|
|
124
124
|
}
|
|
125
125
|
// Lib default: enum → Badge with variant lookup; other → truncated mono text.
|
|
126
126
|
if (field.type === 'enum') {
|
|
127
|
-
const option = field
|
|
127
|
+
const option = findEnumOption(field, value)
|
|
128
128
|
return (
|
|
129
129
|
<Badge
|
|
130
130
|
variant={option?.variant}
|
|
@@ -146,6 +146,13 @@ function Em({ children }: { children: string }) {
|
|
|
146
146
|
return <span className="font-mono text-[12px] text-ink-4">{children}</span>
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** The declared option for an enum value, if the field carries one. Central so
|
|
150
|
+
* the badge, the multi-value summary and the single-value summary agree on
|
|
151
|
+
* what a stored value is called. */
|
|
152
|
+
function findEnumOption(field: FilterField, value: string) {
|
|
153
|
+
return field.options?.find((option) => option.value === value)
|
|
154
|
+
}
|
|
155
|
+
|
|
149
156
|
export function formatValue(
|
|
150
157
|
field: FilterField,
|
|
151
158
|
operator: ComparisonOperator,
|
|
@@ -161,7 +168,7 @@ export function formatValue(
|
|
|
161
168
|
: []
|
|
162
169
|
if (arr.length === 0) return '—'
|
|
163
170
|
if (field.type === 'enum') {
|
|
164
|
-
const labels = arr.map((v) => field
|
|
171
|
+
const labels = arr.map((v) => findEnumOption(field, v)?.label ?? v)
|
|
165
172
|
return labels.length <= 2
|
|
166
173
|
? labels.join(', ')
|
|
167
174
|
: `${labels[0]}, ${labels[1]} +${labels.length - 2}`
|
|
@@ -170,6 +177,14 @@ export function formatValue(
|
|
|
170
177
|
}
|
|
171
178
|
if (field.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false'
|
|
172
179
|
if (value === '' || value == null) return '—'
|
|
180
|
+
// An enum stores a system value ("technical_decider") and displays its
|
|
181
|
+
// declared label ("Technical Decider"). The multi-value branch above and the
|
|
182
|
+
// badge below already do this; a single value read as the raw slug, so the
|
|
183
|
+
// same filter said "is technical_decider" or "is any of Technical Decider"
|
|
184
|
+
// depending only on the operator.
|
|
185
|
+
if (field.type === 'enum') {
|
|
186
|
+
return findEnumOption(field, String(value))?.label ?? String(value)
|
|
187
|
+
}
|
|
173
188
|
if (field.type === 'number' || field.type === 'integer') {
|
|
174
189
|
const n = Number(value)
|
|
175
190
|
return Number.isFinite(n) ? n.toLocaleString() : String(value)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* =========================================================================
|
|
2
|
+
Field-label composition — how a nested or related field reads in the
|
|
3
|
+
picker, the chips rail and the operator row.
|
|
4
|
+
|
|
5
|
+
A `FilterField` path is hierarchical: an object leaf (`address.city`) and
|
|
6
|
+
a field reached through a relation (`company_id.name`) both render as a
|
|
7
|
+
breadcrumb. The separator lives here because BOTH sides compose it — the
|
|
8
|
+
consumer builds `FilterField.label` from an entity, and the chips rail
|
|
9
|
+
builds its own breadcrumb from `parentLabel` — and a mismatch between the
|
|
10
|
+
two shows up as one chip reading "Company · Name" next to another reading
|
|
11
|
+
"Company / Name".
|
|
12
|
+
========================================================================= */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* U+00B7 MIDDLE DOT, spaced. The same separator the app already uses for
|
|
16
|
+
* breadcrumb-ish text ("Segment · SaaS decision makers"), and quieter than a
|
|
17
|
+
* slash at the small type sizes these labels render at — the eye reads the
|
|
18
|
+
* words, not the punctuation.
|
|
19
|
+
*/
|
|
20
|
+
export const FIELD_LABEL_SEPARATOR = ' · '
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Joins breadcrumb parts with {@link FIELD_LABEL_SEPARATOR}, skipping empties
|
|
24
|
+
* so a field with no parent renders as its own label rather than with a
|
|
25
|
+
* leading separator.
|
|
26
|
+
*/
|
|
27
|
+
export function joinFieldLabel(...parts: ReadonlyArray<string | undefined | null>): string {
|
|
28
|
+
return parts.filter((part): part is string => !!part && part.length > 0).join(FIELD_LABEL_SEPARATOR)
|
|
29
|
+
}
|
|
@@ -14,6 +14,7 @@ import { OperatorPicker } from './operator-picker'
|
|
|
14
14
|
import { operatorById, operatorsForType } from './operators'
|
|
15
15
|
import { useShowDetails } from './show-details-store'
|
|
16
16
|
import { AttributeTypeIcon } from '../attribute-type-icon'
|
|
17
|
+
import { FIELD_LABEL_SEPARATOR } from './field-label'
|
|
17
18
|
import type { ComparisonOperator, FilterField, FilterGroup, FilterValue } from './types'
|
|
18
19
|
|
|
19
20
|
/* =========================================================================
|
|
@@ -370,7 +371,12 @@ function Chip({
|
|
|
370
371
|
</span>
|
|
371
372
|
) : null}
|
|
372
373
|
<span className="font-sans text-[12.5px] text-ink-2">
|
|
373
|
-
{field.parentLabel ?
|
|
374
|
+
{field.parentLabel ? (
|
|
375
|
+
<span className="text-ink-4">
|
|
376
|
+
{field.parentLabel}
|
|
377
|
+
{FIELD_LABEL_SEPARATOR}
|
|
378
|
+
</span>
|
|
379
|
+
) : null}
|
|
374
380
|
<span className="text-ink">{field.shortLabel}</span>
|
|
375
381
|
</span>
|
|
376
382
|
<span className="font-mono text-[11px] text-ink-3">{op?.label}</span>
|
|
@@ -52,14 +52,14 @@ const customerFields: FilterField[] = [
|
|
|
52
52
|
path: 'owner.name',
|
|
53
53
|
shortLabel: 'Name',
|
|
54
54
|
parentLabel: 'Owner',
|
|
55
|
-
label: 'Owner
|
|
55
|
+
label: 'Owner · Name',
|
|
56
56
|
type: 'string',
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
path: 'owner.email',
|
|
60
60
|
shortLabel: 'Email',
|
|
61
61
|
parentLabel: 'Owner',
|
|
62
|
-
label: 'Owner
|
|
62
|
+
label: 'Owner · Email',
|
|
63
63
|
type: 'string',
|
|
64
64
|
inputType: 'email',
|
|
65
65
|
},
|
|
@@ -67,7 +67,7 @@ const customerFields: FilterField[] = [
|
|
|
67
67
|
path: 'address.country',
|
|
68
68
|
shortLabel: 'Country',
|
|
69
69
|
parentLabel: 'Address',
|
|
70
|
-
label: 'Address
|
|
70
|
+
label: 'Address · Country',
|
|
71
71
|
type: 'enum',
|
|
72
72
|
options: [
|
|
73
73
|
{ value: 'us', label: 'United States' },
|