@proteos/ui 0.45.0 → 0.46.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 +605 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +174 -7
- package/dist/index.d.ts +174 -7
- package/dist/index.js +601 -64
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +5 -0
- package/src/components/avatar/avatar.stories.tsx +37 -1
- package/src/components/avatar/avatar.test.tsx +42 -0
- package/src/components/avatar/avatar.tsx +39 -4
- package/src/components/avatar-group/avatar-group.test.tsx +19 -0
- package/src/components/avatar-group/avatar-group.tsx +18 -3
- package/src/components/badge/badge.stories.tsx +27 -1
- package/src/components/badge/badge.test.tsx +26 -0
- package/src/components/badge/badge.tsx +48 -4
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +24 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +25 -4
- package/src/components/color-input/color-input.stories.tsx +72 -0
- package/src/components/color-input/color-input.test.tsx +95 -0
- package/src/components/color-input/color-input.tsx +208 -0
- package/src/components/color-input/index.ts +2 -0
- package/src/components/draft-message-composer/draft-message-composer.stories.tsx +1 -1
- package/src/components/icon-picker/icon-picker.stories.tsx +77 -0
- package/src/components/icon-picker/icon-picker.test.tsx +88 -0
- package/src/components/icon-picker/icon-picker.tsx +336 -0
- package/src/components/icon-picker/index.ts +2 -0
- package/src/components/record-filter/chip-value.tsx +10 -3
- package/src/components/record-filter/filter-value-input.tsx +12 -2
- package/src/components/record-filter/operators.ts +6 -6
- package/src/components/record-filter/record-filter.stories.tsx +1 -1
- package/src/components/record-filter/types.ts +4 -0
- package/src/components/table/table.stories.tsx +12 -13
- package/src/components/toast/index.ts +9 -0
- package/src/components/toast/toast.stories.tsx +58 -0
- package/src/components/toast/toast.test.tsx +34 -0
- package/src/components/toast/toast.tsx +121 -0
- package/src/index.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
274
|
-
return
|
|
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',
|
|
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
|
-
<
|
|
181
|
+
<TableHeaderCell
|
|
183
182
|
width={COLUMN_WIDTHS.id}
|
|
184
183
|
sortDirection={dirFor('id')}
|
|
185
|
-
|
|
184
|
+
onSortToggle={() => onSort('id')}
|
|
186
185
|
>
|
|
187
186
|
Shipment
|
|
188
|
-
</
|
|
189
|
-
<
|
|
187
|
+
</TableHeaderCell>
|
|
188
|
+
<TableHeaderCell
|
|
190
189
|
width={COLUMN_WIDTHS.lane}
|
|
191
190
|
sortDirection={dirFor('lane')}
|
|
192
|
-
|
|
191
|
+
onSortToggle={() => onSort('lane')}
|
|
193
192
|
>
|
|
194
193
|
Lane
|
|
195
|
-
</
|
|
196
|
-
<
|
|
194
|
+
</TableHeaderCell>
|
|
195
|
+
<TableHeaderCell
|
|
197
196
|
width={COLUMN_WIDTHS.carrier}
|
|
198
197
|
sortDirection={dirFor('carrier')}
|
|
199
|
-
|
|
198
|
+
onSortToggle={() => onSort('carrier')}
|
|
200
199
|
>
|
|
201
200
|
Carrier
|
|
202
|
-
</
|
|
201
|
+
</TableHeaderCell>
|
|
203
202
|
<TableHeaderCell width={COLUMN_WIDTHS.status}>Status</TableHeaderCell>
|
|
204
|
-
<
|
|
203
|
+
<TableHeaderCell
|
|
205
204
|
width={COLUMN_WIDTHS.rate}
|
|
206
205
|
align="right"
|
|
207
206
|
sortDirection={dirFor('rate')}
|
|
208
|
-
|
|
207
|
+
onSortToggle={() => onSort('rate')}
|
|
209
208
|
>
|
|
210
209
|
Rate
|
|
211
|
-
</
|
|
210
|
+
</TableHeaderCell>
|
|
212
211
|
<TableHeaderSpacer />
|
|
213
212
|
</TableHeader>
|
|
214
213
|
<TableBody>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/button/button'
|
|
4
|
+
|
|
5
|
+
import { createToastQueue, ToastRegion } from './toast'
|
|
6
|
+
|
|
7
|
+
const queue = createToastQueue()
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof ToastRegion> = {
|
|
10
|
+
title: 'Components/Toast',
|
|
11
|
+
component: ToastRegion,
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
parameters: { layout: 'padded' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj<typeof ToastRegion>
|
|
18
|
+
|
|
19
|
+
export const Basic: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<div className="flex gap-2">
|
|
22
|
+
<Button
|
|
23
|
+
variant="secondary"
|
|
24
|
+
onPress={() => queue.add({ title: 'Saved', variant: 'info' }, { timeout: 4000 })}
|
|
25
|
+
>
|
|
26
|
+
Info
|
|
27
|
+
</Button>
|
|
28
|
+
<Button
|
|
29
|
+
variant="secondary"
|
|
30
|
+
onPress={() =>
|
|
31
|
+
queue.add(
|
|
32
|
+
{
|
|
33
|
+
title: 'Workflow finished',
|
|
34
|
+
description: 'sync-hubspot succeeded in 0:12',
|
|
35
|
+
variant: 'success',
|
|
36
|
+
},
|
|
37
|
+
{ timeout: 5000 },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
>
|
|
41
|
+
Success
|
|
42
|
+
</Button>
|
|
43
|
+
<Button
|
|
44
|
+
variant="secondary"
|
|
45
|
+
onPress={() =>
|
|
46
|
+
queue.add({
|
|
47
|
+
title: 'Workflow failed',
|
|
48
|
+
description: 'node "Create deal": hubspot returned 429 rate_limited',
|
|
49
|
+
variant: 'danger',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
Danger (sticky)
|
|
54
|
+
</Button>
|
|
55
|
+
<ToastRegion queue={queue} />
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { createToastQueue, ToastRegion } from './toast'
|
|
6
|
+
|
|
7
|
+
describe('Toast', () => {
|
|
8
|
+
it('renders nothing until a toast is queued, then shows title + description', () => {
|
|
9
|
+
const queue = createToastQueue()
|
|
10
|
+
render(<ToastRegion queue={queue} />)
|
|
11
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
|
12
|
+
|
|
13
|
+
act(() => {
|
|
14
|
+
queue.add({
|
|
15
|
+
title: 'Workflow finished',
|
|
16
|
+
description: 'sync-hubspot succeeded',
|
|
17
|
+
variant: 'success',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
expect(screen.getByText('Workflow finished')).toBeInTheDocument()
|
|
21
|
+
expect(screen.getByText('sync-hubspot succeeded')).toBeInTheDocument()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('dismisses via the close button', async () => {
|
|
25
|
+
const queue = createToastQueue()
|
|
26
|
+
render(<ToastRegion queue={queue} />)
|
|
27
|
+
act(() => {
|
|
28
|
+
queue.add({ title: 'Hello' })
|
|
29
|
+
})
|
|
30
|
+
const user = userEvent.setup()
|
|
31
|
+
await user.click(screen.getByRole('button', { name: 'Dismiss' }))
|
|
32
|
+
expect(screen.queryByText('Hello')).not.toBeInTheDocument()
|
|
33
|
+
})
|
|
34
|
+
})
|