@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.
- package/dist/index.cjs +720 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +191 -8
- package/dist/index.d.ts +191 -8
- package/dist/index.js +715 -74
- 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/checkbox/checkbox.tsx +8 -1
- 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/data-table/data-table.test.tsx +112 -0
- package/src/components/data-table/data-table.tsx +135 -11
- package/src/components/data-table/index.ts +1 -1
- package/src/components/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
- package/src/test/setup.ts +30 -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,112 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { DataTable, type InfiniteQueryLike } from './data-table'
|
|
7
|
+
|
|
8
|
+
type Row = { id: string; name: string }
|
|
9
|
+
|
|
10
|
+
const ROWS: Row[] = [
|
|
11
|
+
{ id: 'r-1', name: 'Alpha' },
|
|
12
|
+
{ id: 'r-2', name: 'Beta' },
|
|
13
|
+
{ id: 'r-3', name: 'Gamma' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function query(rows: Row[]): InfiniteQueryLike<Row> {
|
|
17
|
+
return {
|
|
18
|
+
data: { pages: [{ data: rows, meta: { items_total: rows.length } }] },
|
|
19
|
+
isLoading: false,
|
|
20
|
+
isFetchingNextPage: false,
|
|
21
|
+
hasNextPage: false,
|
|
22
|
+
error: null,
|
|
23
|
+
fetchNextPage: vi.fn(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const columns = [{ id: 'name', accessorKey: 'name', header: 'Name', size: 160 }]
|
|
28
|
+
|
|
29
|
+
function Harness({ onChange }: { onChange?: (ids: Set<string>) => void }) {
|
|
30
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
31
|
+
return (
|
|
32
|
+
<DataTable<Row>
|
|
33
|
+
query={query(ROWS)}
|
|
34
|
+
columns={columns}
|
|
35
|
+
ariaLabel="Records"
|
|
36
|
+
selectionMode="multiple"
|
|
37
|
+
selectedIds={selected}
|
|
38
|
+
onSelectionChange={(ids) => {
|
|
39
|
+
setSelected(ids)
|
|
40
|
+
onChange?.(ids)
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The body is virtualized against a real scroll box, which jsdom cannot
|
|
47
|
+
// measure (every rect is 0×0, so the virtualizer yields no items and no row
|
|
48
|
+
// ever renders). Swap the virtualizer for a pass-through that emits every
|
|
49
|
+
// row — the windowing itself is TanStack's to test, not ours.
|
|
50
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
51
|
+
useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: () => number }) => {
|
|
52
|
+
const size = estimateSize()
|
|
53
|
+
return {
|
|
54
|
+
getTotalSize: () => count * size,
|
|
55
|
+
getVirtualItems: () =>
|
|
56
|
+
Array.from({ length: count }, (_unused, index) => ({
|
|
57
|
+
index,
|
|
58
|
+
key: index,
|
|
59
|
+
start: index * size,
|
|
60
|
+
size,
|
|
61
|
+
})),
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
describe('DataTable selection', () => {
|
|
67
|
+
it('renders no checkboxes when selection is off', () => {
|
|
68
|
+
render(<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" />)
|
|
69
|
+
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('select-all toggles every loaded row, then clears', async () => {
|
|
73
|
+
const user = userEvent.setup()
|
|
74
|
+
const onChange = vi.fn()
|
|
75
|
+
render(<Harness onChange={onChange} />)
|
|
76
|
+
|
|
77
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
|
|
78
|
+
expect(onChange).toHaveBeenLastCalledWith(new Set(['r-1', 'r-2', 'r-3']))
|
|
79
|
+
|
|
80
|
+
await user.click(screen.getByRole('checkbox', { name: 'Deselect all rows' }))
|
|
81
|
+
expect(onChange).toHaveBeenLastCalledWith(new Set())
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('selecting one row leaves select-all indeterminate', async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
render(<Harness />)
|
|
87
|
+
|
|
88
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select row 1' }))
|
|
89
|
+
|
|
90
|
+
const selectAll = screen.getByRole('checkbox', { name: 'Select all rows' })
|
|
91
|
+
expect(selectAll).toBePartiallyChecked()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not open a row when its checkbox is clicked', async () => {
|
|
95
|
+
const user = userEvent.setup()
|
|
96
|
+
const onRowClick = vi.fn()
|
|
97
|
+
render(
|
|
98
|
+
<DataTable<Row>
|
|
99
|
+
query={query(ROWS)}
|
|
100
|
+
columns={columns}
|
|
101
|
+
ariaLabel="Records"
|
|
102
|
+
selectionMode="multiple"
|
|
103
|
+
selectedIds={new Set()}
|
|
104
|
+
onSelectionChange={vi.fn()}
|
|
105
|
+
onRowClick={onRowClick}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await user.click(screen.getByRole('checkbox', { name: 'Select row 1' }))
|
|
110
|
+
expect(onRowClick).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from 'react'
|
|
25
25
|
|
|
26
26
|
import { cn } from '../../lib/cn'
|
|
27
|
+
import { Checkbox } from '../checkbox'
|
|
27
28
|
import {
|
|
28
29
|
isColumnDragData,
|
|
29
30
|
type SortDirection,
|
|
@@ -138,6 +139,20 @@ export type DataTableProps<TRow> = {
|
|
|
138
139
|
* at slot='end'. Sits inside the scrollable area below the rows.
|
|
139
140
|
*/
|
|
140
141
|
bodyAfter?: ReactNode
|
|
142
|
+
/**
|
|
143
|
+
* Row selection. `none` (default) renders no checkbox column; `multiple`
|
|
144
|
+
* prepends a sticky leading checkbox column and turns the header checkbox
|
|
145
|
+
* into a tri-state select-all.
|
|
146
|
+
*
|
|
147
|
+
* Selection is CONTROLLED — the owner holds the ids because it is the one
|
|
148
|
+
* that acts on them (running a batch action, clearing after a save). The
|
|
149
|
+
* header checkbox covers the rows currently LOADED: an infinite list can't
|
|
150
|
+
* honestly claim to select rows it has never fetched, so "select everything
|
|
151
|
+
* matching the filter" belongs to the owner's toolbar, not here.
|
|
152
|
+
*/
|
|
153
|
+
selectionMode?: 'none' | 'multiple'
|
|
154
|
+
selectedIds?: Set<string>
|
|
155
|
+
onSelectionChange?: (ids: Set<string>) => void
|
|
141
156
|
/**
|
|
142
157
|
* Draw vertical gridlines between body cells. The header always has them;
|
|
143
158
|
* the body opts in (e.g. inline-edit mode wants the full Excel-style grid).
|
|
@@ -148,6 +163,9 @@ export type DataTableProps<TRow> = {
|
|
|
148
163
|
|
|
149
164
|
const DEFAULT_ROW_HEIGHT = 32
|
|
150
165
|
|
|
166
|
+
/** Column id of the leading checkbox column added by `selectionMode`. */
|
|
167
|
+
export const SELECTION_COLUMN_ID = '__select__'
|
|
168
|
+
|
|
151
169
|
export function DataTable<TRow>({
|
|
152
170
|
query,
|
|
153
171
|
columns,
|
|
@@ -172,6 +190,9 @@ export function DataTable<TRow>({
|
|
|
172
190
|
extraRows,
|
|
173
191
|
bodyBefore,
|
|
174
192
|
bodyAfter,
|
|
193
|
+
selectionMode = 'none',
|
|
194
|
+
selectedIds,
|
|
195
|
+
onSelectionChange,
|
|
175
196
|
columnBorders = false,
|
|
176
197
|
}: DataTableProps<TRow>) {
|
|
177
198
|
const rows = useMemo<TRow[]>(() => {
|
|
@@ -183,21 +204,124 @@ export function DataTable<TRow>({
|
|
|
183
204
|
return [...startExtras, ...dataRows, ...endExtras]
|
|
184
205
|
}, [query.data, extraRows])
|
|
185
206
|
|
|
207
|
+
const resolveRowId = useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
getRowId ??
|
|
210
|
+
((row: TRow, index: number) => {
|
|
211
|
+
const r = row as Record<string, unknown>
|
|
212
|
+
if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
|
|
213
|
+
if (typeof r.slug === 'string') return r.slug
|
|
214
|
+
return String(index)
|
|
215
|
+
}),
|
|
216
|
+
[getRowId],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const selectionEnabled = selectionMode === 'multiple'
|
|
220
|
+
const selected = useMemo(() => selectedIds ?? new Set<string>(), [selectedIds])
|
|
221
|
+
// Ids of the rows currently LOADED — what the header checkbox operates on.
|
|
222
|
+
const loadedIds = useMemo(
|
|
223
|
+
() => (selectionEnabled ? rows.map((row, index) => resolveRowId(row, index)) : []),
|
|
224
|
+
[selectionEnabled, rows, resolveRowId],
|
|
225
|
+
)
|
|
226
|
+
// Anchor for shift-click range selection, in loaded-row index space.
|
|
227
|
+
const lastToggledIndexRef = useRef<number | null>(null)
|
|
228
|
+
|
|
229
|
+
const toggleRow = (id: string, index: number, isRange: boolean) => {
|
|
230
|
+
if (!onSelectionChange) return
|
|
231
|
+
const next = new Set(selected)
|
|
232
|
+
const anchor = lastToggledIndexRef.current
|
|
233
|
+
if (isRange && anchor !== null) {
|
|
234
|
+
const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]
|
|
235
|
+
// A range drag always ADDS — matching how file managers and mail
|
|
236
|
+
// clients behave; use individual clicks to take rows back out.
|
|
237
|
+
for (let i = from; i <= to; i += 1) {
|
|
238
|
+
const rangeId = loadedIds[i]
|
|
239
|
+
if (rangeId !== undefined) next.add(rangeId)
|
|
240
|
+
}
|
|
241
|
+
} else if (next.has(id)) {
|
|
242
|
+
next.delete(id)
|
|
243
|
+
} else {
|
|
244
|
+
next.add(id)
|
|
245
|
+
}
|
|
246
|
+
lastToggledIndexRef.current = index
|
|
247
|
+
onSelectionChange(next)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const loadedSelectedCount = loadedIds.reduce((count, id) => count + (selected.has(id) ? 1 : 0), 0)
|
|
251
|
+
const allLoadedSelected = loadedIds.length > 0 && loadedSelectedCount === loadedIds.length
|
|
252
|
+
|
|
253
|
+
const toggleAllLoaded = () => {
|
|
254
|
+
if (!onSelectionChange) return
|
|
255
|
+
const next = new Set(selected)
|
|
256
|
+
if (allLoadedSelected) {
|
|
257
|
+
for (const id of loadedIds) next.delete(id)
|
|
258
|
+
} else {
|
|
259
|
+
for (const id of loadedIds) next.add(id)
|
|
260
|
+
}
|
|
261
|
+
lastToggledIndexRef.current = null
|
|
262
|
+
onSelectionChange(next)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// RAC's Checkbox onChange carries no modifier keys, so the shift state is
|
|
266
|
+
// captured on the preceding pointer event.
|
|
267
|
+
const rangeModifierRef = useRef(false)
|
|
268
|
+
|
|
269
|
+
const selectionColumn = useMemo<ColumnDef<TRow>>(
|
|
270
|
+
() => ({
|
|
271
|
+
id: SELECTION_COLUMN_ID,
|
|
272
|
+
size: 36,
|
|
273
|
+
enableSorting: false,
|
|
274
|
+
meta: { align: 'center', sticky: 'left', cellClassName: 'px-0 justify-center' },
|
|
275
|
+
header: () => (
|
|
276
|
+
<span className="inline-flex items-center justify-center w-full">
|
|
277
|
+
<Checkbox
|
|
278
|
+
aria-label={allLoadedSelected ? 'Deselect all rows' : 'Select all rows'}
|
|
279
|
+
isSelected={allLoadedSelected}
|
|
280
|
+
isIndeterminate={loadedSelectedCount > 0 && !allLoadedSelected}
|
|
281
|
+
isDisabled={loadedIds.length === 0}
|
|
282
|
+
onChange={toggleAllLoaded}
|
|
283
|
+
/>
|
|
284
|
+
</span>
|
|
285
|
+
),
|
|
286
|
+
cell: ({ row }) => (
|
|
287
|
+
// Stop the click here: selecting a row must never also open it.
|
|
288
|
+
<span
|
|
289
|
+
className="inline-flex items-center justify-center w-full"
|
|
290
|
+
onClick={(event) => event.stopPropagation()}
|
|
291
|
+
onKeyDown={(event) => {
|
|
292
|
+
event.stopPropagation()
|
|
293
|
+
rangeModifierRef.current = event.shiftKey
|
|
294
|
+
}}
|
|
295
|
+
onPointerDownCapture={(event) => {
|
|
296
|
+
rangeModifierRef.current = event.shiftKey
|
|
297
|
+
}}
|
|
298
|
+
role="presentation"
|
|
299
|
+
>
|
|
300
|
+
<Checkbox
|
|
301
|
+
aria-label={`Select row ${row.index + 1}`}
|
|
302
|
+
isSelected={selected.has(row.id)}
|
|
303
|
+
onChange={() => toggleRow(row.id, row.index, rangeModifierRef.current)}
|
|
304
|
+
/>
|
|
305
|
+
</span>
|
|
306
|
+
),
|
|
307
|
+
}),
|
|
308
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: handlers read the latest selection via closure recreated on these inputs
|
|
309
|
+
[selected, loadedIds, allLoadedSelected, loadedSelectedCount],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const tableColumns = useMemo(
|
|
313
|
+
() => (selectionEnabled ? [selectionColumn, ...columns] : columns),
|
|
314
|
+
[selectionEnabled, selectionColumn, columns],
|
|
315
|
+
)
|
|
316
|
+
|
|
186
317
|
const table = useReactTable({
|
|
187
318
|
data: rows,
|
|
188
|
-
columns,
|
|
319
|
+
columns: tableColumns,
|
|
189
320
|
state: { sorting: sorting ?? [] },
|
|
190
321
|
...(onSortingChange ? { onSortingChange } : {}),
|
|
191
322
|
manualSorting: true,
|
|
192
323
|
getCoreRowModel: getCoreRowModel(),
|
|
193
|
-
getRowId:
|
|
194
|
-
? (row, index) => getRowId(row, index)
|
|
195
|
-
: (row, index) => {
|
|
196
|
-
const r = row as Record<string, unknown>
|
|
197
|
-
if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
|
|
198
|
-
if (typeof r.slug === 'string') return r.slug
|
|
199
|
-
return String(index)
|
|
200
|
-
},
|
|
324
|
+
getRowId: (row, index) => resolveRowId(row, index),
|
|
201
325
|
})
|
|
202
326
|
|
|
203
327
|
const tableRows = table.getRowModel().rows
|
|
@@ -243,8 +367,8 @@ export function DataTable<TRow>({
|
|
|
243
367
|
})
|
|
244
368
|
|
|
245
369
|
const totalColumnWidth = useMemo(
|
|
246
|
-
() =>
|
|
247
|
-
[
|
|
370
|
+
() => tableColumns.reduce((sum, c) => sum + (c.size ?? 140), 0),
|
|
371
|
+
[tableColumns],
|
|
248
372
|
)
|
|
249
373
|
|
|
250
374
|
// Single global pragmatic-dnd monitor for column reorder. We mirror the
|
|
@@ -7,7 +7,7 @@ export type {
|
|
|
7
7
|
} from './cells'
|
|
8
8
|
export { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
|
|
9
9
|
export type { DataTableProps, InfiniteQueryLike } from './data-table'
|
|
10
|
-
export { DataTable } from './data-table'
|
|
10
|
+
export { DataTable, SELECTION_COLUMN_ID } from './data-table'
|
|
11
11
|
|
|
12
12
|
export type { ActionsColumnOptions } from './make-actions-column'
|
|
13
13
|
export { makeActionsColumn } from './make-actions-column'
|
|
@@ -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),
|