@proteos/ui 0.45.0 → 0.47.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.
Files changed (43) hide show
  1. package/dist/index.cjs +720 -70
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +191 -8
  4. package/dist/index.d.ts +191 -8
  5. package/dist/index.js +715 -74
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/attribute-type-icon/attribute-type-icon.tsx +5 -0
  9. package/src/components/avatar/avatar.stories.tsx +37 -1
  10. package/src/components/avatar/avatar.test.tsx +42 -0
  11. package/src/components/avatar/avatar.tsx +39 -4
  12. package/src/components/avatar-group/avatar-group.test.tsx +19 -0
  13. package/src/components/avatar-group/avatar-group.tsx +18 -3
  14. package/src/components/badge/badge.stories.tsx +27 -1
  15. package/src/components/badge/badge.test.tsx +26 -0
  16. package/src/components/badge/badge.tsx +48 -4
  17. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +24 -0
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +25 -4
  19. package/src/components/checkbox/checkbox.tsx +8 -1
  20. package/src/components/color-input/color-input.stories.tsx +72 -0
  21. package/src/components/color-input/color-input.test.tsx +95 -0
  22. package/src/components/color-input/color-input.tsx +208 -0
  23. package/src/components/color-input/index.ts +2 -0
  24. package/src/components/data-table/data-table.test.tsx +112 -0
  25. package/src/components/data-table/data-table.tsx +135 -11
  26. package/src/components/data-table/index.ts +1 -1
  27. package/src/components/draft-message-composer/draft-message-composer.stories.tsx +1 -1
  28. package/src/components/icon-picker/icon-picker.stories.tsx +77 -0
  29. package/src/components/icon-picker/icon-picker.test.tsx +88 -0
  30. package/src/components/icon-picker/icon-picker.tsx +336 -0
  31. package/src/components/icon-picker/index.ts +2 -0
  32. package/src/components/record-filter/chip-value.tsx +10 -3
  33. package/src/components/record-filter/filter-value-input.tsx +12 -2
  34. package/src/components/record-filter/operators.ts +6 -6
  35. package/src/components/record-filter/record-filter.stories.tsx +1 -1
  36. package/src/components/record-filter/types.ts +4 -0
  37. package/src/components/table/table.stories.tsx +12 -13
  38. package/src/components/toast/index.ts +9 -0
  39. package/src/components/toast/toast.stories.tsx +58 -0
  40. package/src/components/toast/toast.test.tsx +34 -0
  41. package/src/components/toast/toast.tsx +121 -0
  42. package/src/index.ts +3 -0
  43. package/src/test/setup.ts +30 -0
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { IconPicker } from './icon-picker'
5
+
6
+ const meta: Meta<typeof IconPicker> = {
7
+ title: 'Components/IconPicker',
8
+ component: IconPicker,
9
+ tags: ['autodocs'],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[320px]">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof IconPicker>
21
+
22
+ function Controlled({ initial }: { initial: string | null }) {
23
+ const [value, setValue] = useState<string | null>(initial)
24
+ return (
25
+ <IconPicker
26
+ label="Icon"
27
+ description="Stored as the canonical Lucide name."
28
+ value={value}
29
+ onChange={setValue}
30
+ />
31
+ )
32
+ }
33
+
34
+ export const Empty: Story = {
35
+ render: () => <Controlled initial={null} />,
36
+ }
37
+
38
+ export const Preselected: Story = {
39
+ render: () => <Controlled initial="TrendingUp" />,
40
+ }
41
+
42
+ export const ResolvesLooseNames: Story = {
43
+ name: 'Resolves loose names (kebab-case in, canonical out)',
44
+ render: () => <Controlled initial="trending-up" />,
45
+ }
46
+
47
+ /** `compact` — a square, icon-only trigger for dense rows (enum value
48
+ * editors, table toolbars). Same popover; `aria-label` carries the name. */
49
+ export const Compact: Story = {
50
+ render: () => {
51
+ const [value, setValue] = useState<string | null>('CircleDashed')
52
+ const [empty, setEmpty] = useState<string | null>(null)
53
+ return (
54
+ <div className="flex items-center gap-3">
55
+ <IconPicker compact aria-label="Icon" value={value} onChange={setValue} />
56
+ <IconPicker compact size="sm" aria-label="Icon (sm)" value={value} onChange={setValue} />
57
+ <IconPicker compact size="sm" aria-label="Icon (empty)" value={empty} onChange={setEmpty} />
58
+ </div>
59
+ )
60
+ },
61
+ }
62
+
63
+ export const Invalid: Story = {
64
+ render: () => (
65
+ <IconPicker
66
+ label="Icon"
67
+ value={null}
68
+ onChange={() => {}}
69
+ isInvalid
70
+ errorMessage="Pick an icon."
71
+ />
72
+ ),
73
+ }
74
+
75
+ export const Disabled: Story = {
76
+ render: () => <IconPicker label="Icon" value="Layers" onChange={() => {}} isDisabled />,
77
+ }
@@ -0,0 +1,88 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { IconPicker, resolveLucideIcon, resolveLucideIconName } from './icon-picker'
6
+
7
+ describe('resolveLucideIconName', () => {
8
+ it('accepts canonical, kebab-case, and lowercase spellings', () => {
9
+ expect(resolveLucideIconName('TrendingUp')).toBe('TrendingUp')
10
+ expect(resolveLucideIconName('trending-up')).toBe('TrendingUp')
11
+ expect(resolveLucideIconName('trendingup')).toBe('TrendingUp')
12
+ })
13
+
14
+ it('returns null for unknown names and empty input', () => {
15
+ expect(resolveLucideIconName('not-an-icon')).toBeNull()
16
+ expect(resolveLucideIconName(null)).toBeNull()
17
+ expect(resolveLucideIcon('not-an-icon')).toBeNull()
18
+ })
19
+ })
20
+
21
+ describe('IconPicker', () => {
22
+ it('shows the placeholder until a value is set, then the canonical name', () => {
23
+ const { rerender } = render(<IconPicker label="Icon" value={null} onChange={() => {}} />)
24
+ expect(screen.getByRole('button', { name: 'Icon' })).toHaveTextContent('Choose an icon…')
25
+
26
+ rerender(<IconPicker label="Icon" value="trending-up" onChange={() => {}} />)
27
+ expect(screen.getByRole('button', { name: 'Icon' })).toHaveTextContent('TrendingUp')
28
+ })
29
+
30
+ it('searches and emits the canonical PascalCase name', async () => {
31
+ const user = userEvent.setup()
32
+ const onChange = vi.fn()
33
+ render(<IconPicker label="Icon" value={null} onChange={onChange} />)
34
+
35
+ await user.click(screen.getByRole('button', { name: 'Icon' }))
36
+ await user.type(screen.getByRole('textbox', { name: 'Search icons' }), 'trending up')
37
+ await user.click(await screen.findByRole('option', { name: 'TrendingUp' }))
38
+
39
+ expect(onChange).toHaveBeenCalledWith('TrendingUp')
40
+ })
41
+
42
+ it('clears through the Clear button', async () => {
43
+ const user = userEvent.setup()
44
+ const onChange = vi.fn()
45
+ render(<IconPicker label="Icon" value="Layers" onChange={onChange} />)
46
+
47
+ await user.click(screen.getByRole('button', { name: 'Icon' }))
48
+ await user.click(screen.getByRole('button', { name: 'Clear' }))
49
+
50
+ expect(onChange).toHaveBeenCalledWith(null)
51
+ })
52
+
53
+ it('compact: renders an icon-only trigger that still picks canonically', async () => {
54
+ const user = userEvent.setup()
55
+ const onChange = vi.fn()
56
+ render(<IconPicker compact aria-label="Enum icon" value="layers" onChange={onChange} />)
57
+
58
+ const trigger = screen.getByRole('button', { name: 'Enum icon' })
59
+ expect(trigger).toHaveTextContent('')
60
+ expect(trigger).toHaveAttribute('title', 'Layers')
61
+ expect(trigger.querySelector('svg')).not.toBeNull()
62
+
63
+ await user.click(trigger)
64
+ await user.type(screen.getByRole('textbox', { name: 'Search icons' }), 'trending up')
65
+ await user.click(await screen.findByRole('option', { name: 'TrendingUp' }))
66
+
67
+ expect(onChange).toHaveBeenCalledWith('TrendingUp')
68
+ })
69
+
70
+ it('draws one batch up front and appends the next on scroll', async () => {
71
+ const user = userEvent.setup()
72
+ render(<IconPicker label="Icon" value={null} onChange={() => {}} />)
73
+
74
+ await user.click(screen.getByRole('button', { name: 'Icon' }))
75
+
76
+ expect(screen.getAllByRole('option')).toHaveLength(120)
77
+ expect(screen.getByText(/more — keep scrolling/)).toBeInTheDocument()
78
+
79
+ // jsdom has no layout, so every geometry reads 0 and any scroll event
80
+ // counts as "near the bottom" — which is exactly what this asserts: the
81
+ // handler appends a batch instead of leaving the hard cap in place.
82
+ const grid = screen.getByRole('listbox').parentElement
83
+ if (!grid) throw new Error('grid wrapper missing')
84
+ fireEvent.scroll(grid)
85
+
86
+ expect(screen.getAllByRole('option')).toHaveLength(240)
87
+ })
88
+ })
@@ -0,0 +1,336 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import { icons, Search, Shapes, X } from 'lucide-react'
3
+ import { type ReactNode, useMemo, useRef, useState } from 'react'
4
+ import { Input as AriaInput, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'
5
+
6
+ import { cn } from '../../lib/cn'
7
+ import {
8
+ type FieldAppearance,
9
+ FieldCaret,
10
+ type FieldSize,
11
+ fieldControlStyles,
12
+ fieldErrorStyles,
13
+ fieldHintStyles,
14
+ fieldLabelStyles,
15
+ fieldTextStyles,
16
+ } from '../field/field'
17
+
18
+ /* =========================================================================
19
+ IconPicker — a form field for choosing a Lucide icon by name.
20
+
21
+ The value is the icon's canonical PascalCase export name ("TrendingUp"),
22
+ which is what the platform stores wherever an icon rides config (menu
23
+ items, connectors, knowledge spaces). The picker resolves loosely on the
24
+ way IN — kebab-case and lowercase resolve too — but always emits the
25
+ canonical name, so data written through it never needs the fallback path.
26
+
27
+ Built from the org's own field primitives (same chrome as Select/Input)
28
+ plus a RAC grid ListBox, so arrow keys move through the grid and
29
+ selection/focus/ARIA come from RAC rather than hand-rolled handlers.
30
+
31
+ The full set is ~1.5k icons; rendering every SVG into one popover is
32
+ noticeably slow, so the grid caps what it draws and tells you it did —
33
+ the search input is the intended way in, not scrolling.
34
+ ========================================================================= */
35
+
36
+ /** Canonical icon names — the keys of lucide's alias-free `icons` record. */
37
+ export type LucideIconName = keyof typeof icons
38
+
39
+ const ICON_NAMES = Object.keys(icons) as LucideIconName[]
40
+
41
+ /** Lookup by squashed lowercase name, so "trending-up" / "trendingup" resolve. */
42
+ const BY_NORMALIZED = new Map<string, LucideIconName>(
43
+ ICON_NAMES.map((name) => [normalize(name), name]),
44
+ )
45
+
46
+ function normalize(name: string): string {
47
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '')
48
+ }
49
+
50
+ /**
51
+ * Resolves a free-form icon name to its Lucide component, or null when it
52
+ * matches nothing. Null (rather than a baked-in placeholder) so each caller
53
+ * chooses its own fallback glyph.
54
+ */
55
+ export function resolveLucideIcon(name: string | null | undefined): LucideIcon | null {
56
+ const canonical = resolveLucideIconName(name)
57
+ return canonical ? icons[canonical] : null
58
+ }
59
+
60
+ /** Same resolution, returning the canonical PascalCase name instead. */
61
+ export function resolveLucideIconName(name: string | null | undefined): LucideIconName | null {
62
+ if (!name) return null
63
+ return BY_NORMALIZED.get(normalize(name)) ?? null
64
+ }
65
+
66
+ /** Cells drawn per batch; scrolling near the bottom appends another batch. */
67
+ const GRID_PAGE = 120
68
+
69
+ export type IconPickerProps = {
70
+ label?: ReactNode
71
+ description?: ReactNode
72
+ errorMessage?: ReactNode
73
+ /** Trigger text while nothing is chosen. */
74
+ placeholder?: string
75
+ /** Canonical or loose Lucide icon name; null/'' mean "no icon". */
76
+ value: string | null
77
+ /** Fires with the canonical PascalCase name, or null when cleared. */
78
+ onChange: (name: LucideIconName | null) => void
79
+ isDisabled?: boolean
80
+ isInvalid?: boolean
81
+ /** Offer a clear affordance once a value is set. Default true. */
82
+ allowClear?: boolean
83
+ /** Field size. Defaults to `md`. */
84
+ size?: FieldSize
85
+ /** Chrome variant. Defaults to `field`. `cell` flattens the trigger box. */
86
+ appearance?: FieldAppearance
87
+ /** Square, icon-only trigger (no name, no caret) for dense rows. The
88
+ * popover is unchanged. Pair with `aria-label` when there's no `label`. */
89
+ compact?: boolean
90
+ /** Accessible name when no visible `label` is rendered. */
91
+ 'aria-label'?: string
92
+ className?: string
93
+ }
94
+
95
+ /** Compact trigger edge — matches the height of a single-line field at that size. */
96
+ const COMPACT_SIZE: Record<FieldSize, string> = {
97
+ sm: 'size-8',
98
+ md: 'size-[42px]',
99
+ }
100
+
101
+ export function IconPicker({
102
+ label,
103
+ description,
104
+ errorMessage,
105
+ placeholder = 'Choose an icon…',
106
+ value,
107
+ onChange,
108
+ isDisabled = false,
109
+ isInvalid = false,
110
+ allowClear = true,
111
+ size = 'md',
112
+ appearance,
113
+ compact = false,
114
+ 'aria-label': ariaLabel,
115
+ className,
116
+ }: IconPickerProps) {
117
+ const [isOpen, setIsOpen] = useState(false)
118
+ const [query, setQuery] = useState('')
119
+ const [limit, setLimit] = useState(GRID_PAGE)
120
+ const triggerRef = useRef<HTMLButtonElement>(null)
121
+
122
+ const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
123
+ const selectedName = resolveLucideIconName(value)
124
+ const SelectedIcon = selectedName ? icons[selectedName] : null
125
+
126
+ const matches = useMemo(() => {
127
+ const q = normalize(query)
128
+ return q ? ICON_NAMES.filter((name) => normalize(name).includes(q)) : ICON_NAMES
129
+ }, [query])
130
+ const visible = matches.slice(0, limit)
131
+ const hiddenCount = matches.length - visible.length
132
+
133
+ const open = (next: boolean) => {
134
+ setIsOpen(next)
135
+ if (!next) {
136
+ setQuery('')
137
+ setLimit(GRID_PAGE)
138
+ }
139
+ }
140
+
141
+ const search = (next: string) => {
142
+ setQuery(next)
143
+ // A new query is a new list; the old scroll depth means nothing for it.
144
+ setLimit(GRID_PAGE)
145
+ }
146
+
147
+ const onGridScroll = (element: HTMLDivElement) => {
148
+ if (hiddenCount === 0) return
149
+ const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
150
+ if (remaining < 120) setLimit((current) => current + GRID_PAGE)
151
+ }
152
+
153
+ const pick = (name: LucideIconName | null) => {
154
+ onChange(name)
155
+ open(false)
156
+ triggerRef.current?.focus()
157
+ }
158
+
159
+ return (
160
+ <div className={cn('grid grid-cols-1 gap-1.5', className)}>
161
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
162
+
163
+ {compact ? (
164
+ <button
165
+ ref={triggerRef}
166
+ type="button"
167
+ aria-haspopup="dialog"
168
+ aria-expanded={isOpen}
169
+ aria-label={ariaLabel ?? (typeof label === 'string' ? label : 'Icon')}
170
+ title={selectedName ?? placeholder}
171
+ disabled={isDisabled}
172
+ onClick={() => open(!isOpen)}
173
+ data-invalid={isInvalid || undefined}
174
+ className={cn(
175
+ styles.box(),
176
+ 'flex shrink-0 items-center justify-center cursor-pointer',
177
+ appearance === 'cell' ? 'size-[29px]' : COMPACT_SIZE[size],
178
+ 'disabled:cursor-not-allowed disabled:opacity-45',
179
+ )}
180
+ >
181
+ {SelectedIcon ? (
182
+ <SelectedIcon aria-hidden className="size-4 text-ink-2" />
183
+ ) : (
184
+ <Shapes aria-hidden className="size-4 text-ink-4" />
185
+ )}
186
+ </button>
187
+ ) : (
188
+ <button
189
+ ref={triggerRef}
190
+ type="button"
191
+ aria-haspopup="dialog"
192
+ aria-expanded={isOpen}
193
+ aria-label={ariaLabel ?? (typeof label === 'string' ? label : 'Icon')}
194
+ disabled={isDisabled}
195
+ onClick={() => open(!isOpen)}
196
+ data-invalid={isInvalid || undefined}
197
+ className={cn(
198
+ // box carries no padding by contract — content is what gives a
199
+ // single-element control its interior (padding, font, text-size).
200
+ styles.box(),
201
+ styles.content(),
202
+ 'flex w-full items-center gap-2 text-left cursor-pointer',
203
+ 'disabled:cursor-not-allowed disabled:opacity-45',
204
+ )}
205
+ >
206
+ {SelectedIcon ? (
207
+ <SelectedIcon aria-hidden className="size-4 shrink-0 text-ink-2" />
208
+ ) : null}
209
+ <span className={cn('min-w-0 flex-1 truncate', selectedName ? 'text-ink' : 'text-ink-4')}>
210
+ {selectedName ?? placeholder}
211
+ </span>
212
+ <FieldCaret />
213
+ </button>
214
+ )}
215
+
216
+ {description ? <p className={fieldHintStyles}>{description}</p> : null}
217
+ {errorMessage && appearance !== 'cell' ? (
218
+ <p className={fieldErrorStyles}>{errorMessage}</p>
219
+ ) : null}
220
+
221
+ <Popover
222
+ isOpen={isOpen}
223
+ onOpenChange={open}
224
+ triggerRef={triggerRef}
225
+ offset={6}
226
+ placement="bottom start"
227
+ className={cn(
228
+ 'w-[316px] rounded-md border border-rule bg-surface p-2',
229
+ 'shadow-[var(--shadow-raised)]',
230
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
231
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
232
+ )}
233
+ >
234
+ <div className="flex items-center gap-1.5 pb-2">
235
+ <div
236
+ className={cn(
237
+ fieldControlStyles({ size: 'sm' }).box(),
238
+ 'flex min-w-0 flex-1 items-center gap-1.5 px-2.5 py-1.5',
239
+ 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]',
240
+ )}
241
+ >
242
+ <Search aria-hidden className="size-3.5 shrink-0 text-ink-4" />
243
+ {/* Bare RAC input rather than the Input field: this is popover
244
+ furniture, not a form field — no label/description slots. */}
245
+ <AriaInput
246
+ autoFocus
247
+ aria-label="Search icons"
248
+ value={query}
249
+ onChange={(event) => search(event.target.value)}
250
+ placeholder="Search icons…"
251
+ className={cn(
252
+ 'min-w-0 flex-1 bg-transparent font-sans outline-none',
253
+ 'text-ink placeholder:text-ink-4',
254
+ fieldTextStyles.sm,
255
+ )}
256
+ />
257
+ </div>
258
+ {allowClear && selectedName ? (
259
+ <button
260
+ type="button"
261
+ onClick={() => pick(null)}
262
+ className={cn(
263
+ 'flex shrink-0 items-center gap-1 rounded-[6px] px-2 py-1.5 cursor-pointer',
264
+ 'font-sans text-[12px] text-ink-3 transition-colors',
265
+ 'hover:bg-surface-2 hover:text-ink',
266
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)]',
267
+ )}
268
+ >
269
+ <X aria-hidden className="size-3" />
270
+ Clear
271
+ </button>
272
+ ) : null}
273
+ </div>
274
+
275
+ {/* The scroll container is a wrapper rather than the ListBox itself:
276
+ the ListBox must keep its natural height so appended batches grow
277
+ it and the scrollHeight arithmetic stays meaningful. */}
278
+ <div
279
+ className="max-h-64 overflow-y-auto"
280
+ onScroll={(event) => onGridScroll(event.currentTarget)}
281
+ >
282
+ <ListBox
283
+ aria-label="Icons"
284
+ layout="grid"
285
+ selectionMode="single"
286
+ selectedKeys={selectedName ? new Set([selectedName]) : new Set()}
287
+ onSelectionChange={(keys) => {
288
+ if (keys === 'all') return
289
+ const next = [...keys][0]
290
+ // Re-picking the selected icon reports an empty set; keep the
291
+ // value — clearing has its own button.
292
+ if (next !== undefined) pick(next as LucideIconName)
293
+ }}
294
+ className="grid grid-cols-8 gap-0.5 outline-none"
295
+ renderEmptyState={() => (
296
+ <div className={cn('col-span-8 px-2 py-3 text-center', fieldHintStyles)}>
297
+ No icons match
298
+ </div>
299
+ )}
300
+ >
301
+ {visible.map((name) => {
302
+ const Icon = icons[name]
303
+ return (
304
+ <ListBoxItem
305
+ key={name}
306
+ id={name}
307
+ textValue={name}
308
+ // The cell's only content is an aria-hidden svg, so the name
309
+ // must be explicit — it is also what the picker's tests and
310
+ // any consumer's tests select on.
311
+ aria-label={name}
312
+ className={cn(
313
+ 'flex size-[34px] items-center justify-center rounded-[6px]',
314
+ 'cursor-pointer outline-none',
315
+ 'data-[hovered]:bg-surface-2 data-[focused]:bg-surface-2',
316
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-[var(--color-accent)]',
317
+ 'data-[selected]:bg-[color-mix(in_oklab,var(--color-accent)_12%,var(--color-surface))]',
318
+ 'data-[selected]:text-accent text-ink-2',
319
+ )}
320
+ >
321
+ <Icon aria-hidden className="size-4" />
322
+ </ListBoxItem>
323
+ )
324
+ })}
325
+ </ListBox>
326
+ </div>
327
+
328
+ {hiddenCount > 0 ? (
329
+ <p className={cn('px-1 pt-2', fieldHintStyles)}>
330
+ {hiddenCount.toLocaleString()} more — keep scrolling, or search.
331
+ </p>
332
+ ) : null}
333
+ </Popover>
334
+ </div>
335
+ )
336
+ }
@@ -0,0 +1,2 @@
1
+ export type { IconPickerProps, LucideIconName } from './icon-picker'
2
+ export { IconPicker, resolveLucideIcon, resolveLucideIconName } from './icon-picker'
@@ -124,9 +124,16 @@ 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 variant = field.options?.find((o) => o.value === value)?.variant
128
- const label = field.options?.find((o) => o.value === value)?.label ?? value
129
- return <Badge variant={variant}>{label}</Badge>
127
+ const option = field.options?.find((o) => o.value === value)
128
+ return (
129
+ <Badge
130
+ variant={option?.variant}
131
+ {...(option?.color ? { color: option.color } : {})}
132
+ {...(option?.icon ? { icon: option.icon } : {})}
133
+ >
134
+ {option?.label ?? value}
135
+ </Badge>
136
+ )
130
137
  }
