@specbook/ui 0.0.0-stage → 0.2.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/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/worker-ClGb0tE1.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 +61 -3
- package/src/app.tsx +368 -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 +48 -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 +115 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +574 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +24 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +238 -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/OAuthSignIn.tsx +163 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +197 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +627 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +189 -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 +12 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1939 -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 +83 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +289 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +54 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +8 -0
- package/src/viewer/registry.ts +163 -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 +176 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { isDiscovery, type OAuthSettings } from '@specbook/core'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { noteExpiry, recallClientId, redirectUri, rememberClientId, signIn } from '../oauth'
|
|
4
|
+
import { engine } from '../runtime'
|
|
5
|
+
import { secretStore } from '../secrets'
|
|
6
|
+
import { env, version } from '../store'
|
|
7
|
+
|
|
8
|
+
type Status = { kind: 'idle' } | { kind: 'busy' } | { kind: 'error'; message: string }
|
|
9
|
+
|
|
10
|
+
interface Endpoints {
|
|
11
|
+
authorizationUrl: string
|
|
12
|
+
scopes: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Sign in" for a scheme with an authorization code flow, or an OpenID Connect provider. The token lands
|
|
17
|
+
* in the same tab-scoped store, bound to the API's origin, that a pasted value would, so everything
|
|
18
|
+
* downstream is unchanged. A failure or a cancel only reports itself: the paste field next to this stays
|
|
19
|
+
* usable.
|
|
20
|
+
*/
|
|
21
|
+
export function OAuthSignIn({
|
|
22
|
+
service,
|
|
23
|
+
profile,
|
|
24
|
+
origin,
|
|
25
|
+
secret,
|
|
26
|
+
oauth,
|
|
27
|
+
onSignedIn,
|
|
28
|
+
}: {
|
|
29
|
+
service: string
|
|
30
|
+
profile: string
|
|
31
|
+
origin: string
|
|
32
|
+
/** the secret the profile's `apply` reads; the token is stored under it */
|
|
33
|
+
secret: string
|
|
34
|
+
oauth: OAuthSettings
|
|
35
|
+
onSignedIn(): void
|
|
36
|
+
}) {
|
|
37
|
+
// one provider, one remembered client id: keyed by the address that identifies the provider
|
|
38
|
+
const providerKey = isDiscovery(oauth) ? oauth.openIdConnectUrl : oauth.authorizationUrl
|
|
39
|
+
const [clientId, setClientId] = useState(
|
|
40
|
+
() => oauth.clientId ?? recallClientId(providerKey) ?? '',
|
|
41
|
+
)
|
|
42
|
+
const [endpoints, setEndpoints] = useState<Endpoints | undefined>(undefined)
|
|
43
|
+
const [picked, setPicked] = useState<string[]>([])
|
|
44
|
+
const [status, setStatus] = useState<Status>({ kind: 'idle' })
|
|
45
|
+
const abort = useRef<AbortController | undefined>(undefined)
|
|
46
|
+
const busy = status.kind === 'busy'
|
|
47
|
+
const [attempt, setAttempt] = useState(0)
|
|
48
|
+
|
|
49
|
+
// The provider's endpoints are settled before a click: the popup has to open from the click itself,
|
|
50
|
+
// and an OpenID Connect provider has to be asked where it is first.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let on = true
|
|
53
|
+
setEndpoints(undefined)
|
|
54
|
+
setStatus((s) => (s.kind === 'busy' ? s : { kind: 'idle' }))
|
|
55
|
+
engine
|
|
56
|
+
.oauthEndpoints({ env: env.value, version: version.value, profile })
|
|
57
|
+
.then((found) => {
|
|
58
|
+
if (!on) return
|
|
59
|
+
setEndpoints(found)
|
|
60
|
+
// OpenID Connect asks for `openid` alone until more is ticked; a plain OAuth scheme lists what
|
|
61
|
+
// its operations need, so those are all ticked
|
|
62
|
+
setPicked(isDiscovery(oauth) ? ['openid'] : Object.keys(found.scopes))
|
|
63
|
+
})
|
|
64
|
+
.catch((e: unknown) => {
|
|
65
|
+
if (on) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
|
|
66
|
+
})
|
|
67
|
+
return () => {
|
|
68
|
+
on = false
|
|
69
|
+
}
|
|
70
|
+
}, [profile, env.value, version.value, attempt])
|
|
71
|
+
|
|
72
|
+
const scopeNames = Object.keys(endpoints?.scopes ?? {})
|
|
73
|
+
|
|
74
|
+
// called from the click itself: the popup must open before anything is awaited
|
|
75
|
+
const start = () => {
|
|
76
|
+
if (!endpoints) return setAttempt((n) => n + 1)
|
|
77
|
+
const id = clientId.trim()
|
|
78
|
+
if (!id)
|
|
79
|
+
return setStatus({ kind: 'error', message: 'Enter the client id registered for this page.' })
|
|
80
|
+
const ctl = new AbortController()
|
|
81
|
+
abort.current = ctl
|
|
82
|
+
setStatus({ kind: 'busy' })
|
|
83
|
+
rememberClientId(providerKey, id)
|
|
84
|
+
signIn({
|
|
85
|
+
authorizationUrl: endpoints.authorizationUrl,
|
|
86
|
+
clientId: id,
|
|
87
|
+
scopes: picked,
|
|
88
|
+
signal: ctl.signal,
|
|
89
|
+
exchange: (fields, signal) =>
|
|
90
|
+
engine.oauthExchange({ env: env.value, version: version.value, profile, fields, signal }),
|
|
91
|
+
})
|
|
92
|
+
.then((token) => {
|
|
93
|
+
secretStore.set(origin, secret, token.accessToken)
|
|
94
|
+
noteExpiry(origin, secret, token.expiresIn)
|
|
95
|
+
setStatus({ kind: 'idle' })
|
|
96
|
+
onSignedIn()
|
|
97
|
+
})
|
|
98
|
+
.catch((e: unknown) =>
|
|
99
|
+
setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) }),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const failedLookup = !endpoints && status.kind === 'error'
|
|
104
|
+
// the idle line (the address to register) is the only one a phone can do without
|
|
105
|
+
const hint = !busy && status.kind !== 'error' && !!endpoints
|
|
106
|
+
return (
|
|
107
|
+
<span class="needs-var oauth-signin">
|
|
108
|
+
<input
|
|
109
|
+
type="text"
|
|
110
|
+
autocomplete="off"
|
|
111
|
+
aria-label={`Client id for ${service}`}
|
|
112
|
+
placeholder="client id"
|
|
113
|
+
value={clientId}
|
|
114
|
+
disabled={busy}
|
|
115
|
+
onInput={(e) => setClientId((e.target as HTMLInputElement).value)}
|
|
116
|
+
/>
|
|
117
|
+
{busy ? (
|
|
118
|
+
<button type="button" class="link" onClick={() => abort.current?.abort()}>
|
|
119
|
+
Cancel
|
|
120
|
+
</button>
|
|
121
|
+
) : (
|
|
122
|
+
<button type="button" class="btn" disabled={!endpoints && !failedLookup} onClick={start}>
|
|
123
|
+
{failedLookup ? 'Try again' : 'Sign in'}
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
{scopeNames.length > 0 && (
|
|
127
|
+
<details>
|
|
128
|
+
<summary class="muted small">
|
|
129
|
+
Scopes ({picked.length} of {scopeNames.length})
|
|
130
|
+
</summary>
|
|
131
|
+
{scopeNames.map((s) => (
|
|
132
|
+
<label class="small" title={endpoints?.scopes[s] || undefined}>
|
|
133
|
+
<input
|
|
134
|
+
type="checkbox"
|
|
135
|
+
checked={picked.includes(s)}
|
|
136
|
+
disabled={busy}
|
|
137
|
+
onChange={(e) =>
|
|
138
|
+
setPicked((p) =>
|
|
139
|
+
(e.target as HTMLInputElement).checked ? [...p, s] : p.filter((x) => x !== s),
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
/>{' '}
|
|
143
|
+
<code>{s}</code>
|
|
144
|
+
</label>
|
|
145
|
+
))}
|
|
146
|
+
</details>
|
|
147
|
+
)}
|
|
148
|
+
<span
|
|
149
|
+
class={`muted small oauth-status ${hint ? 'oauth-hint' : ''}`}
|
|
150
|
+
role="status"
|
|
151
|
+
aria-live="polite"
|
|
152
|
+
>
|
|
153
|
+
{busy
|
|
154
|
+
? 'Waiting for the sign-in window…'
|
|
155
|
+
: status.kind === 'error'
|
|
156
|
+
? status.message
|
|
157
|
+
: !endpoints
|
|
158
|
+
? 'Looking up the provider…'
|
|
159
|
+
: `Register this redirect URI with the provider: ${redirectUri()}`}
|
|
160
|
+
</span>
|
|
161
|
+
</span>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -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,197 @@
|
|
|
1
|
+
import type { ComponentChildren } from 'preact'
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'
|
|
3
|
+
import { env, environmentNote, 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
|
+
// A layout effect, so the keys work from the moment the panel is on screen: an effect waits for the next
|
|
103
|
+
// frame, and a key pressed (or sent by a test, or a host page) before it was ignored
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
const key = (e: KeyboardEvent) => {
|
|
106
|
+
if (e.key === 'Escape') onClose()
|
|
107
|
+
if (e.key !== 'Tab' || !modal || !aside.current) return
|
|
108
|
+
const items = [...aside.current.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
|
109
|
+
(el) => el.offsetParent !== null,
|
|
110
|
+
)
|
|
111
|
+
const first = items[0]
|
|
112
|
+
const last = items[items.length - 1]
|
|
113
|
+
const root = aside.current.getRootNode() as Document | ShadowRoot
|
|
114
|
+
const at = root.activeElement
|
|
115
|
+
if (!first || !last) return
|
|
116
|
+
if (e.shiftKey && (at === first || at === aside.current)) {
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
last.focus()
|
|
119
|
+
} else if (!e.shiftKey && at === last) {
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
first.focus()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
addEventListener('keydown', key)
|
|
125
|
+
return () => removeEventListener('keydown', key)
|
|
126
|
+
}, [onClose, modal])
|
|
127
|
+
|
|
128
|
+
// a dialog when it dims the page (embeds), a complementary region when docked beside the list
|
|
129
|
+
const attrs = {
|
|
130
|
+
class: `panel ${modal ? '' : 'docked'}`,
|
|
131
|
+
part: 'panel',
|
|
132
|
+
'aria-label': title,
|
|
133
|
+
'aria-modal': modal ? true : undefined,
|
|
134
|
+
role: (modal ? 'dialog' : 'complementary') as 'dialog',
|
|
135
|
+
tabIndex: -1,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
{modal && (
|
|
141
|
+
<button type="button" class="panel-backdrop" aria-label="Close" onClick={onClose} />
|
|
142
|
+
)}
|
|
143
|
+
<section {...attrs} ref={aside}>
|
|
144
|
+
<header class="panel-head" part="header">
|
|
145
|
+
<div class="panel-title">
|
|
146
|
+
<h2>{title}</h2>
|
|
147
|
+
{subtitle && <div class="muted small">{subtitle}</div>}
|
|
148
|
+
</div>
|
|
149
|
+
<span class="spacer" />
|
|
150
|
+
{modal && environments.value.length > 1 && (
|
|
151
|
+
<label class="row">
|
|
152
|
+
<span class="muted">Environment</span>
|
|
153
|
+
<select
|
|
154
|
+
value={env.value}
|
|
155
|
+
onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
|
|
156
|
+
>
|
|
157
|
+
{environments.value.map((e) => (
|
|
158
|
+
<option value={e} title={environmentNote.value[e]}>
|
|
159
|
+
{e}
|
|
160
|
+
</option>
|
|
161
|
+
))}
|
|
162
|
+
</select>
|
|
163
|
+
</label>
|
|
164
|
+
)}
|
|
165
|
+
{(onPrev || onNext) && (
|
|
166
|
+
<span class="stepper">
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
class="icon"
|
|
170
|
+
title="Previous"
|
|
171
|
+
aria-label="Previous"
|
|
172
|
+
disabled={!onPrev}
|
|
173
|
+
onClick={onPrev}
|
|
174
|
+
>
|
|
175
|
+
↑
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
class="icon"
|
|
180
|
+
title="Next"
|
|
181
|
+
aria-label="Next"
|
|
182
|
+
disabled={!onNext}
|
|
183
|
+
onClick={onNext}
|
|
184
|
+
>
|
|
185
|
+
↓
|
|
186
|
+
</button>
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
<button type="button" class="icon" aria-label="Close panel" onClick={onClose}>
|
|
190
|
+
×
|
|
191
|
+
</button>
|
|
192
|
+
</header>
|
|
193
|
+
<div class={`panel-body ${flush ? 'flush' : ''}`}>{children}</div>
|
|
194
|
+
</section>
|
|
195
|
+
</>
|
|
196
|
+
)
|
|
197
|
+
}
|