@specbook/ui 0.1.0 → 0.3.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 (75) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  3. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  4. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  5. package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
  6. package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
  7. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  8. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  9. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +7 -5
  13. package/src/app.tsx +71 -13
  14. package/src/branding.ts +12 -4
  15. package/src/components/Catalog.tsx +128 -51
  16. package/src/components/EnvBadge.tsx +24 -4
  17. package/src/components/EnvironmentMenu.tsx +237 -127
  18. package/src/components/Fields.tsx +18 -1
  19. package/src/components/NewEnvironmentDialog.tsx +118 -0
  20. package/src/components/Palette.tsx +7 -2
  21. package/src/custom-environments.ts +63 -0
  22. package/src/embed/panel.tsx +287 -44
  23. package/src/embed/styles.ts +2 -0
  24. package/src/embed/target.ts +82 -0
  25. package/src/embed/theme.ts +75 -0
  26. package/src/embed.ts +44 -15
  27. package/src/engine.ts +416 -32
  28. package/src/environment-list.ts +16 -0
  29. package/src/main.tsx +14 -1
  30. package/src/nav.ts +12 -1
  31. package/src/oauth-callback.ts +87 -0
  32. package/src/oauth.ts +195 -0
  33. package/src/panel/Body.tsx +27 -4
  34. package/src/panel/Checks.tsx +79 -0
  35. package/src/panel/CodeSample.tsx +16 -8
  36. package/src/panel/Combo.tsx +52 -13
  37. package/src/panel/ControlsGrid.tsx +54 -9
  38. package/src/panel/ErrorBoundary.tsx +36 -0
  39. package/src/panel/Host.tsx +14 -2
  40. package/src/panel/OAuthSignIn.tsx +167 -0
  41. package/src/panel/Panel.tsx +50 -13
  42. package/src/panel/ParamGrid.tsx +15 -8
  43. package/src/panel/RequestPanel.tsx +93 -36
  44. package/src/panel/RequestView.tsx +2 -1
  45. package/src/panel/ResponseView.tsx +25 -14
  46. package/src/panel/SecretsBar.tsx +43 -9
  47. package/src/panel/SequenceDocs.tsx +7 -6
  48. package/src/panel/SequencePanel.tsx +124 -32
  49. package/src/panel/announce.ts +17 -0
  50. package/src/panel/icons.tsx +9 -0
  51. package/src/panel/short.ts +24 -0
  52. package/src/routing.ts +0 -9
  53. package/src/runtime.ts +19 -1
  54. package/src/secrets.ts +4 -2
  55. package/src/storage.ts +58 -0
  56. package/src/store.ts +10 -0
  57. package/src/styles/app.css +69 -6
  58. package/src/styles/embed.css +116 -0
  59. package/src/styles/panel.css +277 -2
  60. package/src/viewer/embed.ts +5 -0
  61. package/src/viewer/main.tsx +5 -1
  62. package/src/viewer/manifests.ts +2 -0
  63. package/src/viewer/protocol.ts +9 -0
  64. package/src/viewer/registry-data.ts +4 -0
  65. package/src/viewer/registry.ts +48 -3
  66. package/src/viewer/worker.ts +20 -7
  67. package/src/virtual.d.ts +8 -0
  68. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  69. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  70. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  71. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  72. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  73. package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
  74. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
  75. package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
@@ -9,40 +9,71 @@ interface Props {
9
9
  /** forwarded so the grid can move between rows with Enter and the arrow keys */
10
10
  onKeyDown?: (e: KeyboardEvent) => void
11
11
  cell?: string
12
+ /** shown, not edited: no list, no typing */
13
+ readOnly?: boolean
12
14
  }
13
15
 
14
16
  /** A text input with the spec's allowed values as a dropdown; any other value can still be typed. */
