@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,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
|
+
}
|
|
@@ -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 ??
|
|
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
|
+
})
|