@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,151 @@
1
+ import { useState } from 'react';
2
+
3
+ export interface JsonViewProps {
4
+ /** Any JSON-serializable value. */
5
+ value: unknown;
6
+ /**
7
+ * Depth at and below which object/array nodes start collapsed.
8
+ * `0` collapses the root, `1` collapses everything under the root,
9
+ * `Infinity` (default) leaves everything expanded.
10
+ */
11
+ defaultCollapsedDepth?: number;
12
+ /** Forwarded to the root container. */
13
+ className?: string;
14
+ }
15
+
16
+ type JsonKind =
17
+ | 'object'
18
+ | 'array'
19
+ | 'string'
20
+ | 'number'
21
+ | 'boolean'
22
+ | 'null';
23
+
24
+ function kindOf(value: unknown): JsonKind {
25
+ if (value === null || value === undefined) return 'null';
26
+ if (Array.isArray(value)) return 'array';
27
+ const t = typeof value;
28
+ if (t === 'object') return 'object';
29
+ if (t === 'number') return 'number';
30
+ if (t === 'boolean') return 'boolean';
31
+ return 'string';
32
+ }
33
+
34
+ /**
35
+ * Headless collapsible JSON tree. One structural step above a
36
+ * `<pre>` dump: object/array nodes are independently expandable, but
37
+ * there's no editing and no syntax-color theme — just `data-pyric-*`
38
+ * hooks for the consumer to style.
39
+ *
40
+ * Styling hooks:
41
+ * - `[data-pyric-ui="json-view"]` — root
42
+ * - `[data-pyric-json-node]` — every node, with `data-pyric-json-type`
43
+ * - `[data-pyric-json-toggle]` — the expand/collapse button (containers only)
44
+ * - `[data-pyric-json-node][data-pyric-collapsed]` — a collapsed container
45
+ * - `[data-pyric-json-key]` — the key/index label
46
+ * - `[data-pyric-json-value]` — a primitive value
47
+ * - `[data-pyric-json-summary]` — the `{…}` / `[…]` placeholder when collapsed
48
+ */
49
+ export function JsonView({
50
+ value,
51
+ defaultCollapsedDepth = Infinity,
52
+ className,
53
+ }: JsonViewProps) {
54
+ return (
55
+ <div data-pyric-ui="json-view" className={className}>
56
+ <JsonNode
57
+ value={value}
58
+ depth={0}
59
+ defaultCollapsedDepth={defaultCollapsedDepth}
60
+ />
61
+ </div>
62
+ );
63
+ }
64
+
65
+ interface JsonNodeProps {
66
+ value: unknown;
67
+ /** Object key or array index label — absent on the root. */
68
+ nodeKey?: string;
69
+ depth: number;
70
+ defaultCollapsedDepth: number;
71
+ }
72
+
73
+ function JsonNode({
74
+ value,
75
+ nodeKey,
76
+ depth,
77
+ defaultCollapsedDepth,
78
+ }: JsonNodeProps) {
79
+ const kind = kindOf(value);
80
+ const isContainer = kind === 'object' || kind === 'array';
81
+ const [collapsed, setCollapsed] = useState(
82
+ isContainer && depth >= defaultCollapsedDepth,
83
+ );
84
+
85
+ if (!isContainer) {
86
+ return (
87
+ <div data-pyric-json-node="" data-pyric-json-type={kind}>
88
+ {nodeKey !== undefined ? (
89
+ <span data-pyric-json-key="">{nodeKey}</span>
90
+ ) : null}
91
+ <span data-pyric-json-value="">{formatPrimitive(value, kind)}</span>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ const entries: Array<[string, unknown]> = Array.isArray(value)
97
+ ? value.map((v, i) => [String(i), v])
98
+ : Object.entries(value as Record<string, unknown>);
99
+ const open = kind === 'array' ? '[' : '{';
100
+ const close = kind === 'array' ? ']' : '}';
101
+
102
+ return (
103
+ <div
104
+ data-pyric-json-node=""
105
+ data-pyric-json-type={kind}
106
+ data-pyric-collapsed={collapsed ? '' : undefined}
107
+ >
108
+ <button
109
+ type="button"
110
+ data-pyric-json-toggle=""
111
+ aria-expanded={!collapsed}
112
+ onClick={() => setCollapsed((c) => !c)}
113
+ >
114
+ {nodeKey !== undefined ? (
115
+ <span data-pyric-json-key="">{nodeKey}</span>
116
+ ) : null}
117
+ {collapsed ? (
118
+ <span data-pyric-json-summary="">
119
+ {open}
120
+ {entries.length}
121
+ {close}
122
+ </span>
123
+ ) : (
124
+ <span data-pyric-json-bracket="">{open}</span>
125
+ )}
126
+ </button>
127
+ {collapsed ? null : (
128
+ <>
129
+ <div data-pyric-json-children="">
130
+ {entries.map(([k, v]) => (
131
+ <JsonNode
132
+ key={k}
133
+ value={v}
134
+ nodeKey={k}
135
+ depth={depth + 1}
136
+ defaultCollapsedDepth={defaultCollapsedDepth}
137
+ />
138
+ ))}
139
+ </div>
140
+ <span data-pyric-json-bracket="">{close}</span>
141
+ </>
142
+ )}
143
+ </div>
144
+ );
145
+ }
146
+
147
+ function formatPrimitive(value: unknown, kind: JsonKind): string {
148
+ if (kind === 'null') return 'null';
149
+ if (kind === 'string') return JSON.stringify(value);
150
+ return String(value);
151
+ }
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface SegmentedOption<T extends string> {
4
+ /** The value committed via `onChange` when this segment is picked. */
5
+ value: T;
6
+ /** Visible label. */
7
+ label: ReactNode;
8
+ /**
9
+ * Freeform tone surfaced as `data-pyric-segment-tone` — e.g. `ok`
10
+ * / `error` so the consumer can tint the active label. The library
11
+ * doesn't enumerate tones.
12
+ */
13
+ tone?: string;
14
+ }
15
+
16
+ export interface SegmentedControlProps<T extends string> {
17
+ /** The selectable segments, rendered left-to-right. */
18
+ options: ReadonlyArray<SegmentedOption<T>>;
19
+ /** The currently-selected value. */
20
+ value: T;
21
+ /** Fired with the new value when a segment is clicked. */
22
+ onChange: (value: T) => void;
23
+ /** Forwarded to the container. */
24
+ className?: string;
25
+ /** Accessible label for the radiogroup. */
26
+ ariaLabel?: string;
27
+ }
28
+
29
+ /**
30
+ * Headless segmented control — a single-select group of pill
31
+ * buttons that reads as one widget. Wired as an ARIA radiogroup.
32
+ *
33
+ * Ships no visual styling. Consumers style via:
34
+ * - `[data-pyric-ui="segmented-control"]` — the container
35
+ * - `[data-pyric-segment]` — each option button
36
+ * - `[data-pyric-segment][data-pyric-active]` — the selected one
37
+ * - `[data-pyric-segment-tone="…"]` — tone-tinted options
38
+ */
39
+ export function SegmentedControl<T extends string>({
40
+ options,
41
+ value,
42
+ onChange,
43
+ className,
44
+ ariaLabel,
45
+ }: SegmentedControlProps<T>) {
46
+ return (
47
+ <div
48
+ data-pyric-ui="segmented-control"
49
+ className={className}
50
+ role="radiogroup"
51
+ aria-label={ariaLabel}
52
+ >
53
+ {options.map((opt) => {
54
+ const active = opt.value === value;
55
+ return (
56
+ <button
57
+ key={opt.value}
58
+ type="button"
59
+ role="radio"
60
+ aria-checked={active}
61
+ onClick={() => onChange(opt.value)}
62
+ data-pyric-segment=""
63
+ data-pyric-active={active ? '' : undefined}
64
+ data-pyric-segment-tone={opt.tone}
65
+ >
66
+ {opt.label}
67
+ </button>
68
+ );
69
+ })}
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,161 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { createPortal } from 'react-dom';
11
+
12
+ export type ToastKind = 'info' | 'success' | 'warning' | 'error';
13
+
14
+ export interface ToastInput {
15
+ title: string;
16
+ body?: ReactNode;
17
+ kind?: ToastKind;
18
+ /** Auto-dismiss after this many ms. `0` makes the toast sticky;
19
+ * default is 5000. */
20
+ duration?: number;
21
+ }
22
+
23
+ export interface ToastRecord extends ToastInput {
24
+ id: string;
25
+ }
26
+
27
+ interface ToastContextValue {
28
+ toast: (input: ToastInput) => string;
29
+ dismiss: (id: string) => void;
30
+ toasts: ReadonlyArray<ToastRecord>;
31
+ }
32
+
33
+ const Ctx = createContext<ToastContextValue | null>(null);
34
+
35
+ /**
36
+ * Imperative toast hook. Returns `{ toast, dismiss, toasts }`.
37
+ *
38
+ * const { toast } = useToast();
39
+ * toast({ title: 'Saved.', kind: 'success' });
40
+ *
41
+ * Requires a `<ToastProvider>` ancestor.
42
+ */
43
+ export function useToast(): ToastContextValue {
44
+ const ctx = useContext(Ctx);
45
+ if (!ctx) {
46
+ throw new Error('useToast: missing <ToastProvider> ancestor');
47
+ }
48
+ return ctx;
49
+ }
50
+
51
+ export interface ToastProviderProps {
52
+ children: ReactNode;
53
+ /** Default auto-dismiss in ms. Per-toast `duration` overrides.
54
+ * Default 5000; pass `0` to make sticky-by-default. */
55
+ defaultDuration?: number;
56
+ /** Forwarded to the rendered container. */
57
+ className?: string;
58
+ /** Region label for assistive tech. Defaults to "Notifications". */
59
+ regionLabel?: string;
60
+ }
61
+
62
+ /**
63
+ * Toast queue host. Mounts a single live region into `document.body`
64
+ * via portal and exposes the imperative API via context. Scoped —
65
+ * a subtree can host its own provider for isolated queues if needed.
66
+ *
67
+ * Headless: every node carries structural `data-*` attributes, no
68
+ * shipped CSS. Auto-dismiss timers are kept per-toast.
69
+ */
70
+ export function ToastProvider({
71
+ children,
72
+ defaultDuration = 5000,
73
+ className,
74
+ regionLabel = 'Notifications',
75
+ }: ToastProviderProps) {
76
+ const [toasts, setToasts] = useState<ToastRecord[]>([]);
77
+
78
+ const dismiss = useCallback((id: string) => {
79
+ setToasts((prev) => prev.filter((t) => t.id !== id));
80
+ }, []);
81
+
82
+ const toast = useCallback<ToastContextValue['toast']>(
83
+ (input) => {
84
+ const id = crypto.randomUUID();
85
+ const duration = input.duration ?? defaultDuration;
86
+ setToasts((prev) => [...prev, { id, ...input }]);
87
+ if (duration > 0) {
88
+ // Schedule auto-dismiss. The timer fires `dismiss(id)`
89
+ // which is idempotent (filter on a missing id is a no-op).
90
+ window.setTimeout(() => {
91
+ setToasts((prev) => prev.filter((t) => t.id !== id));
92
+ }, duration);
93
+ }
94
+ return id;
95
+ },
96
+ [defaultDuration],
97
+ );
98
+
99
+ const value = useMemo<ToastContextValue>(
100
+ () => ({ toast, dismiss, toasts }),
101
+ [toast, dismiss, toasts],
102
+ );
103
+
104
+ return (
105
+ <Ctx.Provider value={value}>
106
+ {children}
107
+ <ToastRegion
108
+ toasts={toasts}
109
+ dismiss={dismiss}
110
+ className={className}
111
+ regionLabel={regionLabel}
112
+ />
113
+ </Ctx.Provider>
114
+ );
115
+ }
116
+
117
+ interface ToastRegionProps {
118
+ toasts: ReadonlyArray<ToastRecord>;
119
+ dismiss: (id: string) => void;
120
+ className?: string;
121
+ regionLabel: string;
122
+ }
123
+
124
+ function ToastRegion({ toasts, dismiss, className, regionLabel }: ToastRegionProps) {
125
+ // SSR guard. Astro `client:only` consumers won't see this branch
126
+ // hit, but it keeps the import safe in mixed environments.
127
+ const [mounted, setMounted] = useState(false);
128
+ useEffect(() => setMounted(true), []);
129
+ if (!mounted) return null;
130
+ if (typeof document === 'undefined') return null;
131
+
132
+ return createPortal(
133
+ <ol
134
+ aria-label={regionLabel}
135
+ aria-live="polite"
136
+ data-pyric-ui="toast-region"
137
+ className={className}
138
+ >
139
+ {toasts.map((t) => (
140
+ <li
141
+ key={t.id}
142
+ data-pyric-toast
143
+ data-pyric-toast-kind={t.kind ?? 'info'}
144
+ role={t.kind === 'error' ? 'alert' : 'status'}
145
+ >
146
+ <div data-pyric-toast-title>{t.title}</div>
147
+ {t.body ? <div data-pyric-toast-body>{t.body}</div> : null}
148
+ <button
149
+ type="button"
150
+ data-pyric-toast-dismiss
151
+ aria-label="Dismiss"
152
+ onClick={() => dismiss(t.id)}
153
+ >
154
+ ×
155
+ </button>
156
+ </li>
157
+ ))}
158
+ </ol>,
159
+ document.body,
160
+ );
161
+ }
@@ -0,0 +1,104 @@
1
+ import { useRef, type ReactNode } from 'react';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+
4
+ export interface VirtualListProps<T> {
5
+ items: ReadonlyArray<T>;
6
+ /** Estimated row height in px. `useVirtualizer` measures actual
7
+ * rendered heights via ResizeObserver — this is just the guess
8
+ * used before measurement. Pass a function for variable sizing. */
9
+ estimateSize: number | ((index: number) => number);
10
+ /** Render one row. The library handles positioning + key-by-index. */
11
+ renderItem: (item: T, index: number) => ReactNode;
12
+ /**
13
+ * Number of off-screen rows to render on each side. TanStack
14
+ * default is 5; bump for smoother fast scrolling.
15
+ */
16
+ overscan?: number;
17
+ /** Forwarded to the scroll container. */
18
+ className?: string;
19
+ /** Height the scroll container fills. Default `100%` — the
20
+ * consumer's parent typically constrains height. */
21
+ height?: number | string;
22
+ /** Optional `key` resolver. Defaults to the index. Use when rows
23
+ * reorder so React can preserve component state across moves. */
24
+ getItemKey?: (item: T, index: number) => string | number;
25
+ }
26
+
27
+ /**
28
+ * Thin wrapper around `@tanstack/react-virtual`. Renders a
29
+ * scrollable container with absolutely-positioned rows, drawing
30
+ * only the rows currently in view (plus `overscan` neighbors).
31
+ *
32
+ * Headless — no shipped CSS beyond what's structurally required
33
+ * to position rows (the inner spacer's `height` + each row's
34
+ * `position: absolute; top: …px`). Consumers style via the
35
+ * `className` prop and standard CSS targeting `[data-pyric-ui=
36
+ * "virtual-list"]` on the scroll container and
37
+ * `[data-pyric-virtual-row]` on each row.
38
+ */
39
+ export function VirtualList<T>({
40
+ items,
41
+ estimateSize,
42
+ renderItem,
43
+ overscan = 5,
44
+ className,
45
+ height = '100%',
46
+ getItemKey,
47
+ }: VirtualListProps<T>) {
48
+ const parentRef = useRef<HTMLDivElement | null>(null);
49
+ const estimator =
50
+ typeof estimateSize === 'function' ? estimateSize : () => estimateSize;
51
+
52
+ const virtualizer = useVirtualizer({
53
+ count: items.length,
54
+ getScrollElement: () => parentRef.current,
55
+ estimateSize: estimator,
56
+ overscan,
57
+ });
58
+
59
+ return (
60
+ <div
61
+ ref={parentRef}
62
+ className={className}
63
+ data-pyric-ui="virtual-list"
64
+ style={{
65
+ height,
66
+ overflowY: 'auto',
67
+ // `contain` keeps the browser from spending paint cost on
68
+ // off-screen rows even before the virtualizer culls them.
69
+ contain: 'strict',
70
+ }}
71
+ >
72
+ <div
73
+ data-pyric-virtual-inner
74
+ style={{
75
+ height: `${virtualizer.getTotalSize()}px`,
76
+ width: '100%',
77
+ position: 'relative',
78
+ }}
79
+ >
80
+ {virtualizer.getVirtualItems().map((vi) => {
81
+ const item = items[vi.index];
82
+ const key = getItemKey ? getItemKey(item, vi.index) : vi.key;
83
+ return (
84
+ <div
85
+ key={key}
86
+ data-pyric-virtual-row
87
+ data-index={vi.index}
88
+ ref={virtualizer.measureElement}
89
+ style={{
90
+ position: 'absolute',
91
+ top: 0,
92
+ left: 0,
93
+ width: '100%',
94
+ transform: `translateY(${vi.start}px)`,
95
+ }}
96
+ >
97
+ {renderItem(item, vi.index)}
98
+ </div>
99
+ );
100
+ })}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useRef, useState, type RefObject } from 'react';
2
+
3
+ export type ContainerSize = 'narrow' | 'medium' | 'wide';
4
+
5
+ export interface UseContainerSizeOptions {
6
+ /** Width (px) below which the bucket is `'narrow'`. Default 480. */
7
+ narrowBreakpoint?: number;
8
+ /** Width (px) below which the bucket is `'medium'` (and above
9
+ * which it is `'wide'`). Default 768. */
10
+ mediumBreakpoint?: number;
11
+ }
12
+
13
+ /**
14
+ * Container-query helper. Returns the current size bucket
15
+ * (`'narrow' | 'medium' | 'wide'`) of the element pointed at by
16
+ * the returned ref. Re-fires on resize via `ResizeObserver`.
17
+ *
18
+ * const { ref, size } = useContainerSize();
19
+ * return <div ref={ref} data-size={size}>…</div>;
20
+ *
21
+ * Headless: the consumer styles via `[data-size='narrow']`
22
+ * selectors. The library's feature components use this hook to
23
+ * stamp their roots with `data-size`, which is the policy the
24
+ * survey's modern-CSS subsection landed on (no viewport media
25
+ * queries, no library-side breakpoints — push the threshold
26
+ * decision to the consumer via data attributes).
27
+ */
28
+ export function useContainerSize<T extends HTMLElement = HTMLDivElement>(
29
+ options: UseContainerSizeOptions = {},
30
+ ): { ref: RefObject<T | null>; size: ContainerSize } {
31
+ const ref = useRef<T | null>(null);
32
+ const narrow = options.narrowBreakpoint ?? 480;
33
+ const medium = options.mediumBreakpoint ?? 768;
34
+ const [size, setSize] = useState<ContainerSize>('wide');
35
+
36
+ useEffect(() => {
37
+ const el = ref.current;
38
+ if (!el) return;
39
+ if (typeof ResizeObserver === 'undefined') return;
40
+ const obs = new ResizeObserver((entries) => {
41
+ const entry = entries[0];
42
+ if (!entry) return;
43
+ const w = entry.contentRect.width;
44
+ if (w < narrow) setSize('narrow');
45
+ else if (w < medium) setSize('medium');
46
+ else setSize('wide');
47
+ });
48
+ obs.observe(el);
49
+ return () => obs.disconnect();
50
+ }, [narrow, medium]);
51
+
52
+ return { ref, size };
53
+ }
@@ -0,0 +1,109 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ export type UpdateHighlightKind = 'added' | 'modified';
4
+
5
+ export interface UpdateHighlight {
6
+ kind: UpdateHighlightKind;
7
+ cycle: 0 | 1;
8
+ }
9
+
10
+ export interface UseUpdateHighlightsOptions<T> {
11
+ scope: string;
12
+ entries: ReadonlyMap<string, T>;
13
+ equals?: (previous: T, next: T) => boolean;
14
+ durationMs?: number;
15
+ ready?: boolean;
16
+ }
17
+
18
+ const EMPTY_HIGHLIGHTS: ReadonlyMap<string, UpdateHighlight> = new Map();
19
+
20
+ /**
21
+ * Tracks transient additions and modifications between keyed snapshots.
22
+ * The first ready snapshot in each scope is a silent baseline.
23
+ */
24
+ export function useUpdateHighlights<T>({
25
+ scope,
26
+ entries,
27
+ equals = Object.is,
28
+ durationMs = 1_200,
29
+ ready = true,
30
+ }: UseUpdateHighlightsOptions<T>): ReadonlyMap<string, UpdateHighlight> {
31
+ const baselineRef = useRef<{ scope: string; entries: ReadonlyMap<string, T> } | null>(null);
32
+ const cyclesRef = useRef(new Map<string, 0 | 1>());
33
+ const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
34
+ const [highlights, setHighlights] = useState<ReadonlyMap<string, UpdateHighlight>>(
35
+ EMPTY_HIGHLIGHTS,
36
+ );
37
+
38
+ useEffect(() => {
39
+ const baseline = baselineRef.current;
40
+ if (!ready) {
41
+ if (baseline && baseline.scope !== scope) {
42
+ baselineRef.current = null;
43
+ for (const timer of timersRef.current.values()) clearTimeout(timer);
44
+ timersRef.current.clear();
45
+ cyclesRef.current.clear();
46
+ setHighlights(EMPTY_HIGHLIGHTS);
47
+ }
48
+ return;
49
+ }
50
+ if (!baseline || baseline.scope !== scope) {
51
+ baselineRef.current = { scope, entries };
52
+ for (const timer of timersRef.current.values()) clearTimeout(timer);
53
+ timersRef.current.clear();
54
+ cyclesRef.current.clear();
55
+ setHighlights(EMPTY_HIGHLIGHTS);
56
+ return;
57
+ }
58
+
59
+ const changed = new Map<string, UpdateHighlight>();
60
+ for (const [key, value] of entries) {
61
+ const hadValue = baseline.entries.has(key);
62
+ const previous = baseline.entries.get(key);
63
+ if (hadValue && equals(previous as T, value)) continue;
64
+ const cycle: 0 | 1 = cyclesRef.current.get(key) === 0 ? 1 : 0;
65
+ cyclesRef.current.set(key, cycle);
66
+ changed.set(key, { kind: hadValue ? 'modified' : 'added', cycle });
67
+ }
68
+ baselineRef.current = { scope, entries };
69
+
70
+ setHighlights((current) => {
71
+ const next = new Map(current);
72
+ let didChange = changed.size > 0;
73
+ for (const key of current.keys()) {
74
+ if (!entries.has(key)) {
75
+ next.delete(key);
76
+ didChange = true;
77
+ }
78
+ }
79
+ for (const [key, marker] of changed) next.set(key, marker);
80
+ if (!didChange) return current;
81
+ return next.size === 0 ? EMPTY_HIGHLIGHTS : next;
82
+ });
83
+
84
+ for (const [key, marker] of changed) {
85
+ const priorTimer = timersRef.current.get(key);
86
+ if (priorTimer) clearTimeout(priorTimer);
87
+ const timer = setTimeout(() => {
88
+ timersRef.current.delete(key);
89
+ setHighlights((current) => {
90
+ if (current.get(key)?.cycle !== marker.cycle) return current;
91
+ const next = new Map(current);
92
+ next.delete(key);
93
+ return next.size === 0 ? EMPTY_HIGHLIGHTS : next;
94
+ });
95
+ }, durationMs);
96
+ timersRef.current.set(key, timer);
97
+ }
98
+ }, [durationMs, entries, equals, ready, scope]);
99
+
100
+ useEffect(
101
+ () => () => {
102
+ for (const timer of timersRef.current.values()) clearTimeout(timer);
103
+ timersRef.current.clear();
104
+ },
105
+ [],
106
+ );
107
+
108
+ return highlights;
109
+ }
@@ -0,0 +1,39 @@
1
+ export { CopyButton, type CopyButtonProps } from './CopyButton.js';
2
+ export { Badge, type BadgeProps } from './Badge.js';
3
+ export {
4
+ JsonView,
5
+ type JsonViewProps,
6
+ } from './JsonView.js';
7
+ export {
8
+ SegmentedControl,
9
+ type SegmentedControlProps,
10
+ type SegmentedOption,
11
+ } from './SegmentedControl.js';
12
+ export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog.js';
13
+ export {
14
+ ConfirmProvider,
15
+ useConfirm,
16
+ type ConfirmOptions,
17
+ type ConfirmFn,
18
+ type ConfirmProviderProps,
19
+ } from './useConfirm.js';
20
+ export {
21
+ ToastProvider,
22
+ useToast,
23
+ type ToastInput,
24
+ type ToastRecord,
25
+ type ToastKind,
26
+ type ToastProviderProps,
27
+ } from './Toast.js';
28
+ export { VirtualList, type VirtualListProps } from './VirtualList.js';
29
+ export {
30
+ useContainerSize,
31
+ type ContainerSize,
32
+ type UseContainerSizeOptions,
33
+ } from './hooks/useContainerSize.js';
34
+ export {
35
+ useUpdateHighlights,
36
+ type UpdateHighlight,
37
+ type UpdateHighlightKind,
38
+ type UseUpdateHighlightsOptions,
39
+ } from './hooks/useUpdateHighlights.js';