@proteos/ui 0.61.0 → 0.76.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/{chunk-NWGG5WFL.cjs → chunk-4ACELYEP.cjs} +23 -4
- package/dist/chunk-4ACELYEP.cjs.map +1 -0
- package/dist/{chunk-NSE4VW3Z.js → chunk-XRN5MNDX.js} +24 -5
- package/dist/chunk-XRN5MNDX.js.map +1 -0
- package/dist/index.cjs +811 -304
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +365 -15
- package/dist/index.d.ts +365 -15
- package/dist/index.js +537 -39
- package/dist/index.js.map +1 -1
- package/dist/{pdf-view-RRMAUK3L.cjs → pdf-view-2DSARHNQ.cjs} +7 -7
- package/dist/{pdf-view-RRMAUK3L.cjs.map → pdf-view-2DSARHNQ.cjs.map} +1 -1
- package/dist/{pdf-view-RQ2OZYHJ.js → pdf-view-2QU4FI37.js} +3 -3
- package/dist/{pdf-view-RQ2OZYHJ.js.map → pdf-view-2QU4FI37.js.map} +1 -1
- package/package.json +2 -2
- package/src/components/availability-calendar/availability-calendar.stories.tsx +78 -0
- package/src/components/availability-calendar/availability-calendar.test.tsx +89 -0
- package/src/components/availability-calendar/availability-calendar.tsx +182 -0
- package/src/components/availability-calendar/index.ts +5 -0
- package/src/components/button/button.stories.tsx +22 -1
- package/src/components/button/button.test.tsx +45 -1
- package/src/components/button/button.tsx +32 -2
- package/src/components/conversation-brief-card/conversation-brief-preview.tsx +176 -47
- package/src/components/conversation-brief-card/conversation-brief-review.test.tsx +58 -1
- package/src/components/data-table/data-table.test.tsx +65 -0
- package/src/components/data-table/data-table.tsx +33 -2
- package/src/components/glow-frame/glow-frame.stories.tsx +65 -0
- package/src/components/glow-frame/glow-frame.test.tsx +67 -0
- package/src/components/glow-frame/glow-frame.tsx +182 -0
- package/src/components/glow-frame/index.ts +2 -0
- package/src/components/stepper/index.ts +8 -0
- package/src/components/stepper/stepper.stories.tsx +84 -0
- package/src/components/stepper/stepper.test.tsx +79 -0
- package/src/components/stepper/stepper.tsx +127 -0
- package/src/components/time-slot-list/index.ts +2 -0
- package/src/components/time-slot-list/time-slot-list.stories.tsx +100 -0
- package/src/components/time-slot-list/time-slot-list.test.tsx +120 -0
- package/src/components/time-slot-list/time-slot-list.tsx +154 -0
- package/src/components/tooltip/tooltip.tsx +4 -1
- package/src/index.ts +4 -0
- package/src/styles.css +216 -0
- package/dist/chunk-NSE4VW3Z.js.map +0 -1
- package/dist/chunk-NWGG5WFL.cjs.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react'
|
|
1
2
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from 'react-aria-components'
|
|
2
3
|
import { tv, type VariantProps } from 'tailwind-variants'
|
|
3
4
|
|
|
@@ -23,6 +24,11 @@ import { cn } from '../../lib/cn'
|
|
|
23
24
|
and `disabled:` stay on the native pseudo-classes because the RAC root IS
|
|
24
25
|
the focusable <button>. Hover is always a colour step on the surface ramp,
|
|
25
26
|
never opacity; press is translateY(1px) plus one more ramp step.
|
|
27
|
+
|
|
28
|
+
`isPending` (React Aria's: presses are ignored, the button stays
|
|
29
|
+
focusable and announces itself busy) shows a spinner before the label —
|
|
30
|
+
in place of the icon on the icon sizes — so a save that takes a moment
|
|
31
|
+
reads as working, not as a dead button.
|
|
26
32
|
========================================================================= */
|
|
27
33
|
|
|
28
34
|
// Ink fill with the indigo-tinted hover. Kept verbatim from the old primary.
|
|
@@ -124,16 +130,40 @@ export const buttonStyles = tv({
|
|
|
124
130
|
export type ButtonVariants = VariantProps<typeof buttonStyles>
|
|
125
131
|
export type ButtonProps = AriaButtonProps & ButtonVariants
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
const isIconSize = (size: ButtonVariants['size']) =>
|
|
134
|
+
size === 'icon-sm' || size === 'icon-md' || size === 'icon-lg'
|
|
135
|
+
|
|
136
|
+
export function Button({ variant, size, shape, className, children, ...props }: ButtonProps) {
|
|
128
137
|
return (
|
|
129
138
|
<AriaButton
|
|
130
139
|
{...props}
|
|
131
140
|
className={(renderProps) =>
|
|
132
141
|
cn(
|
|
133
142
|
buttonStyles({ variant, size, shape }),
|
|
143
|
+
renderProps.isPending && 'cursor-progress',
|
|
134
144
|
typeof className === 'function' ? className(renderProps) : className,
|
|
135
145
|
)
|
|
136
146
|
}
|
|
137
|
-
|
|
147
|
+
>
|
|
148
|
+
{(renderProps) => {
|
|
149
|
+
const content = typeof children === 'function' ? children(renderProps) : children
|
|
150
|
+
if (!renderProps.isPending) return content
|
|
151
|
+
const spinner = (
|
|
152
|
+
<Loader2
|
|
153
|
+
className="shrink-0 animate-spin motion-reduce:animate-none"
|
|
154
|
+
aria-hidden
|
|
155
|
+
data-slot="pending-spinner"
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
return isIconSize(size) ? (
|
|
159
|
+
spinner
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
{spinner}
|
|
163
|
+
{content}
|
|
164
|
+
</>
|
|
165
|
+
)
|
|
166
|
+
}}
|
|
167
|
+
</AriaButton>
|
|
138
168
|
)
|
|
139
169
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
-
import { Pencil, Phone } from 'lucide-react'
|
|
2
|
+
import { Pencil, Phone, X } from 'lucide-react'
|
|
3
3
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { Button as AriaButton } from 'react-aria-components'
|
|
5
5
|
|
|
@@ -58,6 +58,16 @@ export interface ConversationBriefPreviewProps {
|
|
|
58
58
|
onEdit?: () => void
|
|
59
59
|
/** Replaces the guidance box wholesale — the host's editing surface. */
|
|
60
60
|
editor?: ReactNode
|
|
61
|
+
/** A right-hand column next to the dial rows (the records the call
|
|
62
|
+
* relates to). Owns its own scrolling. Absent ⇒ single column. */
|
|
63
|
+
aside?: ReactNode
|
|
64
|
+
/** Hides the people · channel · status line under the subject — for a
|
|
65
|
+
* host whose surrounding chrome already says all of that (the call
|
|
66
|
+
* window, where the dial row names the person). */
|
|
67
|
+
isMetaHidden?: boolean
|
|
68
|
+
/** Hides the subject line too — for a host whose window title IS the
|
|
69
|
+
* subject (the call window). Implies `isMetaHidden`. */
|
|
70
|
+
isSubjectHidden?: boolean
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export function ConversationBriefPreview({
|
|
@@ -75,8 +85,15 @@ export function ConversationBriefPreview({
|
|
|
75
85
|
renderContent,
|
|
76
86
|
onEdit,
|
|
77
87
|
editor,
|
|
88
|
+
aside,
|
|
89
|
+
isMetaHidden = false,
|
|
90
|
+
isSubjectHidden = false,
|
|
78
91
|
}: ConversationBriefPreviewProps) {
|
|
79
92
|
const [pickedContactId, setPickedContactId] = useState('')
|
|
93
|
+
// The dial row rests as "Calling <value>"; a pencil (only when
|
|
94
|
+
// there is a choice — several people or several numbers) swaps it for
|
|
95
|
+
// the selects, which fold back once picked. Mirrors the caller-id row.
|
|
96
|
+
const [isPickingTarget, setIsPickingTarget] = useState(false)
|
|
80
97
|
const contact = contacts.find((entry) => entry.id === pickedContactId) ?? contacts[0] ?? null
|
|
81
98
|
const numbers = useMemo(() => contactPhoneNumbers(contact), [contact])
|
|
82
99
|
const [pickedNumber, setPickedNumber] = useState('')
|
|
@@ -97,31 +114,38 @@ export function ConversationBriefPreview({
|
|
|
97
114
|
|
|
98
115
|
return (
|
|
99
116
|
<div className={cn('flex flex-col gap-3', className)}>
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
{isSubjectHidden ? null : (
|
|
118
|
+
<div className="min-w-0">
|
|
119
|
+
<p className="truncate text-[14px] font-semibold text-ink">
|
|
120
|
+
{brief.subject || names || 'Prepared conversation'}
|
|
121
|
+
</p>
|
|
122
|
+
{isMetaHidden ? null : (
|
|
123
|
+
<p className="font-mono text-[10.5px] text-ink-4">
|
|
124
|
+
{[
|
|
125
|
+
brief.subject ? names : '',
|
|
126
|
+
brief.channel ? `Channel ${brief.channel}` : 'Any channel',
|
|
127
|
+
brief.type_key ? `Type ${brief.type_key}` : '',
|
|
128
|
+
brief.status === 'prepared'
|
|
129
|
+
? 'Prepared'
|
|
130
|
+
: brief.status === 'used'
|
|
131
|
+
? 'Call held'
|
|
132
|
+
: 'Discarded',
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(' · ')}
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
119
140
|
{editor ?? (
|
|
120
|
-
|
|
141
|
+
// A flex column of its own so a host that gives the box `flex-1`
|
|
142
|
+
// (the call window) gets a scrolling box that fills the remaining
|
|
143
|
+
// height; in an unconstrained host (a modal) it just wraps.
|
|
144
|
+
<div className="group/brief-content relative flex min-h-0 flex-1 flex-col">
|
|
121
145
|
<div
|
|
122
146
|
className={cn(
|
|
123
147
|
'rounded-md border border-rule-2 bg-surface-2/60 px-3 py-2.5',
|
|
124
|
-
'text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
|
|
148
|
+
'min-h-0 text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
|
|
125
149
|
renderContent ? null : 'whitespace-pre-wrap',
|
|
126
150
|
contentClassName,
|
|
127
151
|
)}
|
|
@@ -147,31 +171,136 @@ export function ConversationBriefPreview({
|
|
|
147
171
|
)}
|
|
148
172
|
{onCall ? (
|
|
149
173
|
<div className="flex flex-col gap-2 border-t border-rule-2 pt-3">
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
<div
|
|
175
|
+
className={cn(
|
|
176
|
+
'grid gap-3',
|
|
177
|
+
aside ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]' : 'grid-cols-1',
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
181
|
+
{isPickingTarget && (contacts.length > 1 || numbers.length > 1) ? (
|
|
182
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
183
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
184
|
+
Calling
|
|
185
|
+
</p>
|
|
186
|
+
<div className="flex min-w-0 items-start gap-1.5">
|
|
187
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
188
|
+
{contacts.length > 1 ? (
|
|
189
|
+
<Select
|
|
190
|
+
aria-label="Who to call"
|
|
191
|
+
size="sm"
|
|
192
|
+
autoFocus
|
|
193
|
+
selectedKey={contact?.id ?? ''}
|
|
194
|
+
onSelectionChange={(key) => {
|
|
195
|
+
setPickedContactId(String(key))
|
|
196
|
+
setPickedNumber('')
|
|
197
|
+
// Fold once the pick is complete: the chosen person has
|
|
198
|
+
// at most one number, so there is nothing left to choose.
|
|
199
|
+
const picked = contacts.find((entry) => entry.id === String(key))
|
|
200
|
+
if (contactPhoneNumbers(picked).length <= 1) setIsPickingTarget(false)
|
|
201
|
+
}}
|
|
202
|
+
options={contacts.map((entry) => ({
|
|
203
|
+
id: entry.id,
|
|
204
|
+
label: entry.name || entry.id,
|
|
205
|
+
}))}
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
{numbers.length > 1 ? (
|
|
209
|
+
<Select
|
|
210
|
+
aria-label="Number to dial"
|
|
211
|
+
size="sm"
|
|
212
|
+
{...(contacts.length > 1 ? {} : { autoFocus: true })}
|
|
213
|
+
selectedKey={phoneNumber}
|
|
214
|
+
onSelectionChange={(key) => {
|
|
215
|
+
if (key !== null) setPickedNumber(String(key))
|
|
216
|
+
setIsPickingTarget(false)
|
|
217
|
+
}}
|
|
218
|
+
options={numbers.map((number) => ({ id: number, label: number }))}
|
|
219
|
+
/>
|
|
220
|
+
) : null}
|
|
221
|
+
</div>
|
|
222
|
+
<Button
|
|
223
|
+
variant="ghost"
|
|
224
|
+
size="icon-sm"
|
|
225
|
+
aria-label="Keep the current number"
|
|
226
|
+
onPress={() => setIsPickingTarget(false)}
|
|
227
|
+
>
|
|
228
|
+
<X aria-hidden />
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
) : (
|
|
233
|
+
<div className="group/dial flex min-w-0 flex-col gap-1">
|
|
234
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
235
|
+
Calling
|
|
236
|
+
</p>
|
|
237
|
+
<div className="flex min-h-9 min-w-0 items-center gap-2">
|
|
238
|
+
{(() => {
|
|
239
|
+
const hasChoice = contacts.length > 1 || numbers.length > 1
|
|
240
|
+
const valueText = phoneNumber
|
|
241
|
+
? contacts.length > 1
|
|
242
|
+
? `${contact?.name || contact?.id} · ${phoneNumber}`
|
|
243
|
+
: phoneNumber
|
|
244
|
+
: contact
|
|
245
|
+
? `${contacts.length > 1 ? `${contact.name || contact.id} · ` : ''}no phone number on file`
|
|
246
|
+
: 'No contact to call.'
|
|
247
|
+
const valueClassName = cn(
|
|
248
|
+
'min-w-0 truncate text-[12px] tabular-nums',
|
|
249
|
+
phoneNumber ? 'text-ink-2' : 'text-warning',
|
|
250
|
+
)
|
|
251
|
+
// Same weight as a related-record row: a small muted
|
|
252
|
+
// glyph in front, 12px text, mid-row.
|
|
253
|
+
const icon = (
|
|
254
|
+
<Phone
|
|
255
|
+
className={cn(
|
|
256
|
+
'size-3 shrink-0',
|
|
257
|
+
phoneNumber ? 'text-ink-4' : 'text-warning',
|
|
258
|
+
)}
|
|
259
|
+
aria-hidden
|
|
260
|
+
/>
|
|
261
|
+
)
|
|
262
|
+
// With something to choose, the value itself opens the pick
|
|
263
|
+
// (the pencil is the hint, not the only handle).
|
|
264
|
+
return hasChoice ? (
|
|
265
|
+
<AriaButton
|
|
266
|
+
className={cn(
|
|
267
|
+
'flex min-w-0 cursor-pointer items-center gap-2 rounded-sm text-left outline-none',
|
|
268
|
+
'hover:text-ink focus-visible:shadow-[var(--shadow-focus)]',
|
|
269
|
+
)}
|
|
270
|
+
onPress={() => setIsPickingTarget(true)}
|
|
271
|
+
>
|
|
272
|
+
{icon}
|
|
273
|
+
<span className={valueClassName} title={phoneNumber}>
|
|
274
|
+
{valueText}
|
|
275
|
+
</span>
|
|
276
|
+
</AriaButton>
|
|
277
|
+
) : (
|
|
278
|
+
<>
|
|
279
|
+
{icon}
|
|
280
|
+
<span className={valueClassName} title={phoneNumber}>
|
|
281
|
+
{valueText}
|
|
282
|
+
</span>
|
|
283
|
+
</>
|
|
284
|
+
)
|
|
285
|
+
})()}
|
|
286
|
+
{contacts.length > 1 || numbers.length > 1 ? (
|
|
287
|
+
<Button
|
|
288
|
+
variant="ghost"
|
|
289
|
+
size="icon-sm"
|
|
290
|
+
aria-label="Change the number to dial"
|
|
291
|
+
className="opacity-0 transition-opacity group-hover/dial:opacity-100 focus-visible:opacity-100"
|
|
292
|
+
onPress={() => setIsPickingTarget(true)}
|
|
293
|
+
>
|
|
294
|
+
<Pencil aria-hidden />
|
|
295
|
+
</Button>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
{controls}
|
|
301
|
+
</div>
|
|
302
|
+
{aside ? <div className="min-h-0 min-w-0">{aside}</div> : null}
|
|
303
|
+
</div>
|
|
175
304
|
{callDisabledReason ? (
|
|
176
305
|
<p className="text-[12px] text-warning">{callDisabledReason}</p>
|
|
177
306
|
) : null}
|
|
@@ -172,9 +172,66 @@ describe('ConversationBriefPreview', () => {
|
|
|
172
172
|
onCall={onCall}
|
|
173
173
|
/>,
|
|
174
174
|
)
|
|
175
|
+
// Rests as a labelled row naming the first person + number; the pencil
|
|
176
|
+
// reveals the who-to-call select.
|
|
177
|
+
expect(screen.getByText('Calling')).toBeInTheDocument()
|
|
178
|
+
expect(screen.getByText('Alice Example · +4930123456')).toBeInTheDocument()
|
|
179
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change the number to dial' }))
|
|
175
180
|
expect(screen.getByRole('button', { name: /Who to call/ })).toBeInTheDocument()
|
|
181
|
+
await userEvent.click(screen.getByRole('button', { name: /Who to call/ }))
|
|
182
|
+
await userEvent.click(screen.getByRole('option', { name: /Tom Example/ }))
|
|
183
|
+
expect(screen.getByText('Tom Example · +4915500000')).toBeInTheDocument()
|
|
176
184
|
await userEvent.click(screen.getByRole('button', { name: 'Call' }))
|
|
177
|
-
expect(onCall).toHaveBeenCalledWith(expect.objectContaining({ id: 'contact-
|
|
185
|
+
expect(onCall).toHaveBeenCalledWith(expect.objectContaining({ id: 'contact-2' }), '+4915500000')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('shows a single number without a pencil and hides the meta line on request', () => {
|
|
189
|
+
render(
|
|
190
|
+
<ConversationBriefPreview
|
|
191
|
+
brief={brief()}
|
|
192
|
+
contacts={[
|
|
193
|
+
contact({
|
|
194
|
+
addresses: [{ id: 'a2', kind: 'phone', value: '+4930123456' }],
|
|
195
|
+
} as Partial<Contact>),
|
|
196
|
+
]}
|
|
197
|
+
onCall={vi.fn()}
|
|
198
|
+
isMetaHidden
|
|
199
|
+
/>,
|
|
200
|
+
)
|
|
201
|
+
expect(screen.getByText('+4930123456')).toBeInTheDocument()
|
|
202
|
+
expect(
|
|
203
|
+
screen.queryByRole('button', { name: 'Change the number to dial' }),
|
|
204
|
+
).not.toBeInTheDocument()
|
|
205
|
+
expect(screen.queryByText(/Channel phone/)).not.toBeInTheDocument()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('lets a contact with several numbers pick which one to dial', async () => {
|
|
209
|
+
const onSelectionChange = vi.fn()
|
|
210
|
+
render(
|
|
211
|
+
<ConversationBriefPreview
|
|
212
|
+
brief={brief()}
|
|
213
|
+
contacts={[contact()]}
|
|
214
|
+
onCall={vi.fn()}
|
|
215
|
+
isCallButtonHidden
|
|
216
|
+
onSelectionChange={onSelectionChange}
|
|
217
|
+
/>,
|
|
218
|
+
)
|
|
219
|
+
// First number auto-selected; the pencil reveals the number select, the
|
|
220
|
+
// label stays, and the x folds it back without a change.
|
|
221
|
+
expect(screen.getByText('+4930123456')).toBeInTheDocument()
|
|
222
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change the number to dial' }))
|
|
223
|
+
expect(screen.getByText('Calling')).toBeInTheDocument()
|
|
224
|
+
await userEvent.click(screen.getByRole('button', { name: 'Keep the current number' }))
|
|
225
|
+
expect(screen.getByText('+4930123456')).toBeInTheDocument()
|
|
226
|
+
expect(screen.queryByRole('button', { name: /Number to dial/ })).not.toBeInTheDocument()
|
|
227
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change the number to dial' }))
|
|
228
|
+
await userEvent.click(screen.getByRole('button', { name: /Number to dial/ }))
|
|
229
|
+
await userEvent.click(screen.getByRole('option', { name: '+4917612345' }))
|
|
230
|
+
expect(screen.getByText('+4917612345')).toBeInTheDocument()
|
|
231
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(
|
|
232
|
+
expect.objectContaining({ id: 'contact-1' }),
|
|
233
|
+
'+4917612345',
|
|
234
|
+
)
|
|
178
235
|
})
|
|
179
236
|
|
|
180
237
|
it('can hide its Call button and report the target to the host', () => {
|
|
@@ -172,3 +172,68 @@ describe('DataTable grouping', () => {
|
|
|
172
172
|
expect(screen.queryByText('crm')).toBeNull()
|
|
173
173
|
})
|
|
174
174
|
})
|
|
175
|
+
|
|
176
|
+
/* `placeholderData: keepPreviousData` keeps the previous rows on screen while
|
|
177
|
+
a changed filter / sort loads: no skeleton, the body just dims. */
|
|
178
|
+
describe('DataTable placeholder rows', () => {
|
|
179
|
+
it('keeps the rows and dims the body while the query shows placeholder data', () => {
|
|
180
|
+
render(
|
|
181
|
+
<DataTable<Row>
|
|
182
|
+
query={{ ...query(ROWS), isPlaceholderData: true }}
|
|
183
|
+
columns={columns}
|
|
184
|
+
ariaLabel="Records"
|
|
185
|
+
/>,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
189
|
+
expect(screen.queryByRole('status')).toBeNull()
|
|
190
|
+
expect(screen.getByText('Alpha').closest('[aria-busy]')).toHaveAttribute('aria-busy', 'true')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('renders the body at full opacity once the rows are its own', () => {
|
|
194
|
+
render(<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" />)
|
|
195
|
+
|
|
196
|
+
expect(screen.getByText('Alpha').closest('[aria-busy]')).toBeNull()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/* A host router restores the scroller's position on back navigation before
|
|
201
|
+
the table's effects run — the sort-change reset must not undo it on mount. */
|
|
202
|
+
describe('DataTable scroll restoration', () => {
|
|
203
|
+
it('stamps the restoration id on the scroll container', () => {
|
|
204
|
+
const { container } = render(
|
|
205
|
+
<DataTable<Row>
|
|
206
|
+
query={query(ROWS)}
|
|
207
|
+
columns={columns}
|
|
208
|
+
ariaLabel="Records"
|
|
209
|
+
scrollRestorationId="list:customers"
|
|
210
|
+
/>,
|
|
211
|
+
)
|
|
212
|
+
expect(container.querySelector('[data-scroll-restoration-id="list:customers"]')).not.toBeNull()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('keeps the restored position on mount and resets it when the sort changes', () => {
|
|
216
|
+
const scrollTo = vi.fn()
|
|
217
|
+
const original = HTMLElement.prototype.scrollTo
|
|
218
|
+
HTMLElement.prototype.scrollTo = scrollTo
|
|
219
|
+
try {
|
|
220
|
+
const sorting = [{ id: 'name', desc: false }]
|
|
221
|
+
const { rerender } = render(
|
|
222
|
+
<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" sorting={sorting} />,
|
|
223
|
+
)
|
|
224
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
225
|
+
|
|
226
|
+
rerender(
|
|
227
|
+
<DataTable<Row>
|
|
228
|
+
query={query(ROWS)}
|
|
229
|
+
columns={columns}
|
|
230
|
+
ariaLabel="Records"
|
|
231
|
+
sorting={[{ id: 'name', desc: true }]}
|
|
232
|
+
/>,
|
|
233
|
+
)
|
|
234
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 0 })
|
|
235
|
+
} finally {
|
|
236
|
+
HTMLElement.prototype.scrollTo = original
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -59,6 +59,10 @@ type InfinitePage<TRow> = { data: TRow[]; meta?: { items_total?: number } }
|
|
|
59
59
|
export type InfiniteQueryLike<TRow> = {
|
|
60
60
|
data?: { pages: InfinitePage<TRow>[] } | undefined
|
|
61
61
|
isLoading: boolean
|
|
62
|
+
/** The rows on screen belong to the PREVIOUS query (a filter or sort just
|
|
63
|
+
* changed and the new rows are still loading — `placeholderData:
|
|
64
|
+
* keepPreviousData`). The body dims until the fresh rows land. */
|
|
65
|
+
isPlaceholderData?: boolean
|
|
62
66
|
isFetchingNextPage: boolean
|
|
63
67
|
hasNextPage: boolean | undefined
|
|
64
68
|
error: Error | null
|
|
@@ -73,6 +77,18 @@ export type DataTableProps<TRow> = {
|
|
|
73
77
|
onSortingChange?: OnChangeFn<SortingState>
|
|
74
78
|
rowHeight?: number
|
|
75
79
|
overscan?: number
|
|
80
|
+
/**
|
|
81
|
+
* Stable id stamped on the scroll container as `data-scroll-restoration-id`,
|
|
82
|
+
* so a router that restores element scroll on back/forward finds this table
|
|
83
|
+
* by id instead of by its (fragile) DOM path.
|
|
84
|
+
*/
|
|
85
|
+
scrollRestorationId?: string
|
|
86
|
+
/**
|
|
87
|
+
* Scroll offset the table first renders at — a restored position from the
|
|
88
|
+
* host's router. The virtualizer starts its window there, so the rows at
|
|
89
|
+
* that offset paint on the first frame instead of a blank band.
|
|
90
|
+
*/
|
|
91
|
+
initialScrollOffset?: number
|
|
76
92
|
/**
|
|
77
93
|
* Called when a row is clicked. Provides both the original data row and
|
|
78
94
|
* the underlying event so consumers can inspect modifier keys.
|
|
@@ -200,6 +216,8 @@ export function DataTable<TRow>({
|
|
|
200
216
|
onSortingChange,
|
|
201
217
|
rowHeight = DEFAULT_ROW_HEIGHT,
|
|
202
218
|
overscan = 8,
|
|
219
|
+
scrollRestorationId,
|
|
220
|
+
initialScrollOffset,
|
|
203
221
|
onRowClick,
|
|
204
222
|
zebra = true,
|
|
205
223
|
emptyState,
|
|
@@ -380,10 +398,14 @@ export function DataTable<TRow>({
|
|
|
380
398
|
}, [tableRows, groupBy])
|
|
381
399
|
|
|
382
400
|
// Reset scroll to top when sorting changes — without this, the user is
|
|
383
|
-
// staring at a different slice of a freshly-sorted dataset.
|
|
401
|
+
// staring at a different slice of a freshly-sorted dataset. Only on a
|
|
402
|
+
// CHANGE: on mount this would wipe the position the host just restored
|
|
403
|
+
// (the router restores in the layout phase, before this effect runs).
|
|
384
404
|
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
|
385
|
-
|
|
405
|
+
const mountedSorting = useRef(sorting)
|
|
386
406
|
useEffect(() => {
|
|
407
|
+
if (sorting === mountedSorting.current) return
|
|
408
|
+
mountedSorting.current = sorting
|
|
387
409
|
scrollerRef.current?.scrollTo({ top: 0 })
|
|
388
410
|
}, [sorting])
|
|
389
411
|
|
|
@@ -407,6 +429,7 @@ export function DataTable<TRow>({
|
|
|
407
429
|
estimateSize: (index) => (listItems[index]?.kind === 'group' ? headingHeight : rowHeight),
|
|
408
430
|
overscan,
|
|
409
431
|
scrollMargin: listOffset,
|
|
432
|
+
...(initialScrollOffset !== undefined ? { initialOffset: initialScrollOffset } : {}),
|
|
410
433
|
})
|
|
411
434
|
|
|
412
435
|
const sentinelRef = useInfiniteScrollSentinel<HTMLDivElement>({
|
|
@@ -487,6 +510,9 @@ export function DataTable<TRow>({
|
|
|
487
510
|
top, and any `sticky left-0` gutter column freezes to the left. */}
|
|
488
511
|
<TableScroller
|
|
489
512
|
ref={scrollerRef}
|
|
513
|
+
{...(scrollRestorationId !== undefined
|
|
514
|
+
? { 'data-scroll-restoration-id': scrollRestorationId }
|
|
515
|
+
: {})}
|
|
490
516
|
// Scroll padding keeps a row scrolled (or focused) into view clear of
|
|
491
517
|
// the sticky header and, when present, the sticky trailing row —
|
|
492
518
|
// otherwise a freshly added last row lands underneath the strip.
|
|
@@ -572,6 +598,11 @@ export function DataTable<TRow>({
|
|
|
572
598
|
<div
|
|
573
599
|
ref={listRef}
|
|
574
600
|
role="presentation"
|
|
601
|
+
aria-busy={query.isPlaceholderData ? true : undefined}
|
|
602
|
+
className={cn(
|
|
603
|
+
'transition-opacity duration-150 motion-reduce:transition-none',
|
|
604
|
+
query.isPlaceholderData && 'opacity-50',
|
|
605
|
+
)}
|
|
575
606
|
style={{ height: totalSize, position: 'relative', width: '100%' }}
|
|
576
607
|
>
|
|
577
608
|
{virtualItems.map((vRow) => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '../button'
|
|
5
|
+
import { TextShimmer } from '../text-shimmer'
|
|
6
|
+
import { GlowFrame, type GlowFrameStatus } from './glow-frame'
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof GlowFrame> = {
|
|
9
|
+
title: 'Components/GlowFrame',
|
|
10
|
+
component: GlowFrame,
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
argTypes: {
|
|
13
|
+
status: { control: 'inline-radio', options: ['idle', 'working', 'ready'] },
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof GlowFrame>
|
|
19
|
+
|
|
20
|
+
const BOX = 'rounded-md border border-rule bg-surface p-4 text-[13px] text-ink-2'
|
|
21
|
+
|
|
22
|
+
export const Playground: Story = {
|
|
23
|
+
args: { status: 'working', className: 'w-[360px] rounded-md' },
|
|
24
|
+
render: (args) => (
|
|
25
|
+
<GlowFrame {...args}>
|
|
26
|
+
<div className={BOX}>Three fields will change on this deal.</div>
|
|
27
|
+
</GlowFrame>
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The full sequence: beams while the model works, a flash when the output
|
|
32
|
+
* lands. */
|
|
33
|
+
export const Sequence: Story = {
|
|
34
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
35
|
+
render: () => {
|
|
36
|
+
const [status, setStatus] = useState<GlowFrameStatus>('idle')
|
|
37
|
+
const run = () => {
|
|
38
|
+
setStatus('working')
|
|
39
|
+
window.setTimeout(() => setStatus('ready'), 3000)
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex w-[360px] flex-col gap-4">
|
|
43
|
+
<GlowFrame status={status} className="rounded-md">
|
|
44
|
+
<div className={BOX}>
|
|
45
|
+
{status === 'working' ? (
|
|
46
|
+
<TextShimmer>Preparing the changes…</TextShimmer>
|
|
47
|
+
) : status === 'ready' ? (
|
|
48
|
+
'Stage → Negotiation, amount → €42,000, close date → 30 Nov.'
|
|
49
|
+
) : (
|
|
50
|
+
'Press run.'
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
</GlowFrame>
|
|
54
|
+
<div className="flex gap-2">
|
|
55
|
+
<Button size="sm" onPress={run} isDisabled={status === 'working'}>
|
|
56
|
+
Run
|
|
57
|
+
</Button>
|
|
58
|
+
<Button size="sm" variant="ghost" onPress={() => setStatus('idle')}>
|
|
59
|
+
Reset
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
},
|
|
65
|
+
}
|