@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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 (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,350 @@
1
+ import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
2
+ import { useDocumentEditor, type UseDocumentEditorResult } from '../hooks/useDocumentEditor.js';
3
+ import { mergeFieldEditors } from '../fieldEditors/registry.js';
4
+ import type { FieldEditorRegistry } from '../fieldEditors/types.js';
5
+ import type { FieldNode } from '../reducers/types.js';
6
+ import type { FieldType } from '../types.js';
7
+ import { useContainerSize } from '../../primitives/hooks/useContainerSize.js';
8
+
9
+ interface EditorContext {
10
+ editor: UseDocumentEditorResult;
11
+ fieldEditors: FieldEditorRegistry;
12
+ }
13
+
14
+ const Ctx = createContext<EditorContext | null>(null);
15
+
16
+ function useEditorContext(): EditorContext {
17
+ const ctx = useContext(Ctx);
18
+ if (!ctx) {
19
+ throw new Error(
20
+ 'DocumentEditor.* components must render inside <DocumentEditor.Root>',
21
+ );
22
+ }
23
+ return ctx;
24
+ }
25
+
26
+ export interface DocumentEditorRootProps {
27
+ /** Initial document data. Built into the editor's tree on first
28
+ * mount; later changes don't rebuild — call `editor.reset()` to
29
+ * re-initialize from a fresh `initial`. */
30
+ initial?: Record<string, unknown>;
31
+ /** Override or extend the built-in field editors. */
32
+ fieldEditors?: FieldEditorRegistry;
33
+ /** Called on every state change with the latest editor state. The
34
+ * parent typically watches `state.isValid` + `state.isDirty` to
35
+ * enable/disable a Save button. */
36
+ onChange?: (state: UseDocumentEditorResult) => void;
37
+ children: ReactNode;
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * Wires the `useDocumentEditor` hook + field-editor registry into a
43
+ * React context so `<DocumentEditor.Fields>` and `<DocumentEditor.AddField>`
44
+ * can render the tree without prop drilling.
45
+ *
46
+ * Pattern: hook + compound component. Consumers wanting full control
47
+ * over the layout call `useDocumentEditor` directly and render their
48
+ * own tree; consumers wanting the default rendering use this.
49
+ */
50
+ export function DocumentEditorRoot({
51
+ initial,
52
+ fieldEditors,
53
+ onChange,
54
+ children,
55
+ className,
56
+ }: DocumentEditorRootProps) {
57
+ const editor = useDocumentEditor({ initial });
58
+ const registry = mergeFieldEditors(fieldEditors);
59
+
60
+ // Hold the latest onChange in a ref so the effect's deps don't
61
+ // include the callback — avoids re-firing onChange on every parent
62
+ // re-render that produces a new function identity.
63
+ const onChangeRef = useRef(onChange);
64
+ onChangeRef.current = onChange;
65
+ useEffect(() => {
66
+ onChangeRef.current?.(editor);
67
+ }, [editor]);
68
+
69
+ const { ref: rootRef, size } = useContainerSize<HTMLDivElement>();
70
+
71
+ return (
72
+ <Ctx.Provider value={{ editor, fieldEditors: registry }}>
73
+ <div
74
+ ref={rootRef}
75
+ className={className}
76
+ data-pyric-ui="document-editor"
77
+ data-pyric-is-valid={editor.isValid ? '' : undefined}
78
+ data-pyric-is-dirty={editor.isDirty ? '' : undefined}
79
+ data-size={size}
80
+ >
81
+ {children}
82
+ </div>
83
+ </Ctx.Provider>
84
+ );
85
+ }
86
+
87
+ /** Read access to the underlying editor state from inside a Root. */
88
+ export function useDocumentEditorContext(): UseDocumentEditorResult {
89
+ return useEditorContext().editor;
90
+ }
91
+
92
+ /**
93
+ * Renders the top-level fields of the document. For finer control,
94
+ * a consumer can call `useDocumentEditorContext()` and render the
95
+ * tree themselves.
96
+ */
97
+ export function DocumentEditorFields() {
98
+ const { editor } = useEditorContext();
99
+ // Stable INSERTION order, never re-sorted by the (currently being
100
+ // typed) key — `childIds` already preserves the order fields were
101
+ // added in, which is exactly what must stay stable while a row's
102
+ // name is edited. Sorting here was the cause of rows visibly
103
+ // reordering mid-keystroke.
104
+ const rootChildren = editor.tree.childIds[editor.tree.rootId] ?? [];
105
+
106
+ return (
107
+ <div data-pyric-editor-fields>
108
+ {rootChildren.map((id) => (
109
+ <Field key={id} nodeId={id} path={fieldPath(editor, id)} />
110
+ ))}
111
+ <AddMapEntry parentId={editor.tree.rootId} />
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function fieldPath(editor: UseDocumentEditorResult, nodeId: string): string {
117
+ const segments: string[] = [];
118
+ let current: FieldNode | undefined = editor.tree.nodes[nodeId];
119
+ while (current && current.parentId != null) {
120
+ const parent: FieldNode | undefined = editor.tree.nodes[current.parentId];
121
+ if (!parent) break;
122
+ if (parent.type === 'array') {
123
+ const idx = (editor.tree.childIds[parent.id] ?? []).indexOf(current.id);
124
+ segments.unshift(`[${idx}]`);
125
+ } else {
126
+ segments.unshift(current.key ?? '');
127
+ }
128
+ current = parent;
129
+ }
130
+ // Join — array index segments don't take a leading dot.
131
+ return segments.reduce((acc, seg) => {
132
+ if (seg.startsWith('[')) return `${acc}${seg}`;
133
+ return acc ? `${acc}.${seg}` : seg;
134
+ }, '');
135
+ }
136
+
137
+ interface FieldProps {
138
+ nodeId: string;
139
+ path: string;
140
+ }
141
+
142
+ function Field({ nodeId, path }: FieldProps) {
143
+ const { editor, fieldEditors } = useEditorContext();
144
+ const node = editor.tree.nodes[nodeId];
145
+ if (!node) return null;
146
+
147
+ if (node.type === 'map') {
148
+ return <MapField node={node} path={path} />;
149
+ }
150
+ if (node.type === 'array') {
151
+ return <ArrayField node={node} path={path} />;
152
+ }
153
+
154
+ const contract = fieldEditors[node.type];
155
+ const EditComponent = contract?.Edit;
156
+ if (!EditComponent) return null;
157
+
158
+ // Errors are computed on every keystroke (so Save can gate on
159
+ // `isValid`), but only DISPLAYED once the field has been touched
160
+ // (blurred) or a submit attempt swept the tree — otherwise a
161
+ // freshly-added empty row would show "required" before the user
162
+ // has typed anything.
163
+ const showError = node.touched ? node.error : undefined;
164
+
165
+ return (
166
+ <div
167
+ data-pyric-field-entry
168
+ data-field-name={node.key ?? undefined}
169
+ data-pyric-error={showError ? '' : undefined}
170
+ onBlur={() => editor.touch(node.id)}
171
+ >
172
+ <FieldChrome node={node} path={path} />
173
+ <EditComponent
174
+ value={node.value as never}
175
+ path={path}
176
+ error={showError}
177
+ onChange={(v: unknown) => editor.setValue(node.id, v)}
178
+ />
179
+ </div>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Per-field chrome: the field's key input (for map children), the
185
+ * type selector, and the remove button. Inline at every non-root
186
+ * node so consumers can hide individual pieces via `data-*` CSS.
187
+ */
188
+ function FieldChrome({ node, path }: { node: FieldNode; path: string }) {
189
+ const { editor } = useEditorContext();
190
+ const parent = node.parentId != null ? editor.tree.nodes[node.parentId] : null;
191
+ const inMap = parent?.type === 'map';
192
+ const isRoot = node.id === editor.tree.rootId;
193
+
194
+ if (isRoot) return null;
195
+
196
+ return (
197
+ <div data-pyric-field-chrome data-pyric-field-path={path}>
198
+ {inMap ? (
199
+ <input
200
+ type="text"
201
+ value={node.key ?? ''}
202
+ onChange={(e) => editor.setKey(node.id, e.target.value)}
203
+ aria-label="Field name"
204
+ data-pyric-field-key-input
205
+ aria-invalid={node.touched && node.error ? 'true' : undefined}
206
+ />
207
+ ) : null}
208
+ <TypeSelect node={node} />
209
+ <button
210
+ type="button"
211
+ onClick={() => editor.remove(node.id)}
212
+ data-pyric-remove
213
+ aria-label="Remove field"
214
+ >
215
+ ×
216
+ </button>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ const ALL_TYPES: FieldType[] = [
222
+ 'string',
223
+ 'number',
224
+ 'boolean',
225
+ 'null',
226
+ 'timestamp',
227
+ 'geopoint',
228
+ 'reference',
229
+ 'bytes',
230
+ 'vector',
231
+ 'map',
232
+ 'array',
233
+ ];
234
+
235
+ function TypeSelect({ node }: { node: FieldNode }) {
236
+ const { editor } = useEditorContext();
237
+ const parent = node.parentId != null ? editor.tree.nodes[node.parentId] : null;
238
+ // Firestore disallows nested arrays — filter that option out
239
+ // when the parent is an array.
240
+ const options = ALL_TYPES.filter((t) => !(parent?.type === 'array' && t === 'array'));
241
+
242
+ return (
243
+ <select
244
+ value={node.type}
245
+ onChange={(e) => editor.setType(node.id, e.target.value as FieldType)}
246
+ aria-label="Field type"
247
+ data-pyric-field-type-select
248
+ >
249
+ {options.map((t) => (
250
+ <option key={t} value={t}>
251
+ {t}
252
+ </option>
253
+ ))}
254
+ </select>
255
+ );
256
+ }
257
+
258
+ interface ContainerFieldProps {
259
+ node: FieldNode;
260
+ path: string;
261
+ }
262
+
263
+ function MapField({ node, path }: ContainerFieldProps) {
264
+ const { editor } = useEditorContext();
265
+ // Stable insertion order — same rationale as `DocumentEditorFields`.
266
+ const children = editor.tree.childIds[node.id] ?? [];
267
+
268
+ return (
269
+ <div
270
+ data-pyric-field-entry
271
+ data-pyric-field-type="map"
272
+ data-field-name={node.key ?? undefined}
273
+ >
274
+ {node.id !== editor.tree.rootId ? <FieldChrome node={node} path={path} /> : null}
275
+ <div data-pyric-map-children>
276
+ {children.map((childId) => {
277
+ const child = editor.tree.nodes[childId];
278
+ if (!child) return null;
279
+ const childPath = path ? `${path}.${child.key ?? ''}` : child.key ?? '';
280
+ return <Field key={childId} nodeId={childId} path={childPath} />;
281
+ })}
282
+ </div>
283
+ <AddMapEntry parentId={node.id} />
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function ArrayField({ node, path }: ContainerFieldProps) {
289
+ const { editor } = useEditorContext();
290
+ const children = editor.tree.childIds[node.id] ?? [];
291
+
292
+ return (
293
+ <div
294
+ data-pyric-field-entry
295
+ data-pyric-field-type="array"
296
+ data-field-name={node.key ?? undefined}
297
+ >
298
+ <FieldChrome node={node} path={path} />
299
+ <ol data-pyric-array-children>
300
+ {children.map((childId, idx) => {
301
+ const childPath = path ? `${path}[${idx}]` : `[${idx}]`;
302
+ return (
303
+ <li key={childId} data-field-index={String(idx)}>
304
+ <Field nodeId={childId} path={childPath} />
305
+ </li>
306
+ );
307
+ })}
308
+ </ol>
309
+ <AddArrayEntry parentId={node.id} />
310
+ </div>
311
+ );
312
+ }
313
+
314
+ function AddMapEntry({ parentId }: { parentId: string }) {
315
+ const { editor } = useEditorContext();
316
+ return (
317
+ <button
318
+ type="button"
319
+ data-pyric-add-map-entry
320
+ onClick={() => editor.addMapEntry(parentId, '', 'string')}
321
+ >
322
+ + Add field
323
+ </button>
324
+ );
325
+ }
326
+
327
+ function AddArrayEntry({ parentId }: { parentId: string }) {
328
+ const { editor } = useEditorContext();
329
+ return (
330
+ <button
331
+ type="button"
332
+ data-pyric-add-array-entry
333
+ onClick={() => editor.addArrayEntry(parentId, 'string')}
334
+ >
335
+ + Add item
336
+ </button>
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Compound root export. Consumers pattern-match via dot access:
342
+ *
343
+ * <DocumentEditor.Root initial={data} onChange={…}>
344
+ * <DocumentEditor.Fields />
345
+ * </DocumentEditor.Root>
346
+ */
347
+ export const DocumentEditor = {
348
+ Root: DocumentEditorRoot,
349
+ Fields: DocumentEditorFields,
350
+ };
@@ -0,0 +1,217 @@
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import type { DocumentReference, QueryDocumentSnapshot } from 'pyric/firestore';
3
+ import { VirtualList } from '../../primitives/VirtualList.js';
4
+ import { useUpdateHighlights } from '../../primitives/hooks/useUpdateHighlights.js';
5
+ import { firestoreValuesEqual } from '../valueEquality.js';
6
+
7
+ /**
8
+ * Both backends carry `.ref` on snapshots at runtime, but the
9
+ * `pyric/firestore` modular interface omits it. Pull it through
10
+ * a structural cast — safe because the property is guaranteed by
11
+ * the underlying SDKs.
12
+ */
13
+ function snapshotRef(snap: QueryDocumentSnapshot): DocumentReference {
14
+ return (snap as unknown as { ref: DocumentReference }).ref;
15
+ }
16
+
17
+ export interface DocumentListProps {
18
+ documents: QueryDocumentSnapshot[];
19
+ isLoading?: boolean;
20
+ error?: Error;
21
+ hasMore?: boolean;
22
+ onSelect?: (ref: DocumentReference) => void;
23
+ /** Fired when the user requests another page. Wire to the hook's
24
+ * `loadMore`. The component will not render a Load More button
25
+ * when this is undefined. */
26
+ onLoadMore?: () => void;
27
+ /**
28
+ * Optional renderer for the row label. Default renders the doc id.
29
+ * Override to show a field value alongside, an icon, etc.
30
+ */
31
+ renderLabel?: (doc: QueryDocumentSnapshot) => ReactNode;
32
+ /**
33
+ * Optional per-row action(s), rendered as a SIBLING of the row's
34
+ * select button inside the entry (not nested in it — so the action
35
+ * carries its own click handling without an invalid button-in-button).
36
+ * Used for row-level affordances like delete.
37
+ */
38
+ renderRowAction?: (doc: QueryDocumentSnapshot) => ReactNode;
39
+ emptyState?: ReactNode;
40
+ className?: string;
41
+ /** Stable collection/query identity used to reset update highlighting when
42
+ * the rendered list changes scope. */
43
+ updateScope?: string;
44
+ /**
45
+ * Above this row count, the list switches to virtualization via
46
+ * `<VirtualList>`. Default 100. Set to `Infinity` to disable
47
+ * (e.g. when measuring layout shifts is more important than
48
+ * scroll perf).
49
+ */
50
+ virtualizeThreshold?: number;
51
+ /**
52
+ * Estimated row height when virtualizing. Default 36 — matches
53
+ * a single-line text button at 13px font + ~10px padding. Pass
54
+ * a function for variable sizing (TanStack measures the actual
55
+ * height via ResizeObserver after the first paint anyway).
56
+ */
57
+ rowHeight?: number | ((index: number) => number);
58
+ /**
59
+ * Pixel height the virtualized scroll container fills. Only
60
+ * applies when `documents.length > virtualizeThreshold`. Default
61
+ * `'60vh'` — consumers usually constrain via their layout.
62
+ */
63
+ virtualizedHeight?: number | string;
64
+ }
65
+
66
+ /**
67
+ * Headless document list. Below `virtualizeThreshold`, renders a
68
+ * plain `<ul>`; above it, switches to TanStack-Virtual via
69
+ * `<VirtualList>` so 10k-doc collections don't bloat the DOM.
70
+ *
71
+ * The hook owns pagination state; this component just renders. The
72
+ * Load More button only renders when `hasMore` is true AND
73
+ * `onLoadMore` is provided. Consumers wanting infinite scroll
74
+ * trigger `onLoadMore` from a sentinel `IntersectionObserver` in
75
+ * their own code — the component doesn't bake that policy in.
76
+ */
77
+ export function DocumentList({
78
+ documents,
79
+ isLoading,
80
+ error,
81
+ hasMore,
82
+ onSelect,
83
+ onLoadMore,
84
+ renderLabel,
85
+ renderRowAction,
86
+ emptyState,
87
+ className,
88
+ updateScope = 'document-list',
89
+ virtualizeThreshold = 100,
90
+ rowHeight = 36,
91
+ virtualizedHeight = '60vh',
92
+ }: DocumentListProps) {
93
+ const updateEntries = useMemo(
94
+ () =>
95
+ new Map(
96
+ documents.map((snapshot) => [snapshotRef(snapshot).path, snapshot.data()] as const),
97
+ ),
98
+ [documents],
99
+ );
100
+ const updates = useUpdateHighlights({
101
+ scope: updateScope,
102
+ entries: updateEntries,
103
+ equals: firestoreValuesEqual,
104
+ ready: !isLoading,
105
+ });
106
+
107
+ if (error) {
108
+ return (
109
+ <div
110
+ className={className}
111
+ data-pyric-ui="document-list"
112
+ data-pyric-error=""
113
+ role="alert"
114
+ >
115
+ {error.message}
116
+ </div>
117
+ );
118
+ }
119
+ if (isLoading && documents.length === 0) {
120
+ return (
121
+ <div
122
+ className={className}
123
+ data-pyric-ui="document-list"
124
+ data-pyric-loading=""
125
+ />
126
+ );
127
+ }
128
+ if (documents.length === 0) {
129
+ return (
130
+ <div className={className} data-pyric-ui="document-list" data-pyric-empty="">
131
+ {emptyState}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ const virtualized = documents.length > virtualizeThreshold;
137
+
138
+ return (
139
+ <div
140
+ className={className}
141
+ data-pyric-ui="document-list"
142
+ data-pyric-virtualized={virtualized ? '' : undefined}
143
+ >
144
+ {virtualized ? (
145
+ <VirtualList
146
+ items={documents}
147
+ estimateSize={rowHeight}
148
+ height={virtualizedHeight}
149
+ getItemKey={(doc) => snapshotRef(doc).path}
150
+ renderItem={(doc) => {
151
+ const update = updates.get(snapshotRef(doc).path);
152
+ return (
153
+ <div
154
+ data-pyric-document-entry
155
+ data-pyric-document-id={doc.id}
156
+ data-pyric-update={update?.kind}
157
+ data-pyric-update-cycle={update?.cycle}
158
+ >
159
+ <button
160
+ type="button"
161
+ onClick={() => onSelect?.(snapshotRef(doc))}
162
+ data-pyric-document-select
163
+ data-pyric-update={update?.kind}
164
+ data-pyric-update-cycle={update?.cycle}
165
+ >
166
+ {renderLabel ? renderLabel(doc) : doc.id}
167
+ </button>
168
+ {renderRowAction ? (
169
+ <span data-pyric-document-action>{renderRowAction(doc)}</span>
170
+ ) : null}
171
+ </div>
172
+ );
173
+ }}
174
+ />
175
+ ) : (
176
+ <ul data-pyric-document-list-items>
177
+ {documents.map((doc) => {
178
+ const ref = snapshotRef(doc);
179
+ const update = updates.get(ref.path);
180
+ return (
181
+ <li
182
+ key={ref.path}
183
+ data-pyric-document-entry
184
+ data-pyric-document-id={doc.id}
185
+ data-pyric-update={update?.kind}
186
+ data-pyric-update-cycle={update?.cycle}
187
+ >
188
+ <button
189
+ type="button"
190
+ onClick={() => onSelect?.(ref)}
191
+ data-pyric-document-select
192
+ data-pyric-update={update?.kind}
193
+ data-pyric-update-cycle={update?.cycle}
194
+ >
195
+ {renderLabel ? renderLabel(doc) : doc.id}
196
+ </button>
197
+ {renderRowAction ? (
198
+ <span data-pyric-document-action>{renderRowAction(doc)}</span>
199
+ ) : null}
200
+ </li>
201
+ );
202
+ })}
203
+ </ul>
204
+ )}
205
+ {hasMore && onLoadMore ? (
206
+ <button
207
+ type="button"
208
+ onClick={onLoadMore}
209
+ data-pyric-load-more
210
+ disabled={isLoading}
211
+ >
212
+ Load more
213
+ </button>
214
+ ) : null}
215
+ </div>
216
+ );
217
+ }