@hilum/ui 3.4.0 → 3.5.1
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/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +789 -115
- package/dist/index.js +10404 -1099
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- package/dist/chunk-MAHPAP65.js.map +0 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
5
|
+
import type { ItemRect } from "./use-proximity-hover";
|
|
6
|
+
|
|
7
|
+
// Run the layout effect on the client (where it must fire before paint, so a
|
|
8
|
+
// merge/split shows on the first frame) and a no-op-safe useEffect on the server.
|
|
9
|
+
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
10
|
+
|
|
11
|
+
// Edge spring for the selected-bg merge/split: moderate, no bounce so converging
|
|
12
|
+
// edges meet exactly instead of overshooting. On a merge the inner corners trail
|
|
13
|
+
// by `cornerDelay`, staying rounded until the halves meet.
|
|
14
|
+
const mergeSpring = { type: "spring" as const, duration: 0.16, bounce: 0 };
|
|
15
|
+
const cornerDelay = 0.07;
|
|
16
|
+
// A boundary resolves after its motion finishes (merge → swap to one block;
|
|
17
|
+
// split → drop), driven by a duration timer rather than onAnimationComplete —
|
|
18
|
+
// framer skips that callback when an animation's target equals its current value
|
|
19
|
+
// (which spam-toggling produces), which would otherwise strand a half. The
|
|
20
|
+
// buffer biases late, by which point the halves have met/parted, so it's unseen.
|
|
21
|
+
const convergeMs = (mergeSpring.duration + cornerDelay) * 1000 + 80;
|
|
22
|
+
const splitMs = mergeSpring.duration * 1000 + 80;
|
|
23
|
+
|
|
24
|
+
// A selected-background block for one render. A run is normally one block; mid
|
|
25
|
+
// merge/split it is drawn as two abutting halves with sharp inner corners.
|
|
26
|
+
type Rect = { top: number; left: number; width: number; height: number };
|
|
27
|
+
export interface SelBlock extends Rect {
|
|
28
|
+
key: string;
|
|
29
|
+
radii: [number, number, number, number]; // tl, tr, br, bl
|
|
30
|
+
instant: boolean; // skip the spring (the zero-shift swap, the split snap-in)
|
|
31
|
+
exitInstant: boolean; // drop without the fade (absorbed half at the swap)
|
|
32
|
+
delayCorners: boolean; // trail the corner straightening (merge converge)
|
|
33
|
+
cornerDelay?: number; // optional per-block delay override
|
|
34
|
+
opacity?: number; // override the hover-derived opacity (commit ghost = 0)
|
|
35
|
+
// State a fresh block animates *from* on mount, so it springs into place
|
|
36
|
+
// instead of snapping when continuity is lost (fast toggling) or the block is
|
|
37
|
+
// inherently new (a split's lower half). Continuous blocks ignore it.
|
|
38
|
+
enterFrom?: { top: number; height: number; radii: [number, number, number, number] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A contiguous run of selected/checked rows, with a stable id so framer-motion
|
|
42
|
+
// can morph it across renders rather than exit+re-enter.
|
|
43
|
+
export type Run = { start: number; end: number; id: number };
|
|
44
|
+
|
|
45
|
+
// One in-flight merge or split; geometry is recomputed from the live runs each
|
|
46
|
+
// render so rapid toggles redirect instead of freezing.
|
|
47
|
+
interface Boundary {
|
|
48
|
+
tid: number;
|
|
49
|
+
kind: "merge" | "split";
|
|
50
|
+
survivorId: number; // persisting run (merged run / split's upper run)
|
|
51
|
+
otherId: number; // merge: absorbed run; split: new lower run
|
|
52
|
+
gapIndex: number; // bridging/deselected row — where the halves meet
|
|
53
|
+
phase: "converge" | "commit" | "splitIn" | "diverge";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Two runs within `outer`, ordered, separated by exactly one row (a single-row
|
|
57
|
+
// bridge — the only shape a click can merge or split).
|
|
58
|
+
function bridgePair(outer: Run, runs: Run[]) {
|
|
59
|
+
const inside = runs
|
|
60
|
+
.filter((r) => r.start >= outer.start && r.end <= outer.end)
|
|
61
|
+
.sort((a, b) => a.start - b.start);
|
|
62
|
+
if (inside.length !== 2) return null;
|
|
63
|
+
const [up, lo] = inside;
|
|
64
|
+
return lo.start === up.end + 2 ? { up, lo, gap: up.end + 1 } : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Merge / split boundary animation ─────────────────────────────
|
|
68
|
+
// When one unselected row bridges two selected runs, their inner edges glide to
|
|
69
|
+
// the bridging row's midpoint (facing corners straightening to sharp), then swap
|
|
70
|
+
// to one block with no visible motion — instead of the surviving block
|
|
71
|
+
// spring-growing over the whole union. Deselecting a middle row plays the
|
|
72
|
+
// inverse: snap into two abutting halves, then glide apart.
|
|
73
|
+
//
|
|
74
|
+
// Given the contiguous selection `runs` (with stable ids), the measured
|
|
75
|
+
// `itemRects`, and the corner radius `R` to round to, this returns the list of
|
|
76
|
+
// background blocks to paint — one per run, or two abutting halves for any run
|
|
77
|
+
// currently mid merge/split. Render them with <SelectionBackgrounds>.
|
|
78
|
+
export function useMergeSplitBlocks(runs: Run[], itemRects: ItemRect[], R: number): SelBlock[] {
|
|
79
|
+
const [boundaries, setBoundaries] = useState<Boundary[]>([]);
|
|
80
|
+
const prevRunsRef = useRef<Run[]>([]);
|
|
81
|
+
const tidRef = useRef(0);
|
|
82
|
+
const timersRef = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
|
83
|
+
const runsSig = runs.map((g) => `${g.id}:${g.start}-${g.end}`).join("|");
|
|
84
|
+
|
|
85
|
+
// Detect merges/splits before paint (so the first frame already shows the
|
|
86
|
+
// halves) and drop any boundary the latest selection invalidated (e.g. the
|
|
87
|
+
// bridge row was toggled again mid-flight).
|
|
88
|
+
useIsoLayoutEffect(() => {
|
|
89
|
+
const prev = prevRunsRef.current;
|
|
90
|
+
const cur = runs;
|
|
91
|
+
const found: Boundary[] = [];
|
|
92
|
+
for (const c of cur) {
|
|
93
|
+
const p = bridgePair(c, prev); // two prev runs collapsed into one
|
|
94
|
+
if (p && (c.id === p.up.id || c.id === p.lo.id))
|
|
95
|
+
found.push({
|
|
96
|
+
tid: ++tidRef.current,
|
|
97
|
+
kind: "merge",
|
|
98
|
+
survivorId: c.id,
|
|
99
|
+
otherId: c.id === p.up.id ? p.lo.id : p.up.id,
|
|
100
|
+
gapIndex: p.gap,
|
|
101
|
+
phase: "converge",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
for (const p of prev) {
|
|
105
|
+
const c = bridgePair(p, cur); // one prev run split into two
|
|
106
|
+
if (c)
|
|
107
|
+
found.push({
|
|
108
|
+
tid: ++tidRef.current,
|
|
109
|
+
kind: "split",
|
|
110
|
+
survivorId: c.up.id,
|
|
111
|
+
otherId: c.lo.id,
|
|
112
|
+
gapIndex: c.gap,
|
|
113
|
+
phase: "splitIn",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
prevRunsRef.current = cur.map((r) => ({ ...r }));
|
|
117
|
+
// Resolve each new boundary after its motion window (merge → swap to one
|
|
118
|
+
// block; split → drop), so an interrupted animation can't strand a half.
|
|
119
|
+
for (const b of found) {
|
|
120
|
+
timersRef.current.set(
|
|
121
|
+
b.tid,
|
|
122
|
+
setTimeout(
|
|
123
|
+
() => {
|
|
124
|
+
timersRef.current.delete(b.tid);
|
|
125
|
+
setBoundaries((bs) =>
|
|
126
|
+
bs.some((x) => x.tid === b.tid)
|
|
127
|
+
? bs.flatMap((x) =>
|
|
128
|
+
x.tid !== b.tid
|
|
129
|
+
? [x]
|
|
130
|
+
: x.kind === "merge"
|
|
131
|
+
? [{ ...x, phase: "commit" as const }]
|
|
132
|
+
: [],
|
|
133
|
+
)
|
|
134
|
+
: bs,
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
b.kind === "merge" ? convergeMs : splitMs,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
setBoundaries((active) => [
|
|
142
|
+
...active.filter((b) =>
|
|
143
|
+
b.kind === "merge"
|
|
144
|
+
? cur.some((c) => c.id === b.survivorId && b.gapIndex > c.start && b.gapIndex < c.end)
|
|
145
|
+
: cur.some((c) => c.id === b.survivorId && c.end === b.gapIndex - 1) &&
|
|
146
|
+
cur.some((c) => c.id === b.otherId && c.start === b.gapIndex + 1),
|
|
147
|
+
),
|
|
148
|
+
...found,
|
|
149
|
+
]);
|
|
150
|
+
}, [runsSig]);
|
|
151
|
+
|
|
152
|
+
// Clear any pending timers on unmount.
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const timers = timersRef.current;
|
|
155
|
+
return () => timers.forEach(clearTimeout);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
// Follow-up render: a fresh split holds its abutting frame once then
|
|
159
|
+
// diverges; a committed merge is dropped.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!boundaries.some((b) => b.phase === "splitIn" || b.phase === "commit")) return;
|
|
162
|
+
setBoundaries((bs) =>
|
|
163
|
+
bs.flatMap((b) =>
|
|
164
|
+
b.phase === "commit" ? [] : [{ ...b, phase: b.phase === "splitIn" ? "diverge" : b.phase }],
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}, [boundaries]);
|
|
168
|
+
|
|
169
|
+
// Build the blocks to paint: one per run, overridden into abutting halves for
|
|
170
|
+
// any run in an in-flight boundary.
|
|
171
|
+
const rectOf = (start: number, end: number): Rect | null => {
|
|
172
|
+
const s = itemRects[start];
|
|
173
|
+
const e = itemRects[end];
|
|
174
|
+
if (!s || !e) return null;
|
|
175
|
+
return {
|
|
176
|
+
top: s.top,
|
|
177
|
+
left: Math.min(s.left, e.left),
|
|
178
|
+
width: Math.max(s.width, e.width),
|
|
179
|
+
height: e.top + e.height - s.top,
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
const blocks: SelBlock[] = [];
|
|
183
|
+
for (const run of runs) {
|
|
184
|
+
const r = rectOf(run.start, run.end);
|
|
185
|
+
if (r)
|
|
186
|
+
blocks.push({
|
|
187
|
+
key: `sel-${run.id}`,
|
|
188
|
+
...r,
|
|
189
|
+
radii: [R, R, R, R],
|
|
190
|
+
instant: false,
|
|
191
|
+
exitInstant: false,
|
|
192
|
+
delayCorners: false,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const byId = new Map(blocks.map((b) => [b.key, b]));
|
|
196
|
+
for (const b of boundaries) {
|
|
197
|
+
const gap = itemRects[b.gapIndex];
|
|
198
|
+
const sv = byId.get(`sel-${b.survivorId}`);
|
|
199
|
+
if (!gap || !sv) continue;
|
|
200
|
+
const midY = gap.top + gap.height / 2;
|
|
201
|
+
if (b.kind === "merge") {
|
|
202
|
+
if (b.phase === "commit") {
|
|
203
|
+
// Zero-shift swap: survivor jumps to the full union (already covered by
|
|
204
|
+
// its top half + the absorbed bottom half). The absorbed half is held
|
|
205
|
+
// one render at opacity 0 so removing it next render can't flash a
|
|
206
|
+
// one-frame overlap with the now-full survivor.
|
|
207
|
+
sv.instant = true;
|
|
208
|
+
blocks.push({
|
|
209
|
+
key: `sel-${b.otherId}`,
|
|
210
|
+
top: midY,
|
|
211
|
+
left: sv.left,
|
|
212
|
+
width: sv.width,
|
|
213
|
+
height: sv.top + sv.height - midY,
|
|
214
|
+
radii: [0, 0, R, R],
|
|
215
|
+
instant: true,
|
|
216
|
+
exitInstant: true,
|
|
217
|
+
delayCorners: false,
|
|
218
|
+
opacity: 0,
|
|
219
|
+
});
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// converge: survivor → top half, absorbed run → bottom-half ghost, inner
|
|
223
|
+
// corners straightening to sharp.
|
|
224
|
+
// Slightly trail lower merges while keeping a baseline and small cap.
|
|
225
|
+
const mergeCornerDelay = Math.min(
|
|
226
|
+
cornerDelay + 0.03,
|
|
227
|
+
Math.max(cornerDelay, cornerDelay + (midY / Math.max(gap.height, 1)) * 0.002),
|
|
228
|
+
);
|
|
229
|
+
const bottom = sv.top + sv.height;
|
|
230
|
+
sv.height = midY - sv.top;
|
|
231
|
+
sv.radii = [R, R, 0, 0];
|
|
232
|
+
sv.delayCorners = true;
|
|
233
|
+
sv.cornerDelay = mergeCornerDelay;
|
|
234
|
+
blocks.push({
|
|
235
|
+
key: `sel-${b.otherId}`,
|
|
236
|
+
top: midY,
|
|
237
|
+
left: sv.left,
|
|
238
|
+
width: sv.width,
|
|
239
|
+
height: bottom - midY,
|
|
240
|
+
radii: [0, 0, R, R],
|
|
241
|
+
// Mount at full corners so a fresh ghost still animates the
|
|
242
|
+
// straightening with the same delay as the survivor.
|
|
243
|
+
enterFrom: { top: midY, height: bottom - midY, radii: [R, R, R, R] },
|
|
244
|
+
instant: false,
|
|
245
|
+
exitInstant: true,
|
|
246
|
+
delayCorners: true,
|
|
247
|
+
cornerDelay: mergeCornerDelay,
|
|
248
|
+
});
|
|
249
|
+
} else if (b.phase === "splitIn") {
|
|
250
|
+
const lo = byId.get(`sel-${b.otherId}`);
|
|
251
|
+
if (!lo) continue;
|
|
252
|
+
// Pin both halves at the seam (identical to the single block); the
|
|
253
|
+
// diverge render then springs them to their real rects.
|
|
254
|
+
const bottom = lo.top + lo.height;
|
|
255
|
+
sv.height = midY - sv.top;
|
|
256
|
+
sv.radii = [R, R, 0, 0];
|
|
257
|
+
sv.instant = true;
|
|
258
|
+
lo.top = midY;
|
|
259
|
+
lo.height = bottom - midY;
|
|
260
|
+
lo.radii = [0, 0, R, R];
|
|
261
|
+
lo.instant = true;
|
|
262
|
+
lo.enterFrom = { top: midY, height: bottom - midY, radii: [0, 0, R, R] };
|
|
263
|
+
}
|
|
264
|
+
// diverge: nothing to override — the steady blocks spring to their real
|
|
265
|
+
// rects from the seam; the timer drops the boundary.
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Split safety net, pinned synchronously. The split boundary above is created
|
|
269
|
+
// in a layout effect that runs *after* this render, so on the very frame a
|
|
270
|
+
// split first appears its fresh lower half would mount at its final rect and
|
|
271
|
+
// snap. Detecting the split here (previous runs vs current) and pinning both
|
|
272
|
+
// halves at the seam guarantees the lower mounts on the seam regardless of
|
|
273
|
+
// render/paint timing (the cause of the rapid-toggle snap).
|
|
274
|
+
for (const p of prevRunsRef.current) {
|
|
275
|
+
const c = bridgePair(p, runs);
|
|
276
|
+
const gap = c && itemRects[c.gap];
|
|
277
|
+
if (!c || !gap) continue;
|
|
278
|
+
const midY = gap.top + gap.height / 2;
|
|
279
|
+
const up = byId.get(`sel-${c.up.id}`);
|
|
280
|
+
const lo = byId.get(`sel-${c.lo.id}`);
|
|
281
|
+
if (!up || !lo) continue;
|
|
282
|
+
const bottom = lo.top + lo.height;
|
|
283
|
+
up.height = midY - up.top;
|
|
284
|
+
up.radii = [R, R, 0, 0];
|
|
285
|
+
up.instant = true;
|
|
286
|
+
lo.top = midY;
|
|
287
|
+
lo.height = bottom - midY;
|
|
288
|
+
lo.radii = [0, 0, R, R];
|
|
289
|
+
lo.instant = true;
|
|
290
|
+
lo.enterFrom = { top: midY, height: bottom - midY, radii: [0, 0, R, R] };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return blocks;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Renders the selected-background blocks produced by useMergeSplitBlocks — one
|
|
297
|
+
// per run, or two abutting halves mid merge/split. `dimmed` drops the opacity to
|
|
298
|
+
// 0.8 (when the user is hovering a non-selected row) to match the standalone
|
|
299
|
+
// hover indicator; a block's own `opacity` override (e.g. the commit ghost)
|
|
300
|
+
// always wins. Corners are driven numerically so merge/split can straighten and
|
|
301
|
+
// re-round individual corners.
|
|
302
|
+
export function SelectionBackgrounds({ blocks, dimmed }: { blocks: SelBlock[]; dimmed: boolean }) {
|
|
303
|
+
return (
|
|
304
|
+
<AnimatePresence>
|
|
305
|
+
{blocks.map((b) => {
|
|
306
|
+
const corner = b.delayCorners
|
|
307
|
+
? { ...mergeSpring, delay: b.cornerDelay ?? cornerDelay }
|
|
308
|
+
: mergeSpring;
|
|
309
|
+
const opacity = b.opacity ?? (dimmed ? 0.8 : 1);
|
|
310
|
+
return (
|
|
311
|
+
<motion.div
|
|
312
|
+
key={b.key}
|
|
313
|
+
aria-hidden
|
|
314
|
+
className="absolute bg-active pointer-events-none"
|
|
315
|
+
initial={
|
|
316
|
+
b.enterFrom
|
|
317
|
+
? {
|
|
318
|
+
opacity,
|
|
319
|
+
top: b.enterFrom.top,
|
|
320
|
+
left: b.left,
|
|
321
|
+
width: b.width,
|
|
322
|
+
height: b.enterFrom.height,
|
|
323
|
+
borderTopLeftRadius: b.enterFrom.radii[0],
|
|
324
|
+
borderTopRightRadius: b.enterFrom.radii[1],
|
|
325
|
+
borderBottomRightRadius: b.enterFrom.radii[2],
|
|
326
|
+
borderBottomLeftRadius: b.enterFrom.radii[3],
|
|
327
|
+
}
|
|
328
|
+
: false
|
|
329
|
+
}
|
|
330
|
+
animate={{
|
|
331
|
+
top: b.top,
|
|
332
|
+
left: b.left,
|
|
333
|
+
width: b.width,
|
|
334
|
+
height: b.height,
|
|
335
|
+
borderTopLeftRadius: b.radii[0],
|
|
336
|
+
borderTopRightRadius: b.radii[1],
|
|
337
|
+
borderBottomRightRadius: b.radii[2],
|
|
338
|
+
borderBottomLeftRadius: b.radii[3],
|
|
339
|
+
opacity,
|
|
340
|
+
}}
|
|
341
|
+
exit={{ opacity: 0, transition: { duration: b.exitInstant ? 0 : 0.12 } }}
|
|
342
|
+
transition={
|
|
343
|
+
b.instant
|
|
344
|
+
? { duration: 0 }
|
|
345
|
+
: {
|
|
346
|
+
...mergeSpring,
|
|
347
|
+
borderTopLeftRadius: corner,
|
|
348
|
+
borderTopRightRadius: corner,
|
|
349
|
+
borderBottomRightRadius: corner,
|
|
350
|
+
borderBottomLeftRadius: corner,
|
|
351
|
+
opacity: { duration: 0.08 },
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/>
|
|
355
|
+
);
|
|
356
|
+
})}
|
|
357
|
+
</AnimatePresence>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
type Dispatch,
|
|
9
|
+
type RefObject,
|
|
10
|
+
type SetStateAction,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
export interface ItemRect {
|
|
14
|
+
top: number;
|
|
15
|
+
height: number;
|
|
16
|
+
left: number;
|
|
17
|
+
width: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface UseProximityHoverOptions {
|
|
21
|
+
axis?: "x" | "y";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UseProximityHoverReturn {
|
|
25
|
+
activeIndex: number | null;
|
|
26
|
+
setActiveIndex: Dispatch<SetStateAction<number | null>>;
|
|
27
|
+
itemRects: ItemRect[];
|
|
28
|
+
sessionRef: RefObject<number>;
|
|
29
|
+
handlers: {
|
|
30
|
+
onMouseMove: (e: React.MouseEvent) => void;
|
|
31
|
+
onMouseEnter: () => void;
|
|
32
|
+
onMouseLeave: () => void;
|
|
33
|
+
};
|
|
34
|
+
registerItem: (index: number, element: HTMLElement | null) => void;
|
|
35
|
+
measureItems: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useProximityHover<T extends HTMLElement>(
|
|
39
|
+
containerRef: RefObject<T | null>,
|
|
40
|
+
options: UseProximityHoverOptions = {},
|
|
41
|
+
): UseProximityHoverReturn {
|
|
42
|
+
const { axis = "y" } = options;
|
|
43
|
+
const itemsRef = useRef(new Map<number, HTMLElement>());
|
|
44
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
45
|
+
const [itemRects, setItemRects] = useState<ItemRect[]>([]);
|
|
46
|
+
const itemRectsRef = useRef<ItemRect[]>([]);
|
|
47
|
+
const sessionRef = useRef(0);
|
|
48
|
+
const rafIdRef = useRef<number | null>(null);
|
|
49
|
+
const remeasureRafIdRef = useRef<number | null>(null);
|
|
50
|
+
|
|
51
|
+
const measureItems = useCallback(() => {
|
|
52
|
+
const container = containerRef.current;
|
|
53
|
+
if (!container) return;
|
|
54
|
+
const rects: ItemRect[] = [];
|
|
55
|
+
itemsRef.current.forEach((element, index) => {
|
|
56
|
+
// Use offset* instead of getBoundingClientRect so measurements are
|
|
57
|
+
// unaffected by CSS transforms (e.g. scaleY animation on the parent
|
|
58
|
+
// motion.div). offsetTop/offsetLeft are layout values relative to the
|
|
59
|
+
// offsetParent (the scroll container), matching the coordinate space
|
|
60
|
+
// used by `position: absolute` children.
|
|
61
|
+
rects[index] = {
|
|
62
|
+
top: element.offsetTop,
|
|
63
|
+
height: element.offsetHeight,
|
|
64
|
+
left: element.offsetLeft,
|
|
65
|
+
width: element.offsetWidth,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
itemRectsRef.current = rects;
|
|
69
|
+
setItemRects(rects);
|
|
70
|
+
}, [containerRef]);
|
|
71
|
+
|
|
72
|
+
const registerItem = useCallback(
|
|
73
|
+
(index: number, element: HTMLElement | null) => {
|
|
74
|
+
if (element) {
|
|
75
|
+
itemsRef.current.set(index, element);
|
|
76
|
+
} else {
|
|
77
|
+
itemsRef.current.delete(index);
|
|
78
|
+
}
|
|
79
|
+
// Coalesce rapid register/unregister calls (e.g. when an AnimatePresence
|
|
80
|
+
// remounts a list of rows) into a single remeasure on the next frame,
|
|
81
|
+
// so consumers don't have to manually call measureItems after the
|
|
82
|
+
// container's children swap.
|
|
83
|
+
if (remeasureRafIdRef.current !== null) {
|
|
84
|
+
cancelAnimationFrame(remeasureRafIdRef.current);
|
|
85
|
+
}
|
|
86
|
+
remeasureRafIdRef.current = requestAnimationFrame(() => {
|
|
87
|
+
remeasureRafIdRef.current = null;
|
|
88
|
+
measureItems();
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
[measureItems],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const handleMouseMove = useCallback(
|
|
95
|
+
(e: React.MouseEvent) => {
|
|
96
|
+
const mouseX = e.clientX;
|
|
97
|
+
const mouseY = e.clientY;
|
|
98
|
+
|
|
99
|
+
if (rafIdRef.current !== null) {
|
|
100
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
rafIdRef.current = requestAnimationFrame(() => {
|
|
104
|
+
rafIdRef.current = null;
|
|
105
|
+
const container = containerRef.current;
|
|
106
|
+
if (!container) return;
|
|
107
|
+
|
|
108
|
+
const containerRect = container.getBoundingClientRect();
|
|
109
|
+
const mousePos = axis === "x" ? mouseX : mouseY;
|
|
110
|
+
|
|
111
|
+
let closestIndex: number | null = null;
|
|
112
|
+
let closestDistance = Infinity;
|
|
113
|
+
let containingIndex: number | null = null;
|
|
114
|
+
|
|
115
|
+
const rects = itemRectsRef.current;
|
|
116
|
+
// Convert content-relative rects to viewport coords using live scroll
|
|
117
|
+
const scrollOffset = axis === "x" ? container.scrollLeft : container.scrollTop;
|
|
118
|
+
const borderOffset = axis === "x" ? container.clientLeft : container.clientTop;
|
|
119
|
+
const containerEdge = axis === "x" ? containerRect.left : containerRect.top;
|
|
120
|
+
// Item rects are layout values (offset*); the container's bounding rect
|
|
121
|
+
// reflects any cumulative ancestor transform: scale. Compute the scale
|
|
122
|
+
// factor so we can map layout coords into the same visual viewport
|
|
123
|
+
// space the mouse cursor lives in.
|
|
124
|
+
const layoutSize = axis === "x" ? container.offsetWidth : container.offsetHeight;
|
|
125
|
+
const visualSize = axis === "x" ? containerRect.width : containerRect.height;
|
|
126
|
+
const scale = layoutSize > 0 ? visualSize / layoutSize : 1;
|
|
127
|
+
|
|
128
|
+
for (let index = 0; index < rects.length; index++) {
|
|
129
|
+
const r = rects[index];
|
|
130
|
+
if (!r) continue;
|
|
131
|
+
|
|
132
|
+
const contentPos = axis === "x" ? r.left : r.top;
|
|
133
|
+
const itemStart = containerEdge + (borderOffset + contentPos - scrollOffset) * scale;
|
|
134
|
+
const itemSize = (axis === "x" ? r.width : r.height) * scale;
|
|
135
|
+
const itemEnd = itemStart + itemSize;
|
|
136
|
+
|
|
137
|
+
if (mousePos >= itemStart && mousePos <= itemEnd) {
|
|
138
|
+
containingIndex = index;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const itemCenter = itemStart + itemSize / 2;
|
|
142
|
+
const distance = Math.abs(mousePos - itemCenter);
|
|
143
|
+
|
|
144
|
+
if (distance < closestDistance) {
|
|
145
|
+
closestDistance = distance;
|
|
146
|
+
closestIndex = index;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setActiveIndex(containingIndex ?? closestIndex);
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
[axis, containerRef],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const handleMouseEnter = useCallback(() => {
|
|
157
|
+
sessionRef.current += 1;
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
const handleMouseLeave = useCallback(() => {
|
|
161
|
+
if (rafIdRef.current !== null) {
|
|
162
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
163
|
+
rafIdRef.current = null;
|
|
164
|
+
}
|
|
165
|
+
setActiveIndex(null);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
// Clean up rAF on unmount
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
return () => {
|
|
171
|
+
if (rafIdRef.current !== null) {
|
|
172
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
173
|
+
}
|
|
174
|
+
if (remeasureRafIdRef.current !== null) {
|
|
175
|
+
cancelAnimationFrame(remeasureRafIdRef.current);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
activeIndex,
|
|
182
|
+
setActiveIndex,
|
|
183
|
+
itemRects,
|
|
184
|
+
sessionRef,
|
|
185
|
+
handlers: {
|
|
186
|
+
onMouseMove: handleMouseMove,
|
|
187
|
+
onMouseEnter: handleMouseEnter,
|
|
188
|
+
onMouseLeave: handleMouseLeave,
|
|
189
|
+
},
|
|
190
|
+
registerItem,
|
|
191
|
+
measureItems,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hook for child items to register themselves with the proximity hover system.
|
|
197
|
+
* Call in useEffect with the item's ref and index.
|
|
198
|
+
*/
|
|
199
|
+
export function useRegisterProximityItem(
|
|
200
|
+
registerItem: (index: number, element: HTMLElement | null) => void,
|
|
201
|
+
index: number,
|
|
202
|
+
ref: RefObject<HTMLElement | null>,
|
|
203
|
+
) {
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
registerItem(index, ref.current);
|
|
206
|
+
return () => registerItem(index, null);
|
|
207
|
+
}, [index, registerItem, ref]);
|
|
208
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./components/activity-feed";
|
|
|
16
16
|
export * from "./components/alert-dialog";
|
|
17
17
|
export * from "./components/alert";
|
|
18
18
|
export * from "./components/aspect-ratio";
|
|
19
|
+
export * from "./components/ask-user-questions";
|
|
19
20
|
export * from "./components/avatar-stack";
|
|
20
21
|
export * from "./components/avatar";
|
|
21
22
|
export * from "./components/badge";
|
|
@@ -28,7 +29,9 @@ export * from "./components/card-heading";
|
|
|
28
29
|
export * from "./components/card";
|
|
29
30
|
export * from "./components/carousel";
|
|
30
31
|
export * from "./components/chart";
|
|
32
|
+
export * from "./components/chat-message";
|
|
31
33
|
export * from "./components/checkbox";
|
|
34
|
+
export * from "./components/checkbox-group";
|
|
32
35
|
export * from "./components/collapsible";
|
|
33
36
|
export * from "./components/color-input";
|
|
34
37
|
export * from "./components/color-picker";
|
|
@@ -42,14 +45,18 @@ export * from "./components/date-picker";
|
|
|
42
45
|
export * from "./components/description-list";
|
|
43
46
|
export * from "./components/dialog";
|
|
44
47
|
export * from "./components/drawer";
|
|
48
|
+
export * from "./components/dropdown";
|
|
45
49
|
export * from "./components/dropdown-menu";
|
|
46
50
|
export * from "./components/empty-state";
|
|
47
51
|
export * from "./components/field";
|
|
52
|
+
export * from "./components/file-thumbnail";
|
|
48
53
|
export * from "./components/file-dropzone";
|
|
49
54
|
export * from "./components/grid-list";
|
|
50
55
|
export * from "./components/help-tooltip";
|
|
51
56
|
export * from "./components/hover-card";
|
|
57
|
+
export * from "./components/input-copy";
|
|
52
58
|
export * from "./components/input-group";
|
|
59
|
+
export * from "./components/input-message";
|
|
53
60
|
export * from "./components/input-number";
|
|
54
61
|
export * from "./components/input-otp";
|
|
55
62
|
export * from "./components/input";
|
|
@@ -57,8 +64,12 @@ export * from "./components/kbd";
|
|
|
57
64
|
export * from "./components/label";
|
|
58
65
|
export * from "./components/media-asset-card";
|
|
59
66
|
export * from "./components/media-object";
|
|
67
|
+
export * from "./components/menu-item";
|
|
60
68
|
export * from "./components/menubar";
|
|
69
|
+
export * from "./components/mobile-drawer";
|
|
61
70
|
export * from "./components/native-select";
|
|
71
|
+
export * from "./components/nav-item";
|
|
72
|
+
export * from "./components/nav-menu";
|
|
62
73
|
export * from "./components/navigation-menu";
|
|
63
74
|
export * from "./components/notification";
|
|
64
75
|
export * from "./components/page-heading";
|
|
@@ -88,7 +99,10 @@ export * from "./components/steps";
|
|
|
88
99
|
export * from "./components/switch";
|
|
89
100
|
export * from "./components/table";
|
|
90
101
|
export * from "./components/tabs";
|
|
102
|
+
export * from "./components/tabs-subtle";
|
|
91
103
|
export * from "./components/textarea";
|
|
104
|
+
export * from "./components/thinking-indicator";
|
|
105
|
+
export * from "./components/thinking-steps";
|
|
92
106
|
export * from "./components/titled-card";
|
|
93
107
|
export * from "./components/toggle-group";
|
|
94
108
|
export * from "./components/toggle";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
import { useSurface, SurfaceProvider } from "./surface-context";
|
|
6
|
+
import { surfaceClasses } from "./surface-classes";
|
|
7
|
+
|
|
8
|
+
interface ElevatedProps extends ComponentPropsWithoutRef<"div"> {
|
|
9
|
+
/**
|
|
10
|
+
* Steps above the current substrate.
|
|
11
|
+
*
|
|
12
|
+
* The component's own surface level becomes `min(substrate + offset, 8)`
|
|
13
|
+
* and is re-provided to descendants via SurfaceProvider, so further
|
|
14
|
+
* nesting walks up the ladder automatically.
|
|
15
|
+
*
|
|
16
|
+
* Conventional offsets:
|
|
17
|
+
* 2 — dropdown / popover / select menu
|
|
18
|
+
* 4 — dialog / modal
|
|
19
|
+
*/
|
|
20
|
+
offset: number;
|
|
21
|
+
/**
|
|
22
|
+
* Override for the shadow level. Defaults to the computed surface level.
|
|
23
|
+
*
|
|
24
|
+
* Pass a fixed value when the component should keep a constant shadow
|
|
25
|
+
* weight regardless of how deeply it's nested — e.g. a dropdown always
|
|
26
|
+
* reads `shadow-surface-3` whether it opens on the page or inside a
|
|
27
|
+
* dialog, even though its background tracks the substrate.
|
|
28
|
+
*/
|
|
29
|
+
shadowLevel?: number;
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const Elevated = forwardRef<HTMLDivElement, ElevatedProps>(
|
|
34
|
+
({ offset, shadowLevel, className, children, ...props }, ref) => {
|
|
35
|
+
const substrate = useSurface();
|
|
36
|
+
const level = Math.min(substrate + offset, 8);
|
|
37
|
+
return (
|
|
38
|
+
<SurfaceProvider value={level}>
|
|
39
|
+
<div
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn(surfaceClasses(level, shadowLevel ?? level), className)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
</SurfaceProvider>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
Elevated.displayName = "Elevated";
|
|
51
|
+
|
|
52
|
+
export { Elevated };
|