@weft-js/warp 0.0.0-stage → 0.1.1

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 (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
package/src/Choice.tsx ADDED
@@ -0,0 +1,105 @@
1
+ import { indicator, uid } from '@weft-js/warp-core'
2
+ import { untrack } from '@weft-js/core'
3
+ import type { Interaction } from '@weft-js/core/interaction'
4
+ import { Icon, type IconRef } from './Icon'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import { useUi } from './use-ui'
7
+ import './fields'
8
+ import '@weft-js/warp-core/styles/inputs/choice.css'
9
+
10
+ const sweepParts = () => <><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span></>
11
+
12
+ /**
13
+ * A native checkbox with role="switch" (03 §4; SIGNATURE §1c): the rail fills through the sweep as the thumb slides.
14
+ * The whole label is the hover target. Applies at once: never inside a form with Save (03 §8).
15
+ */
16
+ export function Switch({ children, hint, size, placement = 'end', ui, ...rest }: { checked?: boolean; hint?: unknown; size?: 'sm' | 'md' | undefined; placement?: 'start' | 'end'; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
17
+ let host: HTMLLabelElement | undefined
18
+ useUi(() => ui, () => host)
19
+ return (
20
+ <label ref={n => (host = n)} data-scope="switch" data-placement={placement} data-size={size === 'sm' ? 'sm' : undefined}>
21
+ <input type="checkbox" role="switch" {...rest} />
22
+ <span data-part="track" data-sweep="">
23
+ <span data-part="rail-fill" aria-hidden="true"><span data-part="rail-hover"></span></span>
24
+ {sweepParts()}
25
+ <span data-part="thumb" aria-hidden="true"></span>
26
+ </span>
27
+ <span data-part="label">{children}</span>
28
+ {hint !== undefined && <span data-part="hint">{hint}</span>}
29
+ </label>
30
+ )
31
+ }
32
+
33
+ /** A native checkbox (03 §4): the box never fills, the check draws in; `indeterminate` is the real property. */
34
+ export function Checkbox({ children, hint, error, indeterminate, ui, ...rest }: { checked?: boolean; indeterminate?: boolean; hint?: unknown; error?: unknown; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
35
+ let input: HTMLInputElement | undefined
36
+ let host: HTMLLabelElement | undefined
37
+ useUi(() => ui, () => host)
38
+ use((el: HTMLInputElement, on: boolean | undefined) => {
39
+ el.indeterminate = !!on
40
+ return { update: v => void (el.indeterminate = !!v), destroy() {} }
41
+ }, () => input, () => indeterminate)
42
+ return (
43
+ <label ref={n => (host = n)} data-scope="checkbox" data-invalid={error ? '' : undefined}>
44
+ <input ref={n => (input = n)} type="checkbox" aria-invalid={error ? 'true' : undefined} {...rest} />
45
+ <span data-part="control" data-sweep="">
46
+ {sweepParts()}
47
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path data-part="check" pathLength="1" d="M5 12.5 L9.5 17 L19 7" /><path data-part="dash" pathLength="1" d="M6 12h12" /></svg>
48
+ </span>
49
+ <span data-part="label">{children}</span>
50
+ {(error ?? hint) !== undefined && <span data-part="hint">{error ?? hint}</span>}
51
+ </label>
52
+ )
53
+ }
54
+
55
+ /** One radio (03 §4): the native input, the sweep ring, a dot that springs in. Use RadioGroup for a set. */
56
+ export function Radio({ children, hint, ui, ...rest }: { hint?: unknown; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
57
+ let host: HTMLLabelElement | undefined
58
+ useUi(() => ui, () => host)
59
+ return (
60
+ <label ref={n => (host = n)} data-scope="radio">
61
+ <input type="radio" {...rest} />
62
+ <span data-part="control" data-sweep="">{sweepParts()}</span>
63
+ <span data-part="label">{children}</span>
64
+ {hint !== undefined && <span data-part="hint">{hint}</span>}
65
+ </label>
66
+ )
67
+ }
68
+
69
+ /** One RadioGroup choice. */
70
+ export type RadioOption<V extends string> = { value: V; label: unknown; hint?: unknown; disabled?: boolean | undefined }
71
+
72
+ /** A radio group: native radios, so arrow keys, form values and the group name are the platform's. */
73
+ export function RadioGroup<V extends string>({ label, value, onChange, options, name }: { label: string; value: V; onChange: (v: V) => void; options: RadioOption<V>[]; name?: string | undefined }) {
74
+ const group = untrack(() => name) ?? uid('radio')
75
+ return (
76
+ <div role="radiogroup" aria-label={label} style="display:grid;gap:var(--warp-space-tight)">
77
+ {options.map(o => <Radio key={o.value} name={group} value={o.value} hint={o.hint} checked={o.value === value} disabled={o.disabled} onChange={() => onChange(o.value)}>{o.label}</Radio>)}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ /** One Segmented choice. */
83
+ export type SegmentedOption<V extends string> = { value: V; label: unknown; icon?: IconRef | undefined; count?: unknown; disabled?: boolean | undefined }
84
+
85
+ /**
86
+ * Segmented (03 §4, review 01 M8): native radios in a group that rests on its ink border; hovering or focusing an
87
+ * unselected option sweeps a ring round that option only; the selected face slides to it on the sweep curve.
88
+ */
89
+ export function Segmented<V extends string>({ label, value, onChange, options, size, name }: { label: string; value: V; onChange: (v: V) => void; options: SegmentedOption<V>[]; size?: 'sm' | 'md' | undefined; name?: string | undefined }) {
90
+ const group = untrack(() => name) ?? uid('seg')
91
+ let root: HTMLDivElement | undefined
92
+ use(indicator, () => root, () => ({ selected: ':scope > label:has(input:checked)', at: value }))
93
+ return (
94
+ <div ref={n => (root = n)} data-scope="segmented" role="radiogroup" aria-label={label} data-size={size === 'sm' ? 'sm' : undefined}>
95
+ <span data-part="indicator" aria-hidden="true" data-instant=""></span>
96
+ {options.map(o => (
97
+ <label key={o.value} data-sweep="">
98
+ <span data-part="sweep" aria-hidden="true"></span>
99
+ <input type="radio" name={group} value={o.value} checked={o.value === value} disabled={o.disabled} onChange={() => onChange(o.value)} />
100
+ <span data-part="item">{o.icon && <Icon name={o.icon} />}{o.label}{o.count !== undefined && <span data-part="count">{o.count}</span>}</span>
101
+ </label>
102
+ ))}
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,65 @@
1
+ import { CopyIcon, CheckIcon } from '@weft-js/warp-icons'
2
+ import { highlight } from '@weft-js/warp-core'
3
+ import './styles'
4
+ import '@weft-js/warp-core/styles/components/button.css'
5
+ import '@weft-js/warp-core/styles/components/copy.css'
6
+ import '@weft-js/warp-core/styles/rich/codeblock.css'
7
+ import '@weft-js/warp-core/styles/rich/code-tabs.css'
8
+
9
+ /** Props for CodeBlock. */
10
+ export interface CodeBlockProps {
11
+ /** The source to show, colour and copy. */
12
+ code: string
13
+ /** tsx by default; ts, js, json and sh are coloured too, anything else stays plain. */
14
+ language?: string | undefined
15
+ /** A file name shown in the bar above the code. */
16
+ filename?: string | undefined
17
+ /** Line numbers. */
18
+ lines?: boolean | undefined
19
+ /** Wrap long lines instead of scrolling. */
20
+ wrap?: boolean | undefined
21
+ }
22
+
23
+ function CopyButton({ text }: { text?: string | undefined }) {
24
+ return (
25
+ <button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-size="sm" data-act="copy" data-copy="" data-copy-text={text} aria-label="Copy code">
26
+ <span data-part="indicator"><CopyIcon /><CheckIcon /></span>
27
+ </button>
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Code, coloured on the server (04 CodeBlock). It has no state, so it hydrates to nothing: `copyButtons()` from
33
+ * `@weft-js/warp-core`, called once anywhere in the app, runs every copy button in the document.
34
+ */
35
+ export function CodeBlock({ code, language = 'tsx', filename, lines, wrap }: CodeBlockProps) {
36
+ return (
37
+ <figure data-scope="codeblock" data-wrap={wrap ? '' : undefined}>
38
+ <div data-part="head">
39
+ {filename && <span data-part="file">{filename}</span>}
40
+ <span class="sp"></span>
41
+ <span data-part="lang">{language}</span>
42
+ <CopyButton />
43
+ </div>
44
+ <pre tabindex="0" data-lines={lines ? '' : undefined}><code
45
+ // oxlint-disable-next-line weft/no-html-sink -- highlight escapes the source before it wraps tokens in spans
46
+ innerHTML={highlight(code, language)}></code></pre>
47
+ </figure>
48
+ )
49
+ }
50
+
51
+ /** Inline code in running text. */
52
+ export function Code({ children }: { children: unknown }) {
53
+ return <code data-scope="code">{children}</code>
54
+ }
55
+
56
+ /** One command to copy, with its `$` (04 Snippet). */
57
+ export function Snippet({ code, prompt = '$' }: { code: string; prompt?: string }) {
58
+ return (
59
+ <div data-scope="snippet">
60
+ <span aria-hidden="true">{prompt}</span>
61
+ <code>{code}</code>
62
+ <CopyButton text={code} />
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,130 @@
1
+ import { codeLayer, indented, jsonProblem, type ChangeReason } from '@weft-js/warp-core'
2
+ import { derived, onCleanup, state, untrack } from '@weft-js/core'
3
+ import { bindable, type Bindable } from './bind'
4
+ import { Button } from './Button'
5
+ import { Field, type FieldShell } from './Field'
6
+ import { use } from '@weft-js/warp-core/weft'
7
+ import '@weft-js/warp-core/styles/rich/body.css'
8
+ import '@weft-js/warp-core/styles/rich/code-input.css'
9
+
10
+ type CodeOwn = Bindable<string> & {
11
+ /** After a form's reset put the value back. */
12
+ onReset?: () => void
13
+ language?: string | undefined
14
+ /** Numbers each line in a gutter. Default `true`; off while `wrap` is on. */
15
+ lineNumbers?: boolean
16
+ tabSize?: 2 | 4
17
+ /** Soft-wraps long lines; line numbers then give way, since a number no longer means one row. */
18
+ wrap?: boolean
19
+ rows?: number
20
+ /** The line to mark in the gutter. */
21
+ errorLine?: number | undefined
22
+ name?: string | undefined
23
+ }
24
+
25
+ /** Props for CodeInput: the field shell, the bound text and the editor's own options. */
26
+ export type CodeInputProps = FieldShell & CodeOwn
27
+
28
+ const LEAVE = 'Tab indents · Esc then Tab leaves'
29
+
30
+ /**
31
+ * A code field (04 CodeInput; inputs-rich.html:2223-2241): a <textarea> over a layer coloured with the CSS Custom
32
+ * Highlight API, so a key press builds no spans; browsers without it show the textarea's own plain text. Tab indents,
33
+ * Shift+Tab outdents, and Esc then Tab leaves, which the hint says. No autocorrect, spellcheck or autocapitalize.
34
+ */
35
+ export function CodeInput({ value, defaultValue, onValueChange, language, lineNumbers = true, tabSize = 2, wrap, rows = 8, errorLine, name, hint, onReset, ...rest }: CodeInputProps) {
36
+ const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
37
+ const lines = derived(() => held.value.split('\n').map((_, n) => n + 1))
38
+ const numbered = derived(() => lineNumbers && !wrap)
39
+ let box: HTMLDivElement | undefined
40
+ let leaving = false
41
+ const set = (area: HTMLTextAreaElement) => held.set(area.value, 'keyboard')
42
+ const onKey = (e: KeyboardEvent) => {
43
+ const area = e.currentTarget as HTMLTextAreaElement
44
+ if (e.key === 'Escape') { leaving = true; return }
45
+ if (e.key !== 'Tab') { if (e.key !== 'Shift') leaving = false; return }
46
+ if (leaving) { leaving = false; return }
47
+ e.preventDefault()
48
+ const r = indented(area.value, area.selectionStart, area.selectionEnd, tabSize, e.shiftKey)
49
+ area.setSelectionRange(r.from, r.to)
50
+ // execCommand keeps the browser's undo history, which setRangeText throws away
51
+ if (!document.execCommand?.('insertText', false, r.insert)) area.setRangeText(r.insert, r.from, r.to, 'end')
52
+ area.setSelectionRange(r.start, r.end)
53
+ set(area)
54
+ }
55
+ use(codeLayer, () => box, () => ({ text: held.value, language }))
56
+ return (
57
+ <Field
58
+ {...rest}
59
+ hint={hint ?? LEAVE}
60
+ attrs={{ 'data-multiline': '' }}
61
+ filled={held.value.length > 0}
62
+ reset={() => { held.reset(); onReset?.() }}
63
+ control={p => (
64
+ <div data-part="body">
65
+ <div ref={n => (box = n)} class="code-in" data-wrap={wrap ? '' : undefined} style={`--_tab:${tabSize}`}>
66
+ {numbered.value && <ol data-part="lines" class="ln" aria-hidden="true">{lines.value.map(n => <li key={n} data-error={n === errorLine ? '' : undefined}>{n}</li>)}</ol>}
67
+ <div data-part="surface">
68
+ <pre data-part="layer" aria-hidden="true">{`${held.value}\n`}</pre>
69
+ <textarea {...p.rest} data-part="input" id={p.id} rows={rows} name={name} spellcheck={false} autocapitalize="off" autocomplete="off" wrap={wrap ? 'soft' : 'off'} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} value={held.value} onInput={e => set(e.currentTarget)} onKeyDown={onKey}>{untrack(() => held.value)}</textarea>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ )}
74
+ />
75
+ )
76
+ }
77
+
78
+ /** Props for JsonEditor: CodeInput with the language fixed to JSON, and a schema check. */
79
+ export type JsonEditorProps = FieldShell & Omit<CodeOwn, 'language' | 'errorLine'> & {
80
+ /** Checks parsed JSON; what it returns shows as the field's error. */
81
+ schema?: (value: unknown) => string | null | undefined
82
+ }
83
+
84
+ /**
85
+ * CodeInput for JSON (04 JsonEditor): parse errors, and an optional schema's, name their line in the field's error and
86
+ * pin the sweep; Format rewrites it with `tabSize`.
87
+ */
88
+ export function JsonEditor({ value, defaultValue, onValueChange, schema, tabSize = 2, ...rest }: JsonEditorProps) {
89
+ const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
90
+ const checked = state(held.value)
91
+ let timer: ReturnType<typeof setTimeout> | undefined
92
+ onCleanup(() => clearTimeout(timer))
93
+ const change = (v: string, reason: ChangeReason) => {
94
+ held.set(v, reason)
95
+ clearTimeout(timer)
96
+ timer = setTimeout(() => (checked.value = v), 250)
97
+ }
98
+ const problem = derived(() => (checked.value.trim() ? jsonProblem(checked.value) : null))
99
+ const failed = derived(() => {
100
+ const p = problem.value
101
+ if (p) return `Invalid JSON${p.line ? ` on line ${p.line}` : ''}: ${p.message}`
102
+ if (!schema || !checked.value.trim()) return undefined
103
+ return schema(JSON.parse(checked.value)) ?? undefined
104
+ })
105
+ const summary = derived(() => {
106
+ if (!checked.value.trim()) return LEAVE
107
+ const o = JSON.parse(checked.value)
108
+ return `Valid JSON · ${o && typeof o === 'object' ? `${Object.keys(o).length} ${Object.keys(o).length === 1 ? 'key' : 'keys'}` : typeof o} · ${LEAVE}`
109
+ })
110
+ const format = () => {
111
+ if (jsonProblem(held.value)) return
112
+ const next = JSON.stringify(JSON.parse(held.value), null, untrack(() => tabSize))
113
+ held.set(next, 'pointer')
114
+ checked.value = next
115
+ }
116
+ return (
117
+ <CodeInput
118
+ {...(rest)}
119
+ tabSize={tabSize}
120
+ language="json"
121
+ value={held.value}
122
+ onValueChange={change}
123
+ onReset={() => { clearTimeout(timer); checked.value = held.value }}
124
+ error={failed.value}
125
+ hint={problem.value ? LEAVE : summary.value}
126
+ errorLine={problem.value?.line}
127
+ end={<span data-part="suffix"><Button emphasis="ghost" tone="neutral" size="sm" disabled={!!problem.value} onClick={format}>Format</Button></span>}
128
+ />
129
+ )
130
+ }
@@ -0,0 +1,113 @@
1
+ import { comboPopup, uid } from '@weft-js/warp-core'
2
+ import { derived, flush, onCleanup, state, untrack } from '@weft-js/core'
3
+ import { Field, type FieldProps } from './Field'
4
+ import { Icon, type IconRef } from './Icon'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import '@weft-js/warp-core/styles/rich/listbox.css'
7
+ import '@weft-js/warp-core/styles/rich/popover.css'
8
+
9
+ export type ComboRow = { main: string; sub?: string | undefined; meta?: string | undefined; icon?: IconRef | undefined }
10
+
11
+ export type ComboFieldProps<T> = FieldProps & {
12
+ /** The app's search. Superseded calls are aborted. */
13
+ find: (q: string, signal: AbortSignal) => Promise<T[]>
14
+ row: (hit: T) => ComboRow
15
+ onPick: (hit: T) => unknown
16
+ /** The text changed: by typing, or cleared by a second Esc. */
17
+ onText?: (text: string) => void
18
+ /** The search failed or took longer than `timeout` ms. */
19
+ onFail?: () => void
20
+ text: () => string
21
+ minChars?: number
22
+ timeout?: number
23
+ /** Announced in the hint while there is no search running. */
24
+ status?: () => string | undefined
25
+ }
26
+
27
+ /**
28
+ * A text field with a list popup (04 §3, APG Combobox, list autocomplete with automatic selection): focus stays in the
29
+ * input. Typing `minChars` opens the list with the first result active, ↓/↑ wrap, Enter picks, Esc closes and keeps the
30
+ * text, a second Esc clears it, ↓ on a closed combobox searches again. Requests are debounced 120ms and aborted when
31
+ * superseded. Shared by PlaceLookupInput and AddressField's line 1.
32
+ */
33
+ export function ComboField<T>({ find, row, onPick, onText, onFail, text, minChars = 2, timeout, status, hint, ...rest }: ComboFieldProps<T>) {
34
+ const id = uid('combo')
35
+ const hits = state<T[]>([])
36
+ const active = state(0)
37
+ const open = state(false)
38
+ const busy = state(false)
39
+ const rows = derived(() => hits.value.map((hit, at) => ({ at, r: untrack(() => row)(hit) })))
40
+ let input: HTMLInputElement | undefined
41
+ let pop: HTMLDivElement | undefined
42
+ let timer: ReturnType<typeof setTimeout> | undefined
43
+ let stop: AbortController | undefined
44
+ const cancel = () => { clearTimeout(timer); stop?.abort(); stop = undefined; busy.value = false }
45
+ onCleanup(cancel)
46
+ use(comboPopup, () => pop, () => ({ open: open.value, anchor: (input?.closest('[data-part="control"]') ?? input) as HTMLElement, onClose: () => (open.value = false) }))
47
+ const run = (q: string) => {
48
+ cancel()
49
+ if (q.trim().length < minChars) { open.value = false; hits.value = []; return }
50
+ // oxlint-disable-next-line typescript/no-misused-promises -- the body catches its own failure, and a sync wrapper would add a closure to a hot field
51
+ timer = setTimeout(async () => {
52
+ const me = (stop = new AbortController())
53
+ busy.value = true
54
+ const late = timeout ? setTimeout(() => me.abort(), timeout) : undefined
55
+ try {
56
+ const r = await find(q.trim(), me.signal)
57
+ if (me !== stop) return
58
+ hits.value = r
59
+ active.value = 0
60
+ open.value = r.length > 0
61
+ } catch {
62
+ if (me === stop || me.signal.aborted) { open.value = false; onFail?.() }
63
+ } finally {
64
+ clearTimeout(late)
65
+ if (me === stop) busy.value = false
66
+ }
67
+ }, 120)
68
+ }
69
+ const pick = (at: number) => { open.value = false; onPick(hits.value[at]!) }
70
+ const onKey = (e: KeyboardEvent) => {
71
+ const el = e.currentTarget as HTMLInputElement
72
+ if (e.key === 'Escape') {
73
+ if (e.defaultPrevented) return // an overlay took it: this popup, or the dialog around a closed one
74
+ e.preventDefault()
75
+ if (el.value) { cancel(); onText?.('') }
76
+ } else if (e.key === 'ArrowDown' && !open.value) { e.preventDefault(); run(el.value) }
77
+ else if (open.value && hits.value.length) {
78
+ const d = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
79
+ if (d) {
80
+ e.preventDefault()
81
+ active.value = (active.value + d + hits.value.length) % hits.value.length
82
+ flush()
83
+ pop?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' })
84
+ } else if (e.key === 'Enter') { e.preventDefault(); pick(active.value) }
85
+ }
86
+ }
87
+ const shownHint = derived(() => (busy.value ? 'Searching…' : status?.() ?? hint))
88
+ return (
89
+ <Field
90
+ {...rest}
91
+ hint={shownHint.value}
92
+ filled={text().length > 0}
93
+ hold={open.value}
94
+ control={p => (
95
+ <input ref={n => (input = n)} {...p.rest} data-part="input" id={p.id} placeholder=" " role="combobox" aria-autocomplete="list" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} aria-activedescendant={open.value && hits.value.length ? `${id}-${active.value}` : undefined} aria-busy={busy.value ? 'true' : undefined} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} autocomplete={p.rest.autocomplete ?? 'off'} value={text()} onInput={e => { const v = (e.currentTarget).value; onText?.(v); run(v) }} onKeyDown={onKey} />
96
+ )}
97
+ end={
98
+ <div ref={n => (pop = n)} data-scope="popover" data-kind="combo" data-sweep="" popover="manual" role="presentation" onPointerDown={e => e.preventDefault()}>
99
+ <span data-part="sweep" aria-hidden="true"></span>
100
+ <ul data-part="listbox" role="listbox" id={id} aria-label={rest.label}>
101
+ {rows.value.map(({ at, r }) => (
102
+ <li key={`${at}:${r.main}`} role="option" id={`${id}-${at}`} aria-selected={at === active.value ? 'true' : 'false'} data-active={at === active.value ? '' : undefined} onClick={() => pick(at)}>
103
+ {r.icon && <Icon name={r.icon} />}
104
+ <span>{r.main}{r.sub && <small> {r.sub}</small>}</span>
105
+ {r.meta && <span data-part="opt-meta">{r.meta}</span>}
106
+ </li>
107
+ ))}
108
+ </ul>
109
+ </div>
110
+ }
111
+ />
112
+ )
113
+ }