@specbook/ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +60 -3
- package/src/app.tsx +363 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +46 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +171 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { JSX } from 'preact'
|
|
2
|
+
|
|
3
|
+
/** Normalised field description, built from either a Control or a described input control. */
|
|
4
|
+
export interface FieldSpec {
|
|
5
|
+
kind: string
|
|
6
|
+
/** a friendlier name than the parameter key, and a sentence about it */
|
|
7
|
+
label?: string
|
|
8
|
+
description?: string
|
|
9
|
+
default?: unknown
|
|
10
|
+
options?: { label: string; value: unknown }[]
|
|
11
|
+
shape?: Record<string, FieldSpec>
|
|
12
|
+
item?: FieldSpec
|
|
13
|
+
min?: number
|
|
14
|
+
max?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function specFromControl(ctl: any): FieldSpec {
|
|
18
|
+
const o = ctl.opts ?? {}
|
|
19
|
+
const meta = {
|
|
20
|
+
...(typeof o.label === 'string' ? { label: o.label as string } : {}),
|
|
21
|
+
...(typeof o.description === 'string' ? { description: o.description as string } : {}),
|
|
22
|
+
}
|
|
23
|
+
return { ...baseSpec(ctl, o), ...meta }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function baseSpec(ctl: any, o: any): FieldSpec {
|
|
27
|
+
switch (ctl.kind) {
|
|
28
|
+
case 'select':
|
|
29
|
+
case 'multiselect': {
|
|
30
|
+
const value = (x: unknown) => (typeof o.value === 'function' ? o.value(x) : x)
|
|
31
|
+
const label = (x: unknown) => (typeof o.label === 'function' ? o.label(x) : String(value(x)))
|
|
32
|
+
return {
|
|
33
|
+
kind: ctl.kind,
|
|
34
|
+
options: (o.options as unknown[]).map((x) => ({ label: label(x), value: value(x) })),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
case 'object':
|
|
38
|
+
return {
|
|
39
|
+
kind: 'object',
|
|
40
|
+
shape: Object.fromEntries(
|
|
41
|
+
Object.entries(o.shape as Record<string, any>).map(([k, v]) => [k, specFromControl(v)]),
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
case 'array':
|
|
45
|
+
return {
|
|
46
|
+
kind: 'array',
|
|
47
|
+
item: specFromControl(o.item),
|
|
48
|
+
default: o.default,
|
|
49
|
+
min: o.min,
|
|
50
|
+
max: o.max,
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
return { kind: ctl.kind, default: o.default, min: o.min, max: o.max }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function defaultFor(spec: FieldSpec): unknown {
|
|
58
|
+
switch (spec.kind) {
|
|
59
|
+
case 'select':
|
|
60
|
+
return spec.options?.[0]?.value
|
|
61
|
+
case 'multiselect':
|
|
62
|
+
return []
|
|
63
|
+
case 'object':
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(spec.shape ?? {}).map(([k, s]) => [k, defaultFor(s)]),
|
|
66
|
+
)
|
|
67
|
+
case 'array':
|
|
68
|
+
return spec.default ?? []
|
|
69
|
+
case 'boolean':
|
|
70
|
+
return spec.default ?? false
|
|
71
|
+
case 'number':
|
|
72
|
+
return spec.default ?? 0
|
|
73
|
+
default:
|
|
74
|
+
return spec.default ?? ''
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface FieldProps {
|
|
79
|
+
name: string
|
|
80
|
+
spec: FieldSpec
|
|
81
|
+
value: unknown
|
|
82
|
+
onChange: (v: unknown) => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
|
|
86
|
+
|
|
87
|
+
export function Field({ name, spec, value, onChange }: FieldProps): JSX.Element {
|
|
88
|
+
const wrap = (body: JSX.Element) => (
|
|
89
|
+
<div class="field">
|
|
90
|
+
<span class="label">{name}</span>
|
|
91
|
+
{body}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
switch (spec.kind) {
|
|
95
|
+
case 'number':
|
|
96
|
+
return wrap(
|
|
97
|
+
<input
|
|
98
|
+
type="number"
|
|
99
|
+
value={Number(value ?? 0)}
|
|
100
|
+
min={spec.min}
|
|
101
|
+
max={spec.max}
|
|
102
|
+
onInput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
case 'boolean':
|
|
106
|
+
return wrap(
|
|
107
|
+
<input
|
|
108
|
+
type="checkbox"
|
|
109
|
+
checked={Boolean(value)}
|
|
110
|
+
onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
|
|
111
|
+
/>,
|
|
112
|
+
)
|
|
113
|
+
case 'date':
|
|
114
|
+
return wrap(
|
|
115
|
+
<input
|
|
116
|
+
type="date"
|
|
117
|
+
value={String(value ?? '')}
|
|
118
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
119
|
+
/>,
|
|
120
|
+
)
|
|
121
|
+
case 'select': {
|
|
122
|
+
const idx = Math.max(
|
|
123
|
+
0,
|
|
124
|
+
(spec.options ?? []).findIndex((o) => same(o.value, value)),
|
|
125
|
+
)
|
|
126
|
+
return wrap(
|
|
127
|
+
<select
|
|
128
|
+
value={idx}
|
|
129
|
+
onChange={(e) =>
|
|
130
|
+
onChange(spec.options?.[Number((e.target as HTMLSelectElement).value)]?.value)
|
|
131
|
+
}
|
|
132
|
+
>
|
|
133
|
+
{(spec.options ?? []).map((o, i) => (
|
|
134
|
+
<option value={i}>{o.label}</option>
|
|
135
|
+
))}
|
|
136
|
+
</select>,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
case 'multiselect': {
|
|
140
|
+
const current = (value as unknown[]) ?? []
|
|
141
|
+
return wrap(
|
|
142
|
+
<div>
|
|
143
|
+
{(spec.options ?? []).map((o) => (
|
|
144
|
+
<label class="row">
|
|
145
|
+
<input
|
|
146
|
+
type="checkbox"
|
|
147
|
+
checked={current.some((c) => same(c, o.value))}
|
|
148
|
+
onChange={(e) =>
|
|
149
|
+
onChange(
|
|
150
|
+
(e.target as HTMLInputElement).checked
|
|
151
|
+
? [...current, o.value]
|
|
152
|
+
: current.filter((c) => !same(c, o.value)),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
/>
|
|
156
|
+
{o.label}
|
|
157
|
+
</label>
|
|
158
|
+
))}
|
|
159
|
+
</div>,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
case 'object': {
|
|
163
|
+
const obj = (value ?? {}) as Record<string, unknown>
|
|
164
|
+
return wrap(
|
|
165
|
+
<div class="nested">
|
|
166
|
+
{Object.entries(spec.shape ?? {}).map(([k, s]) => (
|
|
167
|
+
<Field
|
|
168
|
+
name={k}
|
|
169
|
+
spec={s}
|
|
170
|
+
value={obj[k]}
|
|
171
|
+
onChange={(v) => onChange({ ...obj, [k]: v })}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</div>,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
case 'array': {
|
|
178
|
+
const list = (value as unknown[]) ?? []
|
|
179
|
+
const item = spec.item as FieldSpec
|
|
180
|
+
return wrap(
|
|
181
|
+
<div class="nested">
|
|
182
|
+
{list.map((v, i) => (
|
|
183
|
+
<div class="row">
|
|
184
|
+
<Field
|
|
185
|
+
name={`#${i + 1}`}
|
|
186
|
+
spec={item}
|
|
187
|
+
value={v}
|
|
188
|
+
onChange={(nv) => onChange(list.map((x, j) => (j === i ? nv : x)))}
|
|
189
|
+
/>
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
class="btn"
|
|
193
|
+
disabled={spec.min !== undefined && list.length <= spec.min}
|
|
194
|
+
onClick={() => onChange(list.filter((_, j) => j !== i))}
|
|
195
|
+
>
|
|
196
|
+
Remove
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
class="btn"
|
|
203
|
+
disabled={spec.max !== undefined && list.length >= spec.max}
|
|
204
|
+
onClick={() => onChange([...list, defaultFor(item)])}
|
|
205
|
+
>
|
|
206
|
+
Add
|
|
207
|
+
</button>
|
|
208
|
+
</div>,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
default:
|
|
212
|
+
return wrap(
|
|
213
|
+
<input
|
|
214
|
+
type="text"
|
|
215
|
+
value={String(value ?? '')}
|
|
216
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
217
|
+
/>,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GitHubMark } from '../panel/icons'
|
|
2
|
+
import { REPO_URL } from '../viewer/site'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A way to the repository. Only in the viewer (a project's own published site has its own header and is
|
|
6
|
+
* not ours to put links in).
|
|
7
|
+
*/
|
|
8
|
+
export function GitHubStar() {
|
|
9
|
+
if (!globalThis.__SPECBOOK_VIEWER__?.source) return null
|
|
10
|
+
return (
|
|
11
|
+
<a
|
|
12
|
+
class="btn gh-star"
|
|
13
|
+
href={REPO_URL}
|
|
14
|
+
target="_blank"
|
|
15
|
+
rel="noopener"
|
|
16
|
+
title="Specbook's source is on GitHub"
|
|
17
|
+
>
|
|
18
|
+
<GitHubMark />
|
|
19
|
+
<span class="gh-label">GitHub</span>
|
|
20
|
+
</a>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
|
|
3
|
+
const KEY = 'specbook:list-width'
|
|
4
|
+
export const MIN = 280
|
|
5
|
+
const maxWidth = () => Math.round(innerWidth * 0.55)
|
|
6
|
+
|
|
7
|
+
function load(): number | undefined {
|
|
8
|
+
try {
|
|
9
|
+
const n = Number(localStorage.getItem(KEY))
|
|
10
|
+
return n >= MIN ? n : undefined
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The list column's width in px; undefined = the stylesheet's responsive default. */
|
|
17
|
+
export const listWidth = signal<number | undefined>(load())
|
|
18
|
+
|
|
19
|
+
function set(w: number | undefined) {
|
|
20
|
+
listWidth.value = w === undefined ? undefined : Math.round(Math.max(MIN, Math.min(maxWidth(), w)))
|
|
21
|
+
try {
|
|
22
|
+
if (listWidth.value === undefined) localStorage.removeItem(KEY)
|
|
23
|
+
else localStorage.setItem(KEY, String(listWidth.value))
|
|
24
|
+
} catch {
|
|
25
|
+
/* optional */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A draggable (and keyboard-operable) edge on the list column; double-click restores the default. */
|
|
30
|
+
export function ListResizer() {
|
|
31
|
+
const drag = (e: PointerEvent) => {
|
|
32
|
+
e.preventDefault()
|
|
33
|
+
document.body.classList.add('resizing')
|
|
34
|
+
const move = (m: PointerEvent) => set(m.clientX)
|
|
35
|
+
const up = () => {
|
|
36
|
+
document.body.classList.remove('resizing')
|
|
37
|
+
removeEventListener('pointermove', move)
|
|
38
|
+
removeEventListener('pointerup', up)
|
|
39
|
+
}
|
|
40
|
+
addEventListener('pointermove', move)
|
|
41
|
+
addEventListener('pointerup', up)
|
|
42
|
+
}
|
|
43
|
+
const current = () =>
|
|
44
|
+
listWidth.value ?? document.querySelector<HTMLElement>('.list-col')?.offsetWidth ?? 360
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
// biome-ignore lint/a11y/useSemanticElements: a draggable splitter has no native element
|
|
48
|
+
<div
|
|
49
|
+
class="list-resizer"
|
|
50
|
+
role="separator"
|
|
51
|
+
aria-orientation="vertical"
|
|
52
|
+
aria-label="Resize the list"
|
|
53
|
+
aria-valuenow={listWidth.value}
|
|
54
|
+
aria-valuemin={MIN}
|
|
55
|
+
tabIndex={0}
|
|
56
|
+
title="Drag to resize · double-click to reset"
|
|
57
|
+
onPointerDown={drag}
|
|
58
|
+
onDblClick={() => set(undefined)}
|
|
59
|
+
onKeyDown={(e) => {
|
|
60
|
+
if (e.key === 'ArrowLeft') {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
set(current() - 24)
|
|
63
|
+
} else if (e.key === 'ArrowRight') {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
set(current() + 24)
|
|
66
|
+
}
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { environments } from '../store'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In the viewer a document opens on mock data. Say so, and where the real API is, once, on the stage:
|
|
5
|
+
* someone who has pasted a link and sees a response should not wonder whether it came from the server.
|
|
6
|
+
*/
|
|
7
|
+
export function ModeHint() {
|
|
8
|
+
if (!globalThis.__SPECBOOK_VIEWER__) return null
|
|
9
|
+
const live = environments.value.some((e) => e !== 'mock')
|
|
10
|
+
return (
|
|
11
|
+
<p class="muted overview-mode">
|
|
12
|
+
{live
|
|
13
|
+
? 'Requests start on mock data. Switch Environment to live to call the real API.'
|
|
14
|
+
: 'Requests run on generated mock data: this document does not say where the API lives.'}
|
|
15
|
+
</p>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { type Entry, go, paletteOpen, search } from '../nav'
|
|
3
|
+
|
|
4
|
+
export function Palette({ entries }: { entries: Entry[] }) {
|
|
5
|
+
const [q, setQ] = useState('')
|
|
6
|
+
const [sel, setSel] = useState(0)
|
|
7
|
+
const input = useRef<HTMLInputElement>(null)
|
|
8
|
+
const open = paletteOpen.value
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const onKey = (e: KeyboardEvent) => {
|
|
12
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
13
|
+
e.preventDefault()
|
|
14
|
+
paletteOpen.value = !paletteOpen.value
|
|
15
|
+
} else if (e.key === 'Escape') paletteOpen.value = false
|
|
16
|
+
}
|
|
17
|
+
addEventListener('keydown', onKey)
|
|
18
|
+
return () => removeEventListener('keydown', onKey)
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (open) {
|
|
23
|
+
setQ('')
|
|
24
|
+
setSel(0)
|
|
25
|
+
setTimeout(() => input.current?.focus(), 0)
|
|
26
|
+
}
|
|
27
|
+
}, [open])
|
|
28
|
+
|
|
29
|
+
const results = useMemo(() => search(entries, q, 30), [entries, q])
|
|
30
|
+
if (!open) return null
|
|
31
|
+
|
|
32
|
+
const choose = (e: Entry | undefined) => {
|
|
33
|
+
if (!e) return
|
|
34
|
+
paletteOpen.value = false
|
|
35
|
+
go(e.route)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div class="palette-backdrop">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
class="palette-dismiss"
|
|
43
|
+
aria-label="Close"
|
|
44
|
+
onClick={() => (paletteOpen.value = false)}
|
|
45
|
+
/>
|
|
46
|
+
<div class="palette" role="dialog" aria-label="Jump to">
|
|
47
|
+
<input
|
|
48
|
+
ref={input}
|
|
49
|
+
type="text"
|
|
50
|
+
placeholder="Jump to a sequence or operation…"
|
|
51
|
+
value={q}
|
|
52
|
+
onInput={(e) => {
|
|
53
|
+
setQ((e.target as HTMLInputElement).value)
|
|
54
|
+
setSel(0)
|
|
55
|
+
}}
|
|
56
|
+
onKeyDown={(e) => {
|
|
57
|
+
if (e.key === 'ArrowDown') {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
setSel(Math.min(results.length - 1, sel + 1))
|
|
60
|
+
} else if (e.key === 'ArrowUp') {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
setSel(Math.max(0, sel - 1))
|
|
63
|
+
} else if (e.key === 'Enter') choose(results[sel])
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
<ul>
|
|
67
|
+
{results.map((e, i) => (
|
|
68
|
+
<li>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class={i === sel ? 'sel' : ''}
|
|
72
|
+
onMouseEnter={() => setSel(i)}
|
|
73
|
+
onClick={() => choose(e)}
|
|
74
|
+
>
|
|
75
|
+
<span class="p-title">{e.title}</span>
|
|
76
|
+
<span class="muted">{e.kind === 'seq' ? 'Sequence' : e.group}</span>
|
|
77
|
+
</button>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
{results.length === 0 && <li class="muted pad">Nothing matches “{q}”</li>}
|
|
81
|
+
</ul>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { badgeHref, showBadge } from '../badge'
|
|
2
|
+
import { brand } from '../branding'
|
|
3
|
+
import { isStatic } from '../runtime'
|
|
4
|
+
|
|
5
|
+
export function PoweredBy() {
|
|
6
|
+
const host = globalThis.location?.hostname ?? ''
|
|
7
|
+
if (!showBadge({ host, published: isStatic, badge: brand.value.badge })) return null
|
|
8
|
+
return (
|
|
9
|
+
<p class="powered-by">
|
|
10
|
+
<a href={badgeHref(host)} target="_blank" rel="noopener">
|
|
11
|
+
Powered by Specbook
|
|
12
|
+
</a>
|
|
13
|
+
</p>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks'
|
|
2
|
+
import { route } from '../nav'
|
|
3
|
+
import { ShareIcon } from '../panel/icons'
|
|
4
|
+
import { shareable, shareLinks } from '../viewer/share'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Share, for the hosted viewer only (a project's own site has no viewer global). The menu says why when a
|
|
8
|
+
* link would not work for others, rather than offering one that fails for whoever opens it.
|
|
9
|
+
*/
|
|
10
|
+
export function ShareMenu() {
|
|
11
|
+
const v = globalThis.__SPECBOOK_VIEWER__
|
|
12
|
+
const [open, setOpen] = useState(false)
|
|
13
|
+
const [copied, setCopied] = useState(false)
|
|
14
|
+
if (!v?.source) return null
|
|
15
|
+
void route.value // re-render when the operation changes, so the link follows it
|
|
16
|
+
|
|
17
|
+
const can = shareable(v.source)
|
|
18
|
+
const links = can.ok
|
|
19
|
+
? shareLinks(
|
|
20
|
+
{ origin: location.origin, pathname: location.pathname, hash: location.hash },
|
|
21
|
+
can.spec,
|
|
22
|
+
v.open.title,
|
|
23
|
+
)
|
|
24
|
+
: undefined
|
|
25
|
+
|
|
26
|
+
const copy = async () => {
|
|
27
|
+
if (!links) return
|
|
28
|
+
try {
|
|
29
|
+
await navigator.clipboard.writeText(links.link)
|
|
30
|
+
setCopied(true)
|
|
31
|
+
setTimeout(() => setCopied(false), 1500)
|
|
32
|
+
} catch {
|
|
33
|
+
/* the link is shown in the menu to copy by hand */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div class="share">
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
class="btn"
|
|
42
|
+
aria-expanded={open}
|
|
43
|
+
aria-haspopup="true"
|
|
44
|
+
aria-label="Share"
|
|
45
|
+
onClick={() => setOpen(!open)}
|
|
46
|
+
>
|
|
47
|
+
<ShareIcon />
|
|
48
|
+
<span class="share-label">Share</span>
|
|
49
|
+
</button>
|
|
50
|
+
{open && (
|
|
51
|
+
<div class="share-pop" role="menu">
|
|
52
|
+
{links ? (
|
|
53
|
+
<>
|
|
54
|
+
<input
|
|
55
|
+
class="share-link"
|
|
56
|
+
readOnly
|
|
57
|
+
value={links.link}
|
|
58
|
+
aria-label="Link to this view"
|
|
59
|
+
onFocus={(e) => (e.target as HTMLInputElement).select()}
|
|
60
|
+
/>
|
|
61
|
+
<div class="share-actions">
|
|
62
|
+
<button type="button" class="btn primary" onClick={copy}>
|
|
63
|
+
{copied ? 'Copied' : 'Copy link'}
|
|
64
|
+
</button>
|
|
65
|
+
{typeof navigator.share === 'function' && (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
class="btn"
|
|
69
|
+
onClick={() =>
|
|
70
|
+
void navigator.share({ title: links.title, url: links.link }).catch(() => {})
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
Share…
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
<ul class="share-targets">
|
|
78
|
+
{links.targets.map((t) => (
|
|
79
|
+
<li>
|
|
80
|
+
<a href={t.href} target="_blank" rel="noopener noreferrer">
|
|
81
|
+
{t.label}
|
|
82
|
+
</a>
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
</>
|
|
87
|
+
) : (
|
|
88
|
+
<p class="muted">{can.ok ? '' : can.reason}</p>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { cycleTheme, type Theme, theme } from '../theme'
|
|
2
|
+
|
|
3
|
+
const LABEL: Record<Theme, string> = {
|
|
4
|
+
auto: 'Theme: follows your system. Click for light.',
|
|
5
|
+
light: 'Theme: light. Click for dark.',
|
|
6
|
+
dark: 'Theme: dark. Click to follow your system.',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const Icon = ({ mode }: { mode: Theme }) => (
|
|
10
|
+
<svg
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
width="16"
|
|
13
|
+
height="16"
|
|
14
|
+
fill="none"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
stroke-width="1.8"
|
|
17
|
+
stroke-linecap="round"
|
|
18
|
+
stroke-linejoin="round"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
>
|
|
21
|
+
{mode === 'light' && (
|
|
22
|
+
<>
|
|
23
|
+
<circle cx="12" cy="12" r="4" />
|
|
24
|
+
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
25
|
+
</>
|
|
26
|
+
)}
|
|
27
|
+
{mode === 'dark' && <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5z" />}
|
|
28
|
+
{mode === 'auto' && (
|
|
29
|
+
<>
|
|
30
|
+
<circle cx="12" cy="12" r="8.5" />
|
|
31
|
+
<path d="M12 3.5v17a8.5 8.5 0 0 0 0-17z" fill="currentColor" stroke="none" />
|
|
32
|
+
</>
|
|
33
|
+
)}
|
|
34
|
+
</svg>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** Light / dark / follow the system, remembered in this browser. */
|
|
38
|
+
export function ThemeToggle() {
|
|
39
|
+
const t = theme.value
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
class="icon theme-toggle"
|
|
44
|
+
title={LABEL[t]}
|
|
45
|
+
aria-label={LABEL[t]}
|
|
46
|
+
data-theme-mode={t}
|
|
47
|
+
onClick={cycleTheme}
|
|
48
|
+
>
|
|
49
|
+
<Icon mode={t} />
|
|
50
|
+
</button>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { render } from 'preact'
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
3
|
+
import type { SequenceInfo, Variant } from '../engine'
|
|
4
|
+
import { Host } from '../panel/Host'
|
|
5
|
+
import { engine, registry } from '../runtime'
|
|
6
|
+
import { env, environmentRisk, environments, version } from '../store'
|
|
7
|
+
import { panelSheets } from './styles'
|
|
8
|
+
|
|
9
|
+
export interface Target {
|
|
10
|
+
service?: string
|
|
11
|
+
operation?: string
|
|
12
|
+
sequence?: string
|
|
13
|
+
example?: string
|
|
14
|
+
/** open on the Docs tab instead of Try */
|
|
15
|
+
tab?: 'docs' | 'try'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Loaded {
|
|
19
|
+
variants: Record<string, Variant[]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let loaded: Promise<Loaded> | undefined
|
|
23
|
+
const load = () =>
|
|
24
|
+
(loaded ??= (async () => {
|
|
25
|
+
environments.value = await engine.environments(version.value)
|
|
26
|
+
environmentRisk.value = await engine.environmentRisks(version.value)
|
|
27
|
+
env.value = environments.value.includes('mock') ? 'mock' : (environments.value[0] ?? 'mock')
|
|
28
|
+
return { variants: await engine.loadVariants(registry.requests, version.value) }
|
|
29
|
+
})())
|
|
30
|
+
|
|
31
|
+
/** Headers are read from the file text, so only the requested module (or the odd unresolved ones) is imported. */
|
|
32
|
+
async function loadSequence(id: string) {
|
|
33
|
+
const file = registry.sequenceMeta[id]?.file
|
|
34
|
+
const wanted = (f: string) => (file ? f === file : registry.unresolvedSequences.includes(f))
|
|
35
|
+
const entries = registry.sequences.filter((e) => wanted(e.file))
|
|
36
|
+
const list = await engine.loadSequences(entries, version.value)
|
|
37
|
+
return list.find((s) => s.seq.meta.id === id)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findOperation(t: Target) {
|
|
41
|
+
const services = Object.values(registry.manifests)
|
|
42
|
+
const scope = t.service ? services.filter((m) => m.service === t.service) : services
|
|
43
|
+
for (const m of scope) {
|
|
44
|
+
const op = m.operations.find((o) => o.id === t.operation)
|
|
45
|
+
if (op) return { service: m.service, op }
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Embedded({ target, onClose }: { target: Target; onClose: () => void }) {
|
|
51
|
+
const [data, setData] = useState<Loaded>()
|
|
52
|
+
const [seq, setSeq] = useState<SequenceInfo>()
|
|
53
|
+
const [view, setView] = useState<{ tab: 'docs' | 'try'; example: string }>({
|
|
54
|
+
tab: target.tab ?? 'try',
|
|
55
|
+
example: target.example ?? '',
|
|
56
|
+
})
|
|
57
|
+
const navigate = (to: { tab: 'docs' | 'try'; example?: string }) =>
|
|
58
|
+
setView({ tab: to.tab, example: to.example ?? view.example })
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
load().then(setData)
|
|
61
|
+
if (target.sequence) loadSequence(target.sequence).then(setSeq)
|
|
62
|
+
}, [])
|
|
63
|
+
if (target.sequence)
|
|
64
|
+
return seq ? (
|
|
65
|
+
<Host kind="seq" info={seq} tab={view.tab} onNavigate={navigate} onClose={onClose} />
|
|
66
|
+
) : null
|
|
67
|
+
if (!data) return null
|
|
68
|
+
const found = findOperation(target)
|
|
69
|
+
if (!found) return null
|
|
70
|
+
return (
|
|
71
|
+
<Host
|
|
72
|
+
kind="op"
|
|
73
|
+
service={found.service}
|
|
74
|
+
op={found.op}
|
|
75
|
+
variants={data.variants[`${found.service}/${found.op.id}`] ?? []}
|
|
76
|
+
example={view.example}
|
|
77
|
+
tab={view.tab}
|
|
78
|
+
onNavigate={navigate}
|
|
79
|
+
onClose={onClose}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class SpecbookPanel extends HTMLElement {
|
|
85
|
+
private mount!: HTMLElement
|
|
86
|
+
|
|
87
|
+
connectedCallback() {
|
|
88
|
+
if (this.shadowRoot) return
|
|
89
|
+
const root = this.attachShadow({ mode: 'open' })
|
|
90
|
+
root.adoptedStyleSheets = panelSheets()
|
|
91
|
+
this.mount = document.createElement('div')
|
|
92
|
+
this.mount.className = 'sb-root'
|
|
93
|
+
root.append(this.mount)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
show(target: Target | undefined) {
|
|
97
|
+
render(
|
|
98
|
+
target ? <Embedded target={target} onClose={() => this.show(undefined)} /> : null,
|
|
99
|
+
this.mount,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
customElements.define('specbook-panel', SpecbookPanel)
|
|
105
|
+
|
|
106
|
+
/** One panel serves every Play button on the page. */
|
|
107
|
+
export function show(target: Target) {
|
|
108
|
+
let panel = document.querySelector<SpecbookPanel>('specbook-panel')
|
|
109
|
+
if (!panel) {
|
|
110
|
+
panel = document.createElement('specbook-panel') as SpecbookPanel
|
|
111
|
+
document.body.append(panel)
|
|
112
|
+
}
|
|
113
|
+
panel.show(target)
|
|
114
|
+
}
|