@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,349 @@
1
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'
3
+ import { addCommandHandler, addDeskCommands, DeskCommands, STAGE_ATTRIBUTE, WINDOW_ATTRIBUTE, windowElement } from '../core/commands.js'
4
+ import { focusedId } from '../core/desk.js'
5
+ import type { DeskWindow, Frame, WindowId } from '../core/types.js'
6
+ import { arrangement } from './arrange.js'
7
+ import { useDesk, useDeskState, WindowContext } from './context.js'
8
+ import { WindowBoundary } from './windowBoundary.js'
9
+ import type { WindowFailed, WindowLoading } from './windowBoundary.js'
10
+
11
+ export type DeskLayout = 'desktop' | 'fullscreen'
12
+
13
+ export interface DesktopProps {
14
+ readonly renderWindow: (id: WindowId) => ReactNode
15
+ readonly title: (id: WindowId) => ReactNode
16
+ /** Window-wide controls on the trailing side of the title bar. One or two; more belongs in a toolbar. */
17
+ readonly actions?: (id: WindowId) => ReactNode
18
+ /** Shown when no window is open. */
19
+ readonly empty?: ReactNode
20
+ /** Shown inside a window while its code loads. Default: a quiet "Loading…". */
21
+ readonly loading?: WindowLoading
22
+ /** Shown inside a window that could not open, with a way to try again. */
23
+ readonly failed?: WindowFailed
24
+ /**
25
+ * `auto` (the default) reads the device: a touch screen gets `fullscreen`, one
26
+ * window at a time; anything with a pointer gets the layered `desktop`.
27
+ */
28
+ readonly layout?: DeskLayout | 'auto'
29
+ readonly className?: string
30
+ }
31
+
32
+ /** Touch-first: a coarse pointer that cannot hover. A tablet with a trackpad reports otherwise. */
33
+ const TOUCH = '(pointer: coarse) and (hover: none)'
34
+
35
+ function useMediaQuery(query: string): boolean {
36
+ const subscribe = useCallback(
37
+ (onChange: () => void) => {
38
+ const list = globalThis.matchMedia?.(query)
39
+ list?.addEventListener('change', onChange)
40
+ return () => list?.removeEventListener('change', onChange)
41
+ },
42
+ [query],
43
+ )
44
+ return useSyncExternalStore(
45
+ subscribe,
46
+ () => globalThis.matchMedia?.(query).matches ?? false,
47
+ () => false,
48
+ )
49
+ }
50
+
51
+ const MIN_WIDTH = 240
52
+ const MIN_HEIGHT = 160
53
+ /** How close to the edge a dragged window has to get before it takes that half. */
54
+ const SNAP_MARGIN = 56
55
+
56
+ /** The part of the stage windows are arranged in: inside its padding, with its gap. */
57
+ function arrangementArea(stage: HTMLElement) {
58
+ const style = getComputedStyle(stage)
59
+ const left = parseFloat(style.paddingLeft) || 0
60
+ const right = parseFloat(style.paddingRight) || 0
61
+ const top = parseFloat(style.paddingTop) || 0
62
+ const bottom = parseFloat(style.paddingBottom) || 0
63
+ const gap = parseFloat(style.columnGap) || 0
64
+ return { x: left, y: top, width: stage.clientWidth - left - right, height: stage.clientHeight - top - bottom, gap }
65
+ }
66
+
67
+ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
68
+ const area = arrangementArea(stage)
69
+ const width = (area.width - area.gap) / 2
70
+ return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
71
+ }
72
+
73
+ export function Desktop({ renderWindow, title, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
74
+ const desk = useDesk()
75
+ const state = useDeskState()
76
+ const stage = useRef<HTMLDivElement>(null)
77
+ const touch = useMediaQuery(TOUCH)
78
+ const mode: DeskLayout = layout === 'auto' ? (touch ? 'fullscreen' : 'desktop') : layout
79
+
80
+ useLayoutEffect(() => {
81
+ desk.setStage(() => ({ width: stage.current?.clientWidth ?? 1024, height: stage.current?.clientHeight ?? 768 }))
82
+ }, [desk])
83
+
84
+ useEffect(() => (stage.current ? addDeskCommands(stage.current, desk) : undefined), [desk])
85
+
86
+ // Arrange lays every window out once, as free windows that can be moved again straight away.
87
+ useEffect(() => {
88
+ const element = stage.current
89
+ if (!element || mode !== 'desktop') return undefined
90
+ return addCommandHandler(
91
+ element,
92
+ DeskCommands.arrange,
93
+ () => {
94
+ const { windows, stack } = desk.getState()
95
+ const [focused, previous] = [stack.at(-1), stack.at(-2)]
96
+ if (windows.length === 1) {
97
+ if (focused) desk.fill(focused)
98
+ return
99
+ }
100
+ const frames = arrangement(
101
+ windows.map(w => w.id),
102
+ focused ?? null,
103
+ previous ?? null,
104
+ arrangementArea(element),
105
+ { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
106
+ )
107
+ desk.placeAll(frames)
108
+ // Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
109
+ if (previous) desk.focus(previous)
110
+ windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
111
+ if (focused) desk.focus(focused)
112
+ },
113
+ {
114
+ enabled: () => {
115
+ const { windows } = desk.getState()
116
+ return windows.length > 1 || windows.some(w => w.mode === 'floating')
117
+ },
118
+ },
119
+ )
120
+ }, [desk, mode])
121
+
122
+ const focused = focusedId(state)
123
+
124
+ // Keyboard focus follows the key window, so commands and typing reach the window the person just chose.
125
+ // Not on first render: loading a page should not pull focus away from wherever the browser put it.
126
+ const previousFocus = useRef<WindowId | null | undefined>(undefined)
127
+ useEffect(() => {
128
+ const before = previousFocus.current
129
+ previousFocus.current = focused
130
+ if (before === undefined || before === focused || !focused || !stage.current) return
131
+ const element = windowElement(stage.current, focused)
132
+ if (element instanceof HTMLElement && !element.contains(document.activeElement)) element.focus({ preventScroll: true })
133
+ }, [focused])
134
+
135
+ return (
136
+ <div
137
+ ref={stage}
138
+ className={['desk-stage', className].filter(Boolean).join(' ')}
139
+ data-layout={mode}
140
+ {...{ [STAGE_ATTRIBUTE]: '' }}
141
+ >
142
+ {state.windows.length === 0 && empty}
143
+ {state.windows.map(window => (
144
+ <WindowView
145
+ key={window.id}
146
+ window={window}
147
+ layout={mode}
148
+ depth={state.stack.indexOf(window.id)}
149
+ focused={window.id === focused}
150
+ // One window at a time: the rest stay mounted, keeping their state, and simply wait offstage.
151
+ hidden={mode === 'fullscreen' && window.id !== focused}
152
+ title={title(window.id)}
153
+ actions={actions?.(window.id)}
154
+ >
155
+ {/* Memoised on the id and the render function: moving or focusing a window
156
+ re-renders its chrome, never the app's content inside it. */}
157
+ <WindowContent id={window.id} render={renderWindow} loading={loading} failed={failed} />
158
+ </WindowView>
159
+ ))}
160
+ </div>
161
+ )
162
+ }
163
+
164
+ interface WindowContentProps {
165
+ readonly id: WindowId
166
+ readonly render: (id: WindowId) => ReactNode
167
+ readonly loading: WindowLoading | undefined
168
+ readonly failed: WindowFailed | undefined
169
+ }
170
+
171
+ // Each window loads and fails on its own: one fetching its code, or throwing, never blanks another.
172
+ const WindowContent = memo(function WindowContent({ id, render, loading, failed }: WindowContentProps) {
173
+ return (
174
+ <WindowBoundary id={id} loading={loading} failed={failed}>
175
+ {render(id)}
176
+ </WindowBoundary>
177
+ )
178
+ })
179
+
180
+ interface WindowViewProps {
181
+ readonly window: DeskWindow
182
+ readonly layout: DeskLayout
183
+ readonly hidden?: boolean
184
+ readonly depth: number
185
+ readonly focused: boolean
186
+ readonly title: ReactNode
187
+ readonly actions?: ReactNode
188
+ readonly children: ReactNode
189
+ }
190
+
191
+ /** Moving by the title bar, or resizing from the corner or from the left, right or bottom edge. */
192
+ type Gesture = 'move' | 'resize' | 'left' | 'right' | 'bottom'
193
+
194
+ /** The frame a gesture makes of `origin` after the pointer has travelled dx, dy. */
195
+ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame {
196
+ switch (gesture) {
197
+ case 'move':
198
+ return { ...origin, x: origin.x + dx, y: Math.max(0, origin.y + dy) }
199
+ case 'resize':
200
+ return { ...origin, width: Math.max(MIN_WIDTH, origin.width + dx), height: Math.max(MIN_HEIGHT, origin.height + dy) }
201
+ case 'right':
202
+ return { ...origin, width: Math.max(MIN_WIDTH, origin.width + dx) }
203
+ case 'bottom':
204
+ return { ...origin, height: Math.max(MIN_HEIGHT, origin.height + dy) }
205
+ case 'left': {
206
+ // The right edge stays put, however far the left one is pulled.
207
+ const shift = Math.min(dx, origin.width - MIN_WIDTH)
208
+ return { ...origin, x: origin.x + shift, width: origin.width - shift }
209
+ }
210
+ }
211
+ }
212
+
213
+ function WindowView({ window, layout, hidden, depth, focused, title, actions, children }: WindowViewProps) {
214
+ const desk = useDesk()
215
+ // While dragging, the frame lives here and commits once on release, so a drag
216
+ // re-renders one window rather than notifying every subscriber per pixel.
217
+ const [live, setLive] = useState<Frame | null>(null)
218
+ const element = useRef<HTMLElement>(null)
219
+ const context = useMemo(() => ({ id: window.id, element }), [window.id])
220
+ const titleId = `desk-title-${window.id}`
221
+
222
+ const startGesture = (gesture: Gesture) => (event: ReactPointerEvent<HTMLElement>) => {
223
+ if (event.button !== 0 || layout === 'fullscreen') return
224
+ if (gesture === 'move' && (event.target as HTMLElement).closest('button')) return
225
+ event.preventDefault()
226
+ const stageBox = event.currentTarget.closest<HTMLElement>(`[${STAGE_ATTRIBUTE}]`)?.getBoundingClientRect()
227
+ const windowBox = event.currentTarget.closest<HTMLElement>(`[${WINDOW_ATTRIBUTE}]`)?.getBoundingClientRect()
228
+ // A filled window is freed exactly where it already sits, so it does not jump under the pointer.
229
+ const origin: Frame =
230
+ window.mode === 'floating'
231
+ ? window.frame
232
+ : {
233
+ x: (windowBox?.left ?? 0) - (stageBox?.left ?? 0),
234
+ y: (windowBox?.top ?? 0) - (stageBox?.top ?? 0),
235
+ width: windowBox?.width ?? 480,
236
+ height: windowBox?.height ?? 360,
237
+ }
238
+ let pulledOut = window.mode === 'floating'
239
+ const startX = event.clientX
240
+ const startY = event.clientY
241
+ const handle = event.currentTarget
242
+ handle.setPointerCapture?.(event.pointerId)
243
+ const stage = handle.closest<HTMLElement>(`[${STAGE_ATTRIBUTE}]`)
244
+ let latest = origin
245
+ let snap: 'start' | 'end' | null = null
246
+
247
+ // Dragging a window against an edge offers it that half of the desk.
248
+ const snapAt = (clientX: number) => {
249
+ if (gesture !== 'move' || !stage) return null
250
+ const box = stage.getBoundingClientRect()
251
+ if (clientX - box.left < SNAP_MARGIN) return 'start' as const
252
+ if (box.right - clientX < SNAP_MARGIN) return 'end' as const
253
+ return null
254
+ }
255
+
256
+ const onMove = (move: PointerEvent) => {
257
+ const dx = move.clientX - startX
258
+ const dy = move.clientY - startY
259
+ if (!pulledOut && Math.hypot(dx, dy) > 5) {
260
+ pulledOut = true
261
+ if (window.mode === 'filled') desk.float(window.id, origin)
262
+ }
263
+ latest = reshape(gesture, origin, dx, dy)
264
+ setLive(latest)
265
+ snap = snapAt(move.clientX)
266
+ if (stage) {
267
+ if (snap) stage.dataset.snap = snap
268
+ else delete stage.dataset.snap
269
+ }
270
+ }
271
+ const onUp = () => {
272
+ handle.removeEventListener('pointermove', onMove)
273
+ handle.removeEventListener('pointerup', onUp)
274
+ handle.removeEventListener('pointercancel', onUp)
275
+ setLive(null)
276
+ if (stage) delete stage.dataset.snap
277
+ // Dropped against an edge, it takes that half of the desk — and nothing else moves.
278
+ if (snap && stage) desk.float(window.id, halfFrame(stage, snap))
279
+ else if (pulledOut && latest !== origin) desk.float(window.id, latest)
280
+ }
281
+ handle.addEventListener('pointermove', onMove)
282
+ handle.addEventListener('pointerup', onUp)
283
+ handle.addEventListener('pointercancel', onUp)
284
+ }
285
+
286
+ // Double-clicking a title bar, or the green control, fills the desk with the window and back — as on a Mac.
287
+ const zoom = () => {
288
+ if (layout === 'desktop') desk.toggleMode(window.id)
289
+ }
290
+
291
+ const frame = layout === 'desktop' ? (live ?? (window.mode === 'floating' ? window.frame : null)) : null
292
+ const style: CSSProperties | undefined =
293
+ layout !== 'desktop'
294
+ ? undefined
295
+ : frame
296
+ ? { left: frame.x, top: frame.y, width: frame.width, height: frame.height, zIndex: 10 + depth }
297
+ : { zIndex: 10 + depth }
298
+
299
+ return (
300
+ <WindowContext.Provider value={context}>
301
+ <section
302
+ ref={element}
303
+ tabIndex={-1}
304
+ {...{ [WINDOW_ATTRIBUTE]: window.id }}
305
+ className="desk-window"
306
+ data-mode={layout === 'fullscreen' ? 'fullscreen' : live ? 'floating' : window.mode}
307
+ data-hidden={hidden || undefined}
308
+ inert={hidden || undefined}
309
+ data-focused={focused || undefined}
310
+ data-dragging={live ? true : undefined}
311
+ aria-labelledby={titleId}
312
+ style={style}
313
+ onPointerDownCapture={() => {
314
+ if (!focused) desk.focus(window.id)
315
+ }}
316
+ >
317
+ <header
318
+ className="desk-titlebar"
319
+ onPointerDown={startGesture('move')}
320
+ onDoubleClick={event => {
321
+ if (!(event.target as HTMLElement).closest('button')) zoom()
322
+ }}
323
+ data-draggable={layout === 'desktop' || undefined}
324
+ >
325
+ <div className="desk-controls">
326
+ <button type="button" className="desk-control" data-control="close" aria-label="Close" onClick={() => desk.close(window.id)} />
327
+ {layout === 'desktop' && (
328
+ <button type="button" className="desk-control" data-control="mode" aria-label="Zoom" onClick={zoom} />
329
+ )}
330
+ </div>
331
+ <h2 id={titleId} className="desk-title">
332
+ {title}
333
+ </h2>
334
+ {actions && <div className="desk-window-actions">{actions}</div>}
335
+ </header>
336
+ <div className="desk-body">{children}</div>
337
+ {layout === 'desktop' && (
338
+ <>
339
+ {/* No top edge: the title bar is there, and it moves the window. */}
340
+ <div className="desk-edge" data-edge="left" aria-hidden="true" onPointerDown={startGesture('left')} />
341
+ <div className="desk-edge" data-edge="right" aria-hidden="true" onPointerDown={startGesture('right')} />
342
+ <div className="desk-edge" data-edge="bottom" aria-hidden="true" onPointerDown={startGesture('bottom')} />
343
+ <div className="desk-grip" aria-hidden="true" onPointerDown={startGesture('resize')} />
344
+ </>
345
+ )}
346
+ </section>
347
+ </WindowContext.Provider>
348
+ )
349
+ }
@@ -0,0 +1,258 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+ import type { KeyboardEvent, ReactNode } from 'react'
3
+ import { focusedId, instancesOf, windowType } from '../core/desk.js'
4
+ import type { DeskState, WindowId } from '../core/types.js'
5
+ import { useDesk, useDeskState } from './context.js'
6
+
7
+ export interface DockItem {
8
+ readonly id: string
9
+ readonly label: string
10
+ readonly icon: ReactNode
11
+ /** The window this item opens and reports as running. Defaults to the item's id. */
12
+ readonly window?: WindowId
13
+ /** One line under the label inside a stack. */
14
+ readonly description?: string
15
+ readonly badge?: ReactNode
16
+ /** Replaces the default of opening the item's window. */
17
+ readonly onSelect?: () => void
18
+ readonly disabled?: boolean
19
+ }
20
+
21
+ export interface DockStack {
22
+ readonly id: string
23
+ readonly label: string
24
+ readonly items: readonly DockItem[]
25
+ /** Defaults to the first four item icons, the way a folder previews its contents. */
26
+ readonly icon?: ReactNode
27
+ }
28
+
29
+ export type DockEntry =
30
+ | ({ readonly type: 'item' } & DockItem)
31
+ | ({ readonly type: 'stack' } & DockStack)
32
+ | { readonly type: 'separator'; readonly id: string }
33
+
34
+ export const dockItem = (item: DockItem): DockEntry => ({ type: 'item', ...item })
35
+ export const dockStack = (stack: DockStack): DockEntry => ({ type: 'stack', ...stack })
36
+ export const dockSeparator = (id: string): DockEntry => ({ type: 'separator', id })
37
+
38
+ export interface DockProps {
39
+ readonly entries: readonly DockEntry[]
40
+ readonly label?: string
41
+ /** `overlay` floats the dock over the bottom of its positioned parent; `inline` leaves layout to you. */
42
+ readonly placement?: 'overlay' | 'inline'
43
+ readonly className?: string
44
+ }
45
+
46
+ const windowOf = (item: DockItem) => item.window ?? item.id
47
+
48
+ const statusOf = (state: DeskState, items: readonly DockItem[]) => {
49
+ const key = focusedId(state)
50
+ return {
51
+ // Any window of this kind counts: a second query window is still Query, running.
52
+ running: items.some(i => instancesOf(state, windowOf(i)).length > 0),
53
+ focused: items.some(i => key !== null && windowType(key) === windowOf(i)),
54
+ }
55
+ }
56
+
57
+ /** Moves focus among the buttons in a container with arrow keys, Home and End. */
58
+ function roveFocus(event: KeyboardEvent<HTMLElement>, keys: { next: string[]; previous: string[] }) {
59
+ // Only this container's own buttons: the dock must not rove into an open stack, nor a stack back out.
60
+ const container = event.currentTarget
61
+ const buttons = [...container.querySelectorAll<HTMLButtonElement>('[data-rove]:not(:disabled)')].filter(
62
+ b => b.parentElement?.closest('[role="toolbar"], .desk-stack') === container,
63
+ )
64
+ const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
65
+ if (index < 0) return
66
+ const target =
67
+ keys.next.includes(event.key) ? buttons[(index + 1) % buttons.length]
68
+ : keys.previous.includes(event.key) ? buttons[(index - 1 + buttons.length) % buttons.length]
69
+ : event.key === 'Home' ? buttons[0]
70
+ : event.key === 'End' ? buttons.at(-1)
71
+ : undefined
72
+ if (!target) return
73
+ event.preventDefault()
74
+ buttons.forEach(b => (b.tabIndex = b === target ? 0 : -1))
75
+ target.focus()
76
+ }
77
+
78
+ export function Dock({ entries, label = 'Dock', placement = 'overlay', className }: DockProps) {
79
+ const [openStack, setOpenStack] = useState<string | null>(null)
80
+ const firstFocusable = entries.find(e => e.type !== 'separator')?.id
81
+
82
+ return (
83
+ <div
84
+ role="toolbar"
85
+ aria-label={label}
86
+ aria-orientation="horizontal"
87
+ className={['desk-dock', className].filter(Boolean).join(' ')}
88
+ data-placement={placement}
89
+ onKeyDown={event => {
90
+ if ((event.target as HTMLElement).closest('.desk-stack')) return
91
+ roveFocus(event, { next: ['ArrowRight'], previous: ['ArrowLeft'] })
92
+ }}
93
+ >
94
+ {entries.map(entry => {
95
+ if (entry.type === 'separator') return <span key={entry.id} className="desk-dock-separator" role="separator" />
96
+ if (entry.type === 'item')
97
+ return <DockButton key={entry.id} item={entry} tabIndex={entry.id === firstFocusable ? 0 : -1} />
98
+ return (
99
+ <StackButton
100
+ key={entry.id}
101
+ stack={entry}
102
+ tabIndex={entry.id === firstFocusable ? 0 : -1}
103
+ open={openStack === entry.id}
104
+ setOpenStack={setOpenStack}
105
+ />
106
+ )
107
+ })}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ function Badge({ children }: { readonly children: ReactNode }) {
113
+ return <span className="desk-dock-badge">{children}</span>
114
+ }
115
+
116
+ function DockButton({ item, tabIndex }: { readonly item: DockItem; readonly tabIndex: number }) {
117
+ const desk = useDesk()
118
+ const { running, focused } = statusOf(useDeskState(), [item])
119
+ return (
120
+ <button
121
+ type="button"
122
+ data-rove
123
+ tabIndex={tabIndex}
124
+ className="desk-dock-item"
125
+ aria-label={item.label}
126
+ data-running={running || undefined}
127
+ data-focused={focused || undefined}
128
+ disabled={item.disabled}
129
+ onClick={() => (item.onSelect ? item.onSelect() : desk.open(windowOf(item)))}
130
+ >
131
+ <span className="desk-dock-icon" aria-hidden="true">
132
+ {item.icon}
133
+ </span>
134
+ {item.badge != null && <Badge>{item.badge}</Badge>}
135
+ <span className="desk-dock-tip" aria-hidden="true">
136
+ {item.label}
137
+ </span>
138
+ </button>
139
+ )
140
+ }
141
+
142
+ interface StackButtonProps {
143
+ readonly stack: DockStack
144
+ readonly tabIndex: number
145
+ readonly open: boolean
146
+ /** The dock's own state setter: stable across renders, so effects below do not re-run. */
147
+ readonly setOpenStack: (id: string | null) => void
148
+ }
149
+
150
+ function StackButton({ stack, tabIndex, open, setOpenStack }: StackButtonProps) {
151
+ const desk = useDesk()
152
+ const state = useDeskState()
153
+ const { running, focused } = statusOf(state, stack.items)
154
+ const onOpenChange = (next: boolean) => setOpenStack(next ? stack.id : null)
155
+ const button = useRef<HTMLButtonElement>(null)
156
+ const panel = useRef<HTMLDivElement>(null)
157
+ const panelId = useId()
158
+
159
+ useEffect(() => {
160
+ if (!open) return
161
+ panel.current?.querySelector<HTMLButtonElement>('[data-rove]:not(:disabled)')?.focus()
162
+ const onPointerDown = (event: PointerEvent) => {
163
+ const target = event.target as Node
164
+ if (!panel.current?.contains(target) && !button.current?.contains(target)) setOpenStack(null)
165
+ }
166
+ document.addEventListener('pointerdown', onPointerDown)
167
+ return () => document.removeEventListener('pointerdown', onPointerDown)
168
+ }, [open, setOpenStack])
169
+
170
+ const close = () => {
171
+ onOpenChange(false)
172
+ button.current?.focus()
173
+ }
174
+
175
+ const select = (item: DockItem) => {
176
+ if (item.onSelect) item.onSelect()
177
+ else desk.open(windowOf(item))
178
+ onOpenChange(false)
179
+ }
180
+
181
+ const badges = stack.items.filter(i => i.badge != null).length
182
+
183
+ return (
184
+ <span className="desk-dock-slot">
185
+ <button
186
+ ref={button}
187
+ type="button"
188
+ data-rove
189
+ tabIndex={tabIndex}
190
+ className="desk-dock-item"
191
+ data-stack
192
+ aria-label={stack.label}
193
+ aria-haspopup="dialog"
194
+ aria-expanded={open}
195
+ aria-controls={open ? panelId : undefined}
196
+ data-running={running || undefined}
197
+ data-focused={focused || undefined}
198
+ onClick={() => onOpenChange(!open)}
199
+ >
200
+ <span className="desk-dock-icon" aria-hidden="true">
201
+ {stack.icon ?? (
202
+ <span className="desk-dock-preview">
203
+ {stack.items.slice(0, 4).map(i => (
204
+ <span key={i.id}>{i.icon}</span>
205
+ ))}
206
+ </span>
207
+ )}
208
+ </span>
209
+ {badges > 0 && <Badge>{badges}</Badge>}
210
+ <span className="desk-dock-tip" aria-hidden="true">
211
+ {stack.label}
212
+ </span>
213
+ </button>
214
+ {open && (
215
+ <div
216
+ ref={panel}
217
+ id={panelId}
218
+ role="dialog"
219
+ aria-label={stack.label}
220
+ className="desk-stack"
221
+ onKeyDown={event => {
222
+ if (event.key === 'Escape') {
223
+ event.preventDefault()
224
+ close()
225
+ return
226
+ }
227
+ roveFocus(event, { next: ['ArrowRight', 'ArrowDown'], previous: ['ArrowLeft', 'ArrowUp'] })
228
+ }}
229
+ >
230
+ <p className="desk-stack-title">{stack.label}</p>
231
+ <div className="desk-stack-grid">
232
+ {stack.items.map(item => {
233
+ const status = statusOf(state, [item])
234
+ return (
235
+ <button
236
+ key={item.id}
237
+ type="button"
238
+ data-rove
239
+ className="desk-stack-item"
240
+ data-running={status.running || undefined}
241
+ disabled={item.disabled}
242
+ onClick={() => select(item)}
243
+ >
244
+ <span className="desk-dock-icon" aria-hidden="true">
245
+ {item.icon}
246
+ {item.badge != null && <Badge>{item.badge}</Badge>}
247
+ </span>
248
+ <span className="desk-stack-label">{item.label}</span>
249
+ {item.description && <span className="desk-stack-description">{item.description}</span>}
250
+ </button>
251
+ )
252
+ })}
253
+ </div>
254
+ </div>
255
+ )}
256
+ </span>
257
+ )
258
+ }