@brett_lamy/docstream 1.7.0 → 1.9.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 +18 -0
- package/dist/demo/DemoViewer.d.ts +8 -0
- package/dist/demo/DemoViewer.d.ts.map +1 -1
- package/dist/demo/DemoViewer.js +76 -3
- package/dist/demo/DemoViewer.js.map +1 -1
- package/dist/demo/PathBreadcrumb.d.ts +54 -0
- package/dist/demo/PathBreadcrumb.d.ts.map +1 -0
- package/dist/demo/PathBreadcrumb.js +245 -0
- package/dist/demo/PathBreadcrumb.js.map +1 -0
- package/dist/docs/DocsRenderer.d.ts +9 -2
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +13 -6
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/docs/DocumentProperties.d.ts +11 -0
- package/dist/docs/DocumentProperties.d.ts.map +1 -0
- package/dist/docs/DocumentProperties.js +63 -0
- package/dist/docs/DocumentProperties.js.map +1 -0
- package/dist/docs/TaskCheckbox.d.ts +24 -0
- package/dist/docs/TaskCheckbox.d.ts.map +1 -0
- package/dist/docs/TaskCheckbox.js +103 -0
- package/dist/docs/TaskCheckbox.js.map +1 -0
- package/dist/docs/properties.css +28 -0
- package/dist/docs/tasks.css +13 -0
- package/dist/gitbook/ast.d.ts +14 -0
- package/dist/gitbook/ast.d.ts.map +1 -1
- package/dist/gitbook/ast.js.map +1 -1
- package/dist/gitbook/index.d.ts +3 -0
- package/dist/gitbook/index.d.ts.map +1 -1
- package/dist/gitbook/index.js +2 -0
- package/dist/gitbook/index.js.map +1 -1
- package/dist/gitbook/parse.d.ts.map +1 -1
- package/dist/gitbook/parse.js +17 -3
- package/dist/gitbook/parse.js.map +1 -1
- package/dist/gitbook/properties.d.ts +15 -0
- package/dist/gitbook/properties.d.ts.map +1 -0
- package/dist/gitbook/properties.js +45 -0
- package/dist/gitbook/properties.js.map +1 -0
- package/dist/gitbook/serialize.d.ts.map +1 -1
- package/dist/gitbook/serialize.js +5 -2
- package/dist/gitbook/serialize.js.map +1 -1
- package/dist/gitbook/tasks.d.ts +11 -0
- package/dist/gitbook/tasks.d.ts.map +1 -0
- package/dist/gitbook/tasks.js +21 -0
- package/dist/gitbook/tasks.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/streamdown.d.ts +1 -1
- package/dist/streamdown.d.ts.map +1 -1
- package/dist/streamdown.js +2 -2
- package/dist/streamdown.js.map +1 -1
- package/dist/styles.css +228 -4
- package/package.json +15 -1
- package/src/demo/DemoViewer.tsx +98 -4
- package/src/demo/PathBreadcrumb.tsx +367 -0
- package/src/docs/DocsRenderer.tsx +22 -5
- package/src/docs/DocumentProperties.tsx +84 -0
- package/src/docs/TaskCheckbox.tsx +111 -0
- package/src/docs/properties.css +28 -0
- package/src/docs/tasks.css +13 -0
- package/src/gitbook/ast.ts +16 -0
- package/src/gitbook/index.ts +3 -0
- package/src/gitbook/parse.ts +15 -3
- package/src/gitbook/properties.ts +49 -0
- package/src/gitbook/serialize.ts +5 -2
- package/src/gitbook/tasks.ts +25 -0
- package/src/index.ts +7 -0
- package/src/streamdown.tsx +4 -0
- package/src/styles.css +228 -4
|
@@ -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
|
+
}
|
|
@@ -31,6 +31,10 @@ import { OpenApiOperation } from "../openapi/OpenApiOperation"
|
|
|
31
31
|
import { Mermaid } from "./Mermaid"
|
|
32
32
|
import { HighlightedCode } from "./HighlightedCode"
|
|
33
33
|
import { CitationSources, InlineReference } from "./reference"
|
|
34
|
+
import { DocumentProperties } from "./DocumentProperties"
|
|
35
|
+
import { TaskCheckbox, TaskStateChangeContext, type TaskStateChangeHandler } from "./TaskCheckbox"
|
|
36
|
+
import { getTaskState } from "../gitbook/tasks"
|
|
37
|
+
import { plainText } from "../gitbook/inline"
|
|
34
38
|
|
|
35
39
|
type MathRenderer = (formula: string) => string
|
|
36
40
|
let mathRenderer: MathRenderer | null = null
|
|
@@ -312,6 +316,7 @@ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, {
|
|
|
312
316
|
}
|
|
313
317
|
|
|
314
318
|
function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & Renderers) {
|
|
319
|
+
const onTaskStateChange = useContext(TaskStateChangeContext)
|
|
315
320
|
switch (block.type) {
|
|
316
321
|
case "paragraph":
|
|
317
322
|
return (
|
|
@@ -476,8 +481,9 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
|
|
|
476
481
|
return (
|
|
477
482
|
<Tag className={block.task ? "docs-tasklist" : undefined}>
|
|
478
483
|
{block.items.map((item, i) => (
|
|
479
|
-
<li key={i}>
|
|
480
|
-
{block.task && <
|
|
484
|
+
<li key={i} data-task-state={block.task ? getTaskState(item) : undefined}>
|
|
485
|
+
{block.task && <TaskCheckbox state={getTaskState(item)} label={item.children.filter((b) => b.type === "paragraph").map((b) => b.type === "paragraph" ? plainText(b.children) : "").join(" ") || "Task"}
|
|
486
|
+
onChange={onTaskStateChange ? (state) => onTaskStateChange(item, state) : undefined} />}
|
|
481
487
|
<Blocks blocks={item.children} inline liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
482
488
|
</li>
|
|
483
489
|
))}
|
|
@@ -582,6 +588,10 @@ function Blocks({ blocks, inline, liveRenderer, sourceRenderer }: { blocks: Bloc
|
|
|
582
588
|
}
|
|
583
589
|
|
|
584
590
|
export interface DocRenderOptions {
|
|
591
|
+
/** Make rendered task checkboxes interactive. Update the supplied item and re-render to persist a choice. */
|
|
592
|
+
onTaskStateChange?: TaskStateChangeHandler
|
|
593
|
+
/** Show the document's frontmatter as a property sheet (default true). */
|
|
594
|
+
documentProperties?: boolean | { defaultOpen?: boolean }
|
|
585
595
|
/** Resolves `{% demo %}` blocks. Provided through context, so nested renderers inherit it. */
|
|
586
596
|
demoResolver?: DemoResolver
|
|
587
597
|
/**
|
|
@@ -605,8 +615,9 @@ export interface DocRenderOptions {
|
|
|
605
615
|
pageActions?: boolean | DocPageActionsOptions
|
|
606
616
|
}
|
|
607
617
|
|
|
608
|
-
function withDemoContext({ demoResolver, demoRuntime, demoDependencies, demoVFS }: DocRenderOptions, children: ReactNode) {
|
|
618
|
+
function withDemoContext({ demoResolver, demoRuntime, demoDependencies, demoVFS, onTaskStateChange }: DocRenderOptions, children: ReactNode) {
|
|
609
619
|
let out = children
|
|
620
|
+
if (onTaskStateChange) out = <TaskStateChangeContext.Provider value={onTaskStateChange}>{out}</TaskStateChangeContext.Provider>
|
|
610
621
|
if (demoVFS) out = <DocstreamDemoVFSContext.Provider value={demoVFS}>{out}</DocstreamDemoVFSContext.Provider>
|
|
611
622
|
if (demoRuntime || demoDependencies) {
|
|
612
623
|
out = <DemoRuntimeProvider runtime={demoRuntime} dependencies={demoDependencies}>{out}</DemoRuntimeProvider>
|
|
@@ -650,12 +661,15 @@ export function MarkdownContent({
|
|
|
650
661
|
demoVFS,
|
|
651
662
|
pageActions,
|
|
652
663
|
className,
|
|
664
|
+
documentProperties = true,
|
|
665
|
+
onTaskStateChange,
|
|
653
666
|
}: { markdown: string; className?: string } & Renderers & DocRenderOptions) {
|
|
654
667
|
const doc = useMemo(() => parseMarkdown(markdown), [markdown])
|
|
655
668
|
return withDemoContext(
|
|
656
|
-
{ demoResolver, demoRuntime, demoDependencies, demoVFS },
|
|
669
|
+
{ demoResolver, demoRuntime, demoDependencies, demoVFS, onTaskStateChange },
|
|
657
670
|
<div data-docstream="" className={className}>
|
|
658
671
|
<PageActionsBar markdown={markdown} options={pageActions} />
|
|
672
|
+
{documentProperties && doc.properties ? <DocumentProperties properties={doc.properties} defaultOpen={typeof documentProperties === "object" && documentProperties.defaultOpen} /> : null}
|
|
659
673
|
<Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
660
674
|
<CitationSources citations={doc.citations} />
|
|
661
675
|
</div>,
|
|
@@ -672,11 +686,14 @@ export function DocsRenderer({
|
|
|
672
686
|
demoVFS,
|
|
673
687
|
pageActions,
|
|
674
688
|
markdown,
|
|
689
|
+
documentProperties = true,
|
|
690
|
+
onTaskStateChange,
|
|
675
691
|
}: { doc: DocumentNode; /** Source for page actions; defaults to `serializeMarkdown(doc)`. */ markdown?: string } & Renderers & DocRenderOptions) {
|
|
676
692
|
return withDemoContext(
|
|
677
|
-
{ demoResolver, demoRuntime, demoDependencies, demoVFS },
|
|
693
|
+
{ demoResolver, demoRuntime, demoDependencies, demoVFS, onTaskStateChange },
|
|
678
694
|
<article className="docs-article">
|
|
679
695
|
<PageActionsBar markdown={markdown ?? (() => serializeMarkdown(doc))} options={pageActions} />
|
|
696
|
+
{documentProperties && doc.properties ? <DocumentProperties properties={doc.properties} defaultOpen={typeof documentProperties === "object" && documentProperties.defaultOpen} /> : null}
|
|
680
697
|
<Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
681
698
|
<CitationSources citations={doc.citations} />
|
|
682
699
|
</article>,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { ChevronRight, Plus, X } from "lucide-react"
|
|
3
|
+
import { parseDocument, stringify } from "yaml"
|
|
4
|
+
import type { DocumentPropertiesNode } from "../gitbook/ast"
|
|
5
|
+
import { readDocumentProperties, removeDocumentProperty, setDocumentProperty, type DocumentPropertyValue } from "../gitbook/properties"
|
|
6
|
+
|
|
7
|
+
export interface DocumentPropertiesProps {
|
|
8
|
+
properties: DocumentPropertiesNode
|
|
9
|
+
/** Omit for read-only properties. Edits retain the YAML document's comments and formatting. */
|
|
10
|
+
onChange?: (properties: DocumentPropertiesNode) => void
|
|
11
|
+
defaultOpen?: boolean
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function textValue(value: DocumentPropertyValue): string {
|
|
16
|
+
return value === null ? "" : typeof value === "object" ? stringify(value).trimEnd() : String(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function PropertyField({ name, value, onChange }: { name: string; value: DocumentPropertyValue; onChange: (value: DocumentPropertyValue) => void }) {
|
|
20
|
+
const rendered = textValue(value)
|
|
21
|
+
const [draft, setDraft] = useState(rendered)
|
|
22
|
+
const [error, setError] = useState<string>()
|
|
23
|
+
useEffect(() => { setDraft(rendered); setError(undefined) }, [rendered])
|
|
24
|
+
if (typeof value === "boolean") return <input type="checkbox" aria-label={name} checked={value} onChange={(event) => onChange(event.currentTarget.checked)} />
|
|
25
|
+
const complex = value !== null && typeof value === "object"
|
|
26
|
+
const multiline = complex || (typeof value === "string" && value.includes("\n"))
|
|
27
|
+
const change = (text: string) => {
|
|
28
|
+
setDraft(text)
|
|
29
|
+
if (!complex && typeof value !== "number") onChange(text)
|
|
30
|
+
else if (typeof value === "number" && text !== "" && Number.isFinite(Number(text))) onChange(Number(text))
|
|
31
|
+
}
|
|
32
|
+
const commit = () => {
|
|
33
|
+
if (!complex) return
|
|
34
|
+
try {
|
|
35
|
+
const yaml = parseDocument(draft)
|
|
36
|
+
if (yaml.errors.length) throw yaml.errors[0]
|
|
37
|
+
const next = yaml.toJS({ maxAliasCount: 100 }) as DocumentPropertyValue
|
|
38
|
+
JSON.stringify(next)
|
|
39
|
+
setError(undefined)
|
|
40
|
+
onChange(next)
|
|
41
|
+
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
|
|
42
|
+
}
|
|
43
|
+
return <div className="docs-property-field">
|
|
44
|
+
{multiline ? <textarea aria-label={name} value={draft} rows={Math.min(6, draft.split("\n").length)} aria-invalid={!!error || undefined}
|
|
45
|
+
onChange={(event) => change(event.currentTarget.value)} onBlur={commit} />
|
|
46
|
+
: <input type={typeof value === "number" ? "number" : "text"} aria-label={name} value={draft} placeholder="Empty"
|
|
47
|
+
onChange={(event) => change(event.currentTarget.value)} />}
|
|
48
|
+
{error ? <span role="alert" className="docs-properties-error">{error}</span> : null}
|
|
49
|
+
</div>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The shared, accessible property sheet used by the renderer and the editor. */
|
|
53
|
+
export function DocumentProperties({ properties, onChange, defaultOpen = false, className }: DocumentPropertiesProps) {
|
|
54
|
+
const { values, error } = readDocumentProperties(properties)
|
|
55
|
+
const [adding, setAdding] = useState(false)
|
|
56
|
+
const [name, setName] = useState("")
|
|
57
|
+
const keys = Object.keys(values)
|
|
58
|
+
return <details className={["docs-document-properties", className].filter(Boolean).join(" ")} data-document-properties="" open={defaultOpen || undefined}>
|
|
59
|
+
<summary><ChevronRight size={13} aria-hidden="true" /><span>Properties</span><span className="docs-properties-count">{keys.length}</span></summary>
|
|
60
|
+
{error ? <div className="docs-properties-error" role="alert">
|
|
61
|
+
<p>{error}</p>
|
|
62
|
+
{onChange ? <textarea aria-label="Document properties YAML" value={properties.yaml} onChange={(event) => onChange({ ...properties, yaml: event.currentTarget.value })} />
|
|
63
|
+
: <pre>{properties.yaml}</pre>}
|
|
64
|
+
</div> : <>
|
|
65
|
+
<dl className="docs-properties-list">
|
|
66
|
+
{keys.map((key) => <div key={key} className="docs-property-row">
|
|
67
|
+
<dt>{key}</dt>
|
|
68
|
+
<dd>{onChange ? <PropertyField name={key} value={values[key]} onChange={(value) => onChange(setDocumentProperty(properties, key, value))} />
|
|
69
|
+
: <span className="docs-property-value">{values[key] === null ? "Empty" : typeof values[key] === "boolean" ? String(values[key]) : textValue(values[key])}</span>}</dd>
|
|
70
|
+
{onChange ? <button type="button" className="docs-property-remove" aria-label={`Remove ${key} property`} onClick={() => onChange(removeDocumentProperty(properties, key))}><X size={13} /></button> : null}
|
|
71
|
+
</div>)}
|
|
72
|
+
</dl>
|
|
73
|
+
{onChange ? adding ? <form className="docs-property-add" onSubmit={(event) => {
|
|
74
|
+
event.preventDefault()
|
|
75
|
+
const key = name.trim()
|
|
76
|
+
if (!key || Object.hasOwn(values, key)) return
|
|
77
|
+
onChange(setDocumentProperty(properties, key, "")); setName(""); setAdding(false)
|
|
78
|
+
}}><input aria-label="Property name" placeholder="Property name" autoFocus value={name} onChange={(event) => setName(event.currentTarget.value)} />
|
|
79
|
+
<button type="submit" disabled={!name.trim() || Object.hasOwn(values, name.trim())}>Add</button>
|
|
80
|
+
<button type="button" onClick={() => { setAdding(false); setName("") }}>Cancel</button></form>
|
|
81
|
+
: <button type="button" className="docs-property-add" onClick={() => setAdding(true)}><Plus size={13} />Add property</button> : null}
|
|
82
|
+
</>}
|
|
83
|
+
</details>
|
|
84
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createContext, useEffect, useId, useRef, useState, type CSSProperties } from "react"
|
|
2
|
+
import { createPortal } from "react-dom"
|
|
3
|
+
import { Ban, Check, Circle, CircleCheck, CircleHelp, CircleMinus, CirclePause, CircleX, Clock3 } from "lucide-react"
|
|
4
|
+
import type { ListItemNode, TaskState } from "../gitbook/ast"
|
|
5
|
+
import { TASK_STATES } from "../gitbook/tasks"
|
|
6
|
+
|
|
7
|
+
/** The icons used by the checkbox, tooltip and state picker. */
|
|
8
|
+
export const TASK_STATE_ICONS = {
|
|
9
|
+
pending: Circle,
|
|
10
|
+
"in-progress": Clock3,
|
|
11
|
+
implemented: CircleHelp,
|
|
12
|
+
blocked: Ban,
|
|
13
|
+
parked: CirclePause,
|
|
14
|
+
refuted: CircleX,
|
|
15
|
+
verified: CircleCheck,
|
|
16
|
+
cancelled: CircleMinus,
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export type TaskStateChangeHandler = (item: ListItemNode, state: TaskState) => void
|
|
20
|
+
export const TaskStateChangeContext = createContext<TaskStateChangeHandler | undefined>(undefined)
|
|
21
|
+
|
|
22
|
+
export interface TaskCheckboxProps {
|
|
23
|
+
state: TaskState
|
|
24
|
+
/** A normal click clears any marked state; clicking pending sets verified. */
|
|
25
|
+
onChange?: (state: TaskState) => void
|
|
26
|
+
label?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TOKENS = ["--foreground", "--muted-foreground", "--popover", "--popover-foreground", "--border", "--primary", "--accent", "--accent-foreground"]
|
|
31
|
+
|
|
32
|
+
/** An accessible multi-state task checkbox. Right-click or Shift+F10 opens its keyboard-navigable state menu. */
|
|
33
|
+
export function TaskCheckbox({ state, onChange, label = "Task", className }: TaskCheckboxProps) {
|
|
34
|
+
const input = useRef<HTMLInputElement>(null)
|
|
35
|
+
const menu = useRef<HTMLDivElement>(null)
|
|
36
|
+
const tipId = useId()
|
|
37
|
+
const [popup, setPopup] = useState<{ x: number; y: number; style: CSSProperties }>()
|
|
38
|
+
const [tip, setTip] = useState<{ x: number; y: number }>()
|
|
39
|
+
const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
40
|
+
const meta = TASK_STATES.find((item) => item.id === state) ?? TASK_STATES[0]
|
|
41
|
+
const StateIcon = TASK_STATE_ICONS[meta.id]
|
|
42
|
+
const mixed = state !== "pending" && state !== "verified"
|
|
43
|
+
useEffect(() => { if (input.current) input.current.indeterminate = mixed }, [mixed, state])
|
|
44
|
+
useEffect(() => () => clearTimeout(hoverTimer.current), [])
|
|
45
|
+
const hideTip = () => { clearTimeout(hoverTimer.current); setTip(undefined) }
|
|
46
|
+
const showTip = () => {
|
|
47
|
+
clearTimeout(hoverTimer.current)
|
|
48
|
+
hoverTimer.current = setTimeout(() => {
|
|
49
|
+
const box = input.current?.getBoundingClientRect()
|
|
50
|
+
if (box) setTip({ x: Math.max(80, Math.min(window.innerWidth - 80, box.x + box.width / 2)), y: box.top > 36 ? box.top - 6 : box.bottom + 30 })
|
|
51
|
+
}, 400)
|
|
52
|
+
}
|
|
53
|
+
const open = (x?: number, y?: number) => {
|
|
54
|
+
if (!onChange || !input.current) return
|
|
55
|
+
hideTip()
|
|
56
|
+
const box = input.current.getBoundingClientRect()
|
|
57
|
+
const computed = getComputedStyle(input.current)
|
|
58
|
+
const style = Object.fromEntries(TOKENS.map((token) => [token, computed.getPropertyValue(token)])) as CSSProperties
|
|
59
|
+
style.fontFamily = computed.fontFamily
|
|
60
|
+
style.colorScheme = computed.colorScheme
|
|
61
|
+
setPopup({ x: Math.max(8, Math.min(window.innerWidth - 292, x ?? box.left)), y: Math.max(8, Math.min(window.innerHeight - 330, y ?? box.bottom + 4)), style })
|
|
62
|
+
}
|
|
63
|
+
const close = (restoreFocus = false) => { setPopup(undefined); if (restoreFocus) input.current?.focus({ preventScroll: true }) }
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!popup) return
|
|
66
|
+
menu.current?.querySelector<HTMLButtonElement>(`[data-state="${state}"]`)?.focus()
|
|
67
|
+
const outside = (event: PointerEvent) => { if (event.target instanceof Node && !menu.current?.contains(event.target)) close() }
|
|
68
|
+
const scroll = (event: Event) => { if (!(event.target instanceof Node && menu.current?.contains(event.target))) close() }
|
|
69
|
+
document.addEventListener("pointerdown", outside, true)
|
|
70
|
+
window.addEventListener("resize", scroll)
|
|
71
|
+
window.addEventListener("scroll", scroll, true)
|
|
72
|
+
return () => { document.removeEventListener("pointerdown", outside, true); window.removeEventListener("resize", scroll); window.removeEventListener("scroll", scroll, true) }
|
|
73
|
+
}, [popup])
|
|
74
|
+
return <span className={["docs-task-control", className].filter(Boolean).join(" ")} data-task-state={state}>
|
|
75
|
+
<input ref={input} type="checkbox" className="docs-task-checkbox" aria-label={`${label}: ${meta.label}`} aria-describedby={tip ? tipId : undefined}
|
|
76
|
+
aria-checked={mixed ? "mixed" : state === "verified"} aria-readonly={!onChange || undefined} aria-haspopup={onChange ? "menu" : undefined}
|
|
77
|
+
aria-expanded={onChange ? !!popup : undefined} checked={state === "verified"} readOnly={!onChange}
|
|
78
|
+
onClick={(event) => { if (!onChange) event.preventDefault() }}
|
|
79
|
+
onChange={() => { hideTip(); onChange?.(state === "pending" ? "verified" : "pending") }}
|
|
80
|
+
onMouseDown={(event) => { if (event.button === 0) event.preventDefault() }}
|
|
81
|
+
onMouseEnter={showTip} onMouseLeave={hideTip} onFocus={showTip} onBlur={hideTip}
|
|
82
|
+
onContextMenu={(event) => { if (onChange) { event.preventDefault(); open(event.clientX, event.clientY) } }}
|
|
83
|
+
onKeyDown={(event) => {
|
|
84
|
+
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { event.preventDefault(); open() }
|
|
85
|
+
}} />
|
|
86
|
+
<StateIcon className="docs-task-state-icon" size={20} strokeWidth={1.7} aria-hidden="true" data-state-icon={state} />
|
|
87
|
+
{tip && !popup ? createPortal(<div role="tooltip" id={tipId} className="docs-task-tooltip" style={{ left: tip.x, top: tip.y }}>{meta.label}</div>, document.body) : null}
|
|
88
|
+
{popup ? createPortal(<div ref={menu} role="menu" aria-label="Task state" className="docs-task-state-menu" style={{ ...popup.style, left: popup.x, top: popup.y }}
|
|
89
|
+
onContextMenu={(event) => event.preventDefault()}
|
|
90
|
+
onKeyDown={(event) => {
|
|
91
|
+
const items = [...(menu.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])]
|
|
92
|
+
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
93
|
+
let next: number | undefined
|
|
94
|
+
if (event.key === "ArrowDown") next = (current + 1) % items.length
|
|
95
|
+
if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length
|
|
96
|
+
if (event.key === "Home") next = 0
|
|
97
|
+
if (event.key === "End") next = items.length - 1
|
|
98
|
+
if (event.key === "Escape" || event.key === "Tab") { event.preventDefault(); close(true) }
|
|
99
|
+
if (next !== undefined) { event.preventDefault(); items[next]?.focus() }
|
|
100
|
+
}}>
|
|
101
|
+
{TASK_STATES.map((item) => {
|
|
102
|
+
const Icon = TASK_STATE_ICONS[item.id]
|
|
103
|
+
return <button type="button" role="menuitemradio" key={item.id} data-state={item.id} aria-checked={item.id === state}
|
|
104
|
+
onClick={() => { onChange?.(item.id); close(true) }}>
|
|
105
|
+
<Icon size={19} strokeWidth={1.7} aria-hidden="true" data-state-icon={item.id} />
|
|
106
|
+
<span>{item.label}</span>{item.id === state ? <Check size={14} aria-hidden="true" /> : <span />}
|
|
107
|
+
</button>
|
|
108
|
+
})}
|
|
109
|
+
</div>, document.body) : null}
|
|
110
|
+
</span>
|
|
111
|
+
}
|