@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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  4. package/dist-viewer/apple-touch-icon.png +0 -0
  5. package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
  6. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/index-ez9c20v3.js +1 -0
  9. package/dist-viewer/assets/main-38QGGpNT.js +14 -0
  10. package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
  11. package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
  12. package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
  13. package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
  14. package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
  15. package/dist-viewer/favicon.svg +1 -0
  16. package/dist-viewer/index.html +19 -0
  17. package/dist-viewer/specbook.js +1 -0
  18. package/index.html +12 -0
  19. package/package.json +61 -3
  20. package/src/app.tsx +368 -0
  21. package/src/assets/mark.svg +1 -0
  22. package/src/badge.ts +14 -0
  23. package/src/branding.ts +92 -0
  24. package/src/components/Catalog.tsx +324 -0
  25. package/src/components/EnvBadge.tsx +48 -0
  26. package/src/components/EnvironmentMenu.tsx +262 -0
  27. package/src/components/Fields.tsx +220 -0
  28. package/src/components/GitHubStar.tsx +22 -0
  29. package/src/components/ListResizer.tsx +70 -0
  30. package/src/components/ModeHint.tsx +17 -0
  31. package/src/components/Palette.tsx +85 -0
  32. package/src/components/PoweredBy.tsx +15 -0
  33. package/src/components/ShareMenu.tsx +94 -0
  34. package/src/components/ThemeToggle.tsx +52 -0
  35. package/src/embed/panel.tsx +115 -0
  36. package/src/embed/styles.ts +27 -0
  37. package/src/embed.ts +65 -0
  38. package/src/engine.ts +574 -0
  39. package/src/extension.ts +101 -0
  40. package/src/fielddoc.ts +83 -0
  41. package/src/main.tsx +24 -0
  42. package/src/markdown.ts +133 -0
  43. package/src/nav.ts +118 -0
  44. package/src/oauth-callback.ts +87 -0
  45. package/src/oauth.ts +194 -0
  46. package/src/panel/Body.tsx +182 -0
  47. package/src/panel/BodyEditor.tsx +92 -0
  48. package/src/panel/CodeSample.tsx +238 -0
  49. package/src/panel/Combo.tsx +124 -0
  50. package/src/panel/ControlsGrid.tsx +189 -0
  51. package/src/panel/FieldDoc.tsx +42 -0
  52. package/src/panel/FieldTable.tsx +113 -0
  53. package/src/panel/Host.tsx +100 -0
  54. package/src/panel/OAuthSignIn.tsx +163 -0
  55. package/src/panel/OperationDocs.tsx +181 -0
  56. package/src/panel/Panel.tsx +197 -0
  57. package/src/panel/ParamGrid.tsx +396 -0
  58. package/src/panel/RequestPanel.tsx +627 -0
  59. package/src/panel/RequestView.tsx +108 -0
  60. package/src/panel/ResponseView.tsx +182 -0
  61. package/src/panel/SecretsBar.tsx +189 -0
  62. package/src/panel/SequenceDocs.tsx +132 -0
  63. package/src/panel/SequencePanel.tsx +247 -0
  64. package/src/panel/StepExchange.tsx +118 -0
  65. package/src/panel/completions.ts +114 -0
  66. package/src/panel/draft.ts +106 -0
  67. package/src/panel/fieldnotes-split.ts +55 -0
  68. package/src/panel/fieldnotes.ts +40 -0
  69. package/src/panel/graphql.ts +31 -0
  70. package/src/panel/icons.tsx +85 -0
  71. package/src/panel/jsonCursor.ts +115 -0
  72. package/src/panel/stepnotes-split.ts +38 -0
  73. package/src/panel/stepnotes.ts +58 -0
  74. package/src/routing.ts +120 -0
  75. package/src/runtime.ts +32 -0
  76. package/src/schema.ts +359 -0
  77. package/src/secrets.ts +61 -0
  78. package/src/status.ts +27 -0
  79. package/src/store.ts +12 -0
  80. package/src/styles/app.css +821 -0
  81. package/src/styles/panel.css +1939 -0
  82. package/src/styles/tokens.css +122 -0
  83. package/src/theme.ts +37 -0
  84. package/src/viewer/Opening.tsx +43 -0
  85. package/src/viewer/bundles.ts +74 -0
  86. package/src/viewer/client.ts +54 -0
  87. package/src/viewer/embed.ts +83 -0
  88. package/src/viewer/faker-en.ts +3 -0
  89. package/src/viewer/main.tsx +289 -0
  90. package/src/viewer/mount.ts +52 -0
  91. package/src/viewer/protocol.ts +54 -0
  92. package/src/viewer/recent.ts +22 -0
  93. package/src/viewer/registry-data.ts +8 -0
  94. package/src/viewer/registry.ts +163 -0
  95. package/src/viewer/share.ts +134 -0
  96. package/src/viewer/site.ts +3 -0
  97. package/src/viewer/uploads.ts +53 -0
  98. package/src/viewer/viewer.css +184 -0
  99. package/src/viewer/worker-factory.ts +14 -0
  100. package/src/viewer/worker.ts +176 -0
  101. 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
+ }