131
138
  return (
132
139
  <span className={cn('font-mono text-[12px] text-ink max-w-[10rem] truncate')}>
@@ -270,8 +270,16 @@ function EnumSingleValue({
270
270
  selectedKey={value === '' ? null : value}
271
271
  onSelectionChange={(next) => onChange(next == null ? '' : String(next))}
272
272
  renderItem={(o) => {
273
- const variant = field.options?.find((x) => x.value === o.id)?.variant
274
- return <Badge variant={variant}>{o.label}</Badge>
273
+ const option = field.options?.find((x) => x.value === o.id)
274
+ return (
275
+ <Badge
276
+ variant={option?.variant}
277
+ {...(option?.color ? { color: option.color } : {})}
278
+ {...(option?.icon ? { icon: option.icon } : {})}
279
+ >
280
+ {o.label}
281
+ </Badge>
282
+ )
275
283
  }}
276
284
  {...(isDisabled !== undefined ? { isDisabled } : {})}
277
285
  {...(autoFocus ? { autoFocus: true } : {})}
@@ -300,6 +308,8 @@ function EnumMultiValue({
300
308
  id: o.value,
301
309
  label: o.label,
302
310
  ...(o.variant ? { variant: o.variant } : {}),
311
+ ...(o.color ? { color: o.color } : {}),
312
+ ...(o.icon ? { icon: o.icon } : {}),
303
313
  }))
304
314
  return (
305
315
  <BadgeMultiSelect<string>
@@ -18,25 +18,25 @@ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
18
18
  id: 'eq',
19
19
  label: 'is',
20
20
  arity: 1,
21
- types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user'],
21
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user', 'principal'],
22
22
  },
23
23
  {
24
24
  id: 'ne',
25
25
  label: 'is not',
26
26
  arity: 1,
27
- types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user'],
27
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user', 'principal'],
28
28
  },
29
29
  {
30
30
  id: 'in',
31
31
  label: 'is any of',
32
32
  arity: 'n',
33
- types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user'],
33
+ types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user', 'principal'],
34
34
  },
35
35
  {
36
36
  id: 'not_in',
37
37
  label: 'is none of',
38
38
  arity: 'n',
39
- types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user'],
39
+ types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user', 'principal'],
40
40
  },
41
41
  { id: 'contains', label: 'contains', arity: 1, types: ['string', 'array'] },
42
42
  { id: 'starts_with', label: 'starts with', arity: 1, types: ['string'] },
@@ -49,13 +49,13 @@ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
49
49
  id: 'empty',
50
50
  label: 'is empty',
51
51
  arity: 0,
52
- types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'file'],
52
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'principal', 'file'],
53
53
  },
