@vvedantb/ui 0.18.1 → 0.19.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.
- package/README.md +15 -0
- package/package.json +2 -2
- package/src/ai-elements/chat-jump-rail.tsx +125 -0
- package/src/ai-elements/jump-rail-geometry.ts +84 -0
- package/src/ai-elements/jump-rail.tsx +307 -0
- package/src/index.ts +3 -0
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.
|
|
3
|
+
"version": "0.19.0",
|
|
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.
|
|
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,84 @@
|
|
|
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
|
+
/** Rail height: one spacing step per gap, capped at `maxHeight`. */
|
|
13
|
+
export function resolveJumpRailHeightStyle(
|
|
14
|
+
itemCount: number,
|
|
15
|
+
maxHeight: string = JUMP_RAIL_MAX_HEIGHT_CSS,
|
|
16
|
+
): string {
|
|
17
|
+
const naturalHeight = Math.max(1, (itemCount - 1) * JUMP_RAIL_ITEM_SPACING_PX);
|
|
18
|
+
return `min(${naturalHeight}px, ${maxHeight})`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Tick position down the rail, 0 to 100. */
|
|
22
|
+
export function resolveJumpRailTopPercent(index: number, itemCount: number): number {
|
|
23
|
+
if (itemCount <= 1) return 0;
|
|
24
|
+
return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The item under the pointer, or `null` when the rail has no size or no items. */
|
|
28
|
+
export function resolveJumpRailIndexFromPointer(input: {
|
|
29
|
+
itemCount: number;
|
|
30
|
+
railTop: number;
|
|
31
|
+
railHeight: number;
|
|
32
|
+
pointerY: number;
|
|
33
|
+
}): number | null {
|
|
34
|
+
if (input.itemCount <= 0 || input.railHeight <= 0) return null;
|
|
35
|
+
if (input.itemCount === 1) return 0;
|
|
36
|
+
const progress = Math.max(
|
|
37
|
+
0,
|
|
38
|
+
Math.min(1, (input.pointerY - input.railTop) / input.railHeight),
|
|
39
|
+
);
|
|
40
|
+
return Math.max(
|
|
41
|
+
0,
|
|
42
|
+
Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Preview card `translateY`: pinned to the rail ends, centred between. */
|
|
47
|
+
export function resolveJumpRailTooltipTranslate(index: number, itemCount: number): string {
|
|
48
|
+
if (index === 0) return "0%";
|
|
49
|
+
if (index === itemCount - 1) return "-100%";
|
|
50
|
+
return "-50%";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Width in px of a tick at `distance` items from the hovered one (`null`: no hover). */
|
|
54
|
+
export function resolveJumpRailTickWidthPx(distance: number | null): number {
|
|
55
|
+
if (distance === 0) return 24;
|
|
56
|
+
if (distance === 1) return 16;
|
|
57
|
+
if (distance === 2) return 10;
|
|
58
|
+
return 8;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Tailwind width class for a tick at `distance` from the hovered one (`null`: no hover). */
|
|
62
|
+
export function resolveJumpRailTickWidthClass(distance: number | null): string {
|
|
63
|
+
if (distance === 0) return "w-6";
|
|
64
|
+
if (distance === 1) return "w-4";
|
|
65
|
+
if (distance === 2) return "w-2.5";
|
|
66
|
+
return "w-2";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Level-based inline layout for sub-heading ticks. Level 1 (or none) returns
|
|
71
|
+
* `undefined`, so the tick keeps its width class. Deeper levels indent by
|
|
72
|
+
* `JUMP_RAIL_LEVEL_INDENT_PX` per level and shrink by the same amount.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveJumpRailTickLevelStyle(
|
|
75
|
+
distance: number | null,
|
|
76
|
+
level: number | undefined,
|
|
77
|
+
): { left: number; width: number } | undefined {
|
|
78
|
+
if (level === undefined || !Number.isFinite(level) || level <= 1) return undefined;
|
|
79
|
+
const indent = (Math.floor(level) - 1) * JUMP_RAIL_LEVEL_INDENT_PX;
|
|
80
|
+
return {
|
|
81
|
+
left: indent,
|
|
82
|
+
width: Math.max(JUMP_RAIL_MIN_TICK_WIDTH_PX, resolveJumpRailTickWidthPx(distance) - indent),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -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";
|