@vvedantb/ui 0.18.1 → 0.19.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 CHANGED
@@ -1227,6 +1227,21 @@ const config = {
1227
1227
  ```
1228
1228
 
1229
1229
  - `Conversation`: `classes.content` (`ConversationContent`), `classes.scroll` (a default `scrollClassName`, merged before the prop), `classes.download`, and for `ConversationScrollButton` `classes.scrollButton`, `classes.scrollButtonMotion` (the animated wrapper; `""` renders it without a class) and `classes.scrollButtonFrame` (a static wrapper around the presence; none by default). `scrollButton` holds defaults for `label`, `icon`, `showDelay`, `size` and the motion (`initial`, `animate`, `exit`, `transition`). New `ConversationScrollButton` props: `label` (text after the icon, also `aria-label` and `title`), `icon`, `showDelay` (ms to wait after leaving the bottom before showing; hiding stays immediate; default 0) and `resetKey` (a change hides the button and cancels a pending show). The button now animates with `m` inside its own `LazyMotion`, so it also works under an app's `<LazyMotion strict>`.
1230
+ - `JumpRail`, `ChatJumpRail` (new): a timeline minimap for long scroll areas. Place it in a `relative` parent. `ChatJumpRail` goes inside `Conversation` and reads the scroll viewport and `stopScroll` from its context:
1231
+
1232
+ ```tsx
1233
+ <Conversation>
1234
+ <ConversationContent>{/* rows with data-message-id */}</ConversationContent>
1235
+ <ChatJumpRail
1236
+ messages={buildChatJumpRailTicks(transcript)}
1237
+ renderedFromId={firstRenderedId}
1238
+ onRevealMessage={revealMessage}
1239
+ formatPreview={mentionsToText}
1240
+ />
1241
+ </Conversation>
1242
+ ```
1243
+
1244
+ Outside a `StickToBottom` (for example a page outline) use `JumpRail` with `items` (`{ id, title, preview?, level? }`), a `scrollRef` or `getScrollElement`, and `targetAttribute="data-toc-id"` or `resolveTarget`. `onReveal` runs when a target is not in the DOM; `onBeforeScroll` runs just before it. `onSelect(id)` runs after a click or Enter/Space pick, once the scroll has started or `onReveal` has run, so an app can move focus to the target for keyboard users; `ChatJumpRail` accepts it too. `observeKey` re-observes when the rendered window changes. The geometry helpers (`resolveJumpRailTopPercent`, `resolveJumpRailIndexFromPointer`, `resolveJumpRailHeightStyle`, `resolveJumpRailTickWidthClass`, `resolveJumpRailTickLevelStyle`) are exported.
1230
1245
  - `Message`: `classes.root`, `classes.user`, `classes.assistant` (the role classes of `Message`), `classes.content` (all `MessageContent` classes), `classes.branchSelector`, `classes.branchIcon` (a class in place of the arrows' `size={14}`), and `lazyActionTooltips`: a `MessageAction` mounts its tooltip on first hover or focus (focus opens it), not with the button.
1231
1246
  - `PromptInput`: `classes.group` (the input group, default `overflow-hidden`), `classes.groupIdle` / `classes.groupDragOver` (added while no drag / an accepted drag is over the form), `classes.groupDefault` (added when no `containerClassName` is given), `classes.textarea`, `classes.footer`, `classes.submit`, `classes.tabItem`; `submitIcons.<send|submitted|streaming|error>`, `submitCrossfade` (cross-fade the submit icon on status change) and `menuTriggerAsChild` (`PromptInputActionMenuTrigger` becomes the Radix trigger instead of a button inside it). New `PromptInput` props: `dataDrop` (`{ type, onDrop }`: a non-file drag of that MIME type counts as a drag over the form and `onDrop` gets its string payload) and `inputGroupClassName` (same as `containerClassName`). The attachments context adds `replace(id, file)`; `accept` now matches extension patterns such as `.html` by file name; `useOptionalPromptInputController` is exported.
1232
1247
  - `PromptInputSettings` (new): an overflow menu with a "Model" submenu (one radio list, or a submenu per provider). Entry: `providerLabel(provider)` (default the id), `providerIcon(provider)` (default none), `classes.trigger`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
6
6
  "type": "module",
@@ -172,7 +172,7 @@
172
172
  "vitest": "^3.2.0",
173
173
  "zod": "^4.0.0",
174
174
  "zod3": "npm:zod@^3.25.76",
175
- "@vvedantb/server": "0.7.1"
175
+ "@vvedantb/server": "0.8.1"
176
176
  },
177
177
  "peerDependenciesMeta": {
178
178
  "@clerk/clerk-react": {
@@ -0,0 +1,125 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ import { useStickToBottomContext } from "use-stick-to-bottom";
4
+ import { JumpRail, type JumpRailItem, type JumpRailProps } from "./jump-rail";
5
+
6
+ /** One rail tick: a user turn and the assistant reply to it. */
7
+ export interface ChatJumpRailMessage {
8
+ id: string;
9
+ content: string;
10
+ reply?: string;
11
+ }
12
+
13
+ /** A transcript row, as input to `buildChatJumpRailTicks`. */
14
+ export interface ChatJumpRailTranscriptMessage {
15
+ id: string;
16
+ role: string;
17
+ content: string;
18
+ /** System alerts are skipped, both as ticks and as replies. */
19
+ isSystemAlert?: boolean;
20
+ }
21
+
22
+ /**
23
+ * One tick per user turn. The reply is the next assistant row before the next
24
+ * user row; system alerts are skipped.
25
+ */
26
+ export function buildChatJumpRailTicks(
27
+ messages: readonly ChatJumpRailTranscriptMessage[],
28
+ ): ChatJumpRailMessage[] {
29
+ const ticks: ChatJumpRailMessage[] = [];
30
+ for (let i = 0; i < messages.length; i++) {
31
+ const message = messages[i];
32
+ if (!message || message.role !== "user" || message.isSystemAlert) continue;
33
+ let reply: string | undefined;
34
+ for (let j = i + 1; j < messages.length; j++) {
35
+ const next = messages[j];
36
+ if (!next || next.isSystemAlert) continue;
37
+ if (next.role === "user") break;
38
+ if (next.role === "assistant") {
39
+ reply = next.content;
40
+ break;
41
+ }
42
+ }
43
+ ticks.push({
44
+ id: message.id,
45
+ content: message.content,
46
+ ...(reply !== undefined && reply.length > 0 ? { reply } : {}),
47
+ });
48
+ }
49
+ return ticks;
50
+ }
51
+
52
+ function collapseWhitespace(text: string): string {
53
+ return text.replace(/\s+/g, " ").trim();
54
+ }
55
+
56
+ /**
57
+ * Maps chat ticks to rail items. `formatPreview` turns raw text into display
58
+ * text (for example mention tokens to names); whitespace is collapsed after it.
59
+ * An empty user text becomes "Message".
60
+ */
61
+ export function buildChatJumpRailItems(
62
+ messages: readonly ChatJumpRailMessage[],
63
+ formatPreview: (text: string) => string = (text) => text,
64
+ ): JumpRailItem[] {
65
+ return messages.map((message) => {
66
+ const title = collapseWhitespace(formatPreview(message.content));
67
+ const reply = message.reply ? collapseWhitespace(formatPreview(message.reply)) : "";
68
+ return {
69
+ id: message.id,
70
+ title: title.length > 0 ? title : "Message",
71
+ preview: reply.length > 0 ? reply : null,
72
+ };
73
+ });
74
+ }
75
+
76
+ export interface ChatJumpRailProps
77
+ extends Omit<
78
+ JumpRailProps,
79
+ "items" | "scrollRef" | "getScrollElement" | "onReveal" | "onBeforeScroll" | "observeKey"
80
+ > {
81
+ /** The whole transcript as ticks (see `buildChatJumpRailTicks`), rendered or not. */
82
+ messages: readonly ChatJumpRailMessage[];
83
+ /**
84
+ * First row the transcript renders. A window that grows upwards has to
85
+ * re-observe, so a change here does.
86
+ */
87
+ renderedFromId?: string;
88
+ /** Renders an older turn outside the transcript window, then scrolls to it. */
89
+ onRevealMessage?: (messageId: string) => void;
90
+ /** Raw text to display text. Default: the text as is. */
91
+ formatPreview?: (text: string) => string;
92
+ }
93
+
94
+ /**
95
+ * `JumpRail` for a chat. Must render inside `<Conversation>` (StickToBottom):
96
+ * it reads the scroll viewport and `stopScroll` from its context and resolves
97
+ * `[data-message-id]` targets in it. Jumping to a turn that is not rendered
98
+ * stops the stick-to-bottom scroll and calls `onRevealMessage`.
99
+ */
100
+ export function ChatJumpRail({
101
+ messages,
102
+ renderedFromId,
103
+ onRevealMessage,
104
+ formatPreview,
105
+ testId = "chat-jump-rail",
106
+ ...rest
107
+ }: ChatJumpRailProps) {
108
+ const { scrollRef, stopScroll } = useStickToBottomContext();
109
+ // Memoised on `messages`, which keeps its identity while a turn streams.
110
+ const items = useMemo(
111
+ () => buildChatJumpRailItems(messages, formatPreview),
112
+ [messages, formatPreview],
113
+ );
114
+ return (
115
+ <JumpRail
116
+ {...rest}
117
+ testId={testId}
118
+ items={items}
119
+ scrollRef={scrollRef}
120
+ observeKey={renderedFromId ?? null}
121
+ onBeforeScroll={stopScroll}
122
+ {...(onRevealMessage ? { onReveal: onRevealMessage } : {})}
123
+ />
124
+ );
125
+ }
@@ -0,0 +1,90 @@
1
+ /** Matches t3code `TIMELINE_MINIMAP_ITEM_SPACING`: a compact rail, not full height. */
2
+ export const JUMP_RAIL_ITEM_SPACING_PX = 8;
3
+ /** `dvh`, not `vh`: mobile browser chrome makes `100vh` taller than the visible area. */
4
+ export const JUMP_RAIL_MAX_HEIGHT_CSS = "calc(100dvh - 18rem)";
5
+ /** Fewest items for which the rail renders. */
6
+ export const JUMP_RAIL_MIN_VISIBLE_ITEMS = 2;
7
+ /** Tick x offset per heading level below 1, in px. */
8
+ export const JUMP_RAIL_LEVEL_INDENT_PX = 2;
9
+ /** Narrowest a levelled tick gets, in px. */
10
+ export const JUMP_RAIL_MIN_TICK_WIDTH_PX = 4;
11
+
12
+ /** Shortest rail, in px, so rails with few items stay easy to hover. */
13
+ export const JUMP_RAIL_MIN_HEIGHT_PX = 48;
14
+
15
+ /** Rail height: one spacing step per gap, floored at the minimum, capped at `maxHeight`. */
16
+ export function resolveJumpRailHeightStyle(
17
+ itemCount: number,
18
+ maxHeight: string = JUMP_RAIL_MAX_HEIGHT_CSS,
19
+ ): string {
20
+ const naturalHeight = Math.max(
21
+ JUMP_RAIL_MIN_HEIGHT_PX,
22
+ (itemCount - 1) * JUMP_RAIL_ITEM_SPACING_PX,
23
+ );
24
+ return `min(${naturalHeight}px, ${maxHeight})`;
25
+ }
26
+
27
+ /** Tick position down the rail, 0 to 100. */
28
+ export function resolveJumpRailTopPercent(index: number, itemCount: number): number {
29
+ if (itemCount <= 1) return 0;
30
+ return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100;
31
+ }
32
+
33
+ /** The item under the pointer, or `null` when the rail has no size or no items. */
34
+ export function resolveJumpRailIndexFromPointer(input: {
35
+ itemCount: number;
36
+ railTop: number;
37
+ railHeight: number;
38
+ pointerY: number;
39
+ }): number | null {
40
+ if (input.itemCount <= 0 || input.railHeight <= 0) return null;
41
+ if (input.itemCount === 1) return 0;
42
+ const progress = Math.max(
43
+ 0,
44
+ Math.min(1, (input.pointerY - input.railTop) / input.railHeight),
45
+ );
46
+ return Math.max(
47
+ 0,
48
+ Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))),
49
+ );
50
+ }
51
+
52
+ /** Preview card `translateY`: pinned to the rail ends, centred between. */
53
+ export function resolveJumpRailTooltipTranslate(index: number, itemCount: number): string {
54
+ if (index === 0) return "0%";
55
+ if (index === itemCount - 1) return "-100%";
56
+ return "-50%";
57
+ }
58
+
59
+ /** Width in px of a tick at `distance` items from the hovered one (`null`: no hover). */
60
+ export function resolveJumpRailTickWidthPx(distance: number | null): number {
61
+ if (distance === 0) return 24;
62
+ if (distance === 1) return 16;
63
+ if (distance === 2) return 10;
64
+ return 8;
65
+ }
66
+
67
+ /** Tailwind width class for a tick at `distance` from the hovered one (`null`: no hover). */
68
+ export function resolveJumpRailTickWidthClass(distance: number | null): string {
69
+ if (distance === 0) return "w-6";
70
+ if (distance === 1) return "w-4";
71
+ if (distance === 2) return "w-2.5";
72
+ return "w-2";
73
+ }
74
+
75
+ /**
76
+ * Level-based inline layout for sub-heading ticks. Level 1 (or none) returns
77
+ * `undefined`, so the tick keeps its width class. Deeper levels indent by
78
+ * `JUMP_RAIL_LEVEL_INDENT_PX` per level and shrink by the same amount.
79
+ */
80
+ export function resolveJumpRailTickLevelStyle(
81
+ distance: number | null,
82
+ level: number | undefined,
83
+ ): { left: number; width: number } | undefined {
84
+ if (level === undefined || !Number.isFinite(level) || level <= 1) return undefined;
85
+ const indent = (Math.floor(level) - 1) * JUMP_RAIL_LEVEL_INDENT_PX;
86
+ return {
87
+ left: indent,
88
+ width: Math.max(JUMP_RAIL_MIN_TICK_WIDTH_PX, resolveJumpRailTickWidthPx(distance) - indent),
89
+ };
90
+ }
@@ -0,0 +1,307 @@
1
+ "use client";
2
+ import {
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ type RefObject,
9
+ } from "react";
10
+ import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
11
+ import { tokenTransition } from "../motion/presets";
12
+ import { cn } from "../utils/cn";
13
+ import { useCn } from "../ui/ui-config";
14
+ import {
15
+ JUMP_RAIL_MIN_VISIBLE_ITEMS,
16
+ resolveJumpRailHeightStyle,
17
+ resolveJumpRailIndexFromPointer,
18
+ resolveJumpRailTickLevelStyle,
19
+ resolveJumpRailTickWidthClass,
20
+ resolveJumpRailTooltipTranslate,
21
+ resolveJumpRailTopPercent,
22
+ } from "./jump-rail-geometry";
23
+
24
+ export interface JumpRailItem {
25
+ id: string;
26
+ /** First line of the preview card; also the button's accessible label. */
27
+ title: string;
28
+ /** Up to three lines under the title. */
29
+ preview?: string | null;
30
+ /** Heading level (1 is the longest tick). Deeper levels get shorter, indented ticks. */
31
+ level?: number;
32
+ }
33
+
34
+ export interface JumpRailProps {
35
+ items: readonly JumpRailItem[];
36
+ /** The scroll container. Read when the rail observes and scrolls. */
37
+ scrollRef?: RefObject<HTMLElement | null>;
38
+ /** Alternative to `scrollRef`: returns the scroll container. */
39
+ getScrollElement?: () => HTMLElement | null;
40
+ /** Attribute that holds an item id on its target. Default `data-message-id`. */
41
+ targetAttribute?: string;
42
+ /** Replaces the `[targetAttribute="id"]` lookup inside the scroll container. */
43
+ resolveTarget?: (root: HTMLElement, id: string) => HTMLElement | null;
44
+ /**
45
+ * Called when the target is not in the DOM (for example a windowed
46
+ * transcript). The consumer renders it and scrolls to it.
47
+ */
48
+ onReveal?: (id: string) => void;
49
+ /** Called just before `onReveal`, for example `stopScroll` from use-stick-to-bottom. */
50
+ onBeforeScroll?: () => void;
51
+ /**
52
+ * Called after the user picks an item by click or Enter/Space, once the
53
+ * scroll has started or `onReveal` has run. For example, to move focus to the
54
+ * target for keyboard users.
55
+ */
56
+ onSelect?: (id: string) => void;
57
+ /**
58
+ * Changing this re-observes the targets. Use it when rows render later or
59
+ * the window grows, for example the first rendered id.
60
+ */
61
+ observeKey?: string | number | null;
62
+ /** The rail renders nothing below this many items. Default 2. */
63
+ minVisibleItems?: number;
64
+ /** Merged into the wrapper classes, which place the rail. */
65
+ className?: string;
66
+ /** Wrapper `aria-label`. Default "Jump to message". */
67
+ ariaLabel?: string;
68
+ /** Start of the button label, followed by the active title. Default "Jump to message". */
69
+ buttonLabel?: string;
70
+ /** Button label subject while no item is active. Default "User message". */
71
+ fallbackLabel?: string;
72
+ /** Wrapper `data-testid`. Default `jump-rail`. */
73
+ testId?: string;
74
+ }
75
+
76
+ const DEFAULT_WRAPPER_CLASS =
77
+ "pointer-events-none absolute inset-y-0 left-0 z-30 hidden w-14 lg:pointer-fine:block";
78
+
79
+ function escapeAttributeValue(value: string): string {
80
+ return value.replace(/["\\]/g, "\\$&");
81
+ }
82
+
83
+ function findTarget(
84
+ root: HTMLElement,
85
+ id: string,
86
+ attribute: string,
87
+ resolve: ((root: HTMLElement, id: string) => HTMLElement | null) | undefined,
88
+ ): HTMLElement | null {
89
+ if (resolve) return resolve(root, id);
90
+ return root.querySelector<HTMLElement>(`[${attribute}="${escapeAttributeValue(id)}"]`);
91
+ }
92
+
93
+ /**
94
+ * t3code-style timeline minimap: a short, vertically centred rail of ticks,
95
+ * one per item. Hover scrubbing shows a floating preview; click jumps to the
96
+ * item's target inside the scroll container. Ticks for targets in the top part
97
+ * of the viewport are highlighted. Place it in a `relative` parent.
98
+ */
99
+ export function JumpRail({
100
+ items,
101
+ scrollRef,
102
+ getScrollElement,
103
+ targetAttribute = "data-message-id",
104
+ resolveTarget,
105
+ onReveal,
106
+ onBeforeScroll,
107
+ onSelect,
108
+ observeKey,
109
+ minVisibleItems = JUMP_RAIL_MIN_VISIBLE_ITEMS,
110
+ className,
111
+ ariaLabel = "Jump to message",
112
+ buttonLabel = "Jump to message",
113
+ fallbackLabel = "User message",
114
+ testId = "jump-rail",
115
+ }: JumpRailProps) {
116
+ const merge = useCn();
117
+ const [hoverIndex, setHoverIndex] = useState<number | null>(null);
118
+ const [inViewIds, setInViewIds] = useState<ReadonlySet<string>>(() => new Set());
119
+
120
+ // Latest props for the effect and handlers, so an inline getter or resolver
121
+ // does not rebuild the observer on every render.
122
+ const latest = useRef({ scrollRef, getScrollElement, resolveTarget, targetAttribute });
123
+ latest.current = { scrollRef, getScrollElement, resolveTarget, targetAttribute };
124
+ const readRoot = () =>
125
+ latest.current.getScrollElement?.() ?? latest.current.scrollRef?.current ?? null;
126
+
127
+ const idsKey = JSON.stringify(items.map((item) => item.id));
128
+ const count = items.length;
129
+
130
+ useEffect(() => {
131
+ const root = readRoot();
132
+ if (!root || count < minVisibleItems) return;
133
+ const ids = JSON.parse(idsKey) as string[];
134
+
135
+ const idByTarget = new Map<Element, string>();
136
+ for (const id of ids) {
137
+ const target = findTarget(
138
+ root,
139
+ id,
140
+ latest.current.targetAttribute,
141
+ latest.current.resolveTarget,
142
+ );
143
+ if (target) idByTarget.set(target, id);
144
+ }
145
+ if (idByTarget.size === 0) return;
146
+
147
+ const visible = new Set<string>();
148
+ const observer = new IntersectionObserver(
149
+ (entries) => {
150
+ for (const entry of entries) {
151
+ const id = idByTarget.get(entry.target);
152
+ if (id === undefined) continue;
153
+ if (entry.isIntersecting) visible.add(id);
154
+ else visible.delete(id);
155
+ }
156
+ setInViewIds(new Set(visible));
157
+ },
158
+ { root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
159
+ );
160
+ for (const target of idByTarget.keys()) observer.observe(target);
161
+ return () => observer.disconnect();
162
+ }, [idsKey, count, minVisibleItems, observeKey, scrollRef]);
163
+
164
+ const scrollToItem = (id: string) => {
165
+ const root = readRoot();
166
+ const target = root
167
+ ? findTarget(root, id, targetAttribute, resolveTarget)
168
+ : null;
169
+ if (target) {
170
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
171
+ onSelect?.(id);
172
+ return;
173
+ }
174
+ // Rendering earlier rows grows the content; a reader stuck to the bottom
175
+ // would be pulled back down instead of reaching the item.
176
+ onBeforeScroll?.();
177
+ onReveal?.(id);
178
+ onSelect?.(id);
179
+ };
180
+
181
+ const indexFromPointer = (event: MouseEvent<HTMLElement>) => {
182
+ const rect = event.currentTarget.getBoundingClientRect();
183
+ return resolveJumpRailIndexFromPointer({
184
+ itemCount: count,
185
+ railTop: rect.top,
186
+ railHeight: rect.height,
187
+ pointerY: event.clientY,
188
+ });
189
+ };
190
+
191
+ const moveHoverIndex = (delta: number) => {
192
+ setHoverIndex((current) => Math.max(0, Math.min(count - 1, (current ?? 0) + delta)));
193
+ };
194
+
195
+ const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
196
+ if (event.key === "ArrowDown") {
197
+ event.preventDefault();
198
+ moveHoverIndex(1);
199
+ } else if (event.key === "ArrowUp") {
200
+ event.preventDefault();
201
+ moveHoverIndex(-1);
202
+ } else if (event.key === "Home") {
203
+ event.preventDefault();
204
+ setHoverIndex(0);
205
+ } else if (event.key === "End") {
206
+ event.preventDefault();
207
+ setHoverIndex(count - 1);
208
+ } else if (event.key === "Enter" || event.key === " ") {
209
+ event.preventDefault();
210
+ const active = hoverIndex !== null ? (items[hoverIndex] ?? null) : null;
211
+ if (active) scrollToItem(active.id);
212
+ }
213
+ };
214
+
215
+ if (count < minVisibleItems) return null;
216
+
217
+ const activeIndex = hoverIndex !== null && hoverIndex < count ? hoverIndex : null;
218
+ const activeItem = activeIndex === null ? null : (items[activeIndex] ?? null);
219
+ const activeTopPercent =
220
+ activeIndex === null ? 0 : resolveJumpRailTopPercent(activeIndex, count);
221
+ const activeTranslate =
222
+ activeIndex === null ? "-50%" : resolveJumpRailTooltipTranslate(activeIndex, count);
223
+
224
+ return (
225
+ <div
226
+ className={merge(DEFAULT_WRAPPER_CLASS, className)}
227
+ aria-label={ariaLabel}
228
+ data-testid={testId}
229
+ >
230
+ <div className="relative h-full w-full select-none">
231
+ <button
232
+ type="button"
233
+ aria-label={`${buttonLabel}: ${activeItem?.title ?? fallbackLabel}`}
234
+ className="pointer-events-auto absolute top-1/2 left-2 w-10 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/70"
235
+ style={{ height: resolveJumpRailHeightStyle(count) }}
236
+ onBlur={() => setHoverIndex(null)}
237
+ onFocus={() => setHoverIndex((current) => current ?? 0)}
238
+ onMouseLeave={() => setHoverIndex(null)}
239
+ onMouseMove={(event) => {
240
+ setHoverIndex(indexFromPointer(event));
241
+ }}
242
+ onMouseDown={(event) => {
243
+ event.preventDefault();
244
+ }}
245
+ onClick={(event) => {
246
+ const nextIndex = indexFromPointer(event);
247
+ const next = nextIndex === null ? null : (items[nextIndex] ?? null);
248
+ if (next) scrollToItem(next.id);
249
+ event.currentTarget.blur();
250
+ }}
251
+ onKeyDown={handleKeyDown}
252
+ >
253
+ <div className="absolute top-0 left-3 h-full w-px bg-border/15" />
254
+ {items.map((item, index) => {
255
+ const distance = activeIndex === null ? null : Math.abs(index - activeIndex);
256
+ const inView = inViewIds.has(item.id);
257
+ const levelStyle = resolveJumpRailTickLevelStyle(distance, item.level);
258
+ return (
259
+ <span
260
+ key={item.id}
261
+ aria-hidden="true"
262
+ data-in-view={inView ? "true" : "false"}
263
+ {...(item.level === undefined ? {} : { "data-level": item.level })}
264
+ className={cn(
265
+ "pointer-events-none absolute left-0 h-0.5 -translate-y-1/2 rounded-full bg-muted-foreground/35 transition-colors data-[in-view=true]:bg-foreground/90",
266
+ levelStyle === undefined ? resolveJumpRailTickWidthClass(distance) : undefined,
267
+ distance === 0 && "bg-muted-foreground/75",
268
+ )}
269
+ style={{
270
+ top: `${resolveJumpRailTopPercent(index, count)}%`,
271
+ ...levelStyle,
272
+ }}
273
+ />
274
+ );
275
+ })}
276
+ <LazyMotion features={domAnimation}>
277
+ <AnimatePresence>
278
+ {activeItem ? (
279
+ <m.span
280
+ key={activeItem.id}
281
+ className="pointer-events-none absolute left-8 w-80 rounded-xl bg-popover/95 p-3 text-left text-popover-foreground smooth-shadow-ring-xl shadow-black/25 backdrop-blur-sm"
282
+ style={{
283
+ top: `${activeTopPercent}%`,
284
+ transform: `translateY(${activeTranslate})`,
285
+ }}
286
+ initial={{ opacity: 0 }}
287
+ animate={{ opacity: 1 }}
288
+ exit={{ opacity: 0 }}
289
+ transition={tokenTransition.fast}
290
+ >
291
+ <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
292
+ {activeItem.title}
293
+ </span>
294
+ {activeItem.preview ? (
295
+ <span className="mt-1 line-clamp-3 text-sm leading-5 text-muted-foreground">
296
+ {activeItem.preview}
297
+ </span>
298
+ ) : null}
299
+ </m.span>
300
+ ) : null}
301
+ </AnimatePresence>
302
+ </LazyMotion>
303
+ </button>
304
+ </div>
305
+ </div>
306
+ );
307
+ }
package/src/index.ts CHANGED
@@ -127,6 +127,9 @@ export * from "./ai-elements/conversation";
127
127
  export * from "./ai-elements/file-chip";
128
128
  export * from "./ai-elements/image";
129
129
  export * from "./ai-elements/inline-citation";
130
+ export * from "./ai-elements/jump-rail";
131
+ export * from "./ai-elements/jump-rail-geometry";
132
+ export * from "./ai-elements/chat-jump-rail";
130
133
  export * from "./ai-elements/message";
131
134
  export * from "./ai-elements/model-selector";
132
135
  export * from "./ai-elements/prompt-input";