54
54
  {
55
55
  id: 'not_empty',
56
56
  label: 'is not empty',
57
57
  arity: 0,
58
- types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'file'],
58
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'principal', 'file'],
59
59
  },
60
60
  ]
61
61
 
@@ -40,7 +40,7 @@ const customerFields: FilterField[] = [
40
40
  { value: 'active', label: 'Active', variant: 'success' },
41
41
  { value: 'trialing', label: 'Trialing', variant: 'active' },
42
42
  { value: 'churned', label: 'Churned', variant: 'danger' },
43
- { value: 'paused', label: 'Paused', variant: 'warning' },
43
+ { value: 'paused', label: 'Paused', icon: 'CirclePause', color: '#e0a030' },
44
44
  ],
45
45
  },
46
46
  { path: 'mrr', shortLabel: 'MRR', label: 'MRR', type: 'number' },
@@ -25,6 +25,10 @@ export type FilterFieldOption = {
25
25
  label: string
26
26
  /** Badge color register, used when this option is rendered as a pill. */
27
27
  variant?: BadgeVariants['variant']
28
+ /** `#rrggbb` tint — overrides `variant` when the option renders as a pill. */
29
+ color?: string
30
+ /** Lucide icon name shown in place of the pill's dot. */
31
+ icon?: string
28
32
  }
