@hilum/ui 3.3.4 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +800 -122
  5. package/dist/index.js +10458 -1119
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input-number.tsx +62 -21
  32. package/src/components/input.tsx +6 -3
  33. package/src/components/menu-item.tsx +156 -0
  34. package/src/components/mobile-drawer.tsx +50 -0
  35. package/src/components/nav-item.tsx +43 -0
  36. package/src/components/nav-menu.tsx +47 -0
  37. package/src/components/popover.tsx +3 -1
  38. package/src/components/radio-group.tsx +381 -35
  39. package/src/components/scroll-area.tsx +9 -2
  40. package/src/components/select.tsx +708 -121
  41. package/src/components/sheet.tsx +15 -6
  42. package/src/components/slider.tsx +1583 -20
  43. package/src/components/status-badge.tsx +2 -0
  44. package/src/components/switch.tsx +245 -28
  45. package/src/components/table.tsx +191 -77
  46. package/src/components/tabs-subtle.tsx +411 -0
  47. package/src/components/tabs.tsx +459 -49
  48. package/src/components/textarea.tsx +7 -3
  49. package/src/components/thinking-indicator.tsx +106 -0
  50. package/src/components/thinking-steps.tsx +382 -0
  51. package/src/components/tooltip.tsx +158 -33
  52. package/src/hooks/use-merge-split.tsx +359 -0
  53. package/src/hooks/use-proximity-hover.ts +208 -0
  54. package/src/index.ts +14 -0
  55. package/src/lib/elevated.tsx +52 -0
  56. package/src/lib/font-weight.ts +18 -0
  57. package/src/lib/icon-context.tsx +98 -0
  58. package/src/lib/icon-map.tsx +669 -0
  59. package/src/lib/interaction.ts +102 -0
  60. package/src/lib/scroll-fade.tsx +241 -0
  61. package/src/lib/shape-context.tsx +121 -0
  62. package/src/lib/springs.ts +30 -0
  63. package/src/lib/surface-classes.ts +43 -0
  64. package/src/lib/surface-context.tsx +17 -0
  65. package/src/tokens/tokens.ts +3 -0
  66. 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 };