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