@liberation-data/desk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +436 -0
- package/dist/core/commands.d.ts +40 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +92 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/desk.d.ts +46 -0
- package/dist/core/desk.d.ts.map +1 -0
- package/dist/core/desk.js +178 -0
- package/dist/core/desk.js.map +1 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +54 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/location.d.ts +30 -0
- package/dist/core/location.d.ts.map +1 -0
- package/dist/core/location.js +99 -0
- package/dist/core/location.js.map +1 -0
- package/dist/core/shortcuts.d.ts +36 -0
- package/dist/core/shortcuts.d.ts.map +1 -0
- package/dist/core/shortcuts.js +73 -0
- package/dist/core/shortcuts.js.map +1 -0
- package/dist/core/titles.d.ts +3 -0
- package/dist/core/titles.d.ts.map +1 -0
- package/dist/core/titles.js +26 -0
- package/dist/core/titles.js.map +1 -0
- package/dist/core/types.d.ts +51 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/desk.css +2550 -0
- package/dist/react/Desktop.d.ts +24 -0
- package/dist/react/Desktop.d.ts.map +1 -0
- package/dist/react/Desktop.js +212 -0
- package/dist/react/Desktop.js.map +1 -0
- package/dist/react/Dock.d.ts +42 -0
- package/dist/react/Dock.d.ts.map +1 -0
- package/dist/react/Dock.js +103 -0
- package/dist/react/Dock.js.map +1 -0
- package/dist/react/MenuBar.d.ts +64 -0
- package/dist/react/MenuBar.d.ts.map +1 -0
- package/dist/react/MenuBar.js +245 -0
- package/dist/react/MenuBar.js.map +1 -0
- package/dist/react/appFrame.d.ts +37 -0
- package/dist/react/appFrame.d.ts.map +1 -0
- package/dist/react/appFrame.js +119 -0
- package/dist/react/appFrame.js.map +1 -0
- package/dist/react/arrange.d.ts +24 -0
- package/dist/react/arrange.d.ts.map +1 -0
- package/dist/react/arrange.js +61 -0
- package/dist/react/arrange.js.map +1 -0
- package/dist/react/commands.d.ts +23 -0
- package/dist/react/commands.d.ts.map +1 -0
- package/dist/react/commands.js +41 -0
- package/dist/react/commands.js.map +1 -0
- package/dist/react/context.d.ts +25 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +28 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/controls.d.ts +111 -0
- package/dist/react/controls.d.ts.map +1 -0
- package/dist/react/controls.js +83 -0
- package/dist/react/controls.js.map +1 -0
- package/dist/react/conversation.d.ts +41 -0
- package/dist/react/conversation.d.ts.map +1 -0
- package/dist/react/conversation.js +73 -0
- package/dist/react/conversation.js.map +1 -0
- package/dist/react/dnd.d.ts +51 -0
- package/dist/react/dnd.d.ts.map +1 -0
- package/dist/react/dnd.js +138 -0
- package/dist/react/dnd.js.map +1 -0
- package/dist/react/dragContext.d.ts +32 -0
- package/dist/react/dragContext.d.ts.map +1 -0
- package/dist/react/dragContext.js +14 -0
- package/dist/react/dragContext.js.map +1 -0
- package/dist/react/events.d.ts +41 -0
- package/dist/react/events.d.ts.map +1 -0
- package/dist/react/events.js +91 -0
- package/dist/react/events.js.map +1 -0
- package/dist/react/index.d.ts +43 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +22 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/layers.d.ts +5 -0
- package/dist/react/layers.d.ts.map +1 -0
- package/dist/react/layers.js +65 -0
- package/dist/react/layers.js.map +1 -0
- package/dist/react/overlays.d.ts +67 -0
- package/dist/react/overlays.d.ts.map +1 -0
- package/dist/react/overlays.js +98 -0
- package/dist/react/overlays.js.map +1 -0
- package/dist/react/popup.d.ts +21 -0
- package/dist/react/popup.d.ts.map +1 -0
- package/dist/react/popup.js +94 -0
- package/dist/react/popup.js.map +1 -0
- package/dist/react/search.d.ts +33 -0
- package/dist/react/search.d.ts.map +1 -0
- package/dist/react/search.js +82 -0
- package/dist/react/search.js.map +1 -0
- package/dist/react/setupProgress.d.ts +35 -0
- package/dist/react/setupProgress.d.ts.map +1 -0
- package/dist/react/setupProgress.js +68 -0
- package/dist/react/setupProgress.js.map +1 -0
- package/dist/react/shell.d.ts +17 -0
- package/dist/react/shell.d.ts.map +1 -0
- package/dist/react/shell.js +13 -0
- package/dist/react/shell.js.map +1 -0
- package/dist/react/sidebar.d.ts +23 -0
- package/dist/react/sidebar.d.ts.map +1 -0
- package/dist/react/sidebar.js +26 -0
- package/dist/react/sidebar.js.map +1 -0
- package/dist/react/table.d.ts +39 -0
- package/dist/react/table.d.ts.map +1 -0
- package/dist/react/table.js +86 -0
- package/dist/react/table.js.map +1 -0
- package/dist/react/tasks.d.ts +41 -0
- package/dist/react/tasks.d.ts.map +1 -0
- package/dist/react/tasks.js +79 -0
- package/dist/react/tasks.js.map +1 -0
- package/dist/react/tour.d.ts +42 -0
- package/dist/react/tour.d.ts.map +1 -0
- package/dist/react/tour.js +68 -0
- package/dist/react/tour.js.map +1 -0
- package/dist/react/windowBoundary.d.ts +22 -0
- package/dist/react/windowBoundary.d.ts.map +1 -0
- package/dist/react/windowBoundary.js +50 -0
- package/dist/react/windowBoundary.js.map +1 -0
- package/dist/react/wizard.d.ts +41 -0
- package/dist/react/wizard.d.ts.map +1 -0
- package/dist/react/wizard.js +60 -0
- package/dist/react/wizard.js.map +1 -0
- package/llms.txt +410 -0
- package/package.json +64 -0
- package/src/core/commands.ts +142 -0
- package/src/core/desk.ts +229 -0
- package/src/core/events.ts +99 -0
- package/src/core/index.ts +43 -0
- package/src/core/location.ts +127 -0
- package/src/core/shortcuts.ts +119 -0
- package/src/core/titles.ts +28 -0
- package/src/core/types.ts +49 -0
- package/src/desk.css +2550 -0
- package/src/react/Desktop.tsx +349 -0
- package/src/react/Dock.tsx +258 -0
- package/src/react/MenuBar.tsx +350 -0
- package/src/react/appFrame.tsx +183 -0
- package/src/react/arrange.ts +82 -0
- package/src/react/commands.ts +60 -0
- package/src/react/context.tsx +55 -0
- package/src/react/controls.tsx +364 -0
- package/src/react/conversation.tsx +208 -0
- package/src/react/dnd.tsx +189 -0
- package/src/react/dragContext.tsx +60 -0
- package/src/react/events.tsx +149 -0
- package/src/react/index.ts +64 -0
- package/src/react/layers.ts +66 -0
- package/src/react/overlays.tsx +254 -0
- package/src/react/popup.tsx +176 -0
- package/src/react/search.tsx +177 -0
- package/src/react/setupProgress.ts +113 -0
- package/src/react/shell.tsx +27 -0
- package/src/react/sidebar.tsx +84 -0
- package/src/react/table.tsx +176 -0
- package/src/react/tasks.tsx +176 -0
- package/src/react/tour.tsx +183 -0
- package/src/react/windowBoundary.tsx +110 -0
- package/src/react/wizard.tsx +150 -0
|
@@ -0,0 +1,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
|
+
}
|