@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,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
|
+
}
|