@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,189 @@
|
|
|
1
|
+
import { useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
|
|
3
|
+
import { WINDOW_ATTRIBUTE } from '../core/commands.js'
|
|
4
|
+
import { useDesk, useWindowId } from './context.js'
|
|
5
|
+
import { accepted, DragContext, TARGET_ATTRIBUTE } from './dragContext.js'
|
|
6
|
+
import type { Accepts, Drag } from './dragContext.js'
|
|
7
|
+
|
|
8
|
+
export { DragProvider } from './dragContext.js'
|
|
9
|
+
export type { Accepts, Drag } from './dragContext.js'
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* Dragging something from one window to another — a ride onto the map, a
|
|
13
|
+
* document into a conversation. Built on Pointer Events rather than HTML5
|
|
14
|
+
* drag-and-drop: the same code then works for touch and pen, the preview is
|
|
15
|
+
* ours to draw, and a drag can be cancelled with Escape.
|
|
16
|
+
*
|
|
17
|
+
* What is dragged is a `type` and a `payload`. A target says which types it
|
|
18
|
+
* takes; nothing else about the two windows is shared.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const useDrag = () => {
|
|
22
|
+
const context = useContext(DragContext)
|
|
23
|
+
if (!context) throw new Error('Dragging needs a <DeskProvider> (or a <DragProvider>) above it')
|
|
24
|
+
return context
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What is being dragged right now, for a window that wants to show it is ready. */
|
|
28
|
+
export const useDragging = (): Drag | null => useDrag().state?.drag ?? null
|
|
29
|
+
|
|
30
|
+
export interface DraggableOptions<T> {
|
|
31
|
+
readonly type: string
|
|
32
|
+
readonly payload: T
|
|
33
|
+
/** What follows the pointer. Defaults to the type. */
|
|
34
|
+
readonly preview?: ReactNode
|
|
35
|
+
readonly disabled?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DraggableResult {
|
|
39
|
+
readonly dragProps: { readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void; readonly 'data-desk-draggable': true }
|
|
40
|
+
readonly dragging: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DragSourceOptions {
|
|
44
|
+
readonly type: string
|
|
45
|
+
readonly disabled?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DragSource<T> {
|
|
49
|
+
/** Props for one item in a list: `rowProps={row => source.dragProps(row, row.name)}`. */
|
|
50
|
+
readonly dragProps: (payload: T, preview?: ReactNode) => DraggableResult['dragProps']
|
|
51
|
+
readonly dragging: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One source for a list of things: a hook cannot be called per row, so the row
|
|
56
|
+
* says what it is as it is picked up.
|
|
57
|
+
*/
|
|
58
|
+
export function useDragSource<T>({ type, disabled }: DragSourceOptions): DragSource<T> {
|
|
59
|
+
const { targets, setState } = useDrag()
|
|
60
|
+
const from = useWindowId()
|
|
61
|
+
const desk = useDesk()
|
|
62
|
+
const [dragging, setDragging] = useState(false)
|
|
63
|
+
|
|
64
|
+
const start = useCallback(
|
|
65
|
+
(payload: T, preview: ReactNode) => (event: ReactPointerEvent<HTMLElement>) => {
|
|
66
|
+
if (disabled || event.button !== 0) return
|
|
67
|
+
const startX = event.clientX
|
|
68
|
+
const startY = event.clientY
|
|
69
|
+
const handle = event.currentTarget
|
|
70
|
+
let started = false
|
|
71
|
+
|
|
72
|
+
const findTarget = (x: number, y: number) => {
|
|
73
|
+
const element = document.elementFromPoint(x, y)?.closest(`[${TARGET_ATTRIBUTE}]`)
|
|
74
|
+
const key = element?.getAttribute(TARGET_ATTRIBUTE) ?? null
|
|
75
|
+
const target = key ? targets.get(key) : undefined
|
|
76
|
+
return target && accepted(target.accepts, type) ? key : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onMove = (move: PointerEvent) => {
|
|
80
|
+
if (!started && Math.hypot(move.clientX - startX, move.clientY - startY) < 5) return
|
|
81
|
+
if (!started) {
|
|
82
|
+
started = true
|
|
83
|
+
setDragging(true)
|
|
84
|
+
handle.setPointerCapture?.(move.pointerId)
|
|
85
|
+
}
|
|
86
|
+
move.preventDefault()
|
|
87
|
+
setState({
|
|
88
|
+
drag: { type, payload, from },
|
|
89
|
+
x: move.clientX,
|
|
90
|
+
y: move.clientY,
|
|
91
|
+
over: findTarget(move.clientX, move.clientY),
|
|
92
|
+
preview: preview ?? type,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const finish = (drop: boolean, released?: PointerEvent) => {
|
|
97
|
+
document.removeEventListener('pointermove', onMove)
|
|
98
|
+
document.removeEventListener('pointerup', onUp)
|
|
99
|
+
document.removeEventListener('keydown', onKey)
|
|
100
|
+
if (!started) return
|
|
101
|
+
setDragging(false)
|
|
102
|
+
// Pointer capture sends the release back to where the drag began, and the browser then
|
|
103
|
+
// counts the whole gesture as a click on it. A drag is never also a click.
|
|
104
|
+
const swallow = (click: MouseEvent) => {
|
|
105
|
+
click.stopPropagation()
|
|
106
|
+
click.preventDefault()
|
|
107
|
+
}
|
|
108
|
+
globalThis.addEventListener('click', swallow, { capture: true, once: true })
|
|
109
|
+
setTimeout(() => globalThis.removeEventListener('click', swallow, { capture: true }), 0)
|
|
110
|
+
const key = drop && released ? findTarget(released.clientX, released.clientY) : null
|
|
111
|
+
const target = key ? targets.get(key) : undefined
|
|
112
|
+
setState(null)
|
|
113
|
+
if (!target) return
|
|
114
|
+
target.onDrop({ type, payload, from })
|
|
115
|
+
// The window that took it comes forward: that is where the work moved to.
|
|
116
|
+
const receiving = document.querySelector(`[${TARGET_ATTRIBUTE}="${key}"]`)?.closest(`[${WINDOW_ATTRIBUTE}]`)
|
|
117
|
+
const windowId = receiving?.getAttribute(WINDOW_ATTRIBUTE)
|
|
118
|
+
if (windowId) desk.focus(windowId)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const onUp = (up: PointerEvent) => finish(true, up)
|
|
122
|
+
const onKey = (key: KeyboardEvent) => {
|
|
123
|
+
if (key.key === 'Escape') finish(false)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
document.addEventListener('pointermove', onMove)
|
|
127
|
+
document.addEventListener('pointerup', onUp)
|
|
128
|
+
document.addEventListener('keydown', onKey)
|
|
129
|
+
},
|
|
130
|
+
[disabled, type, from, setState, targets, desk],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
dragProps: useCallback((payload: T, preview?: ReactNode) => ({ onPointerDown: start(payload, preview), 'data-desk-draggable': true as const }), [start]),
|
|
135
|
+
dragging,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Makes one thing picked up and carried. Spread `dragProps` on the element. */
|
|
140
|
+
export function useDraggable<T>({ type, payload, preview, disabled }: DraggableOptions<T>): DraggableResult {
|
|
141
|
+
const source = useDragSource<T>({ type, ...(disabled === undefined ? {} : { disabled }) })
|
|
142
|
+
return { dragProps: source.dragProps(payload, preview), dragging: source.dragging }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface DropTargetOptions<T> {
|
|
146
|
+
readonly accepts: Accepts
|
|
147
|
+
readonly onDrop: (drag: Drag<T>) => void
|
|
148
|
+
readonly disabled?: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface DropTargetResult {
|
|
152
|
+
readonly dropProps: Record<string, string | undefined>
|
|
153
|
+
/** Something acceptable is being carried over this target right now. */
|
|
154
|
+
readonly over: boolean
|
|
155
|
+
/** Something acceptable is being carried somewhere: show where it could go. */
|
|
156
|
+
readonly ready: boolean
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Makes something a place to drop. Spread `dropProps` on the element. */
|
|
160
|
+
export function useDropTarget<T>({ accepts, onDrop, disabled }: DropTargetOptions<T>): DropTargetResult {
|
|
161
|
+
const { state, targets } = useDrag()
|
|
162
|
+
const id = useId()
|
|
163
|
+
const latest = useRef({ accepts, onDrop })
|
|
164
|
+
latest.current = { accepts, onDrop }
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (disabled) return
|
|
168
|
+
targets.set(id, {
|
|
169
|
+
accepts: type => accepted(latest.current.accepts, type),
|
|
170
|
+
onDrop: drag => latest.current.onDrop(drag as Drag<T>),
|
|
171
|
+
})
|
|
172
|
+
return () => {
|
|
173
|
+
targets.delete(id)
|
|
174
|
+
}
|
|
175
|
+
}, [id, targets, disabled])
|
|
176
|
+
|
|
177
|
+
const ready = Boolean(state && !disabled && accepted(accepts, state.drag.type))
|
|
178
|
+
const over = ready && state?.over === id
|
|
179
|
+
return {
|
|
180
|
+
// The marks the stylesheet looks for, so a target lights up without the app wiring it.
|
|
181
|
+
dropProps: {
|
|
182
|
+
[TARGET_ATTRIBUTE]: disabled ? undefined : id,
|
|
183
|
+
...(ready ? { 'data-ready': 'true' } : {}),
|
|
184
|
+
...(over ? { 'data-over': 'true' } : {}),
|
|
185
|
+
},
|
|
186
|
+
over,
|
|
187
|
+
ready,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createContext, useMemo, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The state behind dragging, kept apart from the hooks that use it: a desk
|
|
7
|
+
* provides this, and the hooks reach for the desk — so this file must not.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface Drag<T = unknown> {
|
|
11
|
+
readonly type: string
|
|
12
|
+
readonly payload: T
|
|
13
|
+
/** The window it was picked up in, when it was picked up in one. */
|
|
14
|
+
readonly from: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Accepts = string | readonly string[] | ((type: string) => boolean)
|
|
18
|
+
|
|
19
|
+
export const accepted = (accepts: Accepts, type: string) =>
|
|
20
|
+
typeof accepts === 'function' ? accepts(type) : typeof accepts === 'string' ? accepts === type : accepts.includes(type)
|
|
21
|
+
|
|
22
|
+
export interface Target {
|
|
23
|
+
readonly accepts: Accepts
|
|
24
|
+
readonly onDrop: (drag: Drag) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DragState {
|
|
28
|
+
readonly drag: Drag
|
|
29
|
+
readonly x: number
|
|
30
|
+
readonly y: number
|
|
31
|
+
readonly over: string | null
|
|
32
|
+
readonly preview: ReactNode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DragContextValue {
|
|
36
|
+
readonly state: DragState | null
|
|
37
|
+
readonly targets: Map<string, Target>
|
|
38
|
+
readonly setState: (state: DragState | null) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DragContext = createContext<DragContextValue | null>(null)
|
|
42
|
+
|
|
43
|
+
export const TARGET_ATTRIBUTE = 'data-desk-drop'
|
|
44
|
+
|
|
45
|
+
/** Holds what is being carried. A desk provides one; this is for sharing or nesting. */
|
|
46
|
+
export function DragProvider({ children }: { readonly children: ReactNode }) {
|
|
47
|
+
const [state, setState] = useState<DragState | null>(null)
|
|
48
|
+
const [targets] = useState(() => new Map<string, Target>())
|
|
49
|
+
const value = useMemo(() => ({ state, targets, setState }), [state, targets])
|
|
50
|
+
return (
|
|
51
|
+
<DragContext.Provider value={value}>
|
|
52
|
+
{children}
|
|
53
|
+
{state && (
|
|
54
|
+
<div className="desk-drag-preview" style={{ left: state.x, top: state.y }} aria-hidden="true">
|
|
55
|
+
{state.preview}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</DragContext.Provider>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
import { perform } from '../core/commands.js'
|
|
5
|
+
import { createBus, InputCommands } from '../core/events.js'
|
|
6
|
+
import type { Bus, DeskEvent, EventHandler, InputDescription, SubscribeOptions } from '../core/events.js'
|
|
7
|
+
import type { WindowId } from '../core/types.js'
|
|
8
|
+
import { useCommand, useShortcuts } from './commands.js'
|
|
9
|
+
import { Composer } from './conversation.js'
|
|
10
|
+
import { useFocusTrap, useLayer } from './layers.js'
|
|
11
|
+
import { BusContext, useDesk, useDeskState, useWindowId } from './context.js'
|
|
12
|
+
|
|
13
|
+
/** Supplies a bus of your own, or shares one across several desks. A desk already has one. */
|
|
14
|
+
export function BusProvider({ bus, children }: { readonly bus?: Bus; readonly children: ReactNode }) {
|
|
15
|
+
const [value] = useState(() => bus ?? createBus())
|
|
16
|
+
return <BusContext.Provider value={value}>{children}</BusContext.Provider>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useBus(): Bus {
|
|
20
|
+
const bus = useContext(BusContext)
|
|
21
|
+
if (!bus) throw new Error('useBus must be used inside a <DeskProvider> or <BusProvider>')
|
|
22
|
+
return bus
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Publishes an event, stamped with the window it came from. */
|
|
26
|
+
export function usePublish(): <T>(topic: string, payload: T) => DeskEvent<T> {
|
|
27
|
+
const bus = useBus()
|
|
28
|
+
const from = useWindowId()
|
|
29
|
+
return useCallback((topic, payload) => bus.publish(topic, payload, { from }), [bus, from])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Answers events on a topic (`ride.selected`, `ride.*`, `*`) while mounted. */
|
|
33
|
+
export function useDeskEvent<T = unknown>(topic: string, handler: EventHandler<T>, options: SubscribeOptions = {}): void {
|
|
34
|
+
const bus = useBus()
|
|
35
|
+
const latest = useRef(handler)
|
|
36
|
+
latest.current = handler
|
|
37
|
+
const { replay } = options
|
|
38
|
+
useEffect(() => bus.subscribe<T>(topic, event => latest.current(event), { replay: replay ?? false }), [bus, topic, replay])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WindowInputOptions {
|
|
42
|
+
readonly placeholder?: string
|
|
43
|
+
/** What the bar says the text will reach. Defaults to the window's id. */
|
|
44
|
+
readonly target?: string
|
|
45
|
+
readonly disabled?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Takes the text typed into the desk's input bar while this window is key.
|
|
50
|
+
* A window that registers nothing lets the bar's own fallback have it.
|
|
51
|
+
*/
|
|
52
|
+
export function useWindowInput(handler: (text: string) => void, options: WindowInputOptions = {}): void {
|
|
53
|
+
const windowId = useWindowId()
|
|
54
|
+
const { placeholder, target, disabled } = options
|
|
55
|
+
useCommand<string>(InputCommands.submit, handler, { enabled: !disabled })
|
|
56
|
+
// The bar asks the chain how to present itself; the answer is written into the object it sends.
|
|
57
|
+
useCommand<InputDescription>(InputCommands.describe, description => {
|
|
58
|
+
if (placeholder) description.placeholder = placeholder
|
|
59
|
+
const named = target ?? windowId
|
|
60
|
+
if (named) description.target = named
|
|
61
|
+
description.disabled = disabled ?? false
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface InputBarProps {
|
|
66
|
+
/** Used when no window takes the text. */
|
|
67
|
+
readonly onSubmit: (text: string) => void
|
|
68
|
+
readonly fallbackPlaceholder?: string
|
|
69
|
+
readonly fallbackTarget?: string
|
|
70
|
+
readonly label?: string
|
|
71
|
+
/**
|
|
72
|
+
* `summoned` (the default) stays out of the way until called for, then opens
|
|
73
|
+
* over the middle of the screen. `inline` is always there, wherever you put it.
|
|
74
|
+
*/
|
|
75
|
+
readonly mode?: 'summoned' | 'inline'
|
|
76
|
+
/** Summons the bar. Default `mod+j`; `null` binds nothing (a menu item may own the key). */
|
|
77
|
+
readonly shortcut?: string | null
|
|
78
|
+
readonly className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One place to type, which reaches whichever window you are working in. */
|
|
82
|
+
export function InputBar({
|
|
83
|
+
onSubmit,
|
|
84
|
+
fallbackPlaceholder = 'Type here…',
|
|
85
|
+
fallbackTarget,
|
|
86
|
+
label = 'Type here',
|
|
87
|
+
mode = 'summoned',
|
|
88
|
+
shortcut = 'mod+j',
|
|
89
|
+
className,
|
|
90
|
+
}: InputBarProps) {
|
|
91
|
+
const desk = useDesk()
|
|
92
|
+
const state = useDeskState()
|
|
93
|
+
const [value, setValue] = useState('')
|
|
94
|
+
const [open, setOpen] = useState(mode === 'inline')
|
|
95
|
+
const [description, setDescription] = useState<InputDescription>({})
|
|
96
|
+
const panel = useRef<HTMLDivElement>(null)
|
|
97
|
+
const id = useId()
|
|
98
|
+
|
|
99
|
+
const summoned = mode === 'summoned'
|
|
100
|
+
useCommand(InputCommands.open, () => setOpen(true), { at: 'app' })
|
|
101
|
+
useShortcuts(useMemo(() => (shortcut && summoned ? { [shortcut]: InputCommands.open } : {}), [shortcut, summoned]))
|
|
102
|
+
useLayer(id, summoned && open, () => setOpen(false))
|
|
103
|
+
useFocusTrap(panel, summoned && open)
|
|
104
|
+
|
|
105
|
+
// Re-ask whenever the key window changes, or the bar is summoned: it belongs to whoever is in front.
|
|
106
|
+
const key: WindowId | null = state.stack.at(-1) ?? null
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!open) return
|
|
109
|
+
const answer: InputDescription = {}
|
|
110
|
+
perform(desk, InputCommands.describe, answer)
|
|
111
|
+
setDescription(answer)
|
|
112
|
+
}, [desk, key, open, id])
|
|
113
|
+
|
|
114
|
+
if (!open) return null
|
|
115
|
+
|
|
116
|
+
const submit = (text: string) => {
|
|
117
|
+
if (!perform(desk, InputCommands.submit, text)) onSubmit(text)
|
|
118
|
+
setValue('')
|
|
119
|
+
if (summoned) setOpen(false)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const target = description.target ?? fallbackTarget
|
|
123
|
+
const bar = (
|
|
124
|
+
<div ref={panel} className={['desk-inputbar', className].filter(Boolean).join(' ')} data-mode={mode}>
|
|
125
|
+
<Composer
|
|
126
|
+
value={value}
|
|
127
|
+
onChange={setValue}
|
|
128
|
+
onSubmit={submit}
|
|
129
|
+
label={label}
|
|
130
|
+
placeholder={description.placeholder ?? fallbackPlaceholder}
|
|
131
|
+
maxRows={4}
|
|
132
|
+
{...(description.disabled ? { disabled: true } : {})}
|
|
133
|
+
/>
|
|
134
|
+
{target && (
|
|
135
|
+
<span className="desk-inputbar-target" aria-live="polite">
|
|
136
|
+
goes to <b>{target}</b>
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if (!summoned) return bar
|
|
143
|
+
return createPortal(
|
|
144
|
+
<div className="desk-inputbar-scrim" onPointerDown={event => event.target === event.currentTarget && setOpen(false)}>
|
|
145
|
+
{bar}
|
|
146
|
+
</div>,
|
|
147
|
+
document.body,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export { APP_BRIDGE_SCRIPT, AppFrame, withAppBridge } from './appFrame.js'
|
|
2
|
+
export type { AppFrameProps } from './appFrame.js'
|
|
3
|
+
export { DragProvider, useDraggable, useDragging, useDragSource, useDropTarget } from './dnd.js'
|
|
4
|
+
export type {
|
|
5
|
+
Accepts,
|
|
6
|
+
Drag,
|
|
7
|
+
DraggableOptions,
|
|
8
|
+
DraggableResult,
|
|
9
|
+
DragSource,
|
|
10
|
+
DragSourceOptions,
|
|
11
|
+
DropTargetOptions,
|
|
12
|
+
DropTargetResult,
|
|
13
|
+
} from './dnd.js'
|
|
14
|
+
export { DeskShell } from './shell.js'
|
|
15
|
+
export type { DeskShellProps } from './shell.js'
|
|
16
|
+
export { DeskProvider, useDesk, useDeskState, useWindowId } from './context.js'
|
|
17
|
+
export type { DeskProviderProps } from './context.js'
|
|
18
|
+
export { useCanPerform, useCommand, usePerform, useShortcuts } from './commands.js'
|
|
19
|
+
export type { UseCommandOptions } from './commands.js'
|
|
20
|
+
export { Desktop } from './Desktop.js'
|
|
21
|
+
export type { DeskLayout, DesktopProps } from './Desktop.js'
|
|
22
|
+
export { lazyWindow } from './windowBoundary.js'
|
|
23
|
+
export type { WindowFailed, WindowLoading } from './windowBoundary.js'
|
|
24
|
+
export { Button, Checkbox, ChoiceGroup, RadioGroup, SegmentedControl, Slider, TextField, Toggle } from './controls.js'
|
|
25
|
+
export type {
|
|
26
|
+
ButtonIntent,
|
|
27
|
+
ButtonProps,
|
|
28
|
+
CheckboxProps,
|
|
29
|
+
ChoiceGroupProps,
|
|
30
|
+
ChoiceOption,
|
|
31
|
+
RadioGroupProps,
|
|
32
|
+
RadioOption,
|
|
33
|
+
SegmentedControlProps,
|
|
34
|
+
SegmentedOption,
|
|
35
|
+
SliderProps,
|
|
36
|
+
TextFieldProps,
|
|
37
|
+
ToggleProps,
|
|
38
|
+
} from './controls.js'
|
|
39
|
+
export { Checklist, useTasks } from './tasks.js'
|
|
40
|
+
export { localSetupStore, useSetupProgress } from './setupProgress.js'
|
|
41
|
+
export type { SetupProgress, SetupProgressOptions, SetupRecord, SetupStore } from './setupProgress.js'
|
|
42
|
+
export type { ChecklistProps, Task, TaskProgress, Tasks, TaskState } from './tasks.js'
|
|
43
|
+
export { PopUpButton } from './popup.js'
|
|
44
|
+
export type { PopUpButtonProps, PopUpOption } from './popup.js'
|
|
45
|
+
export { Table } from './table.js'
|
|
46
|
+
export type { Column, Sort, TableProps } from './table.js'
|
|
47
|
+
export { Sidebar } from './sidebar.js'
|
|
48
|
+
export type { SidebarItem, SidebarProps, SidebarSection } from './sidebar.js'
|
|
49
|
+
export { Wizard } from './wizard.js'
|
|
50
|
+
export type { WizardProps, WizardStep } from './wizard.js'
|
|
51
|
+
export { TourBar } from './tour.js'
|
|
52
|
+
export type { Tour, TourBarProps, TourStep } from './tour.js'
|
|
53
|
+
export { SearchCommand, SearchPalette, windowResults } from './search.js'
|
|
54
|
+
export type { SearchPaletteProps, SearchResult } from './search.js'
|
|
55
|
+
export { BusProvider, InputBar, useBus, useDeskEvent, usePublish, useWindowInput } from './events.js'
|
|
56
|
+
export type { InputBarProps, WindowInputOptions } from './events.js'
|
|
57
|
+
export { Composer, Thread } from './conversation.js'
|
|
58
|
+
export type { ComposerProps, Message, ThreadProps } from './conversation.js'
|
|
59
|
+
export { Alert, Popover, Sheet, ToastProvider, useToast } from './overlays.js'
|
|
60
|
+
export type { AlertProps, PopoverProps, PopoverTriggerProps, SheetProps, Toast, ToastInput } from './overlays.js'
|
|
61
|
+
export { MenuBar, menuAction, menuCommand, menuHeader, menuSeparator, windowMenuItems } from './MenuBar.js'
|
|
62
|
+
export type { Menu, MenuBarProps, MenuItem } from './MenuBar.js'
|
|
63
|
+
export { Dock, dockItem, dockSeparator, dockStack } from './Dock.js'
|
|
64
|
+
export type { DockEntry, DockItem, DockProps, DockStack } from './Dock.js'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { RefObject } from 'react'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* What every overlay needs: a shared stack so Escape dismisses the topmost one
|
|
6
|
+
* rather than all of them, and a focus trap that gives focus back on the way out.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const layers: string[] = []
|
|
10
|
+
const isTopLayer = (id: string) => layers.at(-1) === id
|
|
11
|
+
|
|
12
|
+
export function useLayer(id: string, open: boolean, onEscape: () => void) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!open) return
|
|
15
|
+
layers.push(id)
|
|
16
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
17
|
+
if (event.key !== 'Escape' || !isTopLayer(id)) return
|
|
18
|
+
event.preventDefault()
|
|
19
|
+
event.stopPropagation()
|
|
20
|
+
onEscape()
|
|
21
|
+
}
|
|
22
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
23
|
+
return () => {
|
|
24
|
+
const at = layers.lastIndexOf(id)
|
|
25
|
+
if (at >= 0) layers.splice(at, 1)
|
|
26
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TABBABLE =
|
|
32
|
+
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
33
|
+
|
|
34
|
+
const tabbable = (root: HTMLElement) => [...root.querySelectorAll<HTMLElement>(TABBABLE)].filter(el => !el.closest('[inert]'))
|
|
35
|
+
|
|
36
|
+
/** Holds keyboard focus inside `panel` while open, and gives it back on close. */
|
|
37
|
+
export function useFocusTrap(panel: RefObject<HTMLElement | null>, open: boolean) {
|
|
38
|
+
const returnTo = useRef<HTMLElement | null>(null)
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!open) return
|
|
41
|
+
returnTo.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
42
|
+
const first = panel.current && (tabbable(panel.current)[0] ?? panel.current)
|
|
43
|
+
first?.focus({ preventScroll: true })
|
|
44
|
+
|
|
45
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
46
|
+
if (event.key !== 'Tab' || !panel.current) return
|
|
47
|
+
const stops = tabbable(panel.current)
|
|
48
|
+
if (!stops.length) return
|
|
49
|
+
const [first, last] = [stops[0] as HTMLElement, stops.at(-1) as HTMLElement]
|
|
50
|
+
const active = document.activeElement
|
|
51
|
+
if (event.shiftKey && (active === first || !panel.current.contains(active))) {
|
|
52
|
+
event.preventDefault()
|
|
53
|
+
last.focus()
|
|
54
|
+
} else if (!event.shiftKey && active === last) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
first.focus()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
60
|
+
return () => {
|
|
61
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
62
|
+
if (returnTo.current?.isConnected) returnTo.current.focus({ preventScroll: true })
|
|
63
|
+
}
|
|
64
|
+
}, [open, panel])
|
|
65
|
+
}
|
|
66
|
+
|