@proteos/ui 0.44.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.
Files changed (38) hide show
  1. package/dist/index.cjs +605 -60
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +174 -7
  4. package/dist/index.d.ts +174 -7
  5. package/dist/index.js +601 -64
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  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/color-input/color-input.stories.tsx +72 -0
  20. package/src/components/color-input/color-input.test.tsx +95 -0
  21. package/src/components/color-input/color-input.tsx +208 -0
  22. package/src/components/color-input/index.ts +2 -0
  23. package/src/components/draft-message-composer/draft-message-composer.stories.tsx +1 -1
  24. package/src/components/icon-picker/icon-picker.stories.tsx +77 -0
  25. package/src/components/icon-picker/icon-picker.test.tsx +88 -0
  26. package/src/components/icon-picker/icon-picker.tsx +336 -0
  27. package/src/components/icon-picker/index.ts +2 -0
  28. package/src/components/record-filter/chip-value.tsx +10 -3
  29. package/src/components/record-filter/filter-value-input.tsx +12 -2
  30. package/src/components/record-filter/operators.ts +6 -6
  31. package/src/components/record-filter/record-filter.stories.tsx +1 -1
  32. package/src/components/record-filter/types.ts +4 -0
  33. package/src/components/table/table.stories.tsx +12 -13
  34. package/src/components/toast/index.ts +9 -0
  35. package/src/components/toast/toast.stories.tsx +58 -0
  36. package/src/components/toast/toast.test.tsx +34 -0
  37. package/src/components/toast/toast.tsx +121 -0
  38. package/src/index.ts +3 -0
