@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,100 @@
|
|
|
1
|
+
import type { SequenceInfo, Variant } from '../engine'
|
|
2
|
+
import type { PanelTab } from '../nav'
|
|
3
|
+
import { OperationDocs } from './OperationDocs'
|
|
4
|
+
import { Panel } from './Panel'
|
|
5
|
+
import { type OpInfo, RequestPanel } from './RequestPanel'
|
|
6
|
+
import { SequenceDocs } from './SequenceDocs'
|
|
7
|
+
import { SequencePanel } from './SequencePanel'
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
onClose: () => void
|
|
11
|
+
/** dims the page and traps focus; the standalone site docks the panel beside its list instead */
|
|
12
|
+
modal?: boolean
|
|
13
|
+
tab?: PanelTab
|
|
14
|
+
/** switch tab, optionally opening a named example */
|
|
15
|
+
onNavigate: (to: { tab: PanelTab; example?: string }) => void
|
|
16
|
+
onPrev?: () => void
|
|
17
|
+
onNext?: () => void
|
|
18
|
+
} & (
|
|
19
|
+
| { kind: 'op'; service: string; op: OpInfo; variants: Variant[]; example: string }
|
|
20
|
+
| { kind: 'seq'; info: SequenceInfo }
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Lazy entry for everything the side panel needs, so the catalog carries no editor code. */
|
|
24
|
+
export function Host(p: Props) {
|
|
25
|
+
const tab = p.tab ?? 'try'
|
|
26
|
+
const tabs = (
|
|
27
|
+
<div class="rq-tabs panel-tabs" role="tablist">
|
|
28
|
+
{(
|
|
29
|
+
[
|
|
30
|
+
['try', p.kind === 'seq' ? 'Run' : 'Try'],
|
|
31
|
+
['docs', 'Docs'],
|
|
32
|
+
] as const
|
|
33
|
+
).map(([id, label]) => (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
role="tab"
|
|
37
|
+
aria-selected={tab === id}
|
|
38
|
+
class={`rq-tab ${tab === id ? 'on' : ''}`}
|
|
39
|
+
onClick={() => p.onNavigate({ tab: id })}
|
|
40
|
+
>
|
|
41
|
+
{label}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (p.kind === 'seq')
|
|
48
|
+
return (
|
|
49
|
+
<Panel
|
|
50
|
+
title={p.info.seq.meta.title}
|
|
51
|
+
subtitle="Sequence"
|
|
52
|
+
flush
|
|
53
|
+
modal={p.modal}
|
|
54
|
+
onPrev={p.onPrev}
|
|
55
|
+
onNext={p.onNext}
|
|
56
|
+
onClose={p.onClose}
|
|
57
|
+
>
|
|
58
|
+
<div class="panel-split">
|
|
59
|
+
{tabs}
|
|
60
|
+
<div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
|
|
61
|
+
{tab === 'docs' ? <SequenceDocs info={p.info} /> : <SequencePanel info={p.info} />}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</Panel>
|
|
65
|
+
)
|
|
66
|
+
return (
|
|
67
|
+
<Panel
|
|
68
|
+
title={p.op.summary?.replace(/\.$/, '') || p.op.path}
|
|
69
|
+
subtitle={`${p.op.method} ${p.op.path}`}
|
|
70
|
+
flush
|
|
71
|
+
modal={p.modal}
|
|
72
|
+
onPrev={p.onPrev}
|
|
73
|
+
onNext={p.onNext}
|
|
74
|
+
onClose={p.onClose}
|
|
75
|
+
>
|
|
76
|
+
<div class="panel-split">
|
|
77
|
+
{tabs}
|
|
78
|
+
<div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
|
|
79
|
+
{tab === 'docs' ? (
|
|
80
|
+
<OperationDocs
|
|
81
|
+
key={`${p.service}/${p.op.id}`}
|
|
82
|
+
service={p.service}
|
|
83
|
+
op={p.op}
|
|
84
|
+
variants={p.variants}
|
|
85
|
+
onTry={(example) => p.onNavigate({ tab: 'try', example })}
|
|
86
|
+
/>
|
|
87
|
+
) : (
|
|
88
|
+
<RequestPanel
|
|
89
|
+
key={`${p.service}/${p.op.id}`}
|
|
90
|
+
service={p.service}
|
|
91
|
+
op={p.op}
|
|
92
|
+
variants={p.variants}
|
|
93
|
+
initial={p.example}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</Panel>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
2
|
+
import type { Variant } from '../engine'
|
|
3
|
+
import { markdownToHtml } from '../markdown'
|
|
4
|
+
import { registry } from '../runtime'
|
|
5
|
+
import type { Defs, JsonSchema } from '../schema'
|
|
6
|
+
import { unwrap } from '../schema'
|
|
7
|
+
import { statusClass, statusName } from '../status'
|
|
8
|
+
import { FieldTable } from './FieldTable'
|
|
9
|
+
import { useOperationNotes } from './fieldnotes'
|
|
10
|
+
|
|
11
|
+
export interface DocOp {
|
|
12
|
+
id: string
|
|
13
|
+
method: string
|
|
14
|
+
path: string
|
|
15
|
+
summary?: string
|
|
16
|
+
tags: string[]
|
|
17
|
+
statuses: number[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface LongDocs {
|
|
21
|
+
description?: string
|
|
22
|
+
examples: { name: string; title: string; source: string }[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SECTIONS = [
|
|
26
|
+
['path', 'Path'],
|
|
27
|
+
['query', 'Query'],
|
|
28
|
+
['headers', 'Headers'],
|
|
29
|
+
] as const
|
|
30
|
+
|
|
31
|
+
/** Everything the page shows below its header, fetched together so the page is laid out once. */
|
|
32
|
+
interface Loaded {
|
|
33
|
+
long?: LongDocs
|
|
34
|
+
req?: JsonSchema
|
|
35
|
+
defs?: Defs
|
|
36
|
+
/** a saved request's own markdown (`<name>.req.md` beside its `.req.ts`), by file */
|
|
37
|
+
saved: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const soft = <T,>(p: Promise<T> | undefined): Promise<T | undefined> =>
|
|
41
|
+
(p ?? Promise.resolve(undefined)).catch(() => undefined)
|
|
42
|
+
|
|
43
|
+
function loadAll(key: string, service: string, files: string[]): Promise<Loaded> {
|
|
44
|
+
return Promise.all([
|
|
45
|
+
soft(registry.operationDocs[key]?.().then((m: { default: LongDocs }) => m.default)),
|
|
46
|
+
soft(registry.schemas[key]?.().then((m: { default: JsonSchema }) => m.default)),
|
|
47
|
+
soft(registry.definitions[service]?.().then((m: { default: Defs }) => m.default)),
|
|
48
|
+
Promise.all(files.map((f) => soft(registry.docs.find((d) => d.requestFile === f)?.load()))),
|
|
49
|
+
]).then(([long, req, defs, notes]) => ({
|
|
50
|
+
long,
|
|
51
|
+
req,
|
|
52
|
+
defs,
|
|
53
|
+
saved: Object.fromEntries(files.flatMap((f, i) => (notes[i] ? [[f, notes[i] as string]] : []))),
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A saved request's own markdown (`<name>.req.md` beside its `.req.ts`), when it has one. */
|
|
58
|
+
function SavedRequestNote({ variant, html }: { variant: Variant; html?: string }) {
|
|
59
|
+
if (!html) return null
|
|
60
|
+
return (
|
|
61
|
+
<section class="saved-note-doc">
|
|
62
|
+
<h2>
|
|
63
|
+
Saved request <code>{variant.name}</code>
|
|
64
|
+
</h2>
|
|
65
|
+
<div class="doc-custom" dangerouslySetInnerHTML={{ __html: html }} />
|
|
66
|
+
</section>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function OperationDocs({
|
|
71
|
+
service,
|
|
72
|
+
op,
|
|
73
|
+
variants = [],
|
|
74
|
+
onTry,
|
|
75
|
+
}: {
|
|
76
|
+
service: string
|
|
77
|
+
op: DocOp
|
|
78
|
+
variants?: Variant[]
|
|
79
|
+
/** open the Try tab, optionally on a named example */
|
|
80
|
+
onTry: (example?: string) => void
|
|
81
|
+
}) {
|
|
82
|
+
const key = `${service}/${op.id}`
|
|
83
|
+
const [loaded, setLoaded] = useState<Loaded>()
|
|
84
|
+
const files = variants.map((v) => v.file)
|
|
85
|
+
const fileKey = files.join('\n')
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let live = true
|
|
89
|
+
setLoaded(undefined)
|
|
90
|
+
loadAll(key, service, fileKey ? fileKey.split('\n') : []).then((l) => live && setLoaded(l))
|
|
91
|
+
return () => {
|
|
92
|
+
live = false
|
|
93
|
+
}
|
|
94
|
+
}, [key, service, fileKey])
|
|
95
|
+
|
|
96
|
+
const page = useOperationNotes(key)
|
|
97
|
+
// the header is there at once; everything below it appears together once it has all arrived
|
|
98
|
+
const ready = !!loaded && page.loaded
|
|
99
|
+
const { long, req, defs } = loaded ?? { saved: {} }
|
|
100
|
+
const root = req ? unwrap(req, defs) : undefined
|
|
101
|
+
const section = (name: string) => root?.properties?.[name] as JsonSchema | undefined
|
|
102
|
+
const body = section('data')
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<article class="doc" data-op={key}>
|
|
106
|
+
<div class="doc-intro">
|
|
107
|
+
<span class={`method ${op.method}`}>{op.method}</span> <code>{op.path}</code>
|
|
108
|
+
<span class="spacer" />
|
|
109
|
+
<button type="button" class="btn primary" onClick={() => onTry()}>
|
|
110
|
+
Try it
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{ready && (
|
|
115
|
+
<>
|
|
116
|
+
{/* the spec's own text first; the project's page follows it, or stands in for it with `replace: true` */}
|
|
117
|
+
{!(page.found && page.replace) && long?.description && (
|
|
118
|
+
<div
|
|
119
|
+
class="doc-custom"
|
|
120
|
+
dangerouslySetInnerHTML={{ __html: markdownToHtml(long.description) }}
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
123
|
+
{page.intro && (
|
|
124
|
+
<div
|
|
125
|
+
class={`doc-custom ${page.replace ? '' : 'doc-project'}`}
|
|
126
|
+
dangerouslySetInnerHTML={{ __html: page.intro }}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{variants.map((v) => (
|
|
131
|
+
<SavedRequestNote key={v.file} variant={v} html={loaded?.saved[v.file]} />
|
|
132
|
+
))}
|
|
133
|
+
|
|
134
|
+
{SECTIONS.map(([k, title]) =>
|
|
135
|
+
section(k) ? (
|
|
136
|
+
<section>
|
|
137
|
+
<h2>{title} parameters</h2>
|
|
138
|
+
<FieldTable schema={section(k)} defs={defs} section={k} notes={page.notes} />
|
|
139
|
+
</section>
|
|
140
|
+
) : null,
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{body && (
|
|
144
|
+
<section>
|
|
145
|
+
<h2>Request body</h2>
|
|
146
|
+
<FieldTable schema={body} defs={defs} section="data" notes={page.notes} />
|
|
147
|
+
</section>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{op.statuses.length > 0 && (
|
|
151
|
+
<section>
|
|
152
|
+
<h2>Responses</h2>
|
|
153
|
+
<ul class="statuses">
|
|
154
|
+
{op.statuses.map((s) => (
|
|
155
|
+
<li>
|
|
156
|
+
<span class={`state-badge ${statusClass(s)}`}>{s}</span> {statusName(s)}
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
</section>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{(long?.examples.length ?? 0) > 1 && (
|
|
164
|
+
<section>
|
|
165
|
+
<h2>Examples</h2>
|
|
166
|
+
<ul class="examples">
|
|
167
|
+
{long?.examples.map((e) => (
|
|
168
|
+
<li>
|
|
169
|
+
<button type="button" class="link" onClick={() => onTry(e.name)}>
|
|
170
|
+
▶ {e.title}
|
|
171
|
+
</button>
|
|
172
|
+
</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
</section>
|
|
176
|
+
)}
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</article>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ComponentChildren } from 'preact'
|
|
2
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
3
|
+
import { env, environments } from '../store'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
title: string
|
|
7
|
+
subtitle?: string
|
|
8
|
+
/** the body fills the panel and manages its own scrolling */
|
|
9
|
+
flush?: boolean
|
|
10
|
+
/** dims the page and traps focus (embeds); the standalone site leaves its list usable beside the panel */
|
|
11
|
+
modal?: boolean
|
|
12
|
+
onPrev?: () => void
|
|
13
|
+
onNext?: () => void
|
|
14
|
+
onClose: () => void
|
|
15
|
+
children: ComponentChildren
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FOCUSABLE =
|
|
19
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
20
|
+
|
|
21
|
+
/** The three-quarter sheet from the right where requests and sequences run. */
|
|
22
|
+
export function Panel({
|
|
23
|
+
title,
|
|
24
|
+
subtitle,
|
|
25
|
+
flush,
|
|
26
|
+
modal = true,
|
|
27
|
+
onPrev,
|
|
28
|
+
onNext,
|
|
29
|
+
onClose,
|
|
30
|
+
children,
|
|
31
|
+
}: Props) {
|
|
32
|
+
const aside = useRef<HTMLElement>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const root = aside.current?.getRootNode() as Document | ShadowRoot
|
|
36
|
+
const before = root?.activeElement as HTMLElement | null
|
|
37
|
+
if (modal) aside.current?.focus()
|
|
38
|
+
return () => before?.focus?.()
|
|
39
|
+
}, [modal])
|
|
40
|
+
|
|
41
|
+
// The keyboard hides the lower half of a phone's screen and iOS does not reliably scroll a field in
|
|
42
|
+
// a fixed panel above it. While a text field is focused its scroller gets room under the content,
|
|
43
|
+
// and the field is placed near the top of the panel, which the keyboard never covers.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const el = aside.current
|
|
46
|
+
if (!el || typeof matchMedia === 'undefined') return
|
|
47
|
+
const phone = matchMedia('(max-width: 820px)')
|
|
48
|
+
const FIELD = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), textarea'
|
|
49
|
+
let padded: { box: HTMLElement; was: string } | undefined
|
|
50
|
+
let timers: number[] = []
|
|
51
|
+
const scroller = (from: HTMLElement) => {
|
|
52
|
+
for (let n = from.parentElement; n && n !== el; n = n.parentElement) {
|
|
53
|
+
const y = getComputedStyle(n).overflowY
|
|
54
|
+
if (y === 'auto' || y === 'scroll') return n
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
const place = (field: HTMLElement) => {
|
|
59
|
+
const box = scroller(field)
|
|
60
|
+
if (!box) return
|
|
61
|
+
// measured against what is visible: iOS pans its visual viewport under the keyboard
|
|
62
|
+
const vv = typeof visualViewport === 'undefined' ? undefined : visualViewport
|
|
63
|
+
const top = vv?.offsetTop ?? 0
|
|
64
|
+
const height = vv?.height ?? innerHeight
|
|
65
|
+
const at = field.getBoundingClientRect().top
|
|
66
|
+
const want = top + height * 0.2
|
|
67
|
+
if (at > top + height * 0.4 || at < top) box.scrollTop += at - want
|
|
68
|
+
}
|
|
69
|
+
const focusin = (e: Event) => {
|
|
70
|
+
const field = e.target as HTMLElement
|
|
71
|
+
if (!phone.matches || !field.matches?.(FIELD)) return
|
|
72
|
+
const box = scroller(field)
|
|
73
|
+
if (box && padded?.box !== box) {
|
|
74
|
+
if (padded) padded.box.style.paddingBottom = padded.was
|
|
75
|
+
padded = { box, was: box.style.paddingBottom }
|
|
76
|
+
box.style.paddingBottom = '70vh'
|
|
77
|
+
}
|
|
78
|
+
// the keyboard animates in; place the field as it opens and again once it has settled
|
|
79
|
+
for (const t of timers) clearTimeout(t)
|
|
80
|
+
timers = [0, 150, 400, 800].map((ms) => window.setTimeout(() => place(field), ms))
|
|
81
|
+
}
|
|
82
|
+
const focusout = () => {
|
|
83
|
+
timers.push(
|
|
84
|
+
window.setTimeout(() => {
|
|
85
|
+
const at = (el.getRootNode() as Document | ShadowRoot).activeElement
|
|
86
|
+
if (at?.matches?.(FIELD) || !padded) return
|
|
87
|
+
padded.box.style.paddingBottom = padded.was
|
|
88
|
+
padded = undefined
|
|
89
|
+
}, 200),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
el.addEventListener('focusin', focusin)
|
|
93
|
+
el.addEventListener('focusout', focusout)
|
|
94
|
+
return () => {
|
|
95
|
+
el.removeEventListener('focusin', focusin)
|
|
96
|
+
el.removeEventListener('focusout', focusout)
|
|
97
|
+
for (const t of timers) clearTimeout(t)
|
|
98
|
+
if (padded) padded.box.style.paddingBottom = padded.was
|
|
99
|
+
}
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const key = (e: KeyboardEvent) => {
|
|
104
|
+
if (e.key === 'Escape') onClose()
|
|
105
|
+
if (e.key !== 'Tab' || !modal || !aside.current) return
|
|
106
|
+
const items = [...aside.current.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
|
107
|
+
(el) => el.offsetParent !== null,
|
|
108
|
+
)
|
|
109
|
+
const first = items[0]
|
|
110
|
+
const last = items[items.length - 1]
|
|
111
|
+
const root = aside.current.getRootNode() as Document | ShadowRoot
|
|
112
|
+
const at = root.activeElement
|
|
113
|
+
if (!first || !last) return
|
|
114
|
+
if (e.shiftKey && (at === first || at === aside.current)) {
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
last.focus()
|
|
117
|
+
} else if (!e.shiftKey && at === last) {
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
first.focus()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
addEventListener('keydown', key)
|
|
123
|
+
return () => removeEventListener('keydown', key)
|
|
124
|
+
}, [onClose, modal])
|
|
125
|
+
|
|
126
|
+
// a dialog when it dims the page (embeds), a complementary region when docked beside the list
|
|
127
|
+
const attrs = {
|
|
128
|
+
class: `panel ${modal ? '' : 'docked'}`,
|
|
129
|
+
part: 'panel',
|
|
130
|
+
'aria-label': title,
|
|
131
|
+
'aria-modal': modal ? true : undefined,
|
|
132
|
+
role: (modal ? 'dialog' : 'complementary') as 'dialog',
|
|
133
|
+
tabIndex: -1,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
{modal && (
|
|
139
|
+
<button type="button" class="panel-backdrop" aria-label="Close" onClick={onClose} />
|
|
140
|
+
)}
|
|
141
|
+
<section {...attrs} ref={aside}>
|
|
142
|
+
<header class="panel-head" part="header">
|
|
143
|
+
<div class="panel-title">
|
|
144
|
+
<h2>{title}</h2>
|
|
145
|
+
{subtitle && <div class="muted small">{subtitle}</div>}
|
|
146
|
+
</div>
|
|
147
|
+
<span class="spacer" />
|
|
148
|
+
{modal && environments.value.length > 1 && (
|
|
149
|
+
<label class="row">
|
|
150
|
+
<span class="muted">Environment</span>
|
|
151
|
+
<select
|
|
152
|
+
value={env.value}
|
|
153
|
+
onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
|
|
154
|
+
>
|
|
155
|
+
{environments.value.map((e) => (
|
|
156
|
+
<option value={e}>{e}</option>
|
|
157
|
+
))}
|
|
158
|
+
</select>
|
|
159
|
+
</label>
|
|
160
|
+
)}
|
|
161
|
+
{(onPrev || onNext) && (
|
|
162
|
+
<span class="stepper">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="icon"
|
|
166
|
+
title="Previous"
|
|
167
|
+
aria-label="Previous"
|
|
168
|
+
disabled={!onPrev}
|
|
169
|
+
onClick={onPrev}
|
|
170
|
+
>
|
|
171
|
+
↑
|
|
172
|
+
</button>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
class="icon"
|
|
176
|
+
title="Next"
|
|
177
|
+
aria-label="Next"
|
|
178
|
+
disabled={!onNext}
|
|
179
|
+
onClick={onNext}
|
|
180
|
+
>
|
|
181
|
+
↓
|
|
182
|
+
</button>
|
|
183
|
+
</span>
|
|
184
|
+
)}
|
|
185
|
+
<button type="button" class="icon" aria-label="Close panel" onClick={onClose}>
|
|
186
|
+
×
|
|
187
|
+
</button>
|
|
188
|
+
</header>
|
|
189
|
+
<div class={`panel-body ${flush ? 'flush' : ''}`}>{children}</div>
|
|
190
|
+
</section>
|
|
191
|
+
</>
|
|
192
|
+
)
|
|
193
|
+
}
|