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