@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,113 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /*
4
+ * Where someone got to in setup, kept so that closing the tab on step four does
5
+ * not send them back to step one, and so that a finished setup is not shown
6
+ * again. The toolkit does not decide where that lives: this browser by default,
7
+ * or the app's own server.
8
+ *
9
+ * Keep answers to what a person would mind retyping. Never a password or a key:
10
+ * the default store is this browser's local storage.
11
+ */
12
+
13
+ export interface SetupRecord<Answers> {
14
+ readonly index: number
15
+ readonly answers: Answers
16
+ readonly finished: boolean
17
+ }
18
+
19
+ export interface SetupStore<Answers> {
20
+ /** What was saved, or undefined when nothing was. May be async, for a server. */
21
+ load(): SetupRecord<Answers> | undefined | Promise<SetupRecord<Answers> | undefined>
22
+ save(record: SetupRecord<Answers>): void | Promise<void>
23
+ clear(): void | Promise<void>
24
+ }
25
+
26
+ /** This browser's local storage. Quietly does nothing where storage is unavailable. */
27
+ export function localSetupStore<Answers>(key: string): SetupStore<Answers> {
28
+ const name = `desk.setup.${key}`
29
+ return {
30
+ load() {
31
+ try {
32
+ const raw = globalThis.localStorage?.getItem(name)
33
+ return raw ? (JSON.parse(raw) as SetupRecord<Answers>) : undefined
34
+ } catch {
35
+ return undefined
36
+ }
37
+ },
38
+ save(record) {
39
+ try {
40
+ globalThis.localStorage?.setItem(name, JSON.stringify(record))
41
+ } catch {
42
+ // Private windows and full storage: setup still works, it just is not remembered.
43
+ }
44
+ },
45
+ clear() {
46
+ try {
47
+ globalThis.localStorage?.removeItem(name)
48
+ } catch {
49
+ // As above.
50
+ }
51
+ },
52
+ }
53
+ }
54
+
55
+ export interface SetupProgress<Answers> {
56
+ /** False until the store has answered; show nothing, or a quiet loading state, until then. */
57
+ readonly loaded: boolean
58
+ readonly index: number
59
+ readonly setIndex: (index: number) => void
60
+ readonly answers: Answers
61
+ /** Merges into the answers: `answer({ rider: 'Jasper Blues' })`. */
62
+ readonly answer: (patch: Partial<Answers>) => void
63
+ readonly finished: boolean
64
+ readonly finish: () => void
65
+ /** Forgets everything: "Set up again". */
66
+ readonly reset: () => void
67
+ }
68
+
69
+ export interface SetupProgressOptions<Answers> {
70
+ /** Names what is being set up, so two setups do not share a record. */
71
+ readonly key: string
72
+ readonly initial: Answers
73
+ /** Default: this browser's local storage. */
74
+ readonly store?: SetupStore<Answers>
75
+ }
76
+
77
+ export function useSetupProgress<Answers extends object>({ key, initial, store }: SetupProgressOptions<Answers>): SetupProgress<Answers> {
78
+ const storeRef = useRef(store ?? localSetupStore<Answers>(key))
79
+ const initialRef = useRef(initial)
80
+ const [record, setRecord] = useState<SetupRecord<Answers>>({ index: 0, answers: initial, finished: false })
81
+ const [loaded, setLoaded] = useState(false)
82
+
83
+ useEffect(() => {
84
+ let current = true
85
+ void Promise.resolve(storeRef.current.load()).then(saved => {
86
+ if (!current) return
87
+ // Answers added since the record was saved take their initial values.
88
+ if (saved) setRecord({ ...saved, answers: { ...initialRef.current, ...saved.answers } })
89
+ setLoaded(true)
90
+ })
91
+ return () => {
92
+ current = false
93
+ }
94
+ }, [])
95
+
96
+ const update = useCallback((change: (record: SetupRecord<Answers>) => SetupRecord<Answers>) => {
97
+ setRecord(previous => {
98
+ const next = change(previous)
99
+ void storeRef.current.save(next)
100
+ return next
101
+ })
102
+ }, [])
103
+
104
+ const setIndex = useCallback((index: number) => update(r => ({ ...r, index })), [update])
105
+ const answer = useCallback((patch: Partial<Answers>) => update(r => ({ ...r, answers: { ...r.answers, ...patch } })), [update])
106
+ const finish = useCallback(() => update(r => ({ ...r, finished: true })), [update])
107
+ const reset = useCallback(() => {
108
+ void storeRef.current.clear()
109
+ setRecord({ index: 0, answers: initialRef.current, finished: false })
110
+ }, [])
111
+
112
+ return { loaded, index: record.index, setIndex, answers: record.answers, answer, finished: record.finished, finish, reset }
113
+ }
@@ -0,0 +1,27 @@
1
+ import { useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { createDesk } from '../core/desk.js'
4
+ import type { Desk } from '../core/desk.js'
5
+ import type { Bus } from '../core/events.js'
6
+ import type { DeskOptions } from '../core/types.js'
7
+ import { DeskProvider } from './context.js'
8
+ import { BusProvider } from './events.js'
9
+ import { ToastProvider } from './overlays.js'
10
+
11
+ export interface DeskShellProps {
12
+ /** An existing desk. One is made from `options` when this is left out. */
13
+ readonly desk?: Desk
14
+ readonly options?: DeskOptions
15
+ /** Share a bus across desks, or supply your own. A desk already carries one. */
16
+ readonly bus?: Bus
17
+ /** Leave toasts out when the app does not use them. Default true. */
18
+ readonly toasts?: boolean
19
+ readonly children: ReactNode
20
+ }
21
+
22
+ /** Everything an app needs around it, in one component: the desk, its bus, and toasts. */
23
+ export function DeskShell({ desk, options, bus, toasts = true, children }: DeskShellProps) {
24
+ const [made] = useState(() => desk ?? createDesk(options))
25
+ const inner = toasts ? <ToastProvider>{children}</ToastProvider> : children
26
+ return <DeskProvider desk={made}>{bus ? <BusProvider bus={bus}>{inner}</BusProvider> : inner}</DeskProvider>
27
+ }
@@ -0,0 +1,84 @@
1
+ import { useRef } from 'react'
2
+ import type { KeyboardEvent, ReactNode } from 'react'
3
+
4
+ /*
5
+ * A source list: the places inside a window. It is navigation, not a menu, so
6
+ * the chosen item is marked `aria-current` and stays chosen.
7
+ */
8
+
9
+ export interface SidebarItem {
10
+ readonly id: string
11
+ readonly label: string
12
+ readonly icon?: ReactNode
13
+ readonly badge?: ReactNode
14
+ readonly disabled?: boolean
15
+ }
16
+
17
+ export interface SidebarSection {
18
+ readonly id: string
19
+ readonly label?: string
20
+ readonly items: readonly SidebarItem[]
21
+ }
22
+
23
+ export interface SidebarProps {
24
+ readonly sections: readonly SidebarSection[]
25
+ readonly value: string
26
+ readonly onChange: (id: string) => void
27
+ readonly label: string
28
+ readonly footer?: ReactNode
29
+ readonly className?: string
30
+ }
31
+
32
+ export function Sidebar({ sections, value, onChange, label, footer, className }: SidebarProps) {
33
+ const nav = useRef<HTMLElement>(null)
34
+ const items = sections.flatMap(section => section.items).filter(item => !item.disabled)
35
+
36
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
37
+ const keys = { ArrowDown: 1, ArrowUp: -1 } as const
38
+ const delta = keys[event.key as keyof typeof keys]
39
+ if (!delta && event.key !== 'Home' && event.key !== 'End') return
40
+ event.preventDefault()
41
+ const at = items.findIndex(item => item.id === value)
42
+ const next =
43
+ event.key === 'Home' ? items[0]
44
+ : event.key === 'End' ? items.at(-1)
45
+ : items[(at + (delta ?? 1) + items.length) % items.length]
46
+ if (!next) return
47
+ onChange(next.id)
48
+ nav.current?.querySelector<HTMLButtonElement>(`[data-item="${CSS.escape(next.id)}"]`)?.focus()
49
+ }
50
+
51
+ return (
52
+ <nav ref={nav} className={['desk-sidebar', className].filter(Boolean).join(' ')} aria-label={label} onKeyDown={onKeyDown}>
53
+ {sections.map(section => (
54
+ <div key={section.id} className="desk-sidebar-section">
55
+ {section.label && <p className="desk-sidebar-heading">{section.label}</p>}
56
+ <ul>
57
+ {section.items.map(item => {
58
+ const current = item.id === value
59
+ return (
60
+ <li key={item.id}>
61
+ <button
62
+ type="button"
63
+ data-item={item.id}
64
+ className="desk-sidebar-item"
65
+ aria-current={current ? 'page' : undefined}
66
+ data-current={current || undefined}
67
+ disabled={item.disabled}
68
+ tabIndex={current ? 0 : -1}
69
+ onClick={() => onChange(item.id)}
70
+ >
71
+ {item.icon && <span className="desk-sidebar-icon" aria-hidden="true">{item.icon}</span>}
72
+ <span className="desk-sidebar-label">{item.label}</span>
73
+ {item.badge != null && <span className="desk-sidebar-badge">{item.badge}</span>}
74
+ </button>
75
+ </li>
76
+ )
77
+ })}
78
+ </ul>
79
+ </div>
80
+ ))}
81
+ {footer && <div className="desk-sidebar-footer">{footer}</div>}
82
+ </nav>
83
+ )
84
+ }
@@ -0,0 +1,176 @@
1
+ import { useRef } from 'react'
2
+ import type { KeyboardEvent, ReactNode } from 'react'
3
+
4
+ /*
5
+ * A table of rows people read, sort and choose from. It renders what it is
6
+ * given: no paging, no fetching, no opinion about where the rows came from.
7
+ */
8
+
9
+ export interface Column<Row> {
10
+ readonly key: string
11
+ readonly header: ReactNode
12
+ /** Defaults to `row[key]` when the key names a field. */
13
+ readonly render?: (row: Row) => ReactNode
14
+ readonly align?: 'start' | 'end'
15
+ /** Figures line up and sort as numbers when this is set. */
16
+ readonly numeric?: boolean
17
+ readonly sortable?: boolean
18
+ readonly width?: string
19
+ }
20
+
21
+ export interface Sort {
22
+ readonly key: string
23
+ readonly direction: 'ascending' | 'descending'
24
+ }
25
+
26
+ export interface TableProps<Row> {
27
+ readonly rows: readonly Row[]
28
+ readonly columns: readonly Column<Row>[]
29
+ readonly rowId: (row: Row) => string
30
+ /** The accessible name, e.g. "Rides". */
31
+ readonly label: string
32
+ /** One id, or several when `selection` is `multiple`. */
33
+ readonly selected?: string | readonly string[] | null
34
+ /** Default `single`. `multiple` adds ⌘/Ctrl to add one and Shift for a run. */
35
+ readonly selection?: 'single' | 'multiple'
36
+ readonly onSelect?: (id: string, row: Row) => void
37
+ /** Called instead of `onSelect` when `selection` is `multiple`. */
38
+ readonly onSelectionChange?: (ids: string[]) => void
39
+ /** Enter, or a double click. */
40
+ readonly onActivate?: (id: string, row: Row) => void
41
+ readonly sort?: Sort | null
42
+ readonly onSortChange?: (sort: Sort) => void
43
+ readonly empty?: ReactNode
44
+ /** Extra props for each row — how a row becomes draggable. */
45
+ readonly rowProps?: (row: Row) => Record<string, unknown>
46
+ }
47
+
48
+ const cell = <Row,>(row: Row, column: Column<Row>): ReactNode =>
49
+ column.render ? column.render(row) : ((row as Record<string, unknown>)[column.key] as ReactNode)
50
+
51
+ export function Table<Row>({
52
+ rows,
53
+ columns,
54
+ rowId,
55
+ label,
56
+ selected,
57
+ selection = 'single',
58
+ onSelect,
59
+ onSelectionChange,
60
+ onActivate,
61
+ sort,
62
+ onSortChange,
63
+ empty,
64
+ rowProps,
65
+ }: TableProps<Row>) {
66
+ const body = useRef<HTMLTableSectionElement>(null)
67
+ const anchor = useRef<number>(0)
68
+ const chosen = selected == null ? [] : Array.isArray(selected) ? selected : [selected as string]
69
+ const isChosen = (id: string) => chosen.includes(id)
70
+
71
+ const choose = (index: number, modifiers: { readonly add?: boolean; readonly range?: boolean } = {}) => {
72
+ const row = rows[index]
73
+ if (!row) return
74
+ const id = rowId(row)
75
+ if (selection === 'multiple' && onSelectionChange) {
76
+ if (modifiers.range) {
77
+ const [from, to] = [Math.min(anchor.current, index), Math.max(anchor.current, index)]
78
+ onSelectionChange(rows.slice(from, to + 1).map(rowId))
79
+ } else if (modifiers.add) {
80
+ anchor.current = index
81
+ onSelectionChange(isChosen(id) ? chosen.filter(x => x !== id) : [...chosen, id])
82
+ } else {
83
+ anchor.current = index
84
+ onSelectionChange([id])
85
+ }
86
+ return
87
+ }
88
+ anchor.current = index
89
+ onSelect?.(id, row)
90
+ }
91
+
92
+ const focusRow = (index: number, modifiers: { readonly range?: boolean } = {}) => {
93
+ const clamped = Math.max(0, Math.min(index, rows.length - 1))
94
+ if (!rows[clamped]) return
95
+ choose(clamped, modifiers)
96
+ body.current?.querySelectorAll<HTMLElement>('tr')[clamped]?.focus()
97
+ }
98
+
99
+ const onKeyDown = (event: KeyboardEvent<HTMLTableSectionElement>) => {
100
+ const index = [...(body.current?.querySelectorAll('tr') ?? [])].indexOf(document.activeElement as HTMLTableRowElement)
101
+ if (index < 0) return
102
+ const row = rows[index]
103
+ switch (event.key) {
104
+ case 'ArrowDown': focusRow(index + 1, { range: event.shiftKey }); break
105
+ case 'ArrowUp': focusRow(index - 1, { range: event.shiftKey }); break
106
+ case 'Home': focusRow(0, { range: event.shiftKey }); break
107
+ case 'End': focusRow(rows.length - 1, { range: event.shiftKey }); break
108
+ case ' ': if (selection === 'multiple') choose(index, { add: true }); break
109
+ case 'Enter': if (row) onActivate?.(rowId(row), row); break
110
+ default: return
111
+ }
112
+ event.preventDefault()
113
+ }
114
+
115
+ const toggleSort = (column: Column<Row>) => {
116
+ if (!column.sortable || !onSortChange) return
117
+ const direction = sort?.key === column.key && sort.direction === 'ascending' ? 'descending' : 'ascending'
118
+ onSortChange({ key: column.key, direction })
119
+ }
120
+
121
+ if (!rows.length && empty) return <div className="desk-table-empty">{empty}</div>
122
+
123
+ return (
124
+ <div className="desk-table-wrap">
125
+ <table className="desk-table" aria-label={label} aria-multiselectable={selection === 'multiple' || undefined}>
126
+ <thead>
127
+ <tr>
128
+ {columns.map(column => (
129
+ <th
130
+ key={column.key}
131
+ scope="col"
132
+ style={column.width ? { width: column.width } : undefined}
133
+ data-align={column.align ?? (column.numeric ? 'end' : 'start')}
134
+ aria-sort={sort?.key === column.key ? sort.direction : column.sortable ? 'none' : undefined}
135
+ >
136
+ {column.sortable && onSortChange ? (
137
+ <button type="button" className="desk-table-sort" onClick={() => toggleSort(column)}>
138
+ {column.header}
139
+ <span aria-hidden="true">{sort?.key === column.key ? (sort.direction === 'ascending' ? '↑' : '↓') : ''}</span>
140
+ </button>
141
+ ) : (
142
+ column.header
143
+ )}
144
+ </th>
145
+ ))}
146
+ </tr>
147
+ </thead>
148
+ <tbody ref={body} onKeyDown={onKeyDown}>
149
+ {rows.map((row, index) => {
150
+ const id = rowId(row)
151
+ const isSelected = isChosen(id)
152
+ const selectable = Boolean(onSelect || onSelectionChange)
153
+ return (
154
+ <tr
155
+ key={id}
156
+ // One tab stop for the whole table; arrow keys move within it.
157
+ tabIndex={isSelected || (!chosen.length && index === 0) ? 0 : -1}
158
+ aria-selected={selectable ? isSelected : undefined}
159
+ data-selected={isSelected || undefined}
160
+ onClick={event => choose(index, { add: event.metaKey || event.ctrlKey, range: event.shiftKey })}
161
+ onDoubleClick={() => onActivate?.(id, row)}
162
+ {...rowProps?.(row)}
163
+ >
164
+ {columns.map(column => (
165
+ <td key={column.key} data-align={column.align ?? (column.numeric ? 'end' : 'start')} data-numeric={column.numeric || undefined}>
166
+ {cell(row, column)}
167
+ </td>
168
+ ))}
169
+ </tr>
170
+ )
171
+ })}
172
+ </tbody>
173
+ </table>
174
+ </div>
175
+ )
176
+ }
@@ -0,0 +1,176 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { Button } from './controls.js'
4
+
5
+ /*
6
+ * The jobs a setup does while the person watches: save the profile, import the
7
+ * rides, store the key. They run in order, each says how it is getting on,
8
+ * and one that fails stops the rest and says why — with a retry that picks up
9
+ * where it stopped, not from the top.
10
+ */
11
+
12
+ export type TaskState = 'waiting' | 'working' | 'done' | 'failed'
13
+
14
+ export interface Task {
15
+ readonly id: string
16
+ readonly name: ReactNode
17
+ /** The line under the name, until the task reports its own. */
18
+ readonly detail?: ReactNode
19
+ /** The work. `report` replaces the line under the name while it runs: "12 of 24". Throw to fail. */
20
+ readonly run: (report: (detail: ReactNode) => void) => unknown
21
+ }
22
+
23
+ export interface TaskProgress {
24
+ readonly id: string
25
+ readonly name: ReactNode
26
+ readonly state: TaskState
27
+ readonly detail?: ReactNode
28
+ /** Why it failed, for a person. */
29
+ readonly error?: string
30
+ }
31
+
32
+ export interface Tasks {
33
+ readonly items: readonly TaskProgress[]
34
+ readonly running: boolean
35
+ readonly done: boolean
36
+ readonly failed: boolean
37
+ /** Runs every task not yet done, in order. After a failure, this is the retry. */
38
+ readonly start: () => void
39
+ /** Forgets what ran and runs everything again: for a setup step entered a second time. */
40
+ readonly restart: () => void
41
+ }
42
+
43
+ interface Status {
44
+ readonly state: TaskState
45
+ readonly reported?: ReactNode
46
+ readonly error?: string
47
+ }
48
+
49
+ export function useTasks(tasks: readonly Task[]): Tasks {
50
+ // The latest tasks, so a task's `run` sees the answers given since the step was drawn.
51
+ const latest = useRef(tasks)
52
+ latest.current = tasks
53
+ const [status, setStatus] = useState<Readonly<Record<string, Status>>>({})
54
+ const statusNow = useRef(status)
55
+ statusNow.current = status
56
+ // A run that has been superseded — by a restart, or by leaving — stops touching state.
57
+ const generation = useRef(0)
58
+ const [running, setRunning] = useState(false)
59
+
60
+ useEffect(
61
+ () => () => {
62
+ generation.current += 1
63
+ },
64
+ [],
65
+ )
66
+
67
+ const run = useCallback(async (from: Readonly<Record<string, Status>>) => {
68
+ const mine = ++generation.current
69
+ const set = (id: string, next: Status) => {
70
+ if (generation.current !== mine) return
71
+ statusNow.current = { ...statusNow.current, [id]: next }
72
+ setStatus(statusNow.current)
73
+ }
74
+ setRunning(true)
75
+ for (const task of latest.current) {
76
+ if (from[task.id]?.state === 'done') continue
77
+ set(task.id, { state: 'working' })
78
+ try {
79
+ await task.run(detail => set(task.id, { state: 'working', reported: detail }))
80
+ set(task.id, { state: 'done', reported: statusNow.current[task.id]?.reported })
81
+ } catch (failure) {
82
+ set(task.id, { state: 'failed', error: failure instanceof Error ? failure.message : String(failure) })
83
+ break
84
+ }
85
+ if (generation.current !== mine) return
86
+ }
87
+ if (generation.current === mine) setRunning(false)
88
+ }, [])
89
+
90
+ const start = useCallback(() => {
91
+ if (running) return
92
+ void run(statusNow.current)
93
+ }, [run, running])
94
+
95
+ const restart = useCallback(() => {
96
+ statusNow.current = {}
97
+ setStatus({})
98
+ void run({})
99
+ }, [run])
100
+
101
+ const items = tasks.map((task): TaskProgress => {
102
+ const s = status[task.id]
103
+ const detail = s?.reported ?? task.detail
104
+ return {
105
+ id: task.id,
106
+ name: task.name,
107
+ state: s?.state ?? 'waiting',
108
+ ...(detail === undefined ? {} : { detail }),
109
+ ...(s?.error === undefined ? {} : { error: s.error }),
110
+ }
111
+ })
112
+
113
+ return {
114
+ items,
115
+ running,
116
+ done: items.length > 0 && items.every(item => item.state === 'done'),
117
+ failed: items.some(item => item.state === 'failed'),
118
+ start,
119
+ restart,
120
+ }
121
+ }
122
+
123
+ export interface ChecklistProps {
124
+ readonly items: readonly TaskProgress[]
125
+ /** Offered beside a failed task. */
126
+ readonly onRetry?: () => void
127
+ /** A bar above the list, filling as tasks finish. Default true. */
128
+ readonly progress?: boolean
129
+ readonly label?: string
130
+ readonly className?: string
131
+ }
132
+
133
+ /** Tasks as a person watches them: waiting, working, done, or failed with the reason. */
134
+ export function Checklist({ items, onRetry, progress = true, label = 'Progress', className }: ChecklistProps) {
135
+ const done = items.filter(item => item.state === 'done').length
136
+ return (
137
+ <div className={['desk-checklist', className].filter(Boolean).join(' ')}>
138
+ {progress && (
139
+ <div
140
+ className="desk-progress"
141
+ role="progressbar"
142
+ aria-label={label}
143
+ aria-valuemin={0}
144
+ aria-valuemax={items.length}
145
+ aria-valuenow={done}
146
+ >
147
+ <i style={{ width: `${items.length ? (done / items.length) * 100 : 0}%` }} />
148
+ </div>
149
+ )}
150
+ <ul className="desk-tasks" aria-label={label}>
151
+ {items.map(item => (
152
+ <li key={item.id} className="desk-task" data-state={item.state}>
153
+ <span className="desk-task-mark" aria-hidden="true">
154
+ {item.state === 'done' ? '✓' : item.state === 'failed' ? '!' : ''}
155
+ </span>
156
+ <span className="desk-task-text">
157
+ <span className="desk-task-name">{item.name}</span>
158
+ {item.state === 'failed' ? (
159
+ <span className="desk-task-error" role="alert">
160
+ {item.error}
161
+ </span>
162
+ ) : (
163
+ item.detail !== undefined && <span className="desk-task-detail">{item.detail}</span>
164
+ )}
165
+ </span>
166
+ {item.state === 'failed' && onRetry && (
167
+ <Button size="small" onClick={onRetry}>
168
+ Try again
169
+ </Button>
170
+ )}
171
+ </li>
172
+ ))}
173
+ </ul>
174
+ </div>
175
+ )
176
+ }