@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.
- package/package.json +5 -4
- package/src/agents/ContextWindowUsage.tsx +514 -0
- package/src/agents/EmptyState.tsx +27 -0
- package/src/agents/Fold.tsx +64 -0
- package/src/agents/Modal.tsx +65 -0
- package/src/agents/PulsingDot.tsx +28 -0
- package/src/agents/inbrowser-agent-usage.d.ts +141 -0
- package/src/agents/index.ts +28 -0
- package/src/auth/authApi.ts +72 -0
- package/src/auth/claims.ts +63 -0
- package/src/auth/components/AuthProviderToggles.tsx +147 -0
- package/src/auth/components/AuthSignInHelper.tsx +197 -0
- package/src/auth/components/AuthUserForm.tsx +328 -0
- package/src/auth/components/AuthUserList.tsx +219 -0
- package/src/auth/components/ClaimsField.tsx +50 -0
- package/src/auth/components/confirmActions.tsx +114 -0
- package/src/auth/controller.ts +173 -0
- package/src/auth/hooks/index.ts +36 -0
- package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
- package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
- package/src/auth/hooks/useAuthUserEditor.ts +76 -0
- package/src/auth/hooks/useAuthUsers.ts +154 -0
- package/src/auth/index.ts +42 -0
- package/src/auth/providers.ts +28 -0
- package/src/auth/reducers/userEditor.ts +186 -0
- package/src/events/components/ActivityActionItems.tsx +136 -0
- package/src/events/components/ActivityGrid.tsx +197 -0
- package/src/events/components/ActivityGridRow.tsx +65 -0
- package/src/events/components/ProposedChangeDiff.tsx +175 -0
- package/src/events/components/format.ts +21 -0
- package/src/events/components/index.ts +17 -0
- package/src/events/digest.ts +630 -0
- package/src/events/hooks/index.ts +9 -0
- package/src/events/hooks/useActivityDigest.ts +42 -0
- package/src/events/hooks/useActivityStream.ts +86 -0
- package/src/events/index.ts +39 -0
- package/src/events/types.ts +152 -0
- package/src/firestore/components/CollectionList.tsx +78 -0
- package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
- package/src/firestore/components/DocumentEditor.tsx +350 -0
- package/src/firestore/components/DocumentList.tsx +217 -0
- package/src/firestore/components/DocumentPreview.tsx +265 -0
- package/src/firestore/components/FieldRenderer.tsx +25 -0
- package/src/firestore/components/QueryBuilder.tsx +181 -0
- package/src/firestore/components/ReferencePicker.tsx +212 -0
- package/src/firestore/components/TreeEntry.tsx +58 -0
- package/src/firestore/components/context.ts +25 -0
- package/src/firestore/fieldEditors/array.tsx +30 -0
- package/src/firestore/fieldEditors/boolean.tsx +39 -0
- package/src/firestore/fieldEditors/bytes.tsx +53 -0
- package/src/firestore/fieldEditors/geopoint.tsx +71 -0
- package/src/firestore/fieldEditors/map.tsx +33 -0
- package/src/firestore/fieldEditors/null.tsx +27 -0
- package/src/firestore/fieldEditors/number.tsx +43 -0
- package/src/firestore/fieldEditors/reference.tsx +87 -0
- package/src/firestore/fieldEditors/registry.ts +45 -0
- package/src/firestore/fieldEditors/string.tsx +33 -0
- package/src/firestore/fieldEditors/timestamp.tsx +75 -0
- package/src/firestore/fieldEditors/types.ts +68 -0
- package/src/firestore/fieldEditors/vector.tsx +142 -0
- package/src/firestore/firestoreApi.ts +86 -0
- package/src/firestore/hooks/coerceError.ts +34 -0
- package/src/firestore/hooks/index.ts +46 -0
- package/src/firestore/hooks/useCollectionList.ts +102 -0
- package/src/firestore/hooks/useDocumentEditor.ts +161 -0
- package/src/firestore/hooks/useDocumentList.ts +242 -0
- package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
- package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
- package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
- package/src/firestore/hooks/useQueryBuilder.ts +188 -0
- package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
- package/src/firestore/hooks/useReferencePicker.ts +228 -0
- package/src/firestore/import/parseImport.ts +137 -0
- package/src/firestore/index.ts +87 -0
- package/src/firestore/reducers/defaults.ts +38 -0
- package/src/firestore/reducers/documentEditor.ts +239 -0
- package/src/firestore/reducers/tree.ts +140 -0
- package/src/firestore/reducers/types.ts +92 -0
- package/src/firestore/reducers/validation.ts +129 -0
- package/src/firestore/types.ts +231 -0
- package/src/firestore/validation/ids.ts +45 -0
- package/src/firestore/valueEquality.ts +43 -0
- package/src/index.ts +10 -0
- package/src/primitives/Badge.tsx +40 -0
- package/src/primitives/ConfirmDialog.tsx +137 -0
- package/src/primitives/CopyButton.tsx +62 -0
- package/src/primitives/JsonView.tsx +151 -0
- package/src/primitives/SegmentedControl.tsx +72 -0
- package/src/primitives/Toast.tsx +161 -0
- package/src/primitives/VirtualList.tsx +104 -0
- package/src/primitives/hooks/useContainerSize.ts +53 -0
- package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
- package/src/primitives/index.ts +39 -0
- package/src/primitives/useConfirm.tsx +113 -0
- package/src/rtdb/components/RtdbPathBar.tsx +135 -0
- package/src/rtdb/components/RtdbTree.tsx +409 -0
- package/src/rtdb/editor.ts +79 -0
- package/src/rtdb/hooks/useRtdbTree.ts +137 -0
- package/src/rtdb/index.ts +66 -0
- package/src/rtdb/pathInput.ts +47 -0
- package/src/rtdb/reducers/tree.ts +191 -0
- package/src/rtdb/rtdbApi.ts +23 -0
- package/src/rtdb/values.ts +188 -0
- package/src/rules/components/DenialInspector.tsx +227 -0
- package/src/rules/components/format.ts +171 -0
- package/src/rules/components/index.ts +12 -0
- package/src/rules/components/scope.ts +75 -0
- package/src/rules/hooks/index.ts +5 -0
- package/src/rules/hooks/useDenialTrace.ts +100 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/types.ts +91 -0
- package/src/storage/collisionRename.ts +114 -0
- package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
- package/src/storage/components/ObjectBrowser.tsx +219 -0
- package/src/storage/components/ObjectInspector.tsx +169 -0
- package/src/storage/components/PathBreadcrumb.tsx +84 -0
- package/src/storage/components/UploadDropzone.tsx +182 -0
- package/src/storage/folderPlaceholder.ts +40 -0
- package/src/storage/hooks/index.ts +59 -0
- package/src/storage/hooks/useMetadataEditor.ts +329 -0
- package/src/storage/hooks/useObjectUpload.ts +262 -0
- package/src/storage/hooks/usePathState.ts +94 -0
- package/src/storage/hooks/useStorageDelete.ts +195 -0
- package/src/storage/hooks/useStorageList.ts +261 -0
- package/src/storage/hooks/useStorageObject.ts +162 -0
- package/src/storage/hooks/useStorageRulesGate.ts +270 -0
- package/src/storage/hooks/useStorageSelection.ts +90 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/pendingPrefixes.ts +125 -0
- package/src/storage/previews.tsx +120 -0
- package/src/storage/storageApi.ts +54 -0
- package/src/traffic/components/RuleHeatmap.tsx +97 -0
- package/src/traffic/components/TrafficDetail.tsx +160 -0
- package/src/traffic/components/TrafficGroupRow.tsx +91 -0
- package/src/traffic/components/TrafficLineChart.tsx +139 -0
- package/src/traffic/components/TrafficLog.tsx +175 -0
- package/src/traffic/components/TrafficMetricCards.tsx +77 -0
- package/src/traffic/components/TrafficRow.tsx +69 -0
- package/src/traffic/components/TrafficStats.tsx +73 -0
- package/src/traffic/components/TrafficTimeline.tsx +289 -0
- package/src/traffic/components/format.ts +22 -0
- package/src/traffic/components/index.ts +22 -0
- package/src/traffic/hooks/index.ts +60 -0
- package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
- package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
- package/src/traffic/hooks/useTrafficFilter.ts +74 -0
- package/src/traffic/hooks/useTrafficGroups.ts +126 -0
- package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
- package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
- package/src/traffic/hooks/useTrafficStats.ts +77 -0
- package/src/traffic/index.ts +13 -0
- 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';
|