@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,254 @@
1
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import type { ReactElement, ReactNode, RefObject } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { windowElement } from '../core/commands.js'
5
+ import { Button } from './controls.js'
6
+ import { useWindowId } from './context.js'
7
+ import { useFocusTrap, useLayer } from './layers.js'
8
+
9
+ /*
10
+ * Interruption, least to most (HIG.md §7): a popover belongs to one control, a
11
+ * sheet to one window, an alert to the whole app, and a toast interrupts nothing.
12
+ *
13
+ * Escape always dismisses the topmost layer, so overlays share one stack rather
14
+ * than each listening for their own key.
15
+ */
16
+
17
+ /* ── Popover: a small task tied to one control ── */
18
+
19
+ export interface PopoverTriggerProps {
20
+ readonly ref: RefObject<HTMLButtonElement | null>
21
+ readonly 'aria-haspopup': 'dialog'
22
+ readonly 'aria-expanded': boolean
23
+ readonly onClick: () => void
24
+ }
25
+
26
+ export interface PopoverProps {
27
+ readonly open: boolean
28
+ readonly onOpenChange: (open: boolean) => void
29
+ /** The accessible name of the popover. */
30
+ readonly label: string
31
+ /** Renders the control the popover hangs from; spread the props it is given. */
32
+ readonly trigger: (props: PopoverTriggerProps) => ReactElement
33
+ readonly placement?: 'below' | 'above'
34
+ readonly align?: 'start' | 'end'
35
+ readonly children: ReactNode
36
+ }
37
+
38
+ export function Popover({ open, onOpenChange, label, trigger, placement = 'below', align = 'start', children }: PopoverProps) {
39
+ const id = useId()
40
+ const button = useRef<HTMLButtonElement>(null)
41
+ const panel = useRef<HTMLDivElement>(null)
42
+ useLayer(id, open, () => onOpenChange(false))
43
+ useFocusTrap(panel, open)
44
+
45
+ useEffect(() => {
46
+ if (!open) return
47
+ const onPointerDown = (event: PointerEvent) => {
48
+ const target = event.target as Node
49
+ if (!panel.current?.contains(target) && !button.current?.contains(target)) onOpenChange(false)
50
+ }
51
+ document.addEventListener('pointerdown', onPointerDown, true)
52
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
53
+ }, [open, onOpenChange])
54
+
55
+ return (
56
+ <span className="desk-popover-anchor">
57
+ {trigger({ ref: button, 'aria-haspopup': 'dialog', 'aria-expanded': open, onClick: () => onOpenChange(!open) })}
58
+ {open && (
59
+ <div ref={panel} role="dialog" aria-label={label} className="desk-popover" data-placement={placement} data-align={align}>
60
+ {children}
61
+ </div>
62
+ )}
63
+ </span>
64
+ )
65
+ }
66
+
67
+ /* ── Sheet: a task that belongs to one window, and blocks only that window ── */
68
+
69
+ export interface SheetProps {
70
+ readonly open: boolean
71
+ readonly onDismiss: () => void
72
+ readonly title: string
73
+ readonly description?: ReactNode
74
+ /** Buttons, trailing-aligned. Cancel before the default button. */
75
+ readonly actions?: ReactNode
76
+ readonly children?: ReactNode
77
+ }
78
+
79
+ export function Sheet({ open, onDismiss, title, description, actions, children }: SheetProps) {
80
+ const id = useId()
81
+ const panel = useRef<HTMLDivElement>(null)
82
+ const windowId = useWindowId()
83
+ // A sheet blocks its own window and nothing else, so it lives inside that window. It is
84
+ // found in the document rather than through a ref: a window's ref is attached after the
85
+ // effects of the content it holds have already run.
86
+ const [host, setHost] = useState<HTMLElement | null>(null)
87
+ useLayoutEffect(() => {
88
+ if (!open) {
89
+ setHost(null)
90
+ return
91
+ }
92
+ const element = windowId ? windowElement(document, windowId) : null
93
+ setHost(element instanceof HTMLElement ? element : document.body)
94
+ }, [open, windowId])
95
+ useLayer(id, open, onDismiss)
96
+ useFocusTrap(panel, open && host !== null)
97
+ if (!open || !host) return null
98
+
99
+ return createPortal(
100
+ <div className="desk-sheet-scrim">
101
+ <div ref={panel} role="dialog" aria-modal="true" aria-labelledby={`${id}-title`} className="desk-sheet">
102
+ <h2 id={`${id}-title`} className="desk-sheet-title">{title}</h2>
103
+ {description && <p className="desk-sheet-description">{description}</p>}
104
+ {children}
105
+ {actions && <div className="desk-sheet-actions">{actions}</div>}
106
+ </div>
107
+ </div>,
108
+ host,
109
+ )
110
+ }
111
+
112
+ /* ── Alert: the app cannot continue until this is decided. Rare. ── */
113
+
114
+ export interface AlertProps {
115
+ readonly open: boolean
116
+ readonly title: string
117
+ readonly message?: ReactNode
118
+ /** The action the alert is asking about. */
119
+ readonly confirmLabel: string
120
+ readonly onConfirm: () => void
121
+ readonly onCancel: () => void
122
+ readonly cancelLabel?: string
123
+ /** Use for anything that cannot be undone. */
124
+ readonly destructive?: boolean
125
+ }
126
+
127
+ export function Alert({ open, title, message, confirmLabel, onConfirm, onCancel, cancelLabel = 'Cancel', destructive }: AlertProps) {
128
+ const id = useId()
129
+ const panel = useRef<HTMLDivElement>(null)
130
+ useLayer(id, open, onCancel)
131
+ useFocusTrap(panel, open)
132
+ if (!open) return null
133
+
134
+ return createPortal(
135
+ <div className="desk-alert-scrim">
136
+ <div
137
+ ref={panel}
138
+ role="alertdialog"
139
+ aria-modal="true"
140
+ aria-labelledby={`${id}-title`}
141
+ aria-describedby={message ? `${id}-message` : undefined}
142
+ className="desk-alert"
143
+ >
144
+ <h2 id={`${id}-title`} className="desk-alert-title">{title}</h2>
145
+ {message && <p id={`${id}-message`} className="desk-alert-message">{message}</p>}
146
+ <div className="desk-alert-actions">
147
+ <Button onClick={onCancel}>{cancelLabel}</Button>
148
+ <Button intent={destructive ? 'destructive' : 'default'} onClick={onConfirm}>{confirmLabel}</Button>
149
+ </div>
150
+ </div>
151
+ </div>,
152
+ document.body,
153
+ )
154
+ }
155
+
156
+ /* ── Toast: a background action finished; it interrupts nothing ── */
157
+
158
+ export interface Toast {
159
+ readonly id: string
160
+ readonly message: ReactNode
161
+ readonly tone?: 'neutral' | 'ok' | 'bad'
162
+ /** Usually Undo. Dismisses the toast when chosen. */
163
+ readonly action?: { readonly label: string; readonly onSelect: () => void }
164
+ /** Milliseconds. 0 keeps it until dismissed. Default 6000. */
165
+ readonly duration?: number
166
+ }
167
+
168
+ export type ToastInput = Omit<Toast, 'id'> & { readonly id?: string }
169
+
170
+ interface ToastApi {
171
+ show: (toast: ToastInput) => string
172
+ dismiss: (id: string) => void
173
+ }
174
+
175
+ const ToastContext = createContext<ToastApi | null>(null)
176
+
177
+ export function ToastProvider({ children }: { readonly children: ReactNode }) {
178
+ const [toasts, setToasts] = useState<readonly Toast[]>([])
179
+ const counter = useRef(0)
180
+
181
+ const dismiss = useCallback((id: string) => setToasts(list => list.filter(t => t.id !== id)), [])
182
+ const show = useCallback((toast: ToastInput) => {
183
+ const id = toast.id ?? `toast-${(counter.current += 1)}`
184
+ setToasts(list => [...list.filter(t => t.id !== id), { ...toast, id }])
185
+ return id
186
+ }, [])
187
+ const api = useMemo(() => ({ show, dismiss }), [show, dismiss])
188
+
189
+ return (
190
+ <ToastContext.Provider value={api}>
191
+ {children}
192
+ <ToastStack toasts={toasts} onDismiss={dismiss} />
193
+ </ToastContext.Provider>
194
+ )
195
+ }
196
+
197
+ export function useToast(): ToastApi {
198
+ const api = useContext(ToastContext)
199
+ if (!api) throw new Error('useToast must be used inside a <ToastProvider>')
200
+ return api
201
+ }
202
+
203
+ function ToastStack({ toasts, onDismiss }: { readonly toasts: readonly Toast[]; readonly onDismiss: (id: string) => void }) {
204
+ if (!toasts.length) return null
205
+ return createPortal(
206
+ <div className="desk-toasts">
207
+ {toasts.map(toast => (
208
+ <ToastView key={toast.id} toast={toast} onDismiss={() => onDismiss(toast.id)} />
209
+ ))}
210
+ </div>,
211
+ document.body,
212
+ )
213
+ }
214
+
215
+ function ToastView({ toast, onDismiss }: { readonly toast: Toast; readonly onDismiss: () => void }) {
216
+ const [paused, setPaused] = useState(false)
217
+ const duration = toast.duration ?? 6000
218
+
219
+ useEffect(() => {
220
+ if (!duration || paused) return
221
+ const timer = setTimeout(onDismiss, duration)
222
+ return () => clearTimeout(timer)
223
+ }, [duration, paused, onDismiss])
224
+
225
+ return (
226
+ <div
227
+ className="desk-toast"
228
+ data-tone={toast.tone ?? 'neutral'}
229
+ role={toast.tone === 'bad' ? 'alert' : 'status'}
230
+ // Reading it, or reaching for Undo, should not start the clock against you.
231
+ onPointerEnter={() => setPaused(true)}
232
+ onPointerLeave={() => setPaused(false)}
233
+ onFocusCapture={() => setPaused(true)}
234
+ onBlurCapture={() => setPaused(false)}
235
+ >
236
+ <span className="desk-toast-message">{toast.message}</span>
237
+ {toast.action && (
238
+ <Button
239
+ size="small"
240
+ intent="quiet"
241
+ onClick={() => {
242
+ toast.action?.onSelect()
243
+ onDismiss()
244
+ }}
245
+ >
246
+ {toast.action.label}
247
+ </Button>
248
+ )}
249
+ <Button size="small" intent="quiet" aria-label="Dismiss" onClick={onDismiss}>
250
+
251
+ </Button>
252
+ </div>
253
+ )
254
+ }
@@ -0,0 +1,176 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+ import type { KeyboardEvent, ReactNode } from 'react'
3
+
4
+ /*
5
+ * A pop-up button: what a segmented control becomes once there are too many
6
+ * options to show at once, or the options need explaining. It is a form control,
7
+ * not a menu — so, unlike a menu, it takes focus while it is open.
8
+ */
9
+
10
+ export interface PopUpOption<T extends string> {
11
+ readonly value: T
12
+ readonly label: string
13
+ readonly description?: string
14
+ readonly icon?: ReactNode
15
+ readonly disabled?: boolean
16
+ }
17
+
18
+ export interface PopUpButtonProps<T extends string> {
19
+ readonly value: T | null
20
+ readonly onChange: (value: T) => void
21
+ readonly options: readonly PopUpOption<T>[]
22
+ readonly label: string
23
+ /** Hides the label on screen but keeps it for assistive technology. */
24
+ readonly labelHidden?: boolean
25
+ readonly placeholder?: string
26
+ readonly disabled?: boolean
27
+ readonly size?: 'regular' | 'small'
28
+ }
29
+
30
+ export function PopUpButton<T extends string>({
31
+ value,
32
+ onChange,
33
+ options,
34
+ label,
35
+ labelHidden,
36
+ placeholder = 'Choose…',
37
+ disabled,
38
+ size = 'regular',
39
+ }: PopUpButtonProps<T>) {
40
+ const id = useId()
41
+ const [open, setOpen] = useState(false)
42
+ const [active, setActive] = useState(0)
43
+ const button = useRef<HTMLButtonElement>(null)
44
+ const list = useRef<HTMLUListElement>(null)
45
+ const typed = useRef({ text: '', at: 0 })
46
+
47
+ const selectable = options.filter(o => !o.disabled)
48
+ const selected = options.find(o => o.value === value) ?? null
49
+
50
+ const show = (from: 'first' | 'selected' = 'selected') => {
51
+ const start = from === 'selected' && selected ? options.indexOf(selected) : options.findIndex(o => !o.disabled)
52
+ setActive(Math.max(0, start))
53
+ setOpen(true)
54
+ }
55
+
56
+ const close = (returnFocus = true) => {
57
+ setOpen(false)
58
+ if (returnFocus) button.current?.focus()
59
+ }
60
+
61
+ const choose = (index: number) => {
62
+ const option = options[index]
63
+ if (!option || option.disabled) return
64
+ onChange(option.value)
65
+ close()
66
+ }
67
+
68
+ useEffect(() => {
69
+ if (!open) return
70
+ list.current?.focus()
71
+ const onPointerDown = (event: PointerEvent) => {
72
+ const target = event.target as Node
73
+ if (!list.current?.contains(target) && !button.current?.contains(target)) setOpen(false)
74
+ }
75
+ document.addEventListener('pointerdown', onPointerDown, true)
76
+ return () => document.removeEventListener('pointerdown', onPointerDown, true)
77
+ }, [open])
78
+
79
+ const step = (from: number, delta: 1 | -1) => {
80
+ for (let i = 1; i <= options.length; i++) {
81
+ const index = (from + delta * i + options.length * 2) % options.length
82
+ if (!options[index]?.disabled) return index
83
+ }
84
+ return from
85
+ }
86
+
87
+ const onListKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
88
+ switch (event.key) {
89
+ case 'ArrowDown': setActive(step(active, 1)); break
90
+ case 'ArrowUp': setActive(step(active, -1)); break
91
+ case 'Home': setActive(step(-1, 1)); break
92
+ case 'End': setActive(step(options.length, -1)); break
93
+ case 'Enter':
94
+ case ' ': choose(active); break
95
+ case 'Escape':
96
+ case 'Tab': close(); break
97
+ default: {
98
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return
99
+ // Typeahead: letters typed close together look for a word, a single letter cycles.
100
+ const now = Date.now()
101
+ typed.current = { text: now - typed.current.at < 800 ? typed.current.text + event.key : event.key, at: now }
102
+ const search = typed.current.text.toLowerCase()
103
+ const found = selectable.find(o => o.label.toLowerCase().startsWith(search))
104
+ if (found) setActive(options.indexOf(found))
105
+ break
106
+ }
107
+ }
108
+ event.preventDefault()
109
+ }
110
+
111
+ const onButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
112
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
113
+ event.preventDefault()
114
+ show(event.key === 'ArrowDown' ? 'selected' : 'selected')
115
+ }
116
+ }
117
+
118
+ return (
119
+ <div className="desk-field" data-popup>
120
+ <span id={`${id}-label`} className="desk-label" data-hidden={labelHidden || undefined}>
121
+ {label}
122
+ </span>
123
+ <div className="desk-popup-anchor">
124
+ <button
125
+ ref={button}
126
+ type="button"
127
+ className="desk-popup-button"
128
+ data-size={size}
129
+ aria-haspopup="listbox"
130
+ aria-expanded={open}
131
+ aria-labelledby={`${id}-label ${id}-value`}
132
+ disabled={disabled}
133
+ onKeyDown={onButtonKeyDown}
134
+ onClick={() => (open ? close(false) : show())}
135
+ >
136
+ {selected?.icon && <span className="desk-popup-icon" aria-hidden="true">{selected.icon}</span>}
137
+ <span id={`${id}-value`} className="desk-popup-value" data-placeholder={!selected || undefined}>
138
+ {selected?.label ?? placeholder}
139
+ </span>
140
+ <span className="desk-popup-chevron" aria-hidden="true">⌄</span>
141
+ </button>
142
+ {open && (
143
+ <ul
144
+ ref={list}
145
+ role="listbox"
146
+ tabIndex={-1}
147
+ aria-labelledby={`${id}-label`}
148
+ aria-activedescendant={options[active] ? `${id}-option-${active}` : undefined}
149
+ className="desk-popup-list"
150
+ onKeyDown={onListKeyDown}
151
+ >
152
+ {options.map((option, index) => (
153
+ <li
154
+ key={option.value}
155
+ id={`${id}-option-${index}`}
156
+ role="option"
157
+ aria-selected={option.value === value}
158
+ aria-disabled={option.disabled || undefined}
159
+ className="desk-popup-option"
160
+ data-active={index === active || undefined}
161
+ onPointerMove={() => !option.disabled && setActive(index)}
162
+ onClick={() => choose(index)}
163
+ >
164
+ <span className="desk-popup-check" aria-hidden="true">{option.value === value ? '✓' : ''}</span>
165
+ <span className="desk-popup-text">
166
+ <span>{option.label}</span>
167
+ {option.description && <span className="desk-help">{option.description}</span>}
168
+ </span>
169
+ </li>
170
+ ))}
171
+ </ul>
172
+ )}
173
+ </div>
174
+ </div>
175
+ )
176
+ }
@@ -0,0 +1,177 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import type { KeyboardEvent, ReactNode } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import type { Desk } from '../core/desk.js'
5
+ import type { WindowId } from '../core/types.js'
6
+ import { useCommand, useShortcuts } from './commands.js'
7
+ import { useDesk } from './context.js'
8
+ import { useFocusTrap, useLayer } from './layers.js'
9
+
10
+ /*
11
+ * One place to search everything the app knows. The app says what the results
12
+ * are; the palette handles typing, keyboard, grouping and choosing.
13
+ */
14
+
15
+ export interface SearchResult {
16
+ readonly id: string
17
+ readonly title: string
18
+ readonly subtitle?: string
19
+ /** Groups the results under a heading, in the order the groups first appear. */
20
+ readonly group?: string
21
+ readonly icon?: ReactNode
22
+ /** A short word for what this is: `window`, `ride`, `command`. */
23
+ readonly kind?: string
24
+ readonly onSelect: () => void
25
+ }
26
+
27
+ /** The open windows as results, for an app to mix into its own. */
28
+ export function windowResults(desk: Desk, title: (id: WindowId) => string, group = 'Windows'): SearchResult[] {
29
+ return desk.getState().windows.map(w => ({
30
+ id: `window:${w.id}`,
31
+ title: title(w.id),
32
+ group,
33
+ kind: 'window',
34
+ onSelect: () => desk.focus(w.id),
35
+ }))
36
+ }
37
+
38
+ export const SearchCommand = 'desk.search'
39
+
40
+ export interface SearchPaletteProps {
41
+ readonly open: boolean
42
+ readonly onOpenChange: (open: boolean) => void
43
+ /** Called as the person types. May be async; a slower answer never overtakes a newer one. */
44
+ readonly search: (query: string) => readonly SearchResult[] | Promise<readonly SearchResult[]>
45
+ readonly placeholder?: string
46
+ readonly label?: string
47
+ /** Shown before anything is typed. */
48
+ readonly hint?: ReactNode
49
+ /** Shown when a query matches nothing. */
50
+ readonly empty?: (query: string) => ReactNode
51
+ /** Also opens the palette. Default `mod+k`; pass `null` to bind nothing. */
52
+ readonly shortcut?: string | null
53
+ }
54
+
55
+ export function SearchPalette({
56
+ open,
57
+ onOpenChange,
58
+ search,
59
+ placeholder = 'Search',
60
+ label = 'Search',
61
+ hint,
62
+ empty,
63
+ shortcut = 'mod+k',
64
+ }: SearchPaletteProps) {
65
+ const desk = useDesk()
66
+ const id = useId()
67
+ const panel = useRef<HTMLDivElement>(null)
68
+ const [query, setQuery] = useState('')
69
+ const [results, setResults] = useState<readonly SearchResult[]>([])
70
+ const [active, setActive] = useState(0)
71
+
72
+ useLayer(id, open, () => onOpenChange(false))
73
+ useFocusTrap(panel, open)
74
+ useCommand(SearchCommand, () => onOpenChange(true), { at: 'app' })
75
+ // Memoised: bindShortcuts re-registers whenever the keymap object changes identity.
76
+ useShortcuts(useMemo(() => (shortcut ? { [shortcut]: SearchCommand } : {}), [shortcut]))
77
+
78
+ useEffect(() => {
79
+ if (!open) setQuery('')
80
+ }, [open])
81
+
82
+ useEffect(() => {
83
+ if (!open) return
84
+ let current = true
85
+ Promise.resolve(search(query)).then(found => {
86
+ // A slow search for an old query must not replace the answer to a newer one.
87
+ if (!current) return
88
+ setResults(found)
89
+ setActive(0)
90
+ })
91
+ return () => {
92
+ current = false
93
+ }
94
+ }, [open, query, search])
95
+
96
+ if (!open) return null
97
+
98
+ const choose = (result: SearchResult | undefined) => {
99
+ if (!result) return
100
+ onOpenChange(false)
101
+ result.onSelect()
102
+ }
103
+
104
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
105
+ const move = (to: number) => {
106
+ event.preventDefault()
107
+ setActive(Math.max(0, Math.min(to, results.length - 1)))
108
+ }
109
+ switch (event.key) {
110
+ case 'ArrowDown': return move(active + 1)
111
+ case 'ArrowUp': return move(active - 1)
112
+ case 'Home': return move(0)
113
+ case 'End': return move(results.length - 1)
114
+ case 'Enter': {
115
+ event.preventDefault()
116
+ return choose(results[active])
117
+ }
118
+ default:
119
+ }
120
+ }
121
+
122
+ const listId = `${id}-results`
123
+ let group: string | undefined
124
+ return createPortal(
125
+ <div className="desk-search-scrim" onPointerDown={event => event.target === event.currentTarget && onOpenChange(false)}>
126
+ <div ref={panel} role="dialog" aria-label={label} className="desk-search" onKeyDown={onKeyDown}>
127
+ <div className="desk-search-bar">
128
+ <input
129
+ className="desk-search-field"
130
+ role="combobox"
131
+ aria-label={label}
132
+ aria-expanded
133
+ aria-controls={listId}
134
+ aria-activedescendant={results[active] ? `${id}-${active}` : undefined}
135
+ aria-autocomplete="list"
136
+ placeholder={placeholder}
137
+ value={query}
138
+ onChange={event => setQuery(event.target.value)}
139
+ />
140
+ </div>
141
+ {results.length > 0 && (
142
+ <ul id={listId} role="listbox" aria-label={label} className="desk-search-results">
143
+ {results.map((result, index) => {
144
+ const heading = result.group && result.group !== group ? result.group : null
145
+ group = result.group
146
+ return (
147
+ <li key={result.id} className="desk-search-row-wrap">
148
+ {heading && <p className="desk-search-group">{heading}</p>}
149
+ <div
150
+ id={`${id}-${index}`}
151
+ role="option"
152
+ aria-selected={index === active}
153
+ className="desk-search-row"
154
+ data-active={index === active || undefined}
155
+ onPointerMove={() => setActive(index)}
156
+ onClick={() => choose(result)}
157
+ >
158
+ {result.icon && <span className="desk-search-icon" aria-hidden="true">{result.icon}</span>}
159
+ <span className="desk-search-text">
160
+ <span className="desk-search-title">{result.title}</span>
161
+ {result.subtitle && <span className="desk-search-subtitle">{result.subtitle}</span>}
162
+ </span>
163
+ {result.kind && <span className="desk-search-kind">{result.kind}</span>}
164
+ </div>
165
+ </li>
166
+ )
167
+ })}
168
+ </ul>
169
+ )}
170
+ {results.length === 0 && (
171
+ <p className="desk-search-empty">{query.trim() ? (empty?.(query) ?? `Nothing matches “${query}”.`) : hint}</p>
172
+ )}
173
+ </div>
174
+ </div>,
175
+ document.body,
176
+ )
177
+ }