@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,396 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
3
|
+
import type { FieldDoc } from '../fielddoc'
|
|
4
|
+
import { inlineSummary, plainText } from '../markdown'
|
|
5
|
+
import { Combo } from './Combo'
|
|
6
|
+
import type { FieldRow } from './draft'
|
|
7
|
+
|
|
8
|
+
export interface Documented {
|
|
9
|
+
name: string
|
|
10
|
+
doc?: FieldDoc
|
|
11
|
+
/** array parameter: the key may repeat */
|
|
12
|
+
repeat?: boolean
|
|
13
|
+
file?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
title: string
|
|
18
|
+
rows: FieldRow[]
|
|
19
|
+
onChange: (rows: FieldRow[]) => void
|
|
20
|
+
/** Documented fields missing from `rows` are listed unchecked, so the grid doubles as the docs. */
|
|
21
|
+
documented?: Documented[]
|
|
22
|
+
/** docs for keys that are not in `documented` (e.g. bracket keys of a form body) */
|
|
23
|
+
docFor?: (key: string) => FieldDoc | undefined
|
|
24
|
+
/** keys are fixed and rows cannot be added, removed or switched off (path parameters, variables) */
|
|
25
|
+
fixed?: boolean
|
|
26
|
+
/** row key to highlight as a problem */
|
|
27
|
+
bad?: string
|
|
28
|
+
placeholder?: string
|
|
29
|
+
/** the grid sits under a tab that already names it */
|
|
30
|
+
bare?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Shown = FieldRow & { virtual?: true }
|
|
34
|
+
|
|
35
|
+
const COLS_KEY = 'specbook:grid-cols'
|
|
36
|
+
const DEFAULT_COLS = { key: 26, val: 34 }
|
|
37
|
+
const MIN_COL = 10
|
|
38
|
+
|
|
39
|
+
/** Key and Value column widths as percentages of the table, shared by every grid; Description takes the rest. */
|
|
40
|
+
const cols = signal(loadCols())
|
|
41
|
+
|
|
42
|
+
function loadCols(): { key: number; val: number } {
|
|
43
|
+
try {
|
|
44
|
+
const c = JSON.parse(localStorage.getItem(COLS_KEY) ?? 'null')
|
|
45
|
+
if (c && c.key >= MIN_COL && c.val >= MIN_COL && c.key + c.val <= 100 - MIN_COL) return c
|
|
46
|
+
} catch {
|
|
47
|
+
/* fall through */
|
|
48
|
+
}
|
|
49
|
+
return DEFAULT_COLS
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setCols(next: { key: number; val: number } | undefined) {
|
|
53
|
+
const c = next ?? DEFAULT_COLS
|
|
54
|
+
const key = Math.min(100 - 2 * MIN_COL, Math.max(MIN_COL, c.key))
|
|
55
|
+
const val = Math.min(100 - key - MIN_COL, Math.max(MIN_COL, c.val))
|
|
56
|
+
cols.value = { key: Math.round(key * 10) / 10, val: Math.round(val * 10) / 10 }
|
|
57
|
+
try {
|
|
58
|
+
if (next) localStorage.setItem(COLS_KEY, JSON.stringify(cols.value))
|
|
59
|
+
else localStorage.removeItem(COLS_KEY)
|
|
60
|
+
} catch {
|
|
61
|
+
/* optional */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The shared column layout, so other grids (sequence controls) line up with the parameter grids. */
|
|
66
|
+
export function GridColgroup() {
|
|
67
|
+
return (
|
|
68
|
+
<colgroup>
|
|
69
|
+
<col style={{ width: '36px' }} />
|
|
70
|
+
<col style={{ width: `${cols.value.key}%` }} />
|
|
71
|
+
<col style={{ width: `${cols.value.val}%` }} />
|
|
72
|
+
<col />
|
|
73
|
+
<col style={{ width: '60px' }} />
|
|
74
|
+
</colgroup>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The handle on a column's right edge: drag, ←/→ to nudge, double-click to reset. */
|
|
79
|
+
function ColResizer({ which, table }: { which: 'key' | 'val'; table: () => HTMLElement | null }) {
|
|
80
|
+
const move = (dPct: number) => {
|
|
81
|
+
const c = cols.value
|
|
82
|
+
// the Key edge trades width with Value; the Value edge trades with Description
|
|
83
|
+
setCols(
|
|
84
|
+
which === 'key'
|
|
85
|
+
? { key: c.key + dPct, val: c.val - dPct }
|
|
86
|
+
: { key: c.key, val: c.val + dPct },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
const drag = (e: PointerEvent) => {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
const width = table()?.getBoundingClientRect().width ?? 1
|
|
92
|
+
let last = e.clientX
|
|
93
|
+
document.body.classList.add('resizing')
|
|
94
|
+
const onMove = (m: PointerEvent) => {
|
|
95
|
+
move(((m.clientX - last) / width) * 100)
|
|
96
|
+
last = m.clientX
|
|
97
|
+
}
|
|
98
|
+
const up = () => {
|
|
99
|
+
document.body.classList.remove('resizing')
|
|
100
|
+
removeEventListener('pointermove', onMove)
|
|
101
|
+
removeEventListener('pointerup', up)
|
|
102
|
+
}
|
|
103
|
+
addEventListener('pointermove', onMove)
|
|
104
|
+
addEventListener('pointerup', up)
|
|
105
|
+
}
|
|
106
|
+
return (
|
|
107
|
+
// biome-ignore lint/a11y/useSemanticElements: a draggable splitter has no native element
|
|
108
|
+
<span
|
|
109
|
+
class="col-resizer"
|
|
110
|
+
role="separator"
|
|
111
|
+
aria-orientation="vertical"
|
|
112
|
+
aria-label={`Resize the ${which === 'key' ? 'Key' : 'Value'} column`}
|
|
113
|
+
aria-valuenow={Math.round(cols.value[which])}
|
|
114
|
+
aria-valuemin={MIN_COL}
|
|
115
|
+
aria-valuemax={100 - 2 * MIN_COL}
|
|
116
|
+
tabIndex={0}
|
|
117
|
+
title="Drag to resize · double-click to reset"
|
|
118
|
+
onPointerDown={drag}
|
|
119
|
+
onDblClick={() => setCols(undefined)}
|
|
120
|
+
onKeyDown={(e) => {
|
|
121
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
122
|
+
e.preventDefault()
|
|
123
|
+
move(e.key === 'ArrowLeft' ? -2 : 2)
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const KEY = 1
|
|
131
|
+
const VALUE = 2
|
|
132
|
+
|
|
133
|
+
/** `key=value`, `key: value` or `key<TAB>value` lines, as copied from docs, curl or a spreadsheet. */
|
|
134
|
+
export function parsePasted(text: string): { key: string; value: string }[] {
|
|
135
|
+
return text
|
|
136
|
+
.split(/\r?\n/)
|
|
137
|
+
.map((l) => l.trim())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.map((l) => {
|
|
140
|
+
const at = l.search(/[=\t]|:\s/)
|
|
141
|
+
return at < 0
|
|
142
|
+
? { key: l, value: '' }
|
|
143
|
+
: { key: l.slice(0, at).trim(), value: l.slice(at + 1).trim() }
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const Icon = ({ d }: { d: string }) => (
|
|
148
|
+
<svg
|
|
149
|
+
viewBox="0 0 16 16"
|
|
150
|
+
width="14"
|
|
151
|
+
height="14"
|
|
152
|
+
fill="none"
|
|
153
|
+
stroke="currentColor"
|
|
154
|
+
aria-hidden="true"
|
|
155
|
+
>
|
|
156
|
+
<path d={d} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
157
|
+
</svg>
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
export function ParamGrid({
|
|
161
|
+
title,
|
|
162
|
+
rows,
|
|
163
|
+
onChange,
|
|
164
|
+
documented = [],
|
|
165
|
+
docFor,
|
|
166
|
+
fixed,
|
|
167
|
+
bad,
|
|
168
|
+
placeholder,
|
|
169
|
+
bare,
|
|
170
|
+
}: Props) {
|
|
171
|
+
const root = useRef<HTMLElement>(null)
|
|
172
|
+
const table = useRef<HTMLTableElement>(null)
|
|
173
|
+
const focusNext = useRef<string | undefined>(undefined)
|
|
174
|
+
const have = new Set(rows.map((r) => r.key))
|
|
175
|
+
const virtual: Shown[] = documented
|
|
176
|
+
.filter((d) => !have.has(d.name))
|
|
177
|
+
.map((d) => ({ key: d.name, value: '', enabled: false, virtual: true }))
|
|
178
|
+
const all: Shown[] = [...rows, ...virtual]
|
|
179
|
+
const info = (key: string) => documented.find((d) => d.name === key)
|
|
180
|
+
const docOf = (key: string) => info(key)?.doc ?? docFor?.(key)
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!focusNext.current) return
|
|
184
|
+
const el = root.current?.querySelector<HTMLInputElement>(`[data-cell="${focusNext.current}"]`)
|
|
185
|
+
focusNext.current = undefined
|
|
186
|
+
if (!el) return
|
|
187
|
+
el.focus()
|
|
188
|
+
el.setSelectionRange?.(el.value.length, el.value.length)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const real = (r: Shown) => !r.virtual || r.enabled || r.value !== '' || r.file
|
|
192
|
+
const commit = (next: Shown[]) => onChange(next.filter(real).map(({ virtual: _v, ...r }) => r))
|
|
193
|
+
const patch = (i: number, p: Partial<FieldRow>) =>
|
|
194
|
+
commit(all.map((r, j) => (j === i ? { ...r, ...p } : r)))
|
|
195
|
+
|
|
196
|
+
const go = (i: number, col: number) =>
|
|
197
|
+
root.current?.querySelector<HTMLElement>(`[data-cell="${i}:${col}"]`)?.focus()
|
|
198
|
+
const keys = (i: number, col: number) => (e: KeyboardEvent) => {
|
|
199
|
+
if (e.key === 'Enter' || e.key === 'ArrowDown') {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
go(i + (e.shiftKey ? -1 : 1), col)
|
|
202
|
+
} else if (e.key === 'ArrowUp') {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
go(i - 1, col)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const repeat = (i: number) => {
|
|
209
|
+
const r = all[i] as Shown
|
|
210
|
+
const options = docOf(r.key)?.enum ?? []
|
|
211
|
+
const used = new Set(all.filter((x) => x.key === r.key).map((x) => x.value))
|
|
212
|
+
const value = options.find((o) => !used.has(o)) ?? ''
|
|
213
|
+
const added: Shown = { key: r.key, value, enabled: true }
|
|
214
|
+
const next = [...all]
|
|
215
|
+
next.splice(i + 1, 0, added)
|
|
216
|
+
focusNext.current = `${next.filter(real).indexOf(added)}:${VALUE}`
|
|
217
|
+
commit(next)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const paste = (e: ClipboardEvent, at: number) => {
|
|
221
|
+
const text = e.clipboardData?.getData('text') ?? ''
|
|
222
|
+
if (!/\r?\n/.test(text.trim())) return
|
|
223
|
+
e.preventDefault()
|
|
224
|
+
const added = parsePasted(text).map((p) => ({ ...p, enabled: true }))
|
|
225
|
+
const next = [...rows]
|
|
226
|
+
next.splice(at, at < rows.length && !rows[at]?.key ? 1 : 0, ...added)
|
|
227
|
+
onChange(next)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ghost = all.length
|
|
231
|
+
return (
|
|
232
|
+
<section class="grid" aria-label={title} ref={root}>
|
|
233
|
+
{!bare && <h3>{title}</h3>}
|
|
234
|
+
<table ref={table}>
|
|
235
|
+
<GridColgroup />
|
|
236
|
+
<thead>
|
|
237
|
+
<tr>
|
|
238
|
+
<th class="g-check" />
|
|
239
|
+
<th class="g-key">
|
|
240
|
+
Key
|
|
241
|
+
<ColResizer which="key" table={() => table.current} />
|
|
242
|
+
</th>
|
|
243
|
+
<th class="g-val">
|
|
244
|
+
Value
|
|
245
|
+
<ColResizer which="val" table={() => table.current} />
|
|
246
|
+
</th>
|
|
247
|
+
<th class="g-doc">Description</th>
|
|
248
|
+
<th class="g-act" />
|
|
249
|
+
</tr>
|
|
250
|
+
</thead>
|
|
251
|
+
<tbody>
|
|
252
|
+
{all.map((r, i) => {
|
|
253
|
+
const d = docOf(r.key)
|
|
254
|
+
const isFile = info(r.key)?.file
|
|
255
|
+
const locked = fixed || r.virtual || info(r.key)
|
|
256
|
+
return (
|
|
257
|
+
<tr class={`${r.enabled ? '' : 'off'} ${bad === r.key ? 'bad' : ''}`} key={i}>
|
|
258
|
+
<td class="g-check">
|
|
259
|
+
<input
|
|
260
|
+
type="checkbox"
|
|
261
|
+
aria-label={`Send ${r.key}`}
|
|
262
|
+
checked={r.enabled}
|
|
263
|
+
disabled={fixed}
|
|
264
|
+
onChange={(e) => patch(i, { enabled: (e.target as HTMLInputElement).checked })}
|
|
265
|
+
/>
|
|
266
|
+
</td>
|
|
267
|
+
<td class="g-key">
|
|
268
|
+
{locked ? (
|
|
269
|
+
<span class="key">
|
|
270
|
+
<code>{r.key}</code>
|
|
271
|
+
{d?.required && (
|
|
272
|
+
<span class="req" title="required">
|
|
273
|
+
*
|
|
274
|
+
</span>
|
|
275
|
+
)}
|
|
276
|
+
</span>
|
|
277
|
+
) : (
|
|
278
|
+
<input
|
|
279
|
+
class="cell"
|
|
280
|
+
data-cell={`${i}:${KEY}`}
|
|
281
|
+
value={r.key}
|
|
282
|
+
placeholder="key"
|
|
283
|
+
aria-label="Name"
|
|
284
|
+
onInput={(e) => patch(i, { key: (e.target as HTMLInputElement).value })}
|
|
285
|
+
onKeyDown={keys(i, KEY)}
|
|
286
|
+
onPaste={(e) => paste(e, i)}
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
</td>
|
|
290
|
+
<td class="g-val">
|
|
291
|
+
{isFile ? (
|
|
292
|
+
<label class="file-cell">
|
|
293
|
+
<input
|
|
294
|
+
type="file"
|
|
295
|
+
aria-label={`File for ${r.key}`}
|
|
296
|
+
onChange={(e) => {
|
|
297
|
+
const file = (e.target as HTMLInputElement).files?.[0]
|
|
298
|
+
if (file) patch(i, { file, value: file.name, enabled: true })
|
|
299
|
+
}}
|
|
300
|
+
/>
|
|
301
|
+
<span class={r.value ? '' : 'muted'}>{r.value || 'Choose a file…'}</span>
|
|
302
|
+
</label>
|
|
303
|
+
) : d?.enum ? (
|
|
304
|
+
<Combo
|
|
305
|
+
value={r.value}
|
|
306
|
+
options={d.enum.map(String)}
|
|
307
|
+
label={r.key}
|
|
308
|
+
cell={`${i}:${VALUE}`}
|
|
309
|
+
placeholder="value"
|
|
310
|
+
onKeyDown={keys(i, VALUE)}
|
|
311
|
+
onChange={(value) => patch(i, { value, enabled: true })}
|
|
312
|
+
/>
|
|
313
|
+
) : (
|
|
314
|
+
<input
|
|
315
|
+
class="cell"
|
|
316
|
+
data-cell={`${i}:${VALUE}`}
|
|
317
|
+
value={r.value}
|
|
318
|
+
placeholder={placeholder ?? 'value'}
|
|
319
|
+
aria-label={`Value of ${r.key}`}
|
|
320
|
+
onInput={(e) =>
|
|
321
|
+
patch(i, { value: (e.target as HTMLInputElement).value, enabled: true })
|
|
322
|
+
}
|
|
323
|
+
onKeyDown={keys(i, VALUE)}
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
</td>
|
|
327
|
+
<td class="g-doc" title={plainText(d?.description)}>
|
|
328
|
+
{d && (
|
|
329
|
+
<>
|
|
330
|
+
<span class="chip">{d.type}</span>{' '}
|
|
331
|
+
<span
|
|
332
|
+
class="doc-inline"
|
|
333
|
+
dangerouslySetInnerHTML={{ __html: inlineSummary(d.description) }}
|
|
334
|
+
/>
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
</td>
|
|
338
|
+
<td class="g-act">
|
|
339
|
+
{!fixed && r.key && (
|
|
340
|
+
<button
|
|
341
|
+
type="button"
|
|
342
|
+
class="icon"
|
|
343
|
+
title="Repeat this key with another value"
|
|
344
|
+
aria-label={`Repeat ${r.key}`}
|
|
345
|
+
onClick={() => repeat(i)}
|
|
346
|
+
>
|
|
347
|
+
<Icon d="M5.5 5.5h7v7h-7zM3.5 10.5v-7h7" />
|
|
348
|
+
</button>
|
|
349
|
+
)}
|
|
350
|
+
{!fixed && !r.virtual && (
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
class="icon"
|
|
354
|
+
title="Remove"
|
|
355
|
+
aria-label={`Remove ${r.key}`}
|
|
356
|
+
onClick={() => commit(all.filter((_, j) => j !== i))}
|
|
357
|
+
>
|
|
358
|
+
<Icon d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
|
|
359
|
+
</button>
|
|
360
|
+
)}
|
|
361
|
+
</td>
|
|
362
|
+
</tr>
|
|
363
|
+
)
|
|
364
|
+
})}
|
|
365
|
+
{!fixed && (
|
|
366
|
+
<tr class="ghost" key={ghost}>
|
|
367
|
+
<td class="g-check" />
|
|
368
|
+
<td class="g-key">
|
|
369
|
+
<input
|
|
370
|
+
class="cell"
|
|
371
|
+
data-cell={`${ghost}:${KEY}`}
|
|
372
|
+
value=""
|
|
373
|
+
placeholder="Add a key"
|
|
374
|
+
aria-label="New key"
|
|
375
|
+
onInput={(e) => {
|
|
376
|
+
// the new row lands before the unchecked documented ones, so focus follows it there
|
|
377
|
+
focusNext.current = `${rows.length}:${KEY}`
|
|
378
|
+
onChange([
|
|
379
|
+
...rows,
|
|
380
|
+
{ key: (e.target as HTMLInputElement).value, value: '', enabled: true },
|
|
381
|
+
])
|
|
382
|
+
}}
|
|
383
|
+
onKeyDown={keys(ghost, KEY)}
|
|
384
|
+
onPaste={(e) => paste(e, rows.length)}
|
|
385
|
+
/>
|
|
386
|
+
</td>
|
|
387
|
+
<td class="g-val" />
|
|
388
|
+
<td class="g-doc" />
|
|
389
|
+
<td class="g-act" />
|
|
390
|
+
</tr>
|
|
391
|
+
)}
|
|
392
|
+
</tbody>
|
|
393
|
+
</table>
|
|
394
|
+
</section>
|
|
395
|
+
)
|
|
396
|
+
}
|