@@ -0,0 +1,95 @@
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 { ColorInput, normalizeHexColor } from './color-input'
6
+
7
+ describe('normalizeHexColor', () => {
8
+ it('canonicalizes six-digit hex with or without the hash', () => {
9
+ expect(normalizeHexColor('#5B8DEF')).toBe('#5b8def')
10
+ expect(normalizeHexColor('5b8def')).toBe('#5b8def')
11
+ expect(normalizeHexColor(' #5b8def ')).toBe('#5b8def')
12
+ })
13
+
14
+ it('expands three-digit shorthand', () => {
15
+ expect(normalizeHexColor('#abc')).toBe('#aabbcc')
16
+ expect(normalizeHexColor('ABC')).toBe('#aabbcc')
17
+ })
18
+
19
+ it('returns null for anything else', () => {
20
+ expect(normalizeHexColor('')).toBeNull()
21
+ expect(normalizeHexColor(null)).toBeNull()
22
+ expect(normalizeHexColor('red')).toBeNull()
23
+ expect(normalizeHexColor('#12345')).toBeNull()
24
+ expect(normalizeHexColor('#gggggg')).toBeNull()
25
+ })
26
+ })
27
+
28
+ describe('ColorInput', () => {
29
+ it('shows the committed value canonically', () => {
30
+ render(<ColorInput label="Color" value="#5B8DEF" onChange={() => {}} />)
31
+ expect(screen.getByLabelText('Color')).toHaveValue('#5b8def')
32
+ })
33
+
34
+ it('commits only canonical hex while typing', async () => {
35
+ const user = userEvent.setup()
36
+ const onChange = vi.fn()
37
+ render(<ColorInput label="Color" value={null} onChange={onChange} />)
38
+
39
+ await user.type(screen.getByLabelText('Color'), '#FF0000')
40
+ expect(onChange).toHaveBeenCalledTimes(1)
41
+ expect(onChange).toHaveBeenLastCalledWith('#ff0000')
42
+ })
43
+
44
+ it('expands shorthand only on blur', async () => {
45
+ const user = userEvent.setup()
46
+ const onChange = vi.fn()
47
+ render(<ColorInput label="Color" value={null} onChange={onChange} />)
48
+
49
+ const input = screen.getByLabelText('Color')
50
+ await user.type(input, 'abc')
51
+ expect(onChange).not.toHaveBeenCalled()
52
+ expect(input).not.toHaveAttribute('aria-invalid', 'true')
53
+
54
+ await user.tab()
55
+ expect(onChange).toHaveBeenCalledTimes(1)
56
+ expect(onChange).toHaveBeenLastCalledWith('#aabbcc')
57
+ expect(input).toHaveValue('#aabbcc')
58
+ })
59
+
60
+ it('holds invalid drafts without committing and flags them', async () => {
61
+ const user = userEvent.setup()
62
+ const onChange = vi.fn()
63
+ render(<ColorInput label="Color" value={null} onChange={onChange} />)
64
+
65
+ const input = screen.getByLabelText('Color')
66
+ await user.type(input, 'zz')
67
+ expect(onChange).not.toHaveBeenCalled()
68
+ expect(input).toHaveAttribute('aria-invalid', 'true')
69
+ })
70
+
71
+ it('clears with null when emptied', async () => {
72
+ const user = userEvent.setup()
73
+ const onChange = vi.fn()
74
+ render(<ColorInput label="Color" value="#5b8def" onChange={onChange} />)
75
+
76
+ await user.clear(screen.getByLabelText('Color'))
77
+ expect(onChange).toHaveBeenLastCalledWith(null)
78
+ })
79
+
80
+ it('commits a pick from the native color input', () => {
81
+ const onChange = vi.fn()
82
+ render(<ColorInput label="Color" value={null} onChange={onChange} />)
83
+
84
+ const native = screen.getByRole('button', { name: 'Pick color' }).querySelector('input')
85
+ if (!native) throw new Error('native color input missing')
86
+ fireEvent.change(native, { target: { value: '#3f9d6b' } })
87
+
88
+ expect(onChange).toHaveBeenCalledWith('#3f9d6b')
89
+ })
90
+
91
+ it('takes an aria-label when rendered without a visible label', () => {
92
+ render(<ColorInput aria-label="Enum color" value={null} onChange={() => {}} />)
93
+ expect(screen.getByLabelText('Enum color')).toBeInTheDocument()
94
+ })
95
+ })
@@ -0,0 +1,208 @@
1
+ import { Pipette } from 'lucide-react'
2
+ import { type ReactNode, useEffect, useRef, useState } from 'react'
3
+ import { Input as AriaInput, FieldError, Label, Text, TextField } from 'react-aria-components'
4
+
5
+ import { cn } from '../../lib/cn'
6
+ import {
7
+ type FieldAppearance,
8
+ type FieldSize,
9
+ fieldControlStyles,
10
+ fieldErrorStyles,
11
+ fieldHintStyles,
12
+ fieldLabelStyles,
13
+ fieldTextStyles,
14
+ } from '../field/field'
15
+
16
+ /* =========================================================================
17
+ ColorInput — a form field for a `#rrggbb` color.
18
+
19
+ Text-first: the hex is what the platform stores (enum values, labels),
20
+ so the field is a mono text input with a swatch in front of it. The
21
+ swatch doubles as a trigger for the browser's native color picker, which
22
+ is the "good enough for now" picker until a proper one lands — the API
23
+ (value in, canonical hex out) won't change when it does.
24
+
25
+ Commit discipline: `onChange` only ever fires with a canonical lowercase
26
+ `#rrggbb` or `null` (cleared). While the draft is something else — half a
27
+ hex, a named color — the field shows its invalid chrome and holds the
28
+ last committed value, so consumers never see partial input. Shorthand
29
+ (`#abc`) is accepted but only expanded on blur: typing `#ff0000` passes
30
+ through `#ff0`, and committing that transiently would flash the wrong
31
+ color at the consumer.
32
+ ========================================================================= */
33
+
34
+ const HEX6 = /^#?([0-9a-f]{6})$/i
35
+ const HEX3 = /^#?([0-9a-f]{3})$/i
36
+
37
+ /**
38
+ * Normalizes free-form text to a canonical lowercase `#rrggbb`, or null when
39
+ * it isn't a hex color. Accepts a missing `#` and 3-digit shorthand.
40
+ */
41
+ export function normalizeHexColor(text: string | null | undefined): string | null {
42
+ const raw = (text ?? '').trim()
43
+ if (raw === '') return null
44
+ const six = HEX6.exec(raw)
45
+ if (six?.[1]) return `#${six[1].toLowerCase()}`
46
+ const three = HEX3.exec(raw)
47
+ if (three?.[1]) {
48
+ const [r, g, b] = three[1].toLowerCase()
49
+ return `#${r}${r}${g}${g}${b}${b}`
50
+ }
51
+ return null
52
+ }
53
+
54
+ export type ColorInputProps = {
55
+ label?: ReactNode
56
+ description?: ReactNode
57
+ errorMessage?: ReactNode
58
+ /** Shown while empty. Defaults to a sample hex. */
59
+ placeholder?: string
60
+ /** Canonical or loose hex; null/'' mean "no color". */
61
+ value: string | null
62
+ /** Fires with the canonical lowercase `#rrggbb`, or null when cleared. */
63
+ onChange: (hex: string | null) => void
64
+ isDisabled?: boolean
65
+ isInvalid?: boolean
66
+ /** Field size. Defaults to `md`. */
67
+ size?: FieldSize
68
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
69
+ appearance?: FieldAppearance
70
+ /** Accessible name when no visible `label` is rendered. */
71
+ 'aria-label'?: string
72
+ className?: string
73
+ }
74
+
75
+ export function ColorInput({
76
+ label,
77
+ description,
78
+ errorMessage,
79
+ placeholder = '#5b8def',
80
+ value,
81
+ onChange,
82
+ isDisabled = false,
83
+ isInvalid = false,
84
+ size = 'md',
85
+ appearance,
86
+ 'aria-label': ariaLabel,
87
+ className,
88
+ }: ColorInputProps) {
89
+ const committed = normalizeHexColor(value)
90
+ const [draft, setDraft] = useState(committed ?? '')
91
+ const nativeRef = useRef<HTMLInputElement>(null)
92
+
93
+ // An outside change to `value` (reset, another picker) replaces the draft;
94
+ // our own commits already match, so typing is never clobbered mid-word.
95
+ useEffect(() => {
96
+ setDraft((current) => (normalizeHexColor(current) === committed ? current : (committed ?? '')))
97
+ }, [committed])
98
+
99
+ const isDraftInvalid = draft.trim() !== '' && normalizeHexColor(draft) === null
100
+ const invalid = isInvalid || isDraftInvalid
101
+ const styles = fieldControlStyles({ size, invalid, appearance })
102
+ const focusWithin =
103
+ appearance === 'cell'
104
+ ? 'focus-within:border-accent focus-within:shadow-none'
105
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
106
+
107
+ const edit = (next: string) => {
108
+ setDraft(next)
109
+ if (next.trim() === '') {
110
+ if (committed !== null) onChange(null)
111
+ return
112
+ }
113
+ // Live commits need the full six digits; shorthand waits for blur.
114
+ if (!HEX6.test(next.trim())) return
115
+ const hex = normalizeHexColor(next)
116
+ if (hex !== null && hex !== committed) onChange(hex)
117
+ }
118
+
119
+ const settle = () => {
120
+ const hex = normalizeHexColor(draft)
121
+ if (hex === null) return
122
+ setDraft(hex)
123
+ if (hex !== committed) onChange(hex)
124
+ }
125
+
126
+ const pick = (next: string) => {
127
+ const hex = normalizeHexColor(next)
128
+ if (hex === null) return
129
+ setDraft(hex)
130
+ if (hex !== committed) onChange(hex)
131
+ }
132
+
133
+ return (
134
+ <TextField
135
+ value={draft}
136
+ onChange={edit}
137
+ isDisabled={isDisabled}
138
+ isInvalid={invalid}
139
+ // Canonical formatting once the user leaves: "ABC" → "#aabbcc".
140
+ onBlur={settle}
141
+ {...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
142
+ className={cn('grid grid-cols-1 gap-1.5', className)}
143
+ >
144
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
145
+
146
+ <div
147
+ data-disabled={isDisabled || undefined}
148
+ className={cn(
149
+ styles.box(),
150
+ 'flex items-center gap-2',
151
+ size === 'sm' ? 'pl-2' : 'pl-2.5',
152
+ focusWithin,
153
+ )}
154
+ >
155
+ <button
156
+ type="button"
157
+ aria-label="Pick color"
158
+ disabled={isDisabled}
159
+ onClick={() => nativeRef.current?.click()}
160
+ className={cn(
161
+ 'relative flex shrink-0 items-center justify-center rounded-sm border border-rule',
162
+ 'cursor-pointer outline-none',
163
+ 'focus-visible:ring-2 focus-visible:ring-[var(--color-accent)]',
164
+ 'disabled:cursor-not-allowed',
165
+ size === 'sm' ? 'size-4' : 'size-5',
166
+ committed ? null : 'text-ink-4',
167
+ )}
168
+ style={committed ? { backgroundColor: committed } : undefined}
169
+ >
170
+ {committed ? null : <Pipette aria-hidden className="size-2.5" />}
171
+ {/* The browser picker rides a hidden native input — clicking the
172
+ swatch forwards to it. Not tabbable: the text input is the
173
+ keyboard path. */}
174
+ <input
175
+ ref={nativeRef}
176
+ type="color"
177
+ tabIndex={-1}
178
+ aria-hidden
179
+ disabled={isDisabled}
180
+ value={committed ?? '#000000'}
181
+ onChange={(event) => pick(event.target.value)}
182
+ className="pointer-events-none absolute inset-0 size-full opacity-0"
183
+ />
184
+ </button>
185
+ <AriaInput
186
+ placeholder={placeholder}
187
+ spellCheck={false}
188
+ autoComplete="off"
189
+ className={cn(
190
+ styles.content(),
191
+ 'min-w-0 flex-1 bg-transparent font-mono outline-none',
192
+ 'pl-0',
193
+ fieldTextStyles[size],
194
+ )}
195
+ />
196
+ </div>
197
+
198
+ {description ? (
199
+ <Text slot="description" className={fieldHintStyles}>
200
+ {description}
201
+ </Text>
202
+ ) : null}
203
+ {errorMessage && appearance !== 'cell' ? (
204
+ <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
205
+ ) : null}
206
+ </TextField>
207
+ )
208
+ }
@@ -0,0 +1,2 @@
1
+ export type { ColorInputProps } from './color-input'
2
+ export { ColorInput, normalizeHexColor } from './color-input'
@@ -64,7 +64,7 @@ function client(
64
64
  ...options.conversation,
65
65
  } as Conversation
66
66
  const delay = <T,>(value: T): Promise<T> =>
67
- new Promise((resolve) => setTimeout(() => resolve(value), options.loadDelayMs ?? 250))
67
+ new Promise((resolve) => setTimeout(() => resolve(value), options.loadDelayMs ?? 0))
68
68
  return {
69
69
  getMessage: () => delay(message),
70
70
  getConversation: () => delay(conversation),
@@ -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
+ })