@liberation-data/desk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +436 -0
- package/dist/core/commands.d.ts +40 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +92 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/desk.d.ts +46 -0
- package/dist/core/desk.d.ts.map +1 -0
- package/dist/core/desk.js +178 -0
- package/dist/core/desk.js.map +1 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +54 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/location.d.ts +30 -0
- package/dist/core/location.d.ts.map +1 -0
- package/dist/core/location.js +99 -0
- package/dist/core/location.js.map +1 -0
- package/dist/core/shortcuts.d.ts +36 -0
- package/dist/core/shortcuts.d.ts.map +1 -0
- package/dist/core/shortcuts.js +73 -0
- package/dist/core/shortcuts.js.map +1 -0
- package/dist/core/titles.d.ts +3 -0
- package/dist/core/titles.d.ts.map +1 -0
- package/dist/core/titles.js +26 -0
- package/dist/core/titles.js.map +1 -0
- package/dist/core/types.d.ts +51 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/desk.css +2550 -0
- package/dist/react/Desktop.d.ts +24 -0
- package/dist/react/Desktop.d.ts.map +1 -0
- package/dist/react/Desktop.js +212 -0
- package/dist/react/Desktop.js.map +1 -0
- package/dist/react/Dock.d.ts +42 -0
- package/dist/react/Dock.d.ts.map +1 -0
- package/dist/react/Dock.js +103 -0
- package/dist/react/Dock.js.map +1 -0
- package/dist/react/MenuBar.d.ts +64 -0
- package/dist/react/MenuBar.d.ts.map +1 -0
- package/dist/react/MenuBar.js +245 -0
- package/dist/react/MenuBar.js.map +1 -0
- package/dist/react/appFrame.d.ts +37 -0
- package/dist/react/appFrame.d.ts.map +1 -0
- package/dist/react/appFrame.js +119 -0
- package/dist/react/appFrame.js.map +1 -0
- package/dist/react/arrange.d.ts +24 -0
- package/dist/react/arrange.d.ts.map +1 -0
- package/dist/react/arrange.js +61 -0
- package/dist/react/arrange.js.map +1 -0
- package/dist/react/commands.d.ts +23 -0
- package/dist/react/commands.d.ts.map +1 -0
- package/dist/react/commands.js +41 -0
- package/dist/react/commands.js.map +1 -0
- package/dist/react/context.d.ts +25 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +28 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/controls.d.ts +111 -0
- package/dist/react/controls.d.ts.map +1 -0
- package/dist/react/controls.js +83 -0
- package/dist/react/controls.js.map +1 -0
- package/dist/react/conversation.d.ts +41 -0
- package/dist/react/conversation.d.ts.map +1 -0
- package/dist/react/conversation.js +73 -0
- package/dist/react/conversation.js.map +1 -0
- package/dist/react/dnd.d.ts +51 -0
- package/dist/react/dnd.d.ts.map +1 -0
- package/dist/react/dnd.js +138 -0
- package/dist/react/dnd.js.map +1 -0
- package/dist/react/dragContext.d.ts +32 -0
- package/dist/react/dragContext.d.ts.map +1 -0
- package/dist/react/dragContext.js +14 -0
- package/dist/react/dragContext.js.map +1 -0
- package/dist/react/events.d.ts +41 -0
- package/dist/react/events.d.ts.map +1 -0
- package/dist/react/events.js +91 -0
- package/dist/react/events.js.map +1 -0
- package/dist/react/index.d.ts +43 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +22 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/layers.d.ts +5 -0
- package/dist/react/layers.d.ts.map +1 -0
- package/dist/react/layers.js +65 -0
- package/dist/react/layers.js.map +1 -0
- package/dist/react/overlays.d.ts +67 -0
- package/dist/react/overlays.d.ts.map +1 -0
- package/dist/react/overlays.js +98 -0
- package/dist/react/overlays.js.map +1 -0
- package/dist/react/popup.d.ts +21 -0
- package/dist/react/popup.d.ts.map +1 -0
- package/dist/react/popup.js +94 -0
- package/dist/react/popup.js.map +1 -0
- package/dist/react/search.d.ts +33 -0
- package/dist/react/search.d.ts.map +1 -0
- package/dist/react/search.js +82 -0
- package/dist/react/search.js.map +1 -0
- package/dist/react/setupProgress.d.ts +35 -0
- package/dist/react/setupProgress.d.ts.map +1 -0
- package/dist/react/setupProgress.js +68 -0
- package/dist/react/setupProgress.js.map +1 -0
- package/dist/react/shell.d.ts +17 -0
- package/dist/react/shell.d.ts.map +1 -0
- package/dist/react/shell.js +13 -0
- package/dist/react/shell.js.map +1 -0
- package/dist/react/sidebar.d.ts +23 -0
- package/dist/react/sidebar.d.ts.map +1 -0
- package/dist/react/sidebar.js +26 -0
- package/dist/react/sidebar.js.map +1 -0
- package/dist/react/table.d.ts +39 -0
- package/dist/react/table.d.ts.map +1 -0
- package/dist/react/table.js +86 -0
- package/dist/react/table.js.map +1 -0
- package/dist/react/tasks.d.ts +41 -0
- package/dist/react/tasks.d.ts.map +1 -0
- package/dist/react/tasks.js +79 -0
- package/dist/react/tasks.js.map +1 -0
- package/dist/react/tour.d.ts +42 -0
- package/dist/react/tour.d.ts.map +1 -0
- package/dist/react/tour.js +68 -0
- package/dist/react/tour.js.map +1 -0
- package/dist/react/windowBoundary.d.ts +22 -0
- package/dist/react/windowBoundary.d.ts.map +1 -0
- package/dist/react/windowBoundary.js +50 -0
- package/dist/react/windowBoundary.js.map +1 -0
- package/dist/react/wizard.d.ts +41 -0
- package/dist/react/wizard.d.ts.map +1 -0
- package/dist/react/wizard.js +60 -0
- package/dist/react/wizard.js.map +1 -0
- package/llms.txt +410 -0
- package/package.json +64 -0
- package/src/core/commands.ts +142 -0
- package/src/core/desk.ts +229 -0
- package/src/core/events.ts +99 -0
- package/src/core/index.ts +43 -0
- package/src/core/location.ts +127 -0
- package/src/core/shortcuts.ts +119 -0
- package/src/core/titles.ts +28 -0
- package/src/core/types.ts +49 -0
- package/src/desk.css +2550 -0
- package/src/react/Desktop.tsx +349 -0
- package/src/react/Dock.tsx +258 -0
- package/src/react/MenuBar.tsx +350 -0
- package/src/react/appFrame.tsx +183 -0
- package/src/react/arrange.ts +82 -0
- package/src/react/commands.ts +60 -0
- package/src/react/context.tsx +55 -0
- package/src/react/controls.tsx +364 -0
- package/src/react/conversation.tsx +208 -0
- package/src/react/dnd.tsx +189 -0
- package/src/react/dragContext.tsx +60 -0
- package/src/react/events.tsx +149 -0
- package/src/react/index.ts +64 -0
- package/src/react/layers.ts +66 -0
- package/src/react/overlays.tsx +254 -0
- package/src/react/popup.tsx +176 -0
- package/src/react/search.tsx +177 -0
- package/src/react/setupProgress.ts +113 -0
- package/src/react/shell.tsx +27 -0
- package/src/react/sidebar.tsx +84 -0
- package/src/react/table.tsx +176 -0
- package/src/react/tasks.tsx +176 -0
- package/src/react/tour.tsx +183 -0
- package/src/react/windowBoundary.tsx +110 -0
- package/src/react/wizard.tsx +150 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import type { DeskEvent } from '../core/events.js'
|
|
4
|
+
import type { WindowId } from '../core/types.js'
|
|
5
|
+
import { Button } from './controls.js'
|
|
6
|
+
import { useDesk } from './context.js'
|
|
7
|
+
import { useBus } from './events.js'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* A tour drives the desk: it opens the window each step is about and points at
|
|
11
|
+
* the control it is talking about, so the person watches the real app work
|
|
12
|
+
* rather than reading about it. A step can hand over — "your turn" — and wait.
|
|
13
|
+
*
|
|
14
|
+
* The bar holds no content of its own: the steps are the app's.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface TourStep {
|
|
18
|
+
/** Opened and made key before the step is shown. */
|
|
19
|
+
readonly window?: WindowId
|
|
20
|
+
/** What to point at: a `data-tour` name, or any CSS selector. */
|
|
21
|
+
readonly point?: string
|
|
22
|
+
readonly caption: ReactNode
|
|
23
|
+
/** Hands over: the bar offers Done and Skip instead of Next. */
|
|
24
|
+
readonly yourTurn?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* The event that means the person has done it — `ride.selected`, or a topic and a test.
|
|
27
|
+
* The tour moves on by itself when it arrives, so nobody has to say Done.
|
|
28
|
+
*/
|
|
29
|
+
readonly until?: string | { readonly topic: string; readonly when: (event: DeskEvent) => boolean }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Tour {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly name: string
|
|
35
|
+
/** What the tour shows, in a sentence: said when it is offered. */
|
|
36
|
+
readonly description?: ReactNode
|
|
37
|
+
readonly steps: readonly TourStep[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TourBarProps {
|
|
41
|
+
readonly tour: Tour
|
|
42
|
+
/** Called when the last step is finished. */
|
|
43
|
+
readonly onFinish?: () => void
|
|
44
|
+
/** Called when the person stops early. */
|
|
45
|
+
readonly onStop?: () => void
|
|
46
|
+
/**
|
|
47
|
+
* Ask first: the bar invites the person to take the tour, and starts only if they say so.
|
|
48
|
+
* For the first time someone reaches the desktop, where nobody should be dropped without a word.
|
|
49
|
+
*/
|
|
50
|
+
readonly offer?: boolean
|
|
51
|
+
readonly className?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const POINTED = 'data-desk-tour-pointed'
|
|
55
|
+
|
|
56
|
+
function point(step: TourStep | undefined): () => void {
|
|
57
|
+
if (!step?.point) return () => {}
|
|
58
|
+
const selector = step.point.startsWith('[') || step.point.includes('.') || step.point.includes('#') ? step.point : `[data-tour="${step.point}"]`
|
|
59
|
+
const element = document.querySelector<HTMLElement>(selector)
|
|
60
|
+
if (!element) return () => {}
|
|
61
|
+
element.setAttribute(POINTED, '')
|
|
62
|
+
element.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
63
|
+
return () => element.removeAttribute(POINTED)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function TourBar({ tour, onFinish, onStop, offer, className }: TourBarProps) {
|
|
67
|
+
const desk = useDesk()
|
|
68
|
+
const bus = useBus()
|
|
69
|
+
const [offered, setOffered] = useState(offer ?? false)
|
|
70
|
+
const [index, setIndex] = useState(0)
|
|
71
|
+
const step = offered ? undefined : tour.steps[index]
|
|
72
|
+
const total = tour.steps.length
|
|
73
|
+
const stop = useRef<() => void>(() => {})
|
|
74
|
+
|
|
75
|
+
// Enter the step: open the window it is about, then point at its control.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!step) return
|
|
78
|
+
if (step.window) desk.open(step.window)
|
|
79
|
+
stop.current()
|
|
80
|
+
// A frame later: the window it just opened has to exist before it can be pointed at.
|
|
81
|
+
const frame = requestAnimationFrame(() => {
|
|
82
|
+
stop.current = point(step)
|
|
83
|
+
})
|
|
84
|
+
return () => {
|
|
85
|
+
cancelAnimationFrame(frame)
|
|
86
|
+
stop.current()
|
|
87
|
+
stop.current = () => {}
|
|
88
|
+
}
|
|
89
|
+
}, [desk, step, index])
|
|
90
|
+
|
|
91
|
+
const go = useCallback(
|
|
92
|
+
(to: number) => {
|
|
93
|
+
if (to >= total) {
|
|
94
|
+
onFinish?.()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
setIndex(Math.max(0, to))
|
|
98
|
+
},
|
|
99
|
+
[total, onFinish],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// A step that waits for the person moves on when what it asked for happens.
|
|
103
|
+
const until = step?.until
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!until) return undefined
|
|
106
|
+
const topic = typeof until === 'string' ? until : until.topic
|
|
107
|
+
return bus.subscribe(topic, event => {
|
|
108
|
+
if (typeof until === 'string' || until.when(event)) go(index + 1)
|
|
109
|
+
})
|
|
110
|
+
}, [bus, until, go, index])
|
|
111
|
+
|
|
112
|
+
if (offered) {
|
|
113
|
+
return (
|
|
114
|
+
<div className={['desk-tour', className].filter(Boolean).join(' ')} role="region" aria-label={`Tour: ${tour.name}`}>
|
|
115
|
+
<div className="desk-tour-head">
|
|
116
|
+
<b>{tour.name}</b>
|
|
117
|
+
<span className="desk-tour-count">{total} steps</span>
|
|
118
|
+
</div>
|
|
119
|
+
{tour.description && <p className="desk-tour-caption">{tour.description}</p>}
|
|
120
|
+
<div className="desk-tour-actions">
|
|
121
|
+
<span className="desk-tour-spacer" />
|
|
122
|
+
<Button size="small" onClick={() => onStop?.()}>
|
|
123
|
+
Not now
|
|
124
|
+
</Button>
|
|
125
|
+
<Button size="small" intent="default" onClick={() => setOffered(false)}>
|
|
126
|
+
Show me
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!step) return null
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={['desk-tour', className].filter(Boolean).join(' ')} role="region" aria-label={`Tour: ${tour.name}`}>
|
|
137
|
+
<div className="desk-tour-head">
|
|
138
|
+
<b>{tour.name}</b>
|
|
139
|
+
<span className="desk-tour-count">
|
|
140
|
+
Step {index + 1} of {total}
|
|
141
|
+
</span>
|
|
142
|
+
<Button size="small" intent="quiet" aria-label="Stop the tour" onClick={() => onStop?.()}>
|
|
143
|
+
✕
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="desk-tour-progress" role="presentation">
|
|
147
|
+
<i style={{ width: `${((index + 1) / total) * 100}%` }} />
|
|
148
|
+
</div>
|
|
149
|
+
<p className="desk-tour-caption" data-your-turn={step.yourTurn || undefined}>
|
|
150
|
+
{step.yourTurn && <span className="desk-tour-turn">Your turn</span>}
|
|
151
|
+
{step.caption}
|
|
152
|
+
</p>
|
|
153
|
+
<div className="desk-tour-actions">
|
|
154
|
+
<Button size="small" intent="quiet" onClick={() => setIndex(0)}>
|
|
155
|
+
Restart
|
|
156
|
+
</Button>
|
|
157
|
+
<Button size="small" intent="quiet" onClick={() => setIndex(index)}>
|
|
158
|
+
Show me again
|
|
159
|
+
</Button>
|
|
160
|
+
<span className="desk-tour-spacer" />
|
|
161
|
+
{step.yourTurn ? (
|
|
162
|
+
<>
|
|
163
|
+
<Button size="small" onClick={() => go(index + 1)}>Skip</Button>
|
|
164
|
+
{!step.until && (
|
|
165
|
+
<Button size="small" intent="default" onClick={() => go(index + 1)}>
|
|
166
|
+
Done
|
|
167
|
+
</Button>
|
|
168
|
+
)}
|
|
169
|
+
</>
|
|
170
|
+
) : (
|
|
171
|
+
<>
|
|
172
|
+
<Button size="small" disabled={index === 0} onClick={() => go(index - 1)}>
|
|
173
|
+
‹ Back
|
|
174
|
+
</Button>
|
|
175
|
+
<Button size="small" intent="default" onClick={() => go(index + 1)}>
|
|
176
|
+
{index === total - 1 ? 'Finish' : 'Next ›'}
|
|
177
|
+
</Button>
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Component, createContext, lazy, Suspense, useContext } from 'react'
|
|
2
|
+
import type { ComponentType, ErrorInfo, LazyExoticComponent, ReactNode } from 'react'
|
|
3
|
+
import type { WindowId } from '../core/types.js'
|
|
4
|
+
import { Button } from './controls.js'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Each window loads, and fails, on its own. One window fetching its code shows a
|
|
8
|
+
* loading state in that window and nowhere else; one window throwing shows what
|
|
9
|
+
* went wrong in that window, with a way to try again, while the rest of the desk
|
|
10
|
+
* carries on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type WindowLoading = (id: WindowId) => ReactNode
|
|
14
|
+
export type WindowFailed = (id: WindowId, error: Error, reload: () => void) => ReactNode
|
|
15
|
+
|
|
16
|
+
/** How many times this window has been reloaded: a lazy window loads afresh for each. */
|
|
17
|
+
const AttemptContext = createContext(0)
|
|
18
|
+
|
|
19
|
+
const defaultLoading: WindowLoading = () => (
|
|
20
|
+
<div className="desk-window-status" role="status">
|
|
21
|
+
Loading…
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const defaultFailed: WindowFailed = (_id, error, reload) => (
|
|
26
|
+
<div className="desk-window-status" role="alert">
|
|
27
|
+
<b>This window could not open</b>
|
|
28
|
+
<span className="desk-window-status-detail">{error.message}</span>
|
|
29
|
+
<Button size="small" onClick={reload}>
|
|
30
|
+
Reload
|
|
31
|
+
</Button>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
interface BoundaryProps {
|
|
36
|
+
readonly id: WindowId
|
|
37
|
+
readonly failed: WindowFailed | undefined
|
|
38
|
+
readonly children: ReactNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface BoundaryState {
|
|
42
|
+
readonly error: Error | null
|
|
43
|
+
readonly attempt: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class WindowErrorBoundary extends Component<BoundaryProps, BoundaryState> {
|
|
47
|
+
override state: BoundaryState = { error: null, attempt: 0 }
|
|
48
|
+
|
|
49
|
+
static getDerivedStateFromError(error: unknown): Partial<BoundaryState> {
|
|
50
|
+
return { error: error instanceof Error ? error : new Error(String(error)) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override componentDidCatch(error: Error, info: ErrorInfo) {
|
|
54
|
+
// Still reported: a window that cannot open is a bug somebody should hear about.
|
|
55
|
+
console.error(`desk: window "${this.props.id}" could not open`, error, info.componentStack)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private readonly reload = () => this.setState(state => ({ error: null, attempt: state.attempt + 1 }))
|
|
59
|
+
|
|
60
|
+
override render() {
|
|
61
|
+
const { error, attempt } = this.state
|
|
62
|
+
if (error) return (this.props.failed ?? defaultFailed)(this.props.id, error, this.reload)
|
|
63
|
+
// A fresh key remounts the content, so a reload really starts again.
|
|
64
|
+
return (
|
|
65
|
+
<AttemptContext.Provider value={attempt} key={attempt}>
|
|
66
|
+
{this.props.children}
|
|
67
|
+
</AttemptContext.Provider>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function WindowBoundary({
|
|
73
|
+
id,
|
|
74
|
+
loading,
|
|
75
|
+
failed,
|
|
76
|
+
children,
|
|
77
|
+
}: {
|
|
78
|
+
readonly id: WindowId
|
|
79
|
+
readonly loading: WindowLoading | undefined
|
|
80
|
+
readonly failed: WindowFailed | undefined
|
|
81
|
+
readonly children: ReactNode
|
|
82
|
+
}) {
|
|
83
|
+
return (
|
|
84
|
+
<WindowErrorBoundary id={id} failed={failed}>
|
|
85
|
+
<Suspense fallback={(loading ?? defaultLoading)(id)}>{children}</Suspense>
|
|
86
|
+
</WindowErrorBoundary>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A window whose code loads when it is first opened.
|
|
92
|
+
*
|
|
93
|
+
* const RoutePlanner = lazyWindow(() => import('./apps/RoutePlanner'))
|
|
94
|
+
*
|
|
95
|
+
* Unlike `React.lazy`, a load that fails is not remembered: Reload in the window
|
|
96
|
+
* tries the import again, so a dropped connection is not permanent.
|
|
97
|
+
*/
|
|
98
|
+
export function lazyWindow<P extends object>(load: () => Promise<{ default: ComponentType<P> }>): ComponentType<P> {
|
|
99
|
+
const byAttempt = new Map<number, LazyExoticComponent<ComponentType<P>>>()
|
|
100
|
+
function LazyWindow(props: P) {
|
|
101
|
+
const attempt = useContext(AttemptContext)
|
|
102
|
+
let Loaded = byAttempt.get(attempt)
|
|
103
|
+
if (!Loaded) {
|
|
104
|
+
Loaded = lazy(load)
|
|
105
|
+
byAttempt.set(attempt, Loaded)
|
|
106
|
+
}
|
|
107
|
+
return <Loaded {...props} />
|
|
108
|
+
}
|
|
109
|
+
return LazyWindow
|
|
110
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Button } from './controls.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* A setup assistant: one pane, one question at a time, Back and Continue where
|
|
7
|
+
* the eye already is. It owns which step is showing and whether Continue is
|
|
8
|
+
* allowed; the steps themselves are the app's.
|
|
9
|
+
*
|
|
10
|
+
* A step cannot be skipped past by clicking a dot: the dots show where you are
|
|
11
|
+
* and let you go back over what you have already answered, nothing more.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface WizardStep {
|
|
15
|
+
readonly id: string
|
|
16
|
+
/** Named in the progress row, and announced as the step changes. */
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly title: ReactNode
|
|
19
|
+
readonly description?: ReactNode
|
|
20
|
+
readonly body?: ReactNode
|
|
21
|
+
/** Above the title. */
|
|
22
|
+
readonly glyph?: ReactNode
|
|
23
|
+
/** Continue is offered only when this is true. Default: always. */
|
|
24
|
+
readonly complete?: boolean
|
|
25
|
+
/** Replaces Continue's label on this step. */
|
|
26
|
+
readonly continueLabel?: string
|
|
27
|
+
/** A quiet way past this step: "Set up later". */
|
|
28
|
+
readonly skip?: { readonly label: string; readonly onSkip: () => void }
|
|
29
|
+
/** Nothing to do but wait: no Back, and Continue waits for `complete`. */
|
|
30
|
+
readonly working?: boolean
|
|
31
|
+
readonly onEnter?: () => void
|
|
32
|
+
/**
|
|
33
|
+
* Work Continue does before moving on: create the account, check the key, download the maps.
|
|
34
|
+
* While it runs, Continue says so and nothing can be pressed twice. Throw to stay on the step
|
|
35
|
+
* and show why, in the step; return `false` to stay without a message.
|
|
36
|
+
*/
|
|
37
|
+
readonly onContinue?: () => unknown
|
|
38
|
+
/** Continue's label while `onContinue` runs. Default: "Working…". */
|
|
39
|
+
readonly busyLabel?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WizardProps {
|
|
43
|
+
readonly steps: readonly WizardStep[]
|
|
44
|
+
readonly index: number
|
|
45
|
+
readonly onIndexChange: (index: number) => void
|
|
46
|
+
readonly onFinish: () => void
|
|
47
|
+
readonly label?: string
|
|
48
|
+
readonly className?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Wizard({ steps, index, onIndexChange, onFinish, label = 'Setup', className }: WizardProps) {
|
|
52
|
+
const step = steps[index]
|
|
53
|
+
const [direction, setDirection] = useState<'forward' | 'back'>('forward')
|
|
54
|
+
const previous = useRef(index)
|
|
55
|
+
const heading = useRef<HTMLHeadingElement>(null)
|
|
56
|
+
const entered = useRef<(() => void) | undefined>(undefined)
|
|
57
|
+
entered.current = step?.onEnter
|
|
58
|
+
const [busy, setBusy] = useState(false)
|
|
59
|
+
const [error, setError] = useState<string | null>(null)
|
|
60
|
+
// The step a running `onContinue` belongs to: if the person has gone back meanwhile, its result is stale.
|
|
61
|
+
const running = useRef<number | null>(null)
|
|
62
|
+
|
|
63
|
+
// Only when the step changes: a step whose body has a field re-renders on every
|
|
64
|
+
// keystroke, and moving focus to the heading then would eat what was being typed.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setDirection(index >= previous.current ? 'forward' : 'back')
|
|
67
|
+
setBusy(false)
|
|
68
|
+
setError(null)
|
|
69
|
+
running.current = null
|
|
70
|
+
previous.current = index
|
|
71
|
+
entered.current?.()
|
|
72
|
+
// Focus the new step's heading, so a screen reader announces it and the
|
|
73
|
+
// keyboard starts at the top of the pane rather than back at the buttons.
|
|
74
|
+
heading.current?.focus()
|
|
75
|
+
}, [index])
|
|
76
|
+
|
|
77
|
+
if (!step) return null
|
|
78
|
+
|
|
79
|
+
const last = index === steps.length - 1
|
|
80
|
+
const canContinue = step.complete ?? true
|
|
81
|
+
const go = (to: number) => (to >= steps.length ? onFinish() : onIndexChange(Math.max(0, to)))
|
|
82
|
+
|
|
83
|
+
const next = async () => {
|
|
84
|
+
if (!step.onContinue) return go(index + 1)
|
|
85
|
+
if (running.current !== null) return
|
|
86
|
+
running.current = index
|
|
87
|
+
setBusy(true)
|
|
88
|
+
setError(null)
|
|
89
|
+
try {
|
|
90
|
+
const result = await step.onContinue()
|
|
91
|
+
if (running.current !== index) return
|
|
92
|
+
running.current = null
|
|
93
|
+
setBusy(false)
|
|
94
|
+
if (result !== false) go(index + 1)
|
|
95
|
+
} catch (failure) {
|
|
96
|
+
if (running.current !== index) return
|
|
97
|
+
running.current = null
|
|
98
|
+
setBusy(false)
|
|
99
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<section className={['desk-wizard', className].filter(Boolean).join(' ')} aria-label={label}>
|
|
105
|
+
<div className="desk-wizard-content" data-direction={direction} key={step.id}>
|
|
106
|
+
{step.glyph && <div className="desk-wizard-glyph" aria-hidden="true">{step.glyph}</div>}
|
|
107
|
+
<h1 ref={heading} tabIndex={-1} className="desk-wizard-title">{step.title}</h1>
|
|
108
|
+
{step.description && <p className="desk-wizard-description">{step.description}</p>}
|
|
109
|
+
{step.body}
|
|
110
|
+
{error && (
|
|
111
|
+
<p className="desk-wizard-error" role="alert">
|
|
112
|
+
{error}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
<footer className="desk-wizard-foot">
|
|
117
|
+
<ol className="desk-wizard-steps" aria-label="Progress">
|
|
118
|
+
{steps.map((other, i) => (
|
|
119
|
+
<li
|
|
120
|
+
key={other.id}
|
|
121
|
+
className="desk-wizard-dot"
|
|
122
|
+
data-state={i === index ? 'now' : i < index ? 'done' : 'ahead'}
|
|
123
|
+
aria-current={i === index ? 'step' : undefined}
|
|
124
|
+
>
|
|
125
|
+
<span className="desk-wizard-dot-name">{other.name}</span>
|
|
126
|
+
</li>
|
|
127
|
+
))}
|
|
128
|
+
</ol>
|
|
129
|
+
<span className="desk-wizard-step-name" aria-live="polite">
|
|
130
|
+
{step.name}
|
|
131
|
+
</span>
|
|
132
|
+
<div className="desk-wizard-actions">
|
|
133
|
+
{step.skip && (
|
|
134
|
+
<Button intent="quiet" disabled={busy} onClick={step.skip.onSkip}>
|
|
135
|
+
{step.skip.label}
|
|
136
|
+
</Button>
|
|
137
|
+
)}
|
|
138
|
+
{index > 0 && !step.working && (
|
|
139
|
+
<Button disabled={busy} onClick={() => go(index - 1)}>
|
|
140
|
+
Back
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
<Button intent="default" disabled={!canContinue || busy} aria-busy={busy || undefined} onClick={() => void next()}>
|
|
144
|
+
{busy ? (step.busyLabel ?? 'Working…') : (step.continueLabel ?? (last ? 'Done' : 'Continue'))}
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
</footer>
|
|
148
|
+
</section>
|
|
149
|
+
)
|
|
150
|
+
}
|