@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,55 @@
1
+ import { createContext, useContext, useState, useSyncExternalStore } from 'react'
2
+ import type { ReactNode, RefObject } from 'react'
3
+ import { createBus } from '../core/events.js'
4
+ import type { Bus } from '../core/events.js'
5
+ import { createDesk } from '../core/desk.js'
6
+ import { DragProvider } from './dragContext.js'
7
+ import type { Desk } from '../core/desk.js'
8
+ import type { DeskOptions, DeskState, WindowId } from '../core/types.js'
9
+
10
+ const DeskContext = createContext<Desk | null>(null)
11
+
12
+ /** Every desk comes with a bus; <BusProvider> only matters when one has to be shared or supplied. */
13
+ export const BusContext = createContext<Bus | null>(null)
14
+
15
+ export interface DeskProviderProps {
16
+ /** An existing desk, e.g. one a tour runner or router also drives. */
17
+ readonly desk?: Desk
18
+ /** Used to create a desk when none is given. Read once. */
19
+ readonly options?: DeskOptions
20
+ readonly children: ReactNode
21
+ }
22
+
23
+ export function DeskProvider({ desk, options, children }: DeskProviderProps) {
24
+ const [value] = useState(() => desk ?? createDesk(options))
25
+ const [bus] = useState(createBus)
26
+ return (
27
+ <DeskContext.Provider value={value}>
28
+ <BusContext.Provider value={bus}>
29
+ <DragProvider>{children}</DragProvider>
30
+ </BusContext.Provider>
31
+ </DeskContext.Provider>
32
+ )
33
+ }
34
+
35
+ export function useDesk(): Desk {
36
+ const desk = useContext(DeskContext)
37
+ if (!desk) throw new Error('useDesk must be used inside a <DeskProvider>')
38
+ return desk
39
+ }
40
+
41
+ export function useDeskState(): DeskState {
42
+ const desk = useDesk()
43
+ return useSyncExternalStore(desk.subscribe, desk.getState, desk.getState)
44
+ }
45
+
46
+ export interface WindowContextValue {
47
+ readonly id: WindowId
48
+ readonly element: RefObject<HTMLElement | null>
49
+ }
50
+
51
+ /** Present inside a window's content: which window this is, and its element. */
52
+ export const WindowContext = createContext<WindowContextValue | null>(null)
53
+
54
+ /** The id of the window this component is rendered in, or null outside any window. */
55
+ export const useWindowId = (): WindowId | null => useContext(WindowContext)?.id ?? null
@@ -0,0 +1,364 @@
1
+ import { forwardRef, useEffect, useId, useRef } from 'react'
2
+ import type { ButtonHTMLAttributes, InputHTMLAttributes, KeyboardEvent, ReactNode } from 'react'
3
+
4
+ /*
5
+ * Controls carry their own behaviour and accessibility; their look comes only
6
+ * from tokens. They take few style props on purpose — consistency comes from
7
+ * not being able to deviate. See HIG.md §6.
8
+ */
9
+
10
+ export type ButtonIntent = 'normal' | 'default' | 'quiet' | 'destructive'
11
+
12
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
13
+ /** `default` is the one action a view leads with, and the one Return performs. One per view. */
14
+ readonly intent?: ButtonIntent
15
+ readonly size?: 'regular' | 'small'
16
+ /** Before the label. Decorative: an icon-only button still needs `aria-label`. */
17
+ readonly icon?: ReactNode
18
+ }
19
+
20
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
21
+ { intent = 'normal', size = 'regular', icon, children, type = 'button', className, ...rest },
22
+ ref,
23
+ ) {
24
+ return (
25
+ <button
26
+ ref={ref}
27
+ type={type}
28
+ className={['desk-button', className].filter(Boolean).join(' ')}
29
+ data-intent={intent}
30
+ data-size={size}
31
+ data-icon-only={!children || undefined}
32
+ {...rest}
33
+ >
34
+ {icon && <span className="desk-button-icon" aria-hidden="true">{icon}</span>}
35
+ {children}
36
+ </button>
37
+ )
38
+ })
39
+
40
+ export interface SegmentedOption<T extends string> {
41
+ readonly value: T
42
+ readonly label: string
43
+ readonly icon?: ReactNode
44
+ readonly disabled?: boolean
45
+ }
46
+
47
+ export interface SegmentedControlProps<T extends string> {
48
+ /** Two to five options, all worth seeing at once. More than that wants a pop-up menu. */
49
+ readonly options: readonly SegmentedOption<T>[]
50
+ readonly value: T
51
+ readonly onChange: (value: T) => void
52
+ /** The accessible name for the group, e.g. "Filter rides by bike". */
53
+ readonly label: string
54
+ readonly size?: 'regular' | 'small'
55
+ readonly disabled?: boolean
56
+ }
57
+
58
+ export function SegmentedControl<T extends string>({ options, value, onChange, label, size = 'regular', disabled }: SegmentedControlProps<T>) {
59
+ const group = useRef<HTMLDivElement>(null)
60
+
61
+ const move = (event: KeyboardEvent<HTMLDivElement>) => {
62
+ const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as const
63
+ const delta = keys[event.key as keyof typeof keys]
64
+ const usable = options.filter(o => !o.disabled)
65
+ if (!delta || !usable.length) return
66
+ event.preventDefault()
67
+ const at = usable.findIndex(o => o.value === value)
68
+ const next = usable[(at + delta + usable.length) % usable.length]
69
+ if (!next) return
70
+ onChange(next.value)
71
+ // Selection follows focus here, as it does in a segmented control on any platform.
72
+ group.current?.querySelector<HTMLButtonElement>(`[data-value="${CSS.escape(next.value)}"]`)?.focus()
73
+ }
74
+
75
+ return (
76
+ <div ref={group} role="radiogroup" aria-label={label} className="desk-segmented" data-size={size} onKeyDown={move}>
77
+ {options.map(option => {
78
+ const selected = option.value === value
79
+ return (
80
+ <button
81
+ key={option.value}
82
+ type="button"
83
+ role="radio"
84
+ aria-checked={selected}
85
+ data-value={option.value}
86
+ className="desk-segment"
87
+ tabIndex={selected ? 0 : -1}
88
+ disabled={disabled || option.disabled}
89
+ onClick={() => onChange(option.value)}
90
+ >
91
+ {option.icon && <span className="desk-segment-icon" aria-hidden="true">{option.icon}</span>}
92
+ {option.label}
93
+ </button>
94
+ )
95
+ })}
96
+ </div>
97
+ )
98
+ }
99
+
100
+ export interface ToggleProps {
101
+ readonly checked: boolean
102
+ readonly onChange: (checked: boolean) => void
103
+ /** Shown beside the switch. Use `label` alone, or `aria-label` when the context already names it. */
104
+ readonly label?: ReactNode
105
+ readonly description?: ReactNode
106
+ readonly disabled?: boolean
107
+ readonly 'aria-label'?: string
108
+ }
109
+
110
+ /** For a setting that takes effect immediately. One that waits for Save is a checkbox. */
111
+ export function Toggle({ checked, onChange, label, description, disabled, 'aria-label': ariaLabel }: ToggleProps) {
112
+ const id = useId()
113
+ const descriptionId = description ? `${id}-description` : undefined
114
+ const control = (
115
+ <button
116
+ type="button"
117
+ role="switch"
118
+ id={id}
119
+ aria-checked={checked}
120
+ aria-label={ariaLabel}
121
+ aria-describedby={descriptionId}
122
+ className="desk-toggle"
123
+ disabled={disabled}
124
+ onClick={() => onChange(!checked)}
125
+ >
126
+ <span className="desk-toggle-knob" aria-hidden="true" />
127
+ </button>
128
+ )
129
+ if (!label && !description) return control
130
+ return (
131
+ <div className="desk-toggle-row">
132
+ <span className="desk-toggle-text">
133
+ {label && <label htmlFor={id} className="desk-label">{label}</label>}
134
+ {description && <span id={descriptionId} className="desk-help">{description}</span>}
135
+ </span>
136
+ {control}
137
+ </div>
138
+ )
139
+ }
140
+
141
+ export interface CheckboxProps {
142
+ readonly checked: boolean
143
+ /** Some of the things this stands for are checked, and some are not. */
144
+ readonly indeterminate?: boolean
145
+ readonly onChange: (checked: boolean) => void
146
+ readonly label?: ReactNode
147
+ readonly description?: ReactNode
148
+ readonly disabled?: boolean
149
+ readonly 'aria-label'?: string
150
+ }
151
+
152
+ /** For a setting that waits for Save. One that applies at once is a Toggle. */
153
+ export function Checkbox({ checked, indeterminate, onChange, label, description, disabled, 'aria-label': ariaLabel }: CheckboxProps) {
154
+ const id = useId()
155
+ const descriptionId = description ? `${id}-description` : undefined
156
+ const box = useRef<HTMLInputElement>(null)
157
+ // Indeterminate is a property, not an attribute: it cannot be set in markup.
158
+ useEffect(() => {
159
+ if (box.current) box.current.indeterminate = indeterminate ?? false
160
+ }, [indeterminate])
161
+
162
+ const input = (
163
+ <input
164
+ ref={box}
165
+ id={id}
166
+ type="checkbox"
167
+ className="desk-checkbox"
168
+ checked={checked}
169
+ disabled={disabled}
170
+ aria-label={ariaLabel}
171
+ aria-describedby={descriptionId}
172
+ onChange={event => onChange(event.target.checked)}
173
+ />
174
+ )
175
+ if (!label && !description) return input
176
+ return (
177
+ <div className="desk-check-row">
178
+ {input}
179
+ <span className="desk-check-text">
180
+ {label && <label htmlFor={id} className="desk-check-label">{label}</label>}
181
+ {description && <span id={descriptionId} className="desk-help">{description}</span>}
182
+ </span>
183
+ </div>
184
+ )
185
+ }
186
+
187
+ export interface RadioOption<T extends string> {
188
+ readonly value: T
189
+ readonly label: string
190
+ readonly description?: string
191
+ readonly disabled?: boolean
192
+ }
193
+
194
+ export interface RadioGroupProps<T extends string> {
195
+ readonly options: readonly RadioOption<T>[]
196
+ readonly value: T | null
197
+ readonly onChange: (value: T) => void
198
+ readonly label: string
199
+ readonly labelHidden?: boolean
200
+ readonly disabled?: boolean
201
+ }
202
+
203
+ /** Mutually exclusive choices that each need a line of explanation. */
204
+ export function RadioGroup<T extends string>({ options, value, onChange, label, labelHidden, disabled }: RadioGroupProps<T>) {
205
+ const name = useId()
206
+ return (
207
+ <fieldset className="desk-radiogroup" disabled={disabled}>
208
+ <legend className="desk-label" data-hidden={labelHidden || undefined}>{label}</legend>
209
+ {options.map(option => {
210
+ const id = `${name}-${option.value}`
211
+ return (
212
+ <div className="desk-check-row" key={option.value}>
213
+ <input
214
+ id={id}
215
+ type="radio"
216
+ name={name}
217
+ className="desk-radio"
218
+ value={option.value}
219
+ checked={option.value === value}
220
+ disabled={option.disabled}
221
+ aria-describedby={option.description ? `${id}-description` : undefined}
222
+ onChange={() => onChange(option.value)}
223
+ />
224
+ <span className="desk-check-text">
225
+ <label htmlFor={id} className="desk-check-label">{option.label}</label>
226
+ {option.description && <span id={`${id}-description`} className="desk-help">{option.description}</span>}
227
+ </span>
228
+ </div>
229
+ )
230
+ })}
231
+ </fieldset>
232
+ )
233
+ }
234
+
235
+ export interface ChoiceOption<T extends string> {
236
+ readonly value: T
237
+ readonly label: string
238
+ readonly description?: ReactNode
239
+ /** Decorative, beside the label. */
240
+ readonly icon?: ReactNode
241
+ /** A short aside after the label: a size, "needs a key". */
242
+ readonly tag?: ReactNode
243
+ readonly disabled?: boolean
244
+ }
245
+
246
+ export interface ChoiceGroupProps<T extends string> {
247
+ readonly options: readonly ChoiceOption<T>[]
248
+ readonly value: T | null
249
+ readonly onChange: (value: T) => void
250
+ readonly label: string
251
+ readonly labelHidden?: boolean
252
+ }
253
+
254
+ /**
255
+ * The few choices a setup step turns on, as cards: bigger than a radio, smaller than a page.
256
+ * Native radios underneath, so the group is one tab stop and the arrow keys move the choice.
257
+ */
258
+ export function ChoiceGroup<T extends string>({ options, value, onChange, label, labelHidden }: ChoiceGroupProps<T>) {
259
+ const name = useId()
260
+ return (
261
+ <fieldset className="desk-choices">
262
+ <legend className="desk-label" data-hidden={labelHidden || undefined}>{label}</legend>
263
+ {options.map(option => {
264
+ const id = `${name}-${option.value}`
265
+ const chosen = option.value === value
266
+ return (
267
+ <label key={option.value} htmlFor={id} className="desk-choice" data-chosen={chosen || undefined} data-disabled={option.disabled || undefined}>
268
+ <input
269
+ id={id}
270
+ type="radio"
271
+ name={name}
272
+ className="desk-choice-input"
273
+ value={option.value}
274
+ checked={chosen}
275
+ disabled={option.disabled}
276
+ aria-describedby={option.description ? `${id}-description` : undefined}
277
+ onChange={() => onChange(option.value)}
278
+ />
279
+ <span className="desk-choice-mark" aria-hidden="true" />
280
+ {option.icon && <span className="desk-choice-icon" aria-hidden="true">{option.icon}</span>}
281
+ <span className="desk-choice-text">
282
+ <span className="desk-choice-label">{option.label}{option.tag && <span className="desk-choice-tag">{option.tag}</span>}</span>
283
+ {option.description && <span id={`${id}-description`} className="desk-help">{option.description}</span>}
284
+ </span>
285
+ </label>
286
+ )
287
+ })}
288
+ </fieldset>
289
+ )
290
+ }
291
+
292
+ export interface SliderProps {
293
+ readonly value: number
294
+ readonly onChange: (value: number) => void
295
+ readonly label: string
296
+ readonly min?: number
297
+ readonly max?: number
298
+ readonly step?: number
299
+ readonly disabled?: boolean
300
+ /** How the value reads to a person: `v => \`${v} psi\``. Shown beside the label and announced. */
301
+ readonly format?: (value: number) => string
302
+ }
303
+
304
+ export function Slider({ value, onChange, label, min = 0, max = 100, step = 1, disabled, format }: SliderProps) {
305
+ const id = useId()
306
+ const shown = format ? format(value) : String(value)
307
+ return (
308
+ <div className="desk-slider">
309
+ <div className="desk-slider-head">
310
+ <label htmlFor={id} className="desk-label">{label}</label>
311
+ <output htmlFor={id} className="desk-slider-value">{shown}</output>
312
+ </div>
313
+ <input
314
+ id={id}
315
+ type="range"
316
+ className="desk-slider-track"
317
+ min={min}
318
+ max={max}
319
+ step={step}
320
+ value={value}
321
+ disabled={disabled}
322
+ aria-valuetext={format ? shown : undefined}
323
+ onChange={event => onChange(Number(event.target.value))}
324
+ />
325
+ </div>
326
+ )
327
+ }
328
+
329
+ export interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'id'> {
330
+ /** Every field has a label. A placeholder is an example, never the label. */
331
+ readonly label: string
332
+ /** Keep the label for assistive technology, hide it on screen — for a search field in a toolbar. */
333
+ readonly labelHidden?: boolean
334
+ readonly help?: ReactNode
335
+ /** What went wrong and how to fix it. Its presence marks the field invalid. */
336
+ readonly error?: ReactNode
337
+ }
338
+
339
+ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
340
+ { label, labelHidden, help, error, type = 'text', ...rest },
341
+ ref,
342
+ ) {
343
+ const id = useId()
344
+ const helpId = help ? `${id}-help` : undefined
345
+ const errorId = error ? `${id}-error` : undefined
346
+ return (
347
+ <div className="desk-field" data-invalid={error ? true : undefined}>
348
+ <label htmlFor={id} className="desk-label" data-hidden={labelHidden || undefined}>
349
+ {label}
350
+ </label>
351
+ <input
352
+ ref={ref}
353
+ id={id}
354
+ type={type}
355
+ className="desk-input"
356
+ aria-invalid={error ? true : undefined}
357
+ aria-describedby={[errorId, helpId].filter(Boolean).join(' ') || undefined}
358
+ {...rest}
359
+ />
360
+ {error && <span id={errorId} className="desk-error">{error}</span>}
361
+ {help && !error && <span id={helpId} className="desk-help">{help}</span>}
362
+ </div>
363
+ )
364
+ })
@@ -0,0 +1,208 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { KeyboardEvent, ReactNode } from 'react'
3
+ import { Button } from './controls.js'
4
+
5
+ /*
6
+ * A conversation: the thread, and the box you type into. Presentational only —
7
+ * it holds no transport and knows nothing about who is answering. Messages come
8
+ * in as props; sending, streaming and retrying are the app's to do.
9
+ */
10
+
11
+ export interface Message {
12
+ readonly id: string
13
+ /** Matches `me` on the thread for your own messages. Anything else is somebody else. */
14
+ readonly from: string
15
+ /** Shown above the first message of a run. Falls back to `from`. */
16
+ readonly authorName?: string
17
+ readonly body: ReactNode
18
+ readonly at?: Date | string
19
+ /** `sending` dims it, `failed` offers Retry. */
20
+ readonly state?: 'sending' | 'sent' | 'failed'
21
+ }
22
+
23
+ export interface ThreadProps {
24
+ readonly messages: readonly Message[]
25
+ /** Which `from` is you. Default `me`. */
26
+ readonly me?: string
27
+ /** Shown while the other side is composing: `true` for dots, or your own node. */
28
+ readonly typing?: boolean | ReactNode
29
+ readonly onRetry?: (id: string) => void
30
+ readonly empty?: ReactNode
31
+ readonly label?: string
32
+ readonly className?: string
33
+ }
34
+
35
+ const time = (at: Date | string | undefined) =>
36
+ at === undefined ? null
37
+ : typeof at === 'string' ? at
38
+ : at.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
39
+
40
+ export function Thread({ messages, me = 'me', typing, onRetry, empty, label = 'Conversation', className }: ThreadProps) {
41
+ const scroller = useRef<HTMLDivElement>(null)
42
+ const atBottom = useRef(true)
43
+ const [missed, setMissed] = useState(0)
44
+
45
+ const toBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
46
+ const element = scroller.current
47
+ if (!element) return
48
+ element.scrollTo({ top: element.scrollHeight, behavior })
49
+ atBottom.current = true
50
+ setMissed(0)
51
+ }, [])
52
+
53
+ // Follow the conversation only while the reader is at the end of it: scrolling
54
+ // back to read something must not be yanked away by the next message.
55
+ useLayoutEffect(() => {
56
+ if (atBottom.current) toBottom()
57
+ else setMissed(n => n + 1)
58
+ }, [messages.length, toBottom])
59
+
60
+ useEffect(() => {
61
+ toBottom()
62
+ }, [toBottom])
63
+
64
+ const onScroll = () => {
65
+ const element = scroller.current
66
+ if (!element) return
67
+ atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 32
68
+ if (atBottom.current) setMissed(0)
69
+ }
70
+
71
+ let previous: string | null = null
72
+ return (
73
+ <div className={['desk-thread', className].filter(Boolean).join(' ')}>
74
+ <div ref={scroller} className="desk-thread-scroll" role="log" aria-label={label} aria-live="polite" onScroll={onScroll}>
75
+ {messages.length === 0 && !typing && empty}
76
+ {messages.map(message => {
77
+ const mine = message.from === me
78
+ const runStart = message.from !== previous
79
+ previous = message.from
80
+ const stamp = time(message.at)
81
+ return (
82
+ <article
83
+ key={message.id}
84
+ className="desk-message"
85
+ data-mine={mine || undefined}
86
+ data-state={message.state ?? 'sent'}
87
+ data-run-start={runStart || undefined}
88
+ >
89
+ {runStart && !mine && <span className="desk-message-author">{message.authorName ?? message.from}</span>}
90
+ <div className="desk-bubble">{message.body}</div>
91
+ <span className="desk-message-foot">
92
+ {stamp && <time>{stamp}</time>}
93
+ {message.state === 'sending' && <span>Sending…</span>}
94
+ {message.state === 'failed' && (
95
+ <>
96
+ <span className="desk-message-failed">Not sent</span>
97
+ {onRetry && (
98
+ <Button size="small" intent="quiet" onClick={() => onRetry(message.id)}>
99
+ Retry
100
+ </Button>
101
+ )}
102
+ </>
103
+ )}
104
+ </span>
105
+ </article>
106
+ )
107
+ })}
108
+ {typing && (
109
+ <div className="desk-typing" aria-label="Typing">
110
+ {typing === true ? (
111
+ <span className="desk-typing-dots" aria-hidden="true">
112
+ <i />
113
+ <i />
114
+ <i />
115
+ </span>
116
+ ) : (
117
+ typing
118
+ )}
119
+ </div>
120
+ )}
121
+ </div>
122
+ {missed > 0 && (
123
+ <Button className="desk-thread-jump" size="small" onClick={() => toBottom('smooth')}>
124
+ {missed === 1 ? '1 new message' : `${missed} new messages`} ↓
125
+ </Button>
126
+ )}
127
+ </div>
128
+ )
129
+ }
130
+
131
+ export interface ComposerProps {
132
+ readonly value: string
133
+ readonly onChange: (value: string) => void
134
+ /** Called on Enter or Send, with the trimmed text. Clearing the box is yours to do. */
135
+ readonly onSubmit: (value: string) => void
136
+ readonly placeholder?: string
137
+ readonly label?: string
138
+ readonly disabled?: boolean
139
+ /** An answer is arriving: Send becomes Stop when `onStop` is given. */
140
+ readonly busy?: boolean
141
+ readonly onStop?: () => void
142
+ /** Beside the send button — attachments, a model picker. */
143
+ readonly accessory?: ReactNode
144
+ readonly maxRows?: number
145
+ }
146
+
147
+ export function Composer({
148
+ value,
149
+ onChange,
150
+ onSubmit,
151
+ placeholder = 'Say something…',
152
+ label = 'Message',
153
+ disabled,
154
+ busy,
155
+ onStop,
156
+ accessory,
157
+ maxRows = 8,
158
+ }: ComposerProps) {
159
+ const field = useRef<HTMLTextAreaElement>(null)
160
+
161
+ // Grow with the text, up to maxRows, then scroll.
162
+ useLayoutEffect(() => {
163
+ const element = field.current
164
+ if (!element) return
165
+ element.style.height = 'auto'
166
+ const line = parseFloat(getComputedStyle(element).lineHeight || '20') || 20
167
+ element.style.height = `${Math.min(element.scrollHeight, line * maxRows)}px`
168
+ }, [value, maxRows])
169
+
170
+ const submit = () => {
171
+ const text = value.trim()
172
+ if (!text || disabled) return
173
+ onSubmit(text)
174
+ }
175
+
176
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
177
+ // Enter sends; Shift+Enter is a new line. IME composition must never send.
178
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
179
+ event.preventDefault()
180
+ submit()
181
+ }
182
+
183
+ return (
184
+ <div className="desk-composer" data-disabled={disabled || undefined}>
185
+ <textarea
186
+ ref={field}
187
+ className="desk-composer-field"
188
+ aria-label={label}
189
+ rows={1}
190
+ value={value}
191
+ placeholder={placeholder}
192
+ disabled={disabled}
193
+ onChange={event => onChange(event.target.value)}
194
+ onKeyDown={onKeyDown}
195
+ />
196
+ {accessory}
197
+ {busy && onStop ? (
198
+ <Button aria-label="Stop" onClick={onStop}>
199
+ Stop
200
+ </Button>
201
+ ) : (
202
+ <Button intent="default" aria-label="Send" disabled={disabled || !value.trim()} onClick={submit}>
203
+ Send
204
+ </Button>
205
+ )}
206
+ </div>
207
+ )
208
+ }