@brett_lamy/docstream 1.6.1 → 1.8.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 (49) hide show
  1. package/README.md +31 -4
  2. package/dist/demo/DemoViewer.d.ts +8 -0
  3. package/dist/demo/DemoViewer.d.ts.map +1 -1
  4. package/dist/demo/DemoViewer.js +74 -1
  5. package/dist/demo/DemoViewer.js.map +1 -1
  6. package/dist/demo/PathBreadcrumb.d.ts +54 -0
  7. package/dist/demo/PathBreadcrumb.d.ts.map +1 -0
  8. package/dist/demo/PathBreadcrumb.js +245 -0
  9. package/dist/demo/PathBreadcrumb.js.map +1 -0
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/replay/ReplayPlayer.d.ts +64 -0
  15. package/dist/replay/ReplayPlayer.d.ts.map +1 -0
  16. package/dist/replay/ReplayPlayer.js +211 -0
  17. package/dist/replay/ReplayPlayer.js.map +1 -0
  18. package/dist/replay/ReplayPreview.d.ts +24 -12
  19. package/dist/replay/ReplayPreview.d.ts.map +1 -1
  20. package/dist/replay/ReplayPreview.js +37 -32
  21. package/dist/replay/ReplayPreview.js.map +1 -1
  22. package/dist/replay/ReplayStageImpl.js +1 -1
  23. package/dist/replay/ReplayStageImpl.js.map +1 -1
  24. package/dist/replay/demo/index.d.ts +3 -0
  25. package/dist/replay/demo/index.d.ts.map +1 -0
  26. package/dist/replay/demo/index.js +66 -0
  27. package/dist/replay/demo/index.js.map +1 -0
  28. package/dist/replay/index.d.ts +2 -2
  29. package/dist/replay/index.d.ts.map +1 -1
  30. package/dist/replay/index.js +1 -1
  31. package/dist/replay/index.js.map +1 -1
  32. package/dist/replay/types.js +1 -1
  33. package/dist/styles.css +791 -79
  34. package/package.json +7 -1
  35. package/src/demo/DemoViewer.tsx +94 -2
  36. package/src/demo/PathBreadcrumb.tsx +367 -0
  37. package/src/index.ts +2 -1
  38. package/src/replay/ReplayPlayer.tsx +430 -0
  39. package/src/replay/ReplayPreview.tsx +111 -85
  40. package/src/replay/ReplayStageImpl.tsx +1 -1
  41. package/src/replay/demo/index.ts +67 -0
  42. package/src/replay/index.ts +6 -1
  43. package/src/replay/types.ts +1 -1
  44. package/src/styles.css +791 -79
  45. package/dist/replay/NativeReplay.d.ts +0 -9
  46. package/dist/replay/NativeReplay.d.ts.map +0 -1
  47. package/dist/replay/NativeReplay.js +0 -175
  48. package/dist/replay/NativeReplay.js.map +0 -1
  49. package/src/replay/NativeReplay.tsx +0 -240
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -68,6 +68,12 @@
68
68
  "import": "./dist/replay/index.js",
69
69
  "default": "./dist/replay/index.js"
70
70
  },
