@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.
Files changed (43) hide show
  1. package/dist/{chunk-NWGG5WFL.cjs → chunk-4ACELYEP.cjs} +23 -4
  2. package/dist/chunk-4ACELYEP.cjs.map +1 -0
  3. package/dist/{chunk-NSE4VW3Z.js → chunk-XRN5MNDX.js} +24 -5
  4. package/dist/chunk-XRN5MNDX.js.map +1 -0
  5. package/dist/index.cjs +811 -304
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +365 -15
  8. package/dist/index.d.ts +365 -15
  9. package/dist/index.js +537 -39
  10. package/dist/index.js.map +1 -1
  11. package/dist/{pdf-view-RRMAUK3L.cjs → pdf-view-2DSARHNQ.cjs} +7 -7
  12. package/dist/{pdf-view-RRMAUK3L.cjs.map → pdf-view-2DSARHNQ.cjs.map} +1 -1
  13. package/dist/{pdf-view-RQ2OZYHJ.js → pdf-view-2QU4FI37.js} +3 -3
  14. package/dist/{pdf-view-RQ2OZYHJ.js.map → pdf-view-2QU4FI37.js.map} +1 -1
  15. package/package.json +2 -2
  16. package/src/components/availability-calendar/availability-calendar.stories.tsx +78 -0
  17. package/src/components/availability-calendar/availability-calendar.test.tsx +89 -0
  18. package/src/components/availability-calendar/availability-calendar.tsx +182 -0
  19. package/src/components/availability-calendar/index.ts +5 -0
  20. package/src/components/button/button.stories.tsx +22 -1
  21. package/src/components/button/button.test.tsx +45 -1
  22. package/src/components/button/button.tsx +32 -2
  23. package/src/components/conversation-brief-card/conversation-brief-preview.tsx +176 -47
  24. package/src/components/conversation-brief-card/conversation-brief-review.test.tsx +58 -1
  25. package/src/components/data-table/data-table.test.tsx +65 -0
  26. package/src/components/data-table/data-table.tsx +33 -2
  27. package/src/components/glow-frame/glow-frame.stories.tsx +65 -0
  28. package/src/components/glow-frame/glow-frame.test.tsx +67 -0
  29. package/src/components/glow-frame/glow-frame.tsx +182 -0
  30. package/src/components/glow-frame/index.ts +2 -0
  31. package/src/components/stepper/index.ts +8 -0
  32. package/src/components/stepper/stepper.stories.tsx +84 -0
  33. package/src/components/stepper/stepper.test.tsx +79 -0
  34. package/src/components/stepper/stepper.tsx +127 -0
  35. package/src/components/time-slot-list/index.ts +2 -0
  36. package/src/components/time-slot-list/time-slot-list.stories.tsx +100 -0
  37. package/src/components/time-slot-list/time-slot-list.test.tsx +120 -0
  38. package/src/components/time-slot-list/time-slot-list.tsx +154 -0
  39. package/src/components/tooltip/tooltip.tsx +4 -1
  40. package/src/index.ts +4 -0
  41. package/src/styles.css +216 -0
  42. package/dist/chunk-NSE4VW3Z.js.map +0 -1
  43. 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
- export function Button({ variant, size, shape, className, ...props }: ButtonProps) {
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
- <div className="min-w-0">
101
- <p className="truncate text-[14px] font-semibold text-ink">
102
- {brief.subject || names || 'Prepared conversation'}
103
- </p>
104
- <p className="font-mono text-[10.5px] text-ink-4">
105
- {[
106
- brief.subject ? names : '',
107
- brief.channel ? `Channel ${brief.channel}` : 'Any channel',
108
- brief.type_key ? `Type ${brief.type_key}` : '',
109
- brief.status === 'prepared'
110
- ? 'Prepared'
111
- : brief.status === 'used'
112
- ? 'Call held'
113
- : 'Discarded',
114
- ]
115
- .filter(Boolean)
116
- .join(' · ')}
117
- </p>
118
- </div>
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
- <div className="group/brief-content relative">
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
- {contacts.length > 1 ? (
151
- <Select
152
- aria-label="Who to call"
153
- selectedKey={contact?.id ?? ''}
154
- onSelectionChange={(key) => {
155
- setPickedContactId(String(key))
156
- setPickedNumber('')
157
- }}
158
- options={contacts.map((entry) => ({ id: entry.id, label: entry.name || entry.id }))}
159
- />
160
- ) : null}
161
- {numbers.length > 1 ? (
162
- <Select
163
- aria-label="Number to call"
164
- selectedKey={phoneNumber}
165
- onSelectionChange={(key) => setPickedNumber(String(key))}
166
- options={numbers.map((number) => ({ id: number, label: number }))}
167
- />
168
- ) : (
169
- <p className="font-mono text-[12px] text-ink-3">
170
- {phoneNumber ||
171
- (contact ? 'This contact has no phone number on file.' : 'No contact to call.')}
172
- </p>
173
- )}
174
- {controls}
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-1' }), '+4930123456')
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
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — scroll resets on sort change only
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
+ }