@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
package/llms.txt
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# @liberation-data/desk
|
|
2
|
+
|
|
3
|
+
A desktop for the web, in React: windows that fill the desk, layered, and arrange when asked, with a dock, a menu bar,
|
|
4
|
+
commands routed to whoever is responsible, and the controls to fill the windows. Apache-2.0.
|
|
5
|
+
|
|
6
|
+
This file is the whole public surface in one place, for a coding agent. Prose for people:
|
|
7
|
+
`docs/guide.md`. How it should look and behave: `HIG.md`.
|
|
8
|
+
|
|
9
|
+
## Install and entry points
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm install @liberation-data/desk # peer: react ^19
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createDesk } from '@liberation-data/desk' // no DOM, no React
|
|
17
|
+
import { Desktop, DeskProvider } from '@liberation-data/desk/react' // components and hooks
|
|
18
|
+
import '@liberation-data/desk/desk.css' // tokens and styles
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The shape of an app
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
const desk = createDesk()
|
|
25
|
+
|
|
26
|
+
<DeskShell desk={desk}> {/* desk + bus + toasts; or DeskProvider on its own */}
|
|
27
|
+
<MenuBar menus={…} status={…} />
|
|
28
|
+
<div className="screen"> {/* position: relative; the stage fills it */}
|
|
29
|
+
<Desktop title={id => TITLES[id]} renderWindow={id => SURFACES[id]} empty={…} />
|
|
30
|
+
<InputBar onSubmit={…} />
|
|
31
|
+
<Dock entries={…} />
|
|
32
|
+
<SearchPalette open={…} onOpenChange={…} search={…} />
|
|
33
|
+
</div>
|
|
34
|
+
</DeskShell>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Rules that are easy to get wrong:
|
|
38
|
+
- `.desk-stage` fills its parent: give the parent a height, and `position: relative` if the dock or input
|
|
39
|
+
bar overlays it. Set `--desk-inset-bottom` to the height of anything overlaying the bottom.
|
|
40
|
+
- A window is an id; `renderWindow(id)` says what it looks like. Every open window renders, including ones
|
|
41
|
+
offstage in the touch layout — that is what preserves their state.
|
|
42
|
+
- Opening a window that is already open focuses it. For a second window onto the same thing use
|
|
43
|
+
`openInstance`, and render by `windowType(id)`.
|
|
44
|
+
|
|
45
|
+
## Core: `@liberation-data/desk`
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
createDesk(options?: DeskOptions): Desk
|
|
49
|
+
interface DeskOptions { cascade?: Partial<CascadeOptions>; stage?: () => Size; initial?: DeskState }
|
|
50
|
+
|
|
51
|
+
interface Desk {
|
|
52
|
+
getState(): DeskState
|
|
53
|
+
subscribe(listener: (state: DeskState) => void): () => void
|
|
54
|
+
open(id: string, options?: { mode?: 'filled' | 'floating'; frame?: Frame }): void // default fills; a frame floats
|
|
55
|
+
openInstance(type: string, options?): string // 'query', then 'query#2' — returns the id it opened
|
|
56
|
+
close(id: string): void
|
|
57
|
+
closeAll(): void
|
|
58
|
+
focus(id: string): void
|
|
59
|
+
float(id: string, frame?: Frame): void // no frame: last place it sat, else the next cascade step
|
|
60
|
+
fill(id: string): void // fill the desk; remembers the frame for float()
|
|
61
|
+
toggleMode(id: string): void // zoom: fill ⇄ free
|
|
62
|
+
placeAll(frames: Record<string, Frame>): void // free windows at these frames; Arrange uses it
|
|
63
|
+
restore(state: DeskState): void // e.g. from a URL; normalised, not trusted
|
|
64
|
+
setStage(stage: () => Size): void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface DeskState {
|
|
68
|
+
windows: readonly DeskWindow[] // the order they were opened = the order Arrange lays out
|
|
69
|
+
stack: readonly string[] // back to front; the last one is key
|
|
70
|
+
}
|
|
71
|
+
type DeskWindow = { id: string; mode: 'filled' } | { id: string; mode: 'floating'; frame: Frame }
|
|
72
|
+
interface Frame { x: number; y: number; width: number; height: number }
|
|
73
|
+
|
|
74
|
+
focusedId(state): string | null
|
|
75
|
+
windowType(id): string // 'query#2' → 'query'; what to render
|
|
76
|
+
instancesOf(state, type): DeskWindow[] // every window of a kind
|
|
77
|
+
isOpen(state, id): boolean
|
|
78
|
+
normalise(state): DeskState
|
|
79
|
+
cascadeFrame(slot, stage, options?): Frame
|
|
80
|
+
cascadeSlot(frame, stage, options?): number
|
|
81
|
+
nextCascadeFrame(windows, stage, options?): Frame
|
|
82
|
+
EMPTY: DeskState
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every window opens filled, layered over the others; a filled window follows the stage's size. Arrange
|
|
86
|
+
lays windows out once as free windows (`placeAll`); moving or resizing one changes only that one. Dragging a
|
|
87
|
+
filled window frees it where it sits. Edge drops take a half; double-click or the green control zooms. State is immutable; every mutator returns void and notifies
|
|
88
|
+
subscribers only when something actually changed.
|
|
89
|
+
|
|
90
|
+
### URL and history
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
syncWithLocation(desk, stage: () => Size, options?: LocationOptions, env?: LocationEnv): () => void
|
|
94
|
+
serialize(state, options?): URLSearchParams // #w=rides,map~&f=map (`~` = free; the rest fill)
|
|
95
|
+
parse(params, stage, options?): DeskState
|
|
96
|
+
interface LocationOptions { key?: 'w'; focusKey?: 'f'; isKnown?: (id: string) => boolean }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Opening or closing pushes history (Back closes); focus and mode changes replace it. Frames are never in the
|
|
100
|
+
URL. Other hash parameters are left alone. A URL naming no windows adopts whatever the app opened.
|
|
101
|
+
|
|
102
|
+
### Commands (the responder chain)
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
perform(desk, command: string, args?, options?: ChainOptions): boolean // true if something handled it
|
|
106
|
+
canPerform(desk, command: string, options?: ChainOptions): boolean // what a menu asks
|
|
107
|
+
addCommandHandler(element: EventTarget, command, handler, { enabled? }): () => void
|
|
108
|
+
addDeskCommands(stage: EventTarget, desk): () => void
|
|
109
|
+
chainStart(desk, options?): EventTarget
|
|
110
|
+
windowElement(doc, id): Element | null
|
|
111
|
+
DeskCommands = { closeWindow, zoomWindow, arrange, nextWindow, previousWindow }
|
|
112
|
+
// arrange is answered by a mounted Desktop: 1 fills, 2 split, 3 one tall + two stacked, 4+ a grid of usable cells;
|
|
113
|
+
// the focused window takes the first place. Put it in the Window menu as "Arrange", shortcut 'mod+alt+a'.
|
|
114
|
+
WINDOW_ATTRIBUTE = 'data-desk-window'; STAGE_ATTRIBUTE = 'data-desk-stage'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The chain is the DOM: focused element → its window → stage → document. The first responder that implements
|
|
118
|
+
a command decides; if it is disabled the command stops there (AppKit's rule). Events are `desk:perform` and
|
|
119
|
+
`desk:can-perform`, bubbling and composed.
|
|
120
|
+
|
|
121
|
+
### Shortcuts
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
bindShortcuts(desk, keymap: Record<string, string>, { target?, apple? }): () => void
|
|
125
|
+
parseShortcut('mod+shift+z'): Shortcut
|
|
126
|
+
matchesShortcut(event, shortcut, apple?): boolean
|
|
127
|
+
formatShortcut('mod+comma', apple?): string // '⌘,' or 'Ctrl+,'
|
|
128
|
+
isApplePlatform(): boolean
|
|
129
|
+
isInstalledApp(): boolean // gate ⌘W / ⌘` on this; never take ⌘T or ⌘N
|
|
130
|
+
titleCase('ride card'): string // 'Ride Card' — app and window names; a document keeps its own name
|
|
131
|
+
isTitleCase(name): boolean // for a test over the names an app defines
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`mod` is ⌘ on Apple, Ctrl elsewhere. Matching also falls back to the physical key, so ⌥] works on a Mac. A
|
|
135
|
+
shortcut nothing handles leaves the browser's default alone. Bare keys are ignored while typing in a field.
|
|
136
|
+
|
|
137
|
+
### Dragging between windows
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
useDragSource<T>({ type, disabled? }): { dragProps(payload, preview?), dragging } // lists
|
|
141
|
+
useDraggable<T>({ type, payload, preview?, disabled? }): { dragProps, dragging } // one thing
|
|
142
|
+
useDropTarget<T>({ accepts, onDrop, disabled? }): { dropProps, over, ready }
|
|
143
|
+
useDragging(): Drag | null
|
|
144
|
+
interface Drag<T> { type: string; payload: T; from: string | null }
|
|
145
|
+
type Accepts = string | string[] | ((type: string) => boolean)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Pointer Events, so touch and pen work; Escape calls a drag off; the drop brings the receiving window
|
|
149
|
+
forward. `dropProps` carries `data-ready` and `data-over`, which the stylesheet already draws. A
|
|
150
|
+
`DeskProvider` supplies the drag state, as it does the bus.
|
|
151
|
+
|
|
152
|
+
### Events between windows
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
createBus(): Bus
|
|
156
|
+
interface Bus {
|
|
157
|
+
publish<T>(topic: string, payload: T, options?: { from?: string | null }): DeskEvent<T>
|
|
158
|
+
subscribe<T>(topic: string, handler: (e: DeskEvent<T>) => void, options?: { replay?: boolean }): () => void
|
|
159
|
+
last<T>(topic: string): DeskEvent<T> | undefined
|
|
160
|
+
}
|
|
161
|
+
interface DeskEvent<T> { topic: string; payload: T; from: string | null; at: number }
|
|
162
|
+
topicMatches(pattern, topic): boolean // 'ride.*' and '*' are the only wildcards
|
|
163
|
+
InputCommands = { submit: 'desk.input.submit', describe: 'desk.input.describe' }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## React: `@liberation-data/desk/react`
|
|
167
|
+
|
|
168
|
+
### Shell
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
<DeskShell desk?={Desk} options?={DeskOptions} bus?={Bus} toasts?={true}>
|
|
172
|
+
<DeskProvider desk={desk}> // or options={…} to make one
|
|
173
|
+
useDesk(): Desk
|
|
174
|
+
useDeskState(): DeskState
|
|
175
|
+
useWindowId(): string | null // inside a window's content
|
|
176
|
+
|
|
177
|
+
<Desktop
|
|
178
|
+
renderWindow={(id) => ReactNode} // memoised: chrome re-renders, content does not
|
|
179
|
+
title={(id) => ReactNode}
|
|
180
|
+
actions?={(id) => ReactNode} // window-wide controls in the title bar
|
|
181
|
+
loading?={(id) => ReactNode} // inside a window while its code loads
|
|
182
|
+
failed?={(id, error, reload) => ReactNode} // inside a window that could not open
|
|
183
|
+
empty?={ReactNode}
|
|
184
|
+
layout?={'auto' | 'desktop' | 'fullscreen'} // auto: touch screens get one window at a time
|
|
185
|
+
className?={string}
|
|
186
|
+
/>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Loading and failing, per window
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
const RoutePlanner = lazyWindow(() => import('./apps/RoutePlanner')) // use in renderWindow
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Every window has its own Suspense and error boundary: one window loading or throwing never blanks another,
|
|
196
|
+
the menu bar or the dock. A failure is logged with `console.error` and shows Reload, which remounts the
|
|
197
|
+
window; `lazyWindow` retries its import on reload, unlike `React.lazy`, which caches a failed import.
|
|
198
|
+
|
|
199
|
+
### Dock
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
<Dock entries={DockEntry[]} label?="Dock" placement?={'overlay' | 'inline'} />
|
|
203
|
+
dockItem({ id, label, icon, window?, description?, badge?, onSelect?, disabled? })
|
|
204
|
+
dockStack({ id, label, items: DockItem[], icon? })
|
|
205
|
+
dockSeparator(id)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
An item opens `window ?? id` and shows a dot while that window is open, accent when it is key. A stack fans
|
|
209
|
+
out above the dock. One tab stop; arrow keys along it; Escape folds a stack away.
|
|
210
|
+
|
|
211
|
+
### Menu bar
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
<MenuBar menus={Menu[]} status?={Menu[]} leading?={ReactNode} trailing?={ReactNode} shortcuts?={true} />
|
|
215
|
+
interface Menu { id: string; label: string; title?: ReactNode; emphasis?: boolean; items: MenuItem[] | (() => MenuItem[]) }
|
|
216
|
+
// emphasis: the app's own menu, its name in bold, conventionally first
|
|
217
|
+
menuCommand(label, command, { shortcut?, checked?, detail?, args? })
|
|
218
|
+
menuAction(label, onSelect, { disabled?, shortcut?, checked?, detail? })
|
|
219
|
+
menuSeparator(); menuHeader(label)
|
|
220
|
+
windowMenuItems(state, focus, title): MenuItem[]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Command items are enabled via `canPerform` as the menu opens, and menus bind their own shortcuts. Opening a
|
|
224
|
+
menu does not take focus from the key window, so a chosen command reaches the same responder a shortcut
|
|
225
|
+
would. `items` as a function is re-read on every open.
|
|
226
|
+
|
|
227
|
+
### Commands and input
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
useCommand<Args>(command, handler, { enabled?: boolean | (() => boolean), at?: 'window' | 'app' | RefObject })
|
|
231
|
+
usePerform(): (command, args?) => boolean
|
|
232
|
+
useCanPerform(): (command) => boolean
|
|
233
|
+
useShortcuts(keymap, { target?, apple? })
|
|
234
|
+
|
|
235
|
+
useWindowInput(handler: (text: string) => void, { placeholder?, target?, disabled? })
|
|
236
|
+
<InputBar onSubmit={(text) => void} fallbackPlaceholder? fallbackTarget? label?
|
|
237
|
+
mode?={'summoned' | 'inline'} shortcut?={'mod+j' | null} /> // summoned: centred, Escape closes
|
|
238
|
+
InputCommands.open = 'desk.input.open' // a menu item can summon it
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`useCommand` defaults to the window it is rendered in, and to the app outside one.
|
|
242
|
+
|
|
243
|
+
### Dragging between windows
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
useDragSource<T>({ type, disabled? }): { dragProps(payload, preview?), dragging } // lists
|
|
247
|
+
useDraggable<T>({ type, payload, preview?, disabled? }): { dragProps, dragging } // one thing
|
|
248
|
+
useDropTarget<T>({ accepts, onDrop, disabled? }): { dropProps, over, ready }
|
|
249
|
+
useDragging(): Drag | null
|
|
250
|
+
interface Drag<T> { type: string; payload: T; from: string | null }
|
|
251
|
+
type Accepts = string | string[] | ((type: string) => boolean)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Pointer Events, so touch and pen work; Escape calls a drag off; the drop brings the receiving window
|
|
255
|
+
forward. `dropProps` carries `data-ready` and `data-over`, which the stylesheet already draws. A
|
|
256
|
+
`DeskProvider` supplies the drag state, as it does the bus.
|
|
257
|
+
|
|
258
|
+
### Events
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
<BusProvider bus?={Bus}> // optional: a desk already carries a bus
|
|
262
|
+
useBus(): Bus
|
|
263
|
+
usePublish(): <T>(topic, payload) => DeskEvent<T> // stamped with the window it came from
|
|
264
|
+
useDeskEvent<T>(topic, handler, { replay? }) // replay: deliver the last event immediately
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Generated apps
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
<AppFrame title src?|srcDoc? listens?={string[]} says?={string[]} accepts?={Accepts} opens?={string[]}
|
|
271
|
+
sandbox?="allow-scripts allow-forms" />
|
|
272
|
+
withAppBridge(html): string // puts the app-side script in the page's <head>
|
|
273
|
+
APP_BRIDGE_SCRIPT // the same script, to include some other way
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Inside the page: `desk.on(topic, message => …)`, `desk.onDrop(message => …)`, `desk.publish(topic, payload)`,
|
|
277
|
+
`desk.open(window)`, and `desk.window` once connected. Messages carry `desk: 1`. The host ignores anything not
|
|
278
|
+
from its own frame, drops publishes outside `says` and opens outside `opens`, never echoes an app its own
|
|
279
|
+
events, and sets the frame's `pointer-events: none` while a drag is in the air so drops reach it.
|
|
280
|
+
|
|
281
|
+
### Search
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
<SearchPalette open onOpenChange search={(q) => SearchResult[] | Promise<SearchResult[]>}
|
|
285
|
+
placeholder? label? hint? empty?={(q) => ReactNode} shortcut?={'mod+k' | null} />
|
|
286
|
+
interface SearchResult { id; title; subtitle?; group?; icon?; kind?; onSelect(): void }
|
|
287
|
+
windowResults(desk, title, group?): SearchResult[]
|
|
288
|
+
SearchCommand = 'desk.search' // a menu item can open it
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Controls
|
|
292
|
+
|
|
293
|
+
```tsx
|
|
294
|
+
<Button intent?={'normal'|'default'|'quiet'|'destructive'} size?={'regular'|'small'} icon? … />
|
|
295
|
+
<SegmentedControl options={{value,label,icon?,disabled?}[]} value onChange label size? disabled? />
|
|
296
|
+
<Toggle checked onChange label? description? disabled? aria-label? /> // applies at once
|
|
297
|
+
<Checkbox checked indeterminate? onChange label? description? disabled? /> // waits for Save
|
|
298
|
+
<Slider value onChange label min? max? step? disabled? format?={(v) => string} />
|
|
299
|
+
<TextField label labelHidden? help? error? … /> // error marks it invalid
|
|
300
|
+
<RadioGroup options={{value,label,description?,disabled?}[]} value onChange label labelHidden? />
|
|
301
|
+
<ChoiceGroup options={{value,label,description?,icon?,tag?,disabled?}[]} value onChange label labelHidden? />
|
|
302
|
+
// cards for a setup step; native radios, arrow keys move the choice
|
|
303
|
+
<PopUpButton value={T|null} onChange options={{value,label,description?,icon?,disabled?}[]}
|
|
304
|
+
label labelHidden? placeholder? disabled? size? />
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Lists
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
<Table rows columns rowId={(row) => string} label rowProps?={(row) => object}
|
|
311
|
+
selected?={string | string[] | null} selection?={'single' | 'multiple'}
|
|
312
|
+
onSelect? onSelectionChange? onActivate? sort? onSortChange? empty? />
|
|
313
|
+
interface Column<Row> { key; header; render?; align?; numeric?; sortable?; width? }
|
|
314
|
+
interface Sort { key: string; direction: 'ascending' | 'descending' }
|
|
315
|
+
|
|
316
|
+
<Sidebar sections={{ id, label?, items: { id, label, icon?, badge?, disabled? }[] }[]}
|
|
317
|
+
value onChange label footer? />
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Table: one tab stop, arrow keys move the selection, Enter or double click activates, `aria-sort` on
|
|
321
|
+
sortable headings. It renders what it is given — no paging, no fetching.
|
|
322
|
+
|
|
323
|
+
### Overlays
|
|
324
|
+
|
|
325
|
+
```tsx
|
|
326
|
+
<Popover open onOpenChange label trigger={(props) => <Button {...props} />} placement? align? />
|
|
327
|
+
<Sheet open onDismiss title description? actions? /> // renders inside its own window
|
|
328
|
+
<Alert open title message? confirmLabel onConfirm onCancel cancelLabel? destructive? />
|
|
329
|
+
<ToastProvider>; useToast(): { show(toast): string; dismiss(id): void }
|
|
330
|
+
toast.show({ message, tone?: 'neutral'|'ok'|'bad', action?: { label, onSelect }, duration?: 6000 })
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
All share one layer stack: Escape dismisses the topmost. Popover, Sheet and Alert trap focus and return it.
|
|
334
|
+
A toast pauses while the pointer or focus is on it.
|
|
335
|
+
|
|
336
|
+
### Conversation
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
<Thread messages={Message[]} me?="me" typing? onRetry? empty? label? />
|
|
340
|
+
interface Message { id; from; authorName?; body: ReactNode; at?: Date | string; state?: 'sending'|'sent'|'failed' }
|
|
341
|
+
<Composer value onChange onSubmit placeholder? label? disabled? busy? onStop? accessory? maxRows? />
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
No transport of its own. The thread follows new messages only while the reader is at the end. The composer
|
|
345
|
+
sends on Enter, keeps Shift+Enter for a new line, and never sends mid-composition in an input method.
|
|
346
|
+
|
|
347
|
+
### Teaching and first run
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
<TourBar tour={{ id, name, description?, steps }} onFinish? onStop? offer? />
|
|
351
|
+
interface TourStep { window?: string; point?: string; caption: ReactNode; yourTurn?: boolean;
|
|
352
|
+
until?: string | { topic: string; when: (event: DeskEvent) => boolean } }
|
|
353
|
+
|
|
354
|
+
const tasks = useTasks(Task[]): { items: TaskProgress[]; running; done; failed; start(); restart() }
|
|
355
|
+
interface Task { id; name: ReactNode; detail?: ReactNode; run: (report: (detail: ReactNode) => void) => unknown }
|
|
356
|
+
<Checklist items={TaskProgress[]} onRetry? progress?={true} label? /> // a failed row shows its error and Try again
|
|
357
|
+
|
|
358
|
+
const setup = useSetupProgress({ key, initial: Answers, store?: SetupStore })
|
|
359
|
+
// { loaded, index, setIndex, answers, answer(patch), finished, finish(), reset() }
|
|
360
|
+
interface SetupStore<A> { load(): SetupRecord<A> | undefined | Promise<…>; save(record): void | Promise<void>; clear() }
|
|
361
|
+
localSetupStore(key) // the default: localStorage under `desk.setup.<key>`; never put secrets in answers
|
|
362
|
+
|
|
363
|
+
<Wizard steps={WizardStep[]} index onIndexChange onFinish label? />
|
|
364
|
+
interface WizardStep { id; name; title; description?; body?; glyph?; complete?; continueLabel?;
|
|
365
|
+
skip?: { label, onSkip }; working?; onEnter?;
|
|
366
|
+
onContinue?: () => unknown | Promise<unknown>; busyLabel? }
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
A tour step opens its window and points at `point` (a `data-tour` name or a selector). A step with `until` moves on
|
|
370
|
+
when that event is published (no Done button). `offer` shows the tour's name and description with Show me / Not now
|
|
371
|
+
(Not now calls onStop) and opens nothing until accepted — use it right after setup. A wizard step offers
|
|
372
|
+
Continue only when `complete`, and `working` steps offer no Back. `onContinue` runs before moving on: Continue
|
|
373
|
+
shows `busyLabel` (default "Working…") and is disabled; a thrown Error's message is shown in the step and the
|
|
374
|
+
step stays; returning `false` stays silently. Pressing again while it runs does nothing. Tasks run in order and
|
|
375
|
+
stop at the first that throws; `start()` runs whatever is not done (the retry), `restart()` runs them all again.
|
|
376
|
+
For a waiting step: `working: !tasks.failed, complete: tasks.done, onEnter: tasks.restart`.
|
|
377
|
+
|
|
378
|
+
## Styling
|
|
379
|
+
|
|
380
|
+
Every value reads a `--desk-*` token; redefine tokens, never override selectors. Colour roles: `--desk-ground`,
|
|
381
|
+
`--desk-surface`, `--desk-titlebar`, `--desk-ink`, `--desk-muted`, `--desk-rule`, `--desk-accent`,
|
|
382
|
+
`--desk-ok|warn|bad`, `--desk-on-accent`. Spacing `--desk-space-1..7` = 4, 8, 12, 16, 24, 32, 48 px. Also
|
|
383
|
+
`--desk-radius`, `--desk-control-height`, `--desk-control-radius`, `--desk-font`, `--desk-font-mono`,
|
|
384
|
+
`--desk-ease`, `--desk-dock-size`, `--desk-dock-glass`, `--desk-inset-bottom`.
|
|
385
|
+
|
|
386
|
+
Light and dark follow `prefers-color-scheme`; `data-theme="light" | "dark"` on the root wins.
|
|
387
|
+
|
|
388
|
+
## Testing
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
const desk = createDesk({ stage: () => ({ width: 1000, height: 700 }) }) // jsdom measures nothing
|
|
392
|
+
render(<DeskProvider desk={desk}>…</DeskProvider>)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Query a window by `[data-desk-window="id"]`: on a touch layout an offstage window is `inert`, so it is not
|
|
396
|
+
in the accessibility tree.
|
|
397
|
+
|
|
398
|
+
## Facts an agent should not guess
|
|
399
|
+
|
|
400
|
+
- React 19 is a peer dependency; the core entry point imports no React and touches no DOM except through
|
|
401
|
+
the command helpers.
|
|
402
|
+
- Focus follows the key window when it changes, but never on first render.
|
|
403
|
+
- First run (HIG §8): check on Continue with `onContinue`, not on the next step; show waiting work with
|
|
404
|
+
`useTasks` + `Checklist`; open the chosen window last so it is in front; offer the tour (`offer`) once on
|
|
405
|
+
arrival; remember progress with `useSetupProgress`, and never put passwords or keys in its answers.
|
|
406
|
+
- A `DeskProvider` already carries an event bus; `BusProvider` is only for supplying or sharing one.
|
|
407
|
+
- `renderWindow` must be stable (module scope or `useCallback`) for the memoisation to hold.
|
|
408
|
+
- `parse`/`syncWithLocation` warn once when no `isKnown` is given: unknown ids from a URL are then trusted.
|
|
409
|
+
- `npm run size` enforces a gzipped budget: core 10 KiB, react 46 KiB, css 10 KiB.
|
|
410
|
+
- The package is ESM only, with `"sideEffects": ["**/*.css"]`.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liberation-data/desk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A desktop for the web, for React: windows, a dock, menus and commands, controls, tours and setup assistants.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Jasper Blues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/liberation-data/desk.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": [
|
|
13
|
+
"**/*.css"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"llms.txt",
|
|
19
|
+
"NOTICE"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/core/index.d.ts",
|
|
24
|
+
"import": "./dist/core/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./react": {
|
|
27
|
+
"types": "./dist/react/index.d.ts",
|
|
28
|
+
"import": "./dist/react/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./desk.css": "./dist/desk.css"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/desk.css dist/desk.css",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"example": "vite examples/garage",
|
|
37
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
38
|
+
"size": "node scripts/size.mjs",
|
|
39
|
+
"check": "npm run typecheck && npm test && npm run build && npm run size",
|
|
40
|
+
"check:browser": "node scripts/browser-check.mjs"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": "^19.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@testing-library/react": "^16.3.3",
|
|
52
|
+
"@testing-library/user-event": "^14.6.7",
|
|
53
|
+
"@types/react": "^19.3.0",
|
|
54
|
+
"@types/react-dom": "^19.3.0",
|
|
55
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
56
|
+
"jsdom": "^30.0.1",
|
|
57
|
+
"puppeteer-core": "^25.11.0",
|
|
58
|
+
"react": "^19.3.0",
|
|
59
|
+
"react-dom": "^19.3.0",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vite": "^8.3.0",
|
|
62
|
+
"vitest": "^5.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { focusedId } from './desk.js'
|
|
2
|
+
import type { Desk } from './desk.js'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The responder chain, built from the DOM rather than beside it.
|
|
6
|
+
*
|
|
7
|
+
* A command that names no target — Copy, Close, text typed into the desk's input
|
|
8
|
+
* bar — is dispatched as an event from the start of the chain and bubbles up
|
|
9
|
+
* through ancestors: focused element → its window → the stage → the document.
|
|
10
|
+
* The first element with a handler for that command decides. Menus ask the same
|
|
11
|
+
* chain whether a command can be performed before they draw an item enabled.
|
|
12
|
+
*
|
|
13
|
+
* Deciding is AppKit's rule: the first responder that implements a command is
|
|
14
|
+
* its target, and its answer stands. A disabled handler stops the command rather
|
|
15
|
+
* than letting it fall through to something further up the chain.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type CommandId = string
|
|
19
|
+
|
|
20
|
+
/** Marks a window's element, so the chain can start at the key window. */
|
|
21
|
+
export const WINDOW_ATTRIBUTE = 'data-desk-window'
|
|
22
|
+
/** Marks the stage, the desk-level responder. */
|
|
23
|
+
export const STAGE_ATTRIBUTE = 'data-desk-stage'
|
|
24
|
+
|
|
25
|
+
const PERFORM = 'desk:perform'
|
|
26
|
+
const QUERY = 'desk:can-perform'
|
|
27
|
+
|
|
28
|
+
interface PerformDetail {
|
|
29
|
+
readonly command: CommandId
|
|
30
|
+
readonly args: unknown
|
|
31
|
+
handled: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface QueryDetail {
|
|
35
|
+
readonly command: CommandId
|
|
36
|
+
enabled: boolean | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CommandHandler<Args = unknown> = (args: Args) => void
|
|
40
|
+
|
|
41
|
+
export interface CommandOptions {
|
|
42
|
+
/** Whether the handler can act right now. Asked before performing and when a menu opens. Default: always. */
|
|
43
|
+
readonly enabled?: () => boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Makes `element` a responder for `command`. Returns a function that removes it.
|
|
48
|
+
* Attach to `document` for an app-level responder.
|
|
49
|
+
*/
|
|
50
|
+
export function addCommandHandler<Args = unknown>(
|
|
51
|
+
element: EventTarget,
|
|
52
|
+
command: CommandId,
|
|
53
|
+
handler: CommandHandler<Args>,
|
|
54
|
+
options: CommandOptions = {},
|
|
55
|
+
): () => void {
|
|
56
|
+
const enabled = options.enabled ?? (() => true)
|
|
57
|
+
|
|
58
|
+
const onPerform = (event: Event) => {
|
|
59
|
+
const detail = (event as CustomEvent<PerformDetail>).detail
|
|
60
|
+
if (detail.command !== command) return
|
|
61
|
+
event.stopPropagation()
|
|
62
|
+
if (!enabled()) return
|
|
63
|
+
detail.handled = true
|
|
64
|
+
handler(detail.args as Args)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const onQuery = (event: Event) => {
|
|
68
|
+
const detail = (event as CustomEvent<QueryDetail>).detail
|
|
69
|
+
if (detail.command !== command) return
|
|
70
|
+
event.stopPropagation()
|
|
71
|
+
detail.enabled = enabled()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
element.addEventListener(PERFORM, onPerform)
|
|
75
|
+
element.addEventListener(QUERY, onQuery)
|
|
76
|
+
return () => {
|
|
77
|
+
element.removeEventListener(PERFORM, onPerform)
|
|
78
|
+
element.removeEventListener(QUERY, onQuery)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ChainOptions {
|
|
83
|
+
/** Start here instead of at the focused element. */
|
|
84
|
+
readonly from?: Element | null
|
|
85
|
+
readonly document?: Document
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Where a command starts: focus inside the key window, else the key window, else the stage, else the document. */
|
|
89
|
+
export function chainStart(desk: Desk | null, options: ChainOptions = {}): EventTarget {
|
|
90
|
+
if (options.from) return options.from
|
|
91
|
+
const doc = options.document ?? globalThis.document
|
|
92
|
+
const key = desk ? focusedId(desk.getState()) : null
|
|
93
|
+
const window = key ? windowElement(doc, key) : null
|
|
94
|
+
const active = doc.activeElement
|
|
95
|
+
if (window && active && window.contains(active)) return active
|
|
96
|
+
return window ?? doc.querySelector(`[${STAGE_ATTRIBUTE}]`) ?? doc
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const windowElement = (doc: ParentNode, id: string): Element | null =>
|
|
100
|
+
[...doc.querySelectorAll(`[${WINDOW_ATTRIBUTE}]`)].find(el => el.getAttribute(WINDOW_ATTRIBUTE) === id) ?? null
|
|
101
|
+
|
|
102
|
+
/** Sends a command along the chain. Returns whether anything handled it. */
|
|
103
|
+
export function perform<Args = unknown>(desk: Desk | null, command: CommandId, args?: Args, options: ChainOptions = {}): boolean {
|
|
104
|
+
const detail: PerformDetail = { command, args, handled: false }
|
|
105
|
+
chainStart(desk, options).dispatchEvent(new CustomEvent(PERFORM, { detail, bubbles: true, composed: true }))
|
|
106
|
+
return detail.handled
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Asks the chain whether a command would be handled right now. */
|
|
110
|
+
export function canPerform(desk: Desk | null, command: CommandId, options: ChainOptions = {}): boolean {
|
|
111
|
+
const detail: QueryDetail = { command, enabled: undefined }
|
|
112
|
+
chainStart(desk, options).dispatchEvent(new CustomEvent(QUERY, { detail, bubbles: true, composed: true }))
|
|
113
|
+
return detail.enabled ?? false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The commands the desk itself answers, at the stage. */
|
|
117
|
+
export const DeskCommands = {
|
|
118
|
+
closeWindow: 'desk.window.close',
|
|
119
|
+
/** Fills the key window, or frees it: the green control. */
|
|
120
|
+
zoomWindow: 'desk.window.zoom',
|
|
121
|
+
/** Answered by a mounted Desktop, which knows the space windows are laid out in. */
|
|
122
|
+
arrange: 'desk.window.arrange',
|
|
123
|
+
nextWindow: 'desk.window.next',
|
|
124
|
+
previousWindow: 'desk.window.previous',
|
|
125
|
+
} as const
|
|
126
|
+
|
|
127
|
+
export function addDeskCommands(stage: EventTarget, desk: Desk): () => void {
|
|
128
|
+
const key = () => focusedId(desk.getState())
|
|
129
|
+
const cycle = (step: 1 | -1) => {
|
|
130
|
+
const { windows } = desk.getState()
|
|
131
|
+
const current = windows.findIndex(w => w.id === key())
|
|
132
|
+
const next = windows[(current + step + windows.length) % windows.length]
|
|
133
|
+
if (next) desk.focus(next.id)
|
|
134
|
+
}
|
|
135
|
+
const removers = [
|
|
136
|
+
addCommandHandler(stage, DeskCommands.closeWindow, () => { const id = key(); if (id) desk.close(id) }, { enabled: () => key() !== null }),
|
|
137
|
+
addCommandHandler(stage, DeskCommands.zoomWindow, () => { const id = key(); if (id) desk.toggleMode(id) }, { enabled: () => key() !== null }),
|
|
138
|
+
addCommandHandler(stage, DeskCommands.nextWindow, () => cycle(1), { enabled: () => desk.getState().windows.length > 1 }),
|
|
139
|
+
addCommandHandler(stage, DeskCommands.previousWindow, () => cycle(-1), { enabled: () => desk.getState().windows.length > 1 }),
|
|
140
|
+
]
|
|
141
|
+
return () => removers.forEach(remove => remove())
|
|
142
|
+
}
|