@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.
Files changed (172) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +4 -0
  3. package/README.md +436 -0
  4. package/dist/core/commands.d.ts +40 -0
  5. package/dist/core/commands.d.ts.map +1 -0
  6. package/dist/core/commands.js +92 -0
  7. package/dist/core/commands.js.map +1 -0
  8. package/dist/core/desk.d.ts +46 -0
  9. package/dist/core/desk.d.ts.map +1 -0
  10. package/dist/core/desk.js +178 -0
  11. package/dist/core/desk.js.map +1 -0
  12. package/dist/core/events.d.ts +42 -0
  13. package/dist/core/events.d.ts.map +1 -0
  14. package/dist/core/events.js +54 -0
  15. package/dist/core/events.js.map +1 -0
  16. package/dist/core/index.d.ts +13 -0
  17. package/dist/core/index.d.ts.map +1 -0
  18. package/dist/core/index.js +7 -0
  19. package/dist/core/index.js.map +1 -0
  20. package/dist/core/location.d.ts +30 -0
  21. package/dist/core/location.d.ts.map +1 -0
  22. package/dist/core/location.js +99 -0
  23. package/dist/core/location.js.map +1 -0
  24. package/dist/core/shortcuts.d.ts +36 -0
  25. package/dist/core/shortcuts.d.ts.map +1 -0
  26. package/dist/core/shortcuts.js +73 -0
  27. package/dist/core/shortcuts.js.map +1 -0
  28. package/dist/core/titles.d.ts +3 -0
  29. package/dist/core/titles.d.ts.map +1 -0
  30. package/dist/core/titles.js +26 -0
  31. package/dist/core/titles.js.map +1 -0
  32. package/dist/core/types.d.ts +51 -0
  33. package/dist/core/types.d.ts.map +1 -0
  34. package/dist/core/types.js +2 -0
  35. package/dist/core/types.js.map +1 -0
  36. package/dist/desk.css +2550 -0
  37. package/dist/react/Desktop.d.ts +24 -0
  38. package/dist/react/Desktop.d.ts.map +1 -0
  39. package/dist/react/Desktop.js +212 -0
  40. package/dist/react/Desktop.js.map +1 -0
  41. package/dist/react/Dock.d.ts +42 -0
  42. package/dist/react/Dock.d.ts.map +1 -0
  43. package/dist/react/Dock.js +103 -0
  44. package/dist/react/Dock.js.map +1 -0
  45. package/dist/react/MenuBar.d.ts +64 -0
  46. package/dist/react/MenuBar.d.ts.map +1 -0
  47. package/dist/react/MenuBar.js +245 -0
  48. package/dist/react/MenuBar.js.map +1 -0
  49. package/dist/react/appFrame.d.ts +37 -0
  50. package/dist/react/appFrame.d.ts.map +1 -0
  51. package/dist/react/appFrame.js +119 -0
  52. package/dist/react/appFrame.js.map +1 -0
  53. package/dist/react/arrange.d.ts +24 -0
  54. package/dist/react/arrange.d.ts.map +1 -0
  55. package/dist/react/arrange.js +61 -0
  56. package/dist/react/arrange.js.map +1 -0
  57. package/dist/react/commands.d.ts +23 -0
  58. package/dist/react/commands.d.ts.map +1 -0
  59. package/dist/react/commands.js +41 -0
  60. package/dist/react/commands.js.map +1 -0
  61. package/dist/react/context.d.ts +25 -0
  62. package/dist/react/context.d.ts.map +1 -0
  63. package/dist/react/context.js +28 -0
  64. package/dist/react/context.js.map +1 -0
  65. package/dist/react/controls.d.ts +111 -0
  66. package/dist/react/controls.d.ts.map +1 -0
  67. package/dist/react/controls.js +83 -0
  68. package/dist/react/controls.js.map +1 -0
  69. package/dist/react/conversation.d.ts +41 -0
  70. package/dist/react/conversation.d.ts.map +1 -0
  71. package/dist/react/conversation.js +73 -0
  72. package/dist/react/conversation.js.map +1 -0
  73. package/dist/react/dnd.d.ts +51 -0
  74. package/dist/react/dnd.d.ts.map +1 -0
  75. package/dist/react/dnd.js +138 -0
  76. package/dist/react/dnd.js.map +1 -0
  77. package/dist/react/dragContext.d.ts +32 -0
  78. package/dist/react/dragContext.d.ts.map +1 -0
  79. package/dist/react/dragContext.js +14 -0
  80. package/dist/react/dragContext.js.map +1 -0
  81. package/dist/react/events.d.ts +41 -0
  82. package/dist/react/events.d.ts.map +1 -0
  83. package/dist/react/events.js +91 -0
  84. package/dist/react/events.js.map +1 -0
  85. package/dist/react/index.d.ts +43 -0
  86. package/dist/react/index.d.ts.map +1 -0
  87. package/dist/react/index.js +22 -0
  88. package/dist/react/index.js.map +1 -0
  89. package/dist/react/layers.d.ts +5 -0
  90. package/dist/react/layers.d.ts.map +1 -0
  91. package/dist/react/layers.js +65 -0
  92. package/dist/react/layers.js.map +1 -0
  93. package/dist/react/overlays.d.ts +67 -0
  94. package/dist/react/overlays.d.ts.map +1 -0
  95. package/dist/react/overlays.js +98 -0
  96. package/dist/react/overlays.js.map +1 -0
  97. package/dist/react/popup.d.ts +21 -0
  98. package/dist/react/popup.d.ts.map +1 -0
  99. package/dist/react/popup.js +94 -0
  100. package/dist/react/popup.js.map +1 -0
  101. package/dist/react/search.d.ts +33 -0
  102. package/dist/react/search.d.ts.map +1 -0
  103. package/dist/react/search.js +82 -0
  104. package/dist/react/search.js.map +1 -0
  105. package/dist/react/setupProgress.d.ts +35 -0
  106. package/dist/react/setupProgress.d.ts.map +1 -0
  107. package/dist/react/setupProgress.js +68 -0
  108. package/dist/react/setupProgress.js.map +1 -0
  109. package/dist/react/shell.d.ts +17 -0
  110. package/dist/react/shell.d.ts.map +1 -0
  111. package/dist/react/shell.js +13 -0
  112. package/dist/react/shell.js.map +1 -0
  113. package/dist/react/sidebar.d.ts +23 -0
  114. package/dist/react/sidebar.d.ts.map +1 -0
  115. package/dist/react/sidebar.js +26 -0
  116. package/dist/react/sidebar.js.map +1 -0
  117. package/dist/react/table.d.ts +39 -0
  118. package/dist/react/table.d.ts.map +1 -0
  119. package/dist/react/table.js +86 -0
  120. package/dist/react/table.js.map +1 -0
  121. package/dist/react/tasks.d.ts +41 -0
  122. package/dist/react/tasks.d.ts.map +1 -0
  123. package/dist/react/tasks.js +79 -0
  124. package/dist/react/tasks.js.map +1 -0
  125. package/dist/react/tour.d.ts +42 -0
  126. package/dist/react/tour.d.ts.map +1 -0
  127. package/dist/react/tour.js +68 -0
  128. package/dist/react/tour.js.map +1 -0
  129. package/dist/react/windowBoundary.d.ts +22 -0
  130. package/dist/react/windowBoundary.d.ts.map +1 -0
  131. package/dist/react/windowBoundary.js +50 -0
  132. package/dist/react/windowBoundary.js.map +1 -0
  133. package/dist/react/wizard.d.ts +41 -0
  134. package/dist/react/wizard.d.ts.map +1 -0
  135. package/dist/react/wizard.js +60 -0
  136. package/dist/react/wizard.js.map +1 -0
  137. package/llms.txt +410 -0
  138. package/package.json +64 -0
  139. package/src/core/commands.ts +142 -0
  140. package/src/core/desk.ts +229 -0
  141. package/src/core/events.ts +99 -0
  142. package/src/core/index.ts +43 -0
  143. package/src/core/location.ts +127 -0
  144. package/src/core/shortcuts.ts +119 -0
  145. package/src/core/titles.ts +28 -0
  146. package/src/core/types.ts +49 -0
  147. package/src/desk.css +2550 -0
  148. package/src/react/Desktop.tsx +349 -0
  149. package/src/react/Dock.tsx +258 -0
  150. package/src/react/MenuBar.tsx +350 -0
  151. package/src/react/appFrame.tsx +183 -0
  152. package/src/react/arrange.ts +82 -0
  153. package/src/react/commands.ts +60 -0
  154. package/src/react/context.tsx +55 -0
  155. package/src/react/controls.tsx +364 -0
  156. package/src/react/conversation.tsx +208 -0
  157. package/src/react/dnd.tsx +189 -0
  158. package/src/react/dragContext.tsx +60 -0
  159. package/src/react/events.tsx +149 -0
  160. package/src/react/index.ts +64 -0
  161. package/src/react/layers.ts +66 -0
  162. package/src/react/overlays.tsx +254 -0
  163. package/src/react/popup.tsx +176 -0
  164. package/src/react/search.tsx +177 -0
  165. package/src/react/setupProgress.ts +113 -0
  166. package/src/react/shell.tsx +27 -0
  167. package/src/react/sidebar.tsx +84 -0
  168. package/src/react/table.tsx +176 -0
  169. package/src/react/tasks.tsx +176 -0
  170. package/src/react/tour.tsx +183 -0
  171. package/src/react/windowBoundary.tsx +110 -0
  172. 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
+ }