@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.
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
- package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +7 -5
- package/src/app.tsx +71 -13
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +24 -4
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +287 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +416 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +14 -1
- package/src/nav.ts +12 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +195 -0
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -8
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +167 -0
- package/src/panel/Panel.tsx +50 -13
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +93 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +43 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +277 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +9 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +48 -3
- package/src/viewer/worker.ts +20 -7
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
- package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
- package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
- package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
- package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
- package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
- package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
package/src/panel/Combo.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
/**
|
|
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
|
|
24
|
-
|
|
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: `${
|
|
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)
|
|
58
|
+
if (!open) {
|
|
59
|
+
// the next opening starts unplaced, not where it last was
|
|
60
|
+
setPos(undefined)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
38
63
|
place()
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
72
|
+
const close = () => setOpen(false)
|
|
73
|
+
addEventListener('scroll', follow, true)
|
|
43
74
|
addEventListener('resize', close)
|
|
44
75
|
return () => {
|
|
45
|
-
removeEventListener('scroll',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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>
|
|
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
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
}
|
package/src/panel/Host.tsx
CHANGED
|
@@ -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' ?
|
|
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
|
+
}
|
package/src/panel/Panel.tsx
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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-
|
|
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}
|
|
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' : ''}`}>
|
|
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
|
)
|