@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,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
|
+
}
|