29
33
 
30
34
  export type FilterField = {
@@ -3,7 +3,6 @@ import { useState } from 'react'
3
3
 
4
4
  import { Badge } from '../badge'
5
5
  import {
6
- SortableHeaderCell,
7
6
  type SortDirection,
8
7
  Table,
9
8
  TableBody,
@@ -179,36 +178,36 @@ export const Sortable: Story = {
179
178
  return (
180
179
  <Table aria-label="Shipments — sortable">
181
180
  <TableHeader sticky>
182
- <SortableHeaderCell
181
+ <TableHeaderCell
183
182
  width={COLUMN_WIDTHS.id}
184
183
  sortDirection={dirFor('id')}
185
- onPress={() => onSort('id')}
184
+ onSortToggle={() => onSort('id')}
186
185
  >
187
186
  Shipment
188
- </SortableHeaderCell>
189
- <SortableHeaderCell
187
+ </TableHeaderCell>
188
+ <TableHeaderCell
190
189
  width={COLUMN_WIDTHS.lane}
191
190
  sortDirection={dirFor('lane')}
192
- onPress={() => onSort('lane')}
191
+ onSortToggle={() => onSort('lane')}
193
192
  >
194
193
  Lane
195
- </SortableHeaderCell>
196
- <SortableHeaderCell
194
+ </TableHeaderCell>
195
+ <TableHeaderCell
197
196
  width={COLUMN_WIDTHS.carrier}
198
197
  sortDirection={dirFor('carrier')}
199
- onPress={() => onSort('carrier')}
198
+ onSortToggle={() => onSort('carrier')}
200
199
  >
201
200
  Carrier
202
- </SortableHeaderCell>
201
+ </TableHeaderCell>
203
202
  <TableHeaderCell width={COLUMN_WIDTHS.status}>Status</TableHeaderCell>
204
- <SortableHeaderCell
203
+ <TableHeaderCell
205
204
  width={COLUMN_WIDTHS.rate}
206
205
  align="right"
207
206
  sortDirection={dirFor('rate')}
208
- onPress={() => onSort('rate')}
207
+ onSortToggle={() => onSort('rate')}
209
208
  >
210
209
  Rate
211
- </SortableHeaderCell>
210
+ </TableHeaderCell>
212
211
  <TableHeaderSpacer />
213
212
  </TableHeader>
214
213
  <TableBody>