15
- export function Combo({ value, options, onChange, label, placeholder, onKeyDown, cell }: Props) {
17
+ export function Combo({
18
+ value,
19
+ options,
20
+ onChange,
21
+ label,
22
+ placeholder,
23
+ onKeyDown,
24
+ cell,
25
+ readOnly,
26
+ }: Props) {
16
27
  const [open, setOpen] = useState(false)
17
28
  const [at, setAt] = useState(-1)
18
29
  const [pos, setPos] = useState<Record<string, string>>()
19
30
  const box = useRef<HTMLDivElement>(null)
20
31
 
21
- /** The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. */
32
+ /**
33
+ * The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. A
34
+ * `fixed` box is placed against the viewport unless an ancestor contains it (a `container-type` does), so
35
+ * a probe filling the inset-0 box says where its coordinates really start.
36
+ */
22
37
  const place = () => {
23
- const r = box.current?.getBoundingClientRect()
24
- if (!r) return
38
+ const el = box.current
39
+ const r = el?.getBoundingClientRect()
40
+ if (!el || !r) return
41
+ const probe = document.createElement('div')
42
+ probe.style.cssText = 'position:fixed;inset:0;visibility:hidden;pointer-events:none'
43
+ el.append(probe)
44
+ const cb = probe.getBoundingClientRect()
45
+ probe.remove()
25
46
  const below = innerHeight - r.bottom - 8
26
47
  const flip = below < 160 && r.top > below
27
48
  // strings with units: Preact 11 sets style properties as given
28
49
  setPos({
29
- left: `${r.left}px`,
50
+ left: `${r.left - cb.left}px`,
30
51
  width: `${Math.max(r.width, 160)}px`,
31
52
  maxHeight: `${Math.min(240, (flip ? r.top : below) - 4)}px`,
32
- ...(flip ? { bottom: `${innerHeight - r.top + 2}px` } : { top: `${r.bottom + 2}px` }),
53
+ ...(flip ? { bottom: `${cb.bottom - r.top + 2}px` } : { top: `${r.bottom + 2 - cb.top}px` }),
33
54
  })
34
55
  }
35
56
 
36
57
  useEffect(() => {
37
- if (!open) return
58
+ if (!open) {
59
+ // the next opening starts unplaced, not where it last was
60
+ setPos(undefined)
61
+ return
62
+ }
38
63
  place()
39
- const close = (e: Event) => {
40
- if (!(e.target instanceof Node && box.current?.contains(e.target))) setOpen(false)
64
+ // the list follows its field when something scrolls (a phone moves the field clear of its keyboard),
65
+ // and goes once the field is out of sight
66
+ const follow = (e: Event) => {
67
+ if (e.target instanceof Node && box.current?.contains(e.target)) return
68
+ const r = box.current?.getBoundingClientRect()
69
+ if (!r || r.bottom < 0 || r.top > innerHeight) setOpen(false)
70
+ else place()
41
71
  }
42
- addEventListener('scroll', close, true)
72
+ const close = () => setOpen(false)
73
+ addEventListener('scroll', follow, true)
43
74
  addEventListener('resize', close)
44
75
  return () => {
45
- removeEventListener('scroll', close, true)
76
+ removeEventListener('scroll', follow, true)
46
77
  removeEventListener('resize', close)
47
78
  }
48
79
  }, [open])
@@ -73,7 +104,8 @@ export function Combo({ value, options, onChange, label, placeholder, onKeyDown,
73
104
  aria-expanded={open}
74
105
  role="combobox"
75
106
  autocomplete="off"
76
- onFocus={() => setOpen(true)}
107
+ readOnly={readOnly}
108
+ onFocus={() => !readOnly && setOpen(true)}
77
109
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
78
110
  onKeyDown={(e) => {
79
111
  if (open && e.key === 'ArrowDown') {
@@ -99,12 +131,19 @@ export function Combo({ value, options, onChange, label, placeholder, onKeyDown,
99
131
  class="combo-toggle"
100
132
  tabIndex={-1}
101
133
  aria-label={`Show ${label} options`}
134
+ disabled={readOnly}
102
135
  onClick={() => setOpen(!open)}
103
136
  >
104
137
  ▾
105
138
  </button>
106
139
  {open && (
107
- <div class="combo-list" role="listbox" style={pos}>
140
+ // until it is placed it waits out of sight at the corner: in the flow of the table it would grow
141
+ // the scrolling pane for a moment, and the pane jumps to the bottom
142
+ <div
143
+ class="combo-list"
144
+ role="listbox"
145
+ style={pos ?? { top: '0px', left: '0px', visibility: 'hidden' }}
146
+ >
108
147
  {options.map((o, i) => (
109
148
  <button
110
149
  type="button"
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'preact/hooks'
2
- import { defaultFor, type FieldSpec } from '../components/Fields'
2
+ import { defaultFor, type FieldSpec, generateFor } from '../components/Fields'
3
3
  import { Combo } from './Combo'
4
4
 
5
5
  interface Props {
@@ -135,16 +135,48 @@ function ValueCell({
135
135
  }
136
136
  }
137
137
 
138
+ /** A die: the control that makes another generated value. */
139
+ function Die() {
140
+ return (
141
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
142
+ <rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="none" stroke="currentColor" />
143
+ <circle cx="5.2" cy="5.2" r="1.1" fill="currentColor" />
144
+ <circle cx="10.8" cy="10.8" r="1.1" fill="currentColor" />
145
+ <circle cx="8" cy="8" r="1.1" fill="currentColor" />
146
+ </svg>
147
+ )
148
+ }
149
+
138
150
  /**
139
- * A sequence's controls (params, or the choices of an input step) as the same spreadsheet the request
151
+ * A flow's controls (inputs, or the choices of an input step) as the same spreadsheet the request
140
152
  * parameters use: name, value, description.
141
153
  */
142
154
  export function ControlsGrid({ title, specs, values, onChange }: Props) {
143
155
  const names = Object.keys(specs)
144
156
  if (names.length === 0) return null
157
+ const generated = names.filter((n) => specs[n]?.generate)
145
158
  return (
146
159
  <section class="grid controls-grid" aria-label={title}>
147
- <h3>{title}</h3>
160
+ <h3 class="controls-head">
161
+ <span>{title}</span>
162
+ {generated.length > 1 && (
163
+ <button
164
+ type="button"
165
+ class="btn small gen-all"
166
+ title="Make a new value for every generated input"
167
+ onClick={() =>
168
+ onChange({
169
+ ...values,
170
+ ...Object.fromEntries(
171
+ generated.map((n) => [n, generateFor(specs[n] as FieldSpec)]),
172
+ ),
173
+ })
174
+ }
175
+ >
176
+ <Die /> Generate all
177
+ </button>
178
+ )}
179
+ </h3>
148
180
  <table>
149
181
  <colgroup>
150
182
  <col class="c-key" />
@@ -169,12 +201,25 @@ export function ControlsGrid({ title, specs, values, onChange }: Props) {
169
201
  </span>
170
202
  </td>
171
203
  <td class="g-val">
172
- <ValueCell
173
- name={name}
174
- spec={spec}
175
- value={values[name]}
176
- onChange={(v) => onChange({ ...values, [name]: v })}
177
- />
204
+ <div class={spec.generate ? 'cell-gen' : undefined}>
205
+ <ValueCell
206
+ name={name}
207
+ spec={spec}
208
+ value={values[name]}
209
+ onChange={(v) => onChange({ ...values, [name]: v })}
210
+ />
211
+ {spec.generate && (
212
+ <button
213
+ type="button"
214
+ class="gen"
215
+ aria-label={`Generate ${spec.label ?? name}`}
216
+ title="Make another value"
217
+ onClick={() => onChange({ ...values, [name]: generateFor(spec) })}
218
+ >
219
+ <Die />
220
+ </button>
221
+ )}
222
+ </div>
178
223
  </td>
179
224
  <td class="g-doc" title={spec.description}>
180
225
  <span class="chip">{hint(spec)}</span> {spec.description}
@@ -0,0 +1,36 @@
1
+ import { Component, type ComponentChildren } from 'preact'
2
+
3
+ interface Props {
4
+ children: ComponentChildren
5
+ }
6
+
7
+ /**
8
+ * What is drawn in a panel comes from a run, and a run is whatever the API answered and whatever the project's code did with
9
+ * it. If drawing that fails anyway, the panel says so in words and offers the run again, instead of stopping where it was
10
+ * with nothing to click: a render that throws leaves the part of the page under it dead.
11
+ */
12
+ export class ErrorBoundary extends Component<Props, { error?: Error }> {
13
+ state: { error?: Error } = {}
14
+
15
+ componentDidCatch(error: unknown) {
16
+ console.error('specbook: a panel could not be drawn', error)
17
+ this.setState({ error: error instanceof Error ? error : new Error(String(error)) })
18
+ }
19
+
20
+ render() {
21
+ const { error } = this.state
22
+ if (!error) return this.props.children
23
+ return (
24
+ <div class="panel-error" role="alert">
25
+ <p>
26
+ <strong>This could not be shown.</strong> The run finished, but what it returned could not
27
+ be drawn.
28
+ </p>
29
+ <pre>{error.message}</pre>
30
+ <button type="button" class="btn" onClick={() => this.setState({ error: undefined })}>
31
+ Try drawing it again
32
+ </button>
33
+ </div>
34
+ )
35
+ }
36
+ }
@@ -11,6 +11,11 @@ type Props = {
11
11
  /** dims the page and traps focus; the standalone site docks the panel beside its list instead */
12
12
  modal?: boolean
13
13
  tab?: PanelTab
14
+ /** `popover`: a centered dialog (a sheet from the bottom on a phone); `side`: the app's sheet from the right */
15
+ variant?: 'side' | 'popover'
16
+ /** an embed's preset flow inputs, and whether to run as soon as it is ready */
17
+ preset?: Record<string, unknown>
18
+ autoRun?: boolean
14
19
  /** switch tab, optionally opening a named example */
15
20
  onNavigate: (to: { tab: PanelTab; example?: string }) => void
16
21
  onPrev?: () => void
@@ -51,6 +56,7 @@ export function Host(p: Props) {
51
56
  subtitle="Sequence"
52
57
  flush
53
58
  modal={p.modal}
59
+ variant={p.variant}
54
60
  onPrev={p.onPrev}
55
61
  onNext={p.onNext}
56
62
  onClose={p.onClose}
@@ -58,7 +64,11 @@ export function Host(p: Props) {
58
64
  <div class="panel-split">
59
65
  {tabs}
60
66
  <div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
61
- {tab === 'docs' ? <SequenceDocs info={p.info} /> : <SequencePanel info={p.info} />}
67
+ {tab === 'docs' ? (
68
+ <SequenceDocs info={p.info} />
69
+ ) : (
70
+ <SequencePanel info={p.info} preset={p.preset} autoRun={p.autoRun} />
71
+ )}
62
72
  </div>
63
73
  </div>
64
74
  </Panel>
@@ -69,6 +79,7 @@ export function Host(p: Props) {
69
79
  subtitle={`${p.op.method} ${p.op.path}`}
70
80
  flush
71
81
  modal={p.modal}
82
+ variant={p.variant}
72
83
  onPrev={p.onPrev}
73
84
  onNext={p.onNext}
74
85
  onClose={p.onClose}
@@ -86,11 +97,12 @@ export function Host(p: Props) {
86
97
  />
87
98
  ) : (
88
99
  <RequestPanel
89
- key={`${p.service}/${p.op.id}`}
100
+ key={`${p.service}/${p.op.id}${p.example.startsWith('saved:') ? `@${p.example}` : ''}`}
90
101
  service={p.service}
91
102
  op={p.op}
92
103
  variants={p.variants}
93
104
  initial={p.example}
105
+ autoRun={p.autoRun}
94
106
  />
95
107
  )}
96
108
  </div>
@@ -0,0 +1,167 @@
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, envVarsVersion, 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
+ // another environment may sign in somewhere else, with its own client
43
+ useEffect(() => {
44
+ setClientId(oauth.clientId ?? recallClientId(providerKey) ?? '')
45
+ }, [providerKey, oauth.clientId])
46
+ const [endpoints, setEndpoints] = useState<Endpoints | undefined>(undefined)
47
+ const [picked, setPicked] = useState<string[]>([])
48
+ const [status, setStatus] = useState<Status>({ kind: 'idle' })
49
+ const abort = useRef<AbortController | undefined>(undefined)
50
+ const busy = status.kind === 'busy'
51
+ const [attempt, setAttempt] = useState(0)
52
+
53
+ // The provider's endpoints are settled before a click: the popup has to open from the click itself,
54
+ // and an OpenID Connect provider has to be asked where it is first.
55
+ useEffect(() => {
56
+ let on = true
57
+ setEndpoints(undefined)
58
+ setStatus((s) => (s.kind === 'busy' ? s : { kind: 'idle' }))
59
+ engine
60
+ .oauthEndpoints({ env: env.value, version: version.value, profile })
61
+ .then((found) => {
62
+ if (!on) return
63
+ setEndpoints(found)
64
+ // OpenID Connect asks for `openid` alone until more is ticked; a plain OAuth scheme lists what
65
+ // its operations need, so those are all ticked
66
+ setPicked(isDiscovery(oauth) ? ['openid'] : Object.keys(found.scopes))
67
+ })
68
+ .catch((e: unknown) => {
69
+ if (on) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
70
+ })
71
+ return () => {
72
+ on = false
73
+ }
74
+ }, [profile, env.value, version.value, attempt, envVarsVersion.value])
75
+
76
+ const scopeNames = Object.keys(endpoints?.scopes ?? {})
77
+
78
+ // called from the click itself: the popup must open before anything is awaited
79
+ const start = () => {
80
+ if (!endpoints) return setAttempt((n) => n + 1)
81
+ const id = clientId.trim()
82
+ if (!id)
83
+ return setStatus({ kind: 'error', message: 'Enter the client id registered for this page.' })
84
+ const ctl = new AbortController()
85
+ abort.current = ctl
86
+ setStatus({ kind: 'busy' })
87
+ rememberClientId(providerKey, id)
88
+ signIn({
89
+ authorizationUrl: endpoints.authorizationUrl,
90
+ clientId: id,
91
+ scopes: picked,
92
+ signal: ctl.signal,
93
+ exchange: (fields, signal) =>
94
+ engine.oauthExchange({ env: env.value, version: version.value, profile, fields, signal }),
95
+ })
96
+ .then((token) => {
97
+ secretStore.set(origin, secret, token.accessToken)
98
+ noteExpiry(origin, secret, token.expiresIn)
99
+ setStatus({ kind: 'idle' })
100
+ onSignedIn()
101
+ })
102
+ .catch((e: unknown) =>
103
+ setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) }),
104
+ )
105
+ }
106
+
107
+ const failedLookup = !endpoints && status.kind === 'error'
108
+ // the idle line (the address to register) is the only one a phone can do without
109
+ const hint = !busy && status.kind !== 'error' && !!endpoints
110
+ return (
111
+ <span class="needs-var oauth-signin">
112
+ <input
113
+ type="text"
114
+ autocomplete="off"
115
+ aria-label={`Client id for ${service}`}
116
+ placeholder="client id"
117
+ value={clientId}
118
+ disabled={busy}
119
+ onInput={(e) => setClientId((e.target as HTMLInputElement).value)}
120
+ />
121
+ {busy ? (
122
+ <button type="button" class="link" onClick={() => abort.current?.abort()}>
123
+ Cancel
124
+ </button>
125
+ ) : (
126
+ <button type="button" class="btn" disabled={!endpoints && !failedLookup} onClick={start}>
127
+ {failedLookup ? 'Try again' : 'Sign in'}
128
+ </button>
129
+ )}
130
+ {scopeNames.length > 0 && (
131
+ <details>
132
+ <summary class="muted small">
133
+ Scopes ({picked.length} of {scopeNames.length})
134
+ </summary>
135
+ {scopeNames.map((s) => (
136
+ <label class="small" title={endpoints?.scopes[s] || undefined}>
137
+ <input
138
+ type="checkbox"
139
+ checked={picked.includes(s)}
140
+ disabled={busy}
141
+ onChange={(e) =>
142
+ setPicked((p) =>
143
+ (e.target as HTMLInputElement).checked ? [...p, s] : p.filter((x) => x !== s),
144
+ )
145
+ }
146
+ />{' '}
147
+ <code>{s}</code>
148
+ </label>
149
+ ))}
150
+ </details>
151
+ )}
152
+ <span
153
+ class={`muted small oauth-status ${hint ? 'oauth-hint' : ''}`}
154
+ role="status"
155
+ aria-live="polite"
156
+ >
157
+ {busy
158
+ ? 'Waiting for the sign-in window…'
159
+ : status.kind === 'error'
160
+ ? status.message
161
+ : !endpoints
162
+ ? 'Looking up the provider…'
163
+ : `Register this redirect URI with the provider: ${redirectUri()}`}
164
+ </span>
165
+ </span>
166
+ )
167
+ }
@@ -1,6 +1,10 @@
1
1
  import type { ComponentChildren } from 'preact'
2
- import { useEffect, useRef } from 'preact/hooks'
3
- import { env, environments } from '../store'
2
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks'
3
+ import { env, environmentNote, environments } from '../store'
4
+ import { announcement } from './announce'
5
+ import { ErrorBoundary } from './ErrorBoundary'
6
+
7
+ let panelIds = 0
4
8
 
5
9
  interface Props {
6
10
  title: string
@@ -9,6 +13,8 @@ interface Props {
9
13
  flush?: boolean
10
14
  /** dims the page and traps focus (embeds); the standalone site leaves its list usable beside the panel */
11
15
  modal?: boolean
16
+ /** `popover`: a centered dialog on a desktop and a bottom sheet on a phone; `side` is the app's sheet from the right */
17
+ variant?: 'side' | 'popover'
12
18
  onPrev?: () => void
13
19
  onNext?: () => void
14
20
  onClose: () => void
@@ -24,12 +30,22 @@ export function Panel({
24
30
  subtitle,
25
31
  flush,
26
32
  modal = true,
33
+ variant = 'side',
27
34
  onPrev,
28
35
  onNext,
29
36
  onClose,
30
37
  children,
31
38
  }: Props) {
32
39
  const aside = useRef<HTMLElement>(null)
40
+ const titleId = useMemo(() => `sb-panel-title-${++panelIds}`, [])
41
+
42
+ // what was announced for one run is not read out again when the next panel opens
43
+ useEffect(
44
+ () => () => {
45
+ announcement.value = ''
46
+ },
47
+ [],
48
+ )
33
49
 
34
50
  useEffect(() => {
35
51
  const root = aside.current?.getRootNode() as Document | ShadowRoot
@@ -63,14 +79,23 @@ export function Panel({
63
79
  const top = vv?.offsetTop ?? 0
64
80
  const height = vv?.height ?? innerHeight
65
81
  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
82
+ // a dropdown field needs its list visible too, and picking from it needs no keyboard: it is moved
83
+ // only when it is down where the keyboard would be, and not as far
84
+ const dropdown = field.getAttribute('role') === 'combobox'
85
+ const want = top + height * (dropdown ? 0.3 : 0.2)
86
+ if (at > top + height * (dropdown ? 0.55 : 0.4) || at < top) box.scrollTop += at - want
68
87
  }
69
88
  const focusin = (e: Event) => {
70
89
  const field = e.target as HTMLElement
71
90
  if (!phone.matches || !field.matches?.(FIELD)) return
72
91
  const box = scroller(field)
73
- if (box && padded?.box !== box) {
92
+ // a dropdown field high enough on the screen is not moved, so it needs no room below (which would
93
+ // only show a scrollbar)
94
+ const vv = typeof visualViewport === 'undefined' ? undefined : visualViewport
95
+ const low =
96
+ field.getBoundingClientRect().top >
97
+ (vv?.offsetTop ?? 0) + (vv?.height ?? innerHeight) * 0.55
98
+ if (box && padded?.box !== box && (field.getAttribute('role') !== 'combobox' || low)) {
74
99
  if (padded) padded.box.style.paddingBottom = padded.was
75
100
  padded = { box, was: box.style.paddingBottom }
76
101
  box.style.paddingBottom = '70vh'
@@ -99,7 +124,9 @@ export function Panel({
99
124
  }
100
125
  }, [])
101
126
 
102
- useEffect(() => {
127
+ // A layout effect, so the keys work from the moment the panel is on screen: an effect waits for the next
128
+ // frame, and a key pressed (or sent by a test, or a host page) before it was ignored
129
+ useLayoutEffect(() => {
103
130
  const key = (e: KeyboardEvent) => {
104
131
  if (e.key === 'Escape') onClose()
105
132
  if (e.key !== 'Tab' || !modal || !aside.current) return
@@ -119,15 +146,18 @@ export function Panel({
119
146
  first.focus()
120
147
  }
121
148
  }
122
- addEventListener('keydown', key)
123
- return () => removeEventListener('keydown', key)
149
+ // a dialog over someone else's page listens on itself (focus is trapped in it), not on the window; the app's
150
+ // docked panel keeps its window-wide Escape
151
+ const target: EventTarget = modal && aside.current ? aside.current : window
152
+ target.addEventListener('keydown', key as EventListener)
153
+ return () => target.removeEventListener('keydown', key as EventListener)
124
154
  }, [onClose, modal])
125
155
 
126
156
  // a dialog when it dims the page (embeds), a complementary region when docked beside the list
127
157
  const attrs = {
128
- class: `panel ${modal ? '' : 'docked'}`,
158
+ class: `panel ${modal ? '' : 'docked'} ${variant === 'popover' ? 'popover' : ''}`,
129
159
  part: 'panel',
130
- 'aria-label': title,
160
+ 'aria-labelledby': titleId,
131
161
  'aria-modal': modal ? true : undefined,
132
162
  role: (modal ? 'dialog' : 'complementary') as 'dialog',
133
163
  tabIndex: -1,
@@ -141,7 +171,7 @@ export function Panel({
141
171
  <section {...attrs} ref={aside}>
142
172
  <header class="panel-head" part="header">
143
173
  <div class="panel-title">
144
- <h2>{title}</h2>
174
+ <h2 id={titleId}>{title}</h2>
145
175
  {subtitle && <div class="muted small">{subtitle}</div>}
146
176
  </div>
147
177
  <span class="spacer" />
@@ -153,7 +183,9 @@ export function Panel({
153
183
  onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
154
184
  >
155
185
  {environments.value.map((e) => (
156
- <option value={e}>{e}</option>
186
+ <option value={e} title={environmentNote.value[e]}>
187
+ {e}
188
+ </option>
157
189
  ))}
158
190
  </select>
159
191
  </label>
@@ -186,7 +218,12 @@ export function Panel({
186
218
  ×
187
219
  </button>
188
220
  </header>
189
- <div class={`panel-body ${flush ? 'flush' : ''}`}>{children}</div>
221
+ <div class={`panel-body ${flush ? 'flush' : ''}`}>
222
+ <ErrorBoundary>{children}</ErrorBoundary>
223
+ </div>
224
+ <div class="visually-hidden" role="status" aria-live="polite" aria-atomic="true">
225
+ {announcement.value}
226
+ </div>
190
227
  </section>
191
228
  </>
192
229
  )