71
+ "./replay/demo": {
72
+ "source": "./src/replay/demo/index.ts",
73
+ "types": "./dist/replay/demo/index.d.ts",
74
+ "import": "./dist/replay/demo/index.js",
75
+ "default": "./dist/replay/demo/index.js"
76
+ },
71
77
  "./video": {
72
78
  "source": "./src/video/index.ts",
73
79
  "types": "./dist/video/index.d.ts",
@@ -38,6 +38,7 @@ import { PillTabs, Segmented, rovingKeyDown } from "../docs/controls"
38
38
  import { useDemoResolver, useDemoRuntime } from "./context"
39
39
  import { createDocstreamClipboard, type DemoSourceSnapshot, type DocstreamClipboard } from "./clipboard"
40
40
  import { CopyDemoButton } from "./copy-demo"
41
+ import { PathBreadcrumb } from "./PathBreadcrumb"
41
42
  import { languageForPath } from "./glob"
42
43
  import { useDemoVFSContext, useDemoVFSEntry, type DemoVFS } from "./vfs"
43
44
  import { cssToSurface, surfaceStyle, surfaceToCss } from "./surface"
@@ -455,6 +456,22 @@ function fileTree(files: DemoFile[]): TreeRow[] {
455
456
  return rows
456
457
  }
457
458
 
459
+ export const FILES_DEFAULT_WIDTH = 188
460
+ export const FILES_MIN_WIDTH = 140
461
+ /** The file list never takes more than this share of the viewer, so the source pane can't collapse. */
462
+ export const FILES_MAX_SHARE = 0.6
463
+
464
+ /** The widest the file list may be inside a viewer `container` px wide (never below the minimum). */
465
+ export function filesMaxWidth(container: number): number {
466
+ return Math.max(FILES_MIN_WIDTH, Math.floor(container * FILES_MAX_SHARE))
467
+ }
468
+
469
+ /** Clamp a file list width to [min, 60% of the viewer]. An unmeasured viewer (0) only enforces the minimum. */
470
+ export function clampFilesWidth(width: number, container: number): number {
471
+ const max = container > 0 ? filesMaxWidth(container) : Infinity
472
+ return Math.round(Math.min(max, Math.max(FILES_MIN_WIDTH, width)))
473
+ }
474
+
458
475
  function CodeView({ src, files, height, id }: { src: string; files: DemoFile[]; height: string; id: string }) {
459
476
  const [active, setActive] = useState(files[0]?.path ?? "")
460
477
  useEffect(() => {
@@ -465,8 +482,67 @@ function CodeView({ src, files, height, id }: { src: string; files: DemoFile[];
465
482
  const paths = files.map((f) => f.path)
466
483
  const tabId = (path: string) => `${id}-file-${paths.indexOf(path)}`
467
484
  const panelId = `${id}-source`
485
+
486
+ // The file list width is the user's choice (null: the default). It lives here, so it survives file switches.
487
+ const code = useRef<HTMLDivElement | null>(null)
488
+ const [chosen, setChosen] = useState<number | null>(null)
489
+ const [container, setContainer] = useState(0)
490
+ const [dragging, setDragging] = useState(false)
491
+ useEffect(() => {
492
+ const el = code.current
493
+ if (!el || typeof ResizeObserver === "undefined") return
494
+ const observer = new ResizeObserver(() => setContainer(Math.round(el.getBoundingClientRect().width)))
495
+ observer.observe(el)
496
+ return () => observer.disconnect()
497
+ }, [])
498
+ const width = clampFilesWidth(chosen ?? FILES_DEFAULT_WIDTH, container)
499
+ const max = container > 0 ? filesMaxWidth(container) : undefined
500
+
501
+ const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
502
+ const el = code.current
503
+ if (!el || event.button !== 0) return
504
+ event.preventDefault()
505
+ const handle = event.currentTarget
506
+ const pointer = event.pointerId
507
+ try {
508
+ handle.setPointerCapture(pointer)
509
+ } catch {
510
+ /* not capturable (synthetic event): the window listeners still follow the drag */
511
+ }
512
+ const left = el.getBoundingClientRect().left
513
+ setDragging(true)
514
+ const move = (ev: PointerEvent) => setChosen(clampFilesWidth(ev.clientX - left, el.getBoundingClientRect().width))
515
+ const up = () => {
516
+ setDragging(false)
517
+ if (handle.hasPointerCapture?.(pointer)) handle.releasePointerCapture(pointer)
518
+ window.removeEventListener("pointermove", move)
519
+ window.removeEventListener("pointerup", up)
520
+ window.removeEventListener("pointercancel", up)
521
+ }
522
+ window.addEventListener("pointermove", move)
523
+ window.addEventListener("pointerup", up)
524
+ window.addEventListener("pointercancel", up)
525
+ }
526
+
527
+ const onResizeKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
528
+ const step = event.shiftKey ? 50 : 10
529
+ let next: number | undefined
530
+ if (event.key === "ArrowLeft") next = width - step
531
+ else if (event.key === "ArrowRight") next = width + step
532
+ else if (event.key === "Home") next = FILES_MIN_WIDTH
533
+ else if (event.key === "End") next = max ?? width
534
+ if (next === undefined) return
535
+ event.preventDefault()
536
+ setChosen(clampFilesWidth(next, container))
537
+ }
538
+
468
539
  return (
469
- <div className="docs-demo-code" style={{ height }}>
540
+ <div
541
+ ref={code}
542
+ className="docs-demo-code"
543
+ style={{ height, ...(chosen === null ? null : { "--docs-demo-files-w": `${width}px` }) } as CSSProperties}
544
+ data-dragging={dragging || undefined}
545
+ >
470
546
  <div
471
547
  className="docs-demo-files"
472
548
  role="tablist"
@@ -502,9 +578,25 @@ function CodeView({ src, files, height, id }: { src: string; files: DemoFile[];
502
578
  ),
503
579
  )}
504
580
  </div>
581
+ <div
582
+ className="docs-demo-files-handle"
583
+ role="separator"
584
+ tabIndex={0}
585
+ aria-orientation="vertical"
586
+ aria-label="Resize file list"
587
+ aria-controls={panelId}
588
+ aria-valuemin={FILES_MIN_WIDTH}
589
+ aria-valuemax={max}
590
+ aria-valuenow={width}
591
+ aria-valuetext={`${width} pixels wide`}
592
+ data-dragging={dragging || undefined}
593
+ onPointerDown={startDrag}
594
+ onKeyDown={onResizeKey}
595
+ onDoubleClick={() => setChosen(null)}
596
+ />
505
597
  <div className="docs-demo-source" role="tabpanel" id={panelId} aria-labelledby={file ? tabId(file.path) : undefined}>
506
598
  <div className="docs-demo-source-h">
507
- <span className="docs-demo-source-path">{src && file ? `${src}/${file.path}` : file?.path}</span>
599
+ {file ? <PathBreadcrumb src={src} path={file.path} paths={paths} onSelect={setActive} /> : <span className="docs-demo-crumbs" />}
508
600
  {file ? <CopyButton text={file.content} label={`Copy ${file.path}`} /> : null}
509
601
  </div>
510
602
  <div className="docs-demo-source-body" tabIndex={0} aria-label={file ? `${file.path} source` : undefined}>
@@ -0,0 +1,367 @@
1
+ /* The Code view's path header: a VS Code style breadcrumb for the active file.
2
+
3
+ One button per path part (the demo `src` first, then the folders, then the file), with a chevron
4
+ between them. A folder button opens a menu of that folder's siblings (everything in its parent
5
+ folder, the current one marked); the file button lists the files beside it. Picking a file selects
6
+ it, picking a folder opens the first file inside it. When the segments don't fit the container the
7
+ middle collapses into a single `…` that lists the hidden segments.
8
+
9
+ Plain accessible DOM (nav > ol, buttons, role="menu"), so docstream needs nothing beyond React.
10
+ The menu is portalled and `position: fixed`, so the viewer's overflow never clips it. The segment,
11
+ sibling and fit maths are pure and exported (see PathBreadcrumb.test.ts). */
12
+ import {
13
+ useCallback,
14
+ useEffect,
15
+ useId,
16
+ useLayoutEffect,
17
+ useRef,
18
+ useState,
19
+ type CSSProperties,
20
+ type KeyboardEvent as ReactKeyboardEvent,
21
+ } from "react"
22
+ import { createPortal } from "react-dom"
23
+ import { Check, ChevronRight, FileCode2, Folder } from "lucide-react"
24
+
25
+ export interface CrumbSegment {
26
+ /** Unique within the breadcrumb. */
27
+ key: string
28
+ name: string
29
+ /** `src`: a part of the demo id (it names the demo's root); `dir`/`file`: a part of the file path. */
30
+ kind: "src" | "dir" | "file"
31
+ /** The folder or file path within the demo's file list. Empty for `src` segments. */
32
+ path: string
33
+ }
34
+
35
+ export interface CrumbEntry {
36
+ kind: "dir" | "file"
37
+ name: string
38
+ /** The entry's path within the demo's file list (empty for a demo root). */
39
+ path: string
40
+ /** The file to open when the entry is picked: itself for a file, the first file inside for a folder. */
41
+ target: string
42
+ }
43
+
44
+ /** The hidden stretch of a collapsed breadcrumb: segments `start` up to (not including) `end`. */
45
+ export interface CrumbRange {
46
+ start: number
47
+ end: number
48
+ }
49
+
50
+ /** `src` parts, then the folders, then the file. */
51
+ export function buildCrumbs(src: string, filePath: string): CrumbSegment[] {
52
+ const out: CrumbSegment[] = []
53
+ for (const name of src.split("/").filter(Boolean)) out.push({ key: `s${out.length}:${name}`, name, kind: "src", path: "" })
54
+ const parts = filePath.split("/").filter(Boolean)
55
+ parts.forEach((name, index) => {
56
+ const path = parts.slice(0, index + 1).join("/")
57
+ out.push({ key: `p:${path}`, name, kind: index === parts.length - 1 ? "file" : "dir", path })
58
+ })
59
+ return out
60
+ }
61
+
62
+ const parentOf = (path: string) => path.slice(0, Math.max(0, path.lastIndexOf("/")))
63
+
64
+ /** The first file (in list order) at or below `folder`; `""` is the demo root. */
65
+ export function firstFileIn(paths: readonly string[], folder: string): string | undefined {
66
+ return folder === "" ? paths[0] : paths.find((p) => p.startsWith(`${folder}/`))
67
+ }
68
+
69
+ /** The direct children of `folder` (`""` is the root): folders first, then files, each A to Z. */
70
+ export function childEntries(paths: readonly string[], folder: string): CrumbEntry[] {
71
+ const prefix = folder === "" ? "" : `${folder}/`
72
+ const dirs = new Map<string, CrumbEntry>()
73
+ const files: CrumbEntry[] = []
74
+ for (const path of paths) {
75
+ if (!path.startsWith(prefix)) continue
76
+ const rest = path.slice(prefix.length)
77
+ const slash = rest.indexOf("/")
78
+ if (slash < 0) files.push({ kind: "file", name: rest, path, target: path })
79
+ else {
80
+ const name = rest.slice(0, slash)
81
+ if (!dirs.has(name)) dirs.set(name, { kind: "dir", name, path: prefix + name, target: path })
82
+ }
83
+ }
84
+ const byName = (a: CrumbEntry, b: CrumbEntry) => a.name.localeCompare(b.name, undefined, { numeric: true })
85
+ return [...[...dirs.values()].sort(byName), ...files.sort(byName)]
86
+ }
87
+
88
+ /** The menu a segment opens: the entries beside it. A `src` segment is the demo root, so it lists the
89
+ * root; the file segment lists only the files in its folder. */
90
+ export function siblingEntries(paths: readonly string[], segment: CrumbSegment): CrumbEntry[] {
91
+ if (segment.kind === "src") return childEntries(paths, "")
92
+ const entries = childEntries(paths, parentOf(segment.path))
93
+ return segment.kind === "file" ? entries.filter((e) => e.kind === "file") : entries
94
+ }
95
+
96
+ /** The menu behind `…`: the hidden segments, each opening like a folder picked in a sibling menu. */
97
+ export function hiddenEntries(paths: readonly string[], segments: readonly CrumbSegment[], range: CrumbRange): CrumbEntry[] {
98
+ return segments.slice(range.start, range.end).flatMap((segment) => {
99
+ const target = firstFileIn(paths, segment.path)
100
+ return target === undefined ? [] : [{ kind: segment.kind === "file" ? ("file" as const) : ("dir" as const), name: segment.name, path: segment.path, target }]
101
+ })
102
+ }
103
+
104
+ /** Whether the active file is this entry, or inside this folder. */
105
+ export function entryContains(entry: Pick<CrumbEntry, "path">, active: string): boolean {
106
+ return entry.path !== "" && (active === entry.path || active.startsWith(`${entry.path}/`))
107
+ }
108
+
109
+ /**
110
+ * Which middle segments to hide so the breadcrumb fits `available` px. `widths` are the full item
111
+ * widths (each includes its own chevron), `ellipsisWidth` the width of the `…` item. The first and
112
+ * last segments always stay; as many trailing segments as fit are kept; null when everything fits
113
+ * (or there is nothing to hide). With no room at all you still get first, `…`, last.
114
+ */
115
+ export function collapseRange(widths: readonly number[], ellipsisWidth: number, available: number): CrumbRange | null {
116
+ const n = widths.length
117
+ if (n < 3) return null
118
+ if (widths.reduce((sum, w) => sum + w, 0) <= available) return null
119
+ let tail = 0
120
+ for (let end = n - 1; end >= 2; end--) {
121
+ tail += widths[end]
122
+ if (widths[0] + ellipsisWidth + tail > available) return { start: 1, end: Math.min(n - 1, end + 1) }
123
+ }
124
+ return { start: 1, end: 2 }
125
+ }
126
+
127
+ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect
128
+
129
+ interface OpenMenu {
130
+ id: string
131
+ anchor: HTMLElement
132
+ label: string
133
+ entries: CrumbEntry[]
134
+ }
135
+
136
+ export interface PathBreadcrumbProps {
137
+ /** The demo id, shown as the leading segments. */
138
+ src: string
139
+ /** The active file's path. */
140
+ path: string
141
+ /** Every file path in the demo. */
142
+ paths: readonly string[]
143
+ onSelect: (path: string) => void
144
+ className?: string
145
+ }
146
+
147
+ export function PathBreadcrumb({ src, path, paths, onSelect, className }: PathBreadcrumbProps) {
148
+ const segments = buildCrumbs(src, path)
149
+ const nav = useRef<HTMLElement | null>(null)
150
+ const measure = useRef<HTMLOListElement | null>(null)
151
+ const [range, setRange] = useState<CrumbRange | null>(null)
152
+ const [menu, setMenu] = useState<OpenMenu | null>(null)
153
+ const menuId = useId()
154
+ const signature = segments.map((s) => s.name).join("/")
155
+
156
+ const fit = useCallback(() => {
157
+ const el = nav.current
158
+ const items = measure.current ? Array.from(measure.current.children) : []
159
+ if (!el || items.length < 2) return
160
+ const available = el.clientWidth
161
+ if (!available) return // hidden (display: none) or not laid out: keep what we have
162
+ const widths = items.map((item) => item.getBoundingClientRect().width)
163
+ const ellipsis = widths.pop() as number
164
+ const next = collapseRange(widths, ellipsis, available)
165
+ setRange((prev) => (prev?.start === next?.start && prev?.end === next?.end ? prev : next))
166
+ }, [])
167
+
168
+ useIsoLayoutEffect(fit, [fit, signature])
169
+ useEffect(() => {
170
+ const el = nav.current
171
+ if (!el || typeof ResizeObserver === "undefined") return
172
+ const observer = new ResizeObserver(fit)
173
+ observer.observe(el)
174
+ if (measure.current) observer.observe(measure.current)
175
+ return () => observer.disconnect()
176
+ }, [fit])
177
+
178
+ const close = useCallback((refocus: boolean) => {
179
+ setMenu((current) => {
180
+ if (current && refocus && current.anchor.isConnected) current.anchor.focus()
181
+ return null
182
+ })
183
+ }, [])
184
+
185
+ const choose = (entry: CrumbEntry) => {
186
+ setMenu(null)
187
+ onSelect(entry.target)
188
+ // The segments are re-keyed for the new file; park focus on its last segment.
189
+ requestAnimationFrame(() => nav.current?.querySelector<HTMLElement>("[data-crumb-last]")?.focus())
190
+ }
191
+
192
+ const open = (id: string, anchor: HTMLElement, label: string, entries: CrumbEntry[]) =>
193
+ setMenu((current) => (current?.id === id ? null : { id, anchor, label, entries }))
194
+
195
+ const visible: (CrumbSegment | "…")[] = range
196
+ ? [...segments.slice(0, range.start), "…", ...segments.slice(range.end)]
197
+ : segments
198
+
199
+ return (
200
+ <nav ref={nav} className={className ? `docs-demo-crumbs ${className}` : "docs-demo-crumbs"} aria-label="Breadcrumb">
201
+ <ol className="docs-demo-crumbs-list">
202
+ {visible.map((item, index) => {
203
+ const hidden = item === "…"
204
+ const id = hidden ? "…" : item.key
205
+ const last = !hidden && item.kind === "file"
206
+ const expanded = menu?.id === id
207
+ const label = hidden ? "Hidden path segments" : item.kind === "src" ? "Demo files" : `Files in ${parentOf(item.path) || "the demo root"}`
208
+ const entries = () => (hidden ? hiddenEntries(paths, segments, range as CrumbRange) : siblingEntries(paths, item))
209
+ return (
210
+ <li key={id} className="docs-demo-crumb-item" data-last={last || undefined}>
211
+ {index > 0 ? <ChevronRight className="docs-demo-crumb-sep" width={12} height={12} aria-hidden="true" /> : null}
212
+ <button
213
+ type="button"
214
+ className="docs-demo-crumb"
215
+ data-crumb-last={last || undefined}
216
+ data-current={last || undefined}
217
+ aria-current={last ? "page" : undefined}
218
+ aria-haspopup="menu"
219
+ aria-expanded={expanded}
220
+ aria-controls={expanded ? menuId : undefined}
221
+ aria-label={hidden ? "Show hidden path segments" : undefined}
222
+ title={hidden ? undefined : item.kind === "src" ? undefined : item.path}
223
+ onClick={(event) => open(id, event.currentTarget, label, entries())}
224
+ onKeyDown={(event) => {
225
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
226
+ event.preventDefault()
227
+ if (!expanded) setMenu({ id, anchor: event.currentTarget, label, entries: entries() })
228
+ }}
229
+ >
230
+ {last ? <FileCode2 width={13} height={13} aria-hidden="true" /> : null}
231
+ <span className="docs-demo-crumb-text">{hidden ? "…" : item.name}</span>
232
+ </button>
233
+ </li>
234
+ )
235
+ })}
236
+ </ol>
237
+ {/* The same segments, unclipped and unseen: measured to decide what fits. */}
238
+ <ol ref={measure} className="docs-demo-crumbs-list docs-demo-crumbs-measure" aria-hidden="true">
239
+ {[...segments.map((s) => s.name), "…"].map((name, index) => (
240
+ <li key={`${index}:${name}`} className="docs-demo-crumb-item">
241
+ {index > 0 ? <ChevronRight className="docs-demo-crumb-sep" width={12} height={12} /> : null}
242
+ <span className="docs-demo-crumb">
243
+ {index === segments.length - 1 ? <FileCode2 width={13} height={13} /> : null}
244
+ <span className="docs-demo-crumb-text">{name}</span>
245
+ </span>
246
+ </li>
247
+ ))}
248
+ </ol>
249
+ {menu ? <CrumbMenu key={menu.id} id={menuId} menu={menu} active={path} onPick={choose} onClose={close} /> : null}
250
+ </nav>
251
+ )
252
+ }
253
+
254
+ function CrumbMenu({ id, menu, active, onPick, onClose }: {
255
+ id: string
256
+ menu: OpenMenu
257
+ active: string
258
+ onPick: (entry: CrumbEntry) => void
259
+ onClose: (refocus: boolean) => void
260
+ }) {
261
+ const ref = useRef<HTMLDivElement | null>(null)
262
+ const { anchor } = menu
263
+ const [at, setAt] = useState(() => {
264
+ const rect = anchor.getBoundingClientRect()
265
+ return { x: rect.left, y: rect.bottom + 4 }
266
+ })
267
+
268
+ // Place under the trigger, kept inside the viewport (flipped above when there is no room below),
269
+ // and focus the current entry.
270
+ useIsoLayoutEffect(() => {
271
+ const el = ref.current
272
+ if (!el) return
273
+ const rect = anchor.getBoundingClientRect()
274
+ const { width, height } = el.getBoundingClientRect()
275
+ const x = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))
276
+ const below = rect.bottom + 4
277
+ const y = below + height > window.innerHeight - 8 && rect.top - 4 - height >= 8 ? rect.top - 4 - height : below
278
+ setAt({ x, y })
279
+ const items = Array.from(el.querySelectorAll<HTMLElement>("[role=menuitem]"))
280
+ ;(items.find((item) => item.hasAttribute("data-current")) ?? items[0])?.focus({ preventScroll: true })
281
+ }, [anchor])
282
+
283
+ useEffect(() => {
284
+ const onDown = (event: PointerEvent) => {
285
+ const target = event.target as Node
286
+ if (!ref.current?.contains(target) && !anchor.contains(target)) onClose(false)
287
+ }
288
+ const onScroll = (event: Event) => {
289
+ if (!ref.current?.contains(event.target as Node)) onClose(false)
290
+ }
291
+ const dismiss = () => onClose(false)
292
+ document.addEventListener("pointerdown", onDown, true)
293
+ window.addEventListener("scroll", onScroll, true)
294
+ window.addEventListener("resize", dismiss)
295
+ return () => {
296
+ document.removeEventListener("pointerdown", onDown, true)
297
+ window.removeEventListener("scroll", onScroll, true)
298
+ window.removeEventListener("resize", dismiss)
299
+ }
300
+ }, [anchor, onClose])
301
+
302
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
303
+ const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[role=menuitem]"))
304
+ const index = items.indexOf(document.activeElement as HTMLElement)
305
+ let next = -1
306
+ if (event.key === "ArrowDown") next = (index + 1) % items.length
307
+ else if (event.key === "ArrowUp") next = (index - 1 + items.length) % items.length
308
+ else if (event.key === "Home") next = 0
309
+ else if (event.key === "End") next = items.length - 1
310
+ else if (event.key === "Escape") {
311
+ event.preventDefault()
312
+ event.stopPropagation()
313
+ onClose(true)
314
+ return
315
+ } else if (event.key === "Tab") {
316
+ onClose(true) // focus returns to the trigger; the Tab then moves on from there
317
+ return
318
+ } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
319
+ const key = event.key.toLowerCase()
320
+ const from = Math.max(index, 0)
321
+ for (let step = 1; step <= items.length; step++) {
322
+ const candidate = (from + step) % items.length
323
+ if (items[candidate].textContent?.trim().toLowerCase().startsWith(key)) {
324
+ next = candidate
325
+ break
326
+ }
327
+ }
328
+ }
329
+ if (next < 0) return
330
+ event.preventDefault()
331
+ items[next]?.focus()
332
+ }
333
+
334
+ const host = anchor.closest("[data-docstream], .docs-article") ?? document.body
335
+ return createPortal(
336
+ <div
337
+ ref={ref}
338
+ id={id}
339
+ role="menu"
340
+ aria-label={menu.label}
341
+ className="docs-demo-crumb-menu"
342
+ style={{ "--crumb-x": `${Math.round(at.x)}px`, "--crumb-y": `${Math.round(at.y)}px` } as CSSProperties}
343
+ onKeyDown={onKeyDown}
344
+ >
345
+ {menu.entries.map((entry) => {
346
+ const current = entryContains(entry, active)
347
+ return (
348
+ <button
349
+ key={`${entry.kind}:${entry.path}:${entry.name}`}
350
+ type="button"
351
+ role="menuitem"
352
+ tabIndex={-1}
353
+ className="docs-demo-crumb-option"
354
+ data-current={current || undefined}
355
+ aria-current={current || undefined}
356
+ onClick={() => onPick(entry)}
357
+ >
358
+ {entry.kind === "dir" ? <Folder width={13} height={13} aria-hidden="true" /> : <FileCode2 width={13} height={13} aria-hidden="true" />}
359
+ <span className="docs-demo-crumb-option-text">{entry.name}</span>
360
+ {current ? <Check className="docs-demo-crumb-check" width={13} height={13} aria-hidden="true" /> : null}
361
+ </button>
362
+ )
363
+ })}
364
+ </div>,
365
+ host,
366
+ )
367
+ }
package/src/index.ts CHANGED
@@ -68,11 +68,12 @@ export type {
68
68
  } from "./demo"
69
69
  export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
70
70
  export type { CitationPreview, CitationPreviewResolver, DocstreamRefContextValue } from "./docs/reference"
71
- export { ReplayEmbed, ReplayPreview } from "./replay/ReplayPreview"
71
+ export { ReplayEmbed, ReplayPreview, formatReplayTime } from "./replay/ReplayPreview"
72
72
  export type {
73
73
  ReplayEventsSource,
74
74
  ReplayEventsUrlSource,
75
75
  ReplayPageSource,
76
+ ReplayPreviewHandle,
76
77
  ReplayPreviewProps,
77
78
  ReplaySource,
78
79
  } from "./replay/ReplayPreview"