@weft-js/warp 0.0.0-stage → 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 (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/Field.tsx ADDED
@@ -0,0 +1,147 @@
1
+ import { AlertIcon, SearchIcon, XIcon } from '@weft-js/warp-icons'
2
+ import { field, formInvalid, formReset, labelChars, uid } from '@weft-js/warp-core'
3
+ import { derived, state, untrack } from '@weft-js/core'
4
+ import { Icon, type IconRef } from './Icon'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import './fields'
7
+ import '@weft-js/warp-core/styles/inputs/text.css'
8
+
9
+ interface FieldBase {
10
+ /** The accessible name, drawn as the floating border label (or above, classic). Required. */
11
+ label: string
12
+ hint?: unknown
13
+ /** Shown instead of the hint; marks the field invalid. */
14
+ error?: unknown
15
+ /** Leading icon: it sweeps with the border. */
16
+ icon?: IconRef | undefined
17
+ /** Text or an element inside the field before the value, such as `$`. */
18
+ prefix?: unknown
19
+ /** Text or an element inside the field after the value, such as `kg`. */
20
+ suffix?: unknown
21
+ /** floating (default, or the app's `warp({ fields })`) · classic: the label above. */
22
+ variant?: 'floating' | 'classic' | undefined
23
+ /** `line`: only a bottom line under the value, no box or fill. The boxed field otherwise. */
24
+ appearance?: 'line' | undefined
25
+ size?: 'sm' | 'md' | 'lg' | undefined
26
+ /** A clear button (shown once there is a value). */
27
+ clearable?: boolean | undefined
28
+ /** Maximum length, shown as a counter. */
29
+ counter?: number | undefined
30
+ id?: string | undefined
31
+ value?: string | undefined
32
+ disabled?: boolean | undefined
33
+ /** Shows the value and lets it be selected and copied, but not changed. */
34
+ readonly?: boolean | undefined
35
+ onInput?: ((e: InputEvent & { currentTarget: HTMLInputElement & HTMLTextAreaElement }) => void) | undefined
36
+ }
37
+
38
+ /** Props for the text fields; anything else lands on the control. */
39
+ export interface FieldProps extends FieldBase { [attr: string]: unknown }
40
+
41
+ /** FieldProps for a component with its own value type: Omit on FieldProps would drop what the index signature hides. */
42
+ export type FieldShell = Omit<FieldBase, 'value' | 'defaultValue'> & { [attr: string]: unknown }
43
+
44
+ // `invalid` is a getter and `rest` the live props: the control is made once and its attributes update in place
45
+ export type Control = (p: { id: string; readonly describedby?: string | undefined; readonly invalid: boolean; rest: Record<string, unknown> }) => unknown
46
+
47
+ /**
48
+ * The field shell (03 §1 anatomy, verbatim order): control (sweep-base, sweep, icon, label, prefix, input, suffix,
49
+ * clear, end slot) then message (hint | error, counter). Everything is in the server HTML, label letters included;
50
+ * after mount warp-core's field() measures the label's per-letter delays and notch and wires clear, counter and focus.
51
+ */
52
+ export function Field({ label, hint, error, icon, prefix, suffix, variant, appearance, size, clearable, counter, id: given, control, end, filled, attrs, group, labelled, hold, wrap, reset, ...rest }: FieldProps & { control: Control; end?: unknown; filled?: boolean; attrs?: Record<string, unknown>; group?: boolean; /** The control takes its name from `aria-labelledby`, not `for`: a press on the label must not click a button. */ labelled?: boolean; hold?: boolean; wrap?: boolean; /** Puts held state back when the field's form resets. */ reset?: () => void }) {
53
+ const id = untrack(() => given) ?? uid('field')
54
+ // the browser's message for a constraint that failed (03 §1.3): `error` wins over it
55
+ const missing = state<string | undefined>(undefined)
56
+ const problem = derived(() => error ?? missing.value)
57
+ const glyph = derived(() => icon)
58
+ // an error can arrive long after mount, so the block and what points at it follow it
59
+ const message = derived(() => hint !== undefined || !!problem.value || counter !== undefined)
60
+ const msg = derived(() => (message.value ? `${id}-msg` : undefined))
61
+ // keyed by position: a label repeats letters
62
+ const chars = derived(() => labelChars(String((label as string | number | undefined) ?? '')).map((c, at) => ({ c, at })))
63
+ // one omit() for every read: the compiler expands `rest` afresh at each use (the proxy reads stay live)
64
+ const live = untrack(() => rest)
65
+ let root: HTMLDivElement | undefined
66
+ const shell = { id, get describedby() { return msg.value }, get invalid() { return !!problem.value }, rest: live }
67
+ use(field, () => root, () => [live.value, label] as const)
68
+ use(formInvalid, () => root, () => (m: string | undefined) => (missing.value = m))
69
+ if (untrack(() => reset)) use(formReset, () => root, () => () => reset!())
70
+ return (
71
+ <div
72
+ ref={n => (root = n)}
73
+ data-scope="field"
74
+ data-variant={variant}
75
+ data-appearance={appearance}
76
+ data-size={size === 'md' ? undefined : size}
77
+ data-counter={counter}
78
+ data-invalid={problem.value ? '' : undefined}
79
+ data-disabled={live.disabled ? '' : undefined}
80
+ data-readonly={live.readonly ? '' : undefined}
81
+ {...attrs}
82
+ >
83
+ <div data-part="control" data-icon={typeof glyph.value === 'string' ? glyph.value : glyph.value ? '' : undefined} data-sweep="" data-filled={filled ?? live.value ? '' : undefined} data-hold={hold ? '' : undefined} data-wrap={wrap ? '' : undefined}>
84
+ <span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
85
+ {glyph.value && <span data-part="icon" aria-hidden="true"><span data-part="sweep-base"><Icon name={glyph.value} /></span><span data-part="sweep"><Icon name={glyph.value} /></span></span>}
86
+ <label data-part="label" for={group || labelled ? undefined : id} id={group || labelled ? `${id}-l` : undefined}>
87
+ <span data-part="label-text">{label}</span>
88
+ <span data-part="label-chars" aria-hidden="true">{chars.value.map(x => <span key={x.at} data-char data-c={x.c}></span>)}</span>
89
+ </label>
90
+ {prefix !== undefined && <span data-part="prefix">{prefix}</span>}
91
+ {group
92
+ ? <div data-part="body" role="group" aria-labelledby={`${id}-l`} aria-describedby={msg.value} aria-invalid={problem.value ? 'true' : undefined}>{untrack(() => control(shell))}</div>
93
+ : untrack(() => control(shell))}
94
+ {suffix !== undefined && <span data-part="suffix">{suffix}</span>}
95
+ {clearable && <button type="button" data-part="clear" tabindex="-1" aria-label={`Clear ${label}`}><XIcon /></button>}
96
+ {end}
97
+ </div>
98
+ {message.value && (
99
+ <div data-part="message" id={msg.value}>
100
+ {problem.value ? <span data-part="error" role="alert"><AlertIcon /><span>{problem.value}</span></span> : hint !== undefined && <span data-part="hint">{hint}</span>}
101
+ {counter !== undefined && <span data-part="counter" aria-live="off"></span>}
102
+ </div>
103
+ )}
104
+ </div>
105
+ )
106
+ }
107
+
108
+ /** A text field. Every native input attribute passes through (type, autocomplete, inputmode, onInput…). */
109
+ export function Input({ placeholder, ...props }: FieldProps & { placeholder?: string; type?: string }) {
110
+ return (
111
+ <Field
112
+ {...props}
113
+ control={p => (
114
+ // a placeholder, " " at least: the label floats on :placeholder-shown (03 §1 decision 3)
115
+ <input data-part="input" id={p.id} placeholder={placeholder ?? ' '} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} {...p.rest} />
116
+ )}
117
+ />
118
+ )
119
+ }
120
+
121
+ /** A search field: the search icon, type=search, a clear button (03 §3 SearchInput; suggestions come with Combobox). */
122
+ export function Search({ placeholder, ...props }: FieldProps & { placeholder?: string }) {
123
+ return (
124
+ <Field
125
+ icon={SearchIcon}
126
+ clearable
127
+ {...props}
128
+ attrs={{ role: 'search' }}
129
+ control={p => (
130
+ <input data-part="input" id={p.id} type="search" enterkeyhint="search" autocomplete="off" placeholder={placeholder ?? ' '} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} {...p.rest} />
131
+ )}
132
+ />
133
+ )
134
+ }
135
+
136
+ /** A textarea: grows from `rows` lines (3) to 10 or `rows`, Cmd/Ctrl+Enter submits its form (03 §3). */
137
+ export function Textarea({ placeholder, rows = 3, ...props }: FieldProps & { placeholder?: string; rows?: number }) {
138
+ return (
139
+ <Field
140
+ {...props}
141
+ control={p => (
142
+ // field-sizing: content ignores the rows attribute, so the height reads it from --_rows
143
+ <textarea data-part="input" id={p.id} rows={rows} style={`--_rows:${rows}`} enterkeyhint="enter" placeholder={placeholder ?? ' '} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} {...p.rest}>{untrack(() => p.rest.value)}</textarea>
144
+ )}
145
+ />
146
+ )
147
+ }
package/src/Icon.tsx ADDED
@@ -0,0 +1,8 @@
1
+ // warp's `icon` props: a name, or an icon from @weft-js/warp-icons. warp's own fixed glyphs import their icon directly.
2
+ import './styles'
3
+ import { AlertIcon, AlertCircleIcon, CheckCircleIcon, InfoIcon } from '@weft-js/warp-icons'
4
+
5
+ /** `<svg data-icon aria-hidden focusable=false>`: decorative, never named (03 §7); `label` names it. */
6
+ export { Icon, type IconName, type IconRef } from '@weft-js/warp-icons/icon'
7
+
8
+ export const toneIcon = { info: InfoIcon, neutral: InfoIcon, accent: InfoIcon, positive: CheckCircleIcon, caution: AlertIcon, critical: AlertCircleIcon } as const
package/src/Kbd.tsx ADDED
@@ -0,0 +1,6 @@
1
+ import './styles'
2
+
3
+ /** One key (05 §6): `<Kbd>⌘</Kbd><Kbd>K</Kbd>`. */
4
+ export function Kbd({ children }: { children: unknown }) {
5
+ return <kbd data-scope="kbd">{children}</kbd>
6
+ }
@@ -0,0 +1,114 @@
1
+ import { XIcon } from '@weft-js/warp-icons'
2
+ import { flush, onCleanup, state, untrack } from '@weft-js/core'
3
+ import { formReset } from '@weft-js/warp-core'
4
+ import { bindable, type Bindable } from './bind'
5
+ import { Button } from './Button'
6
+ import { Segmented } from './Choice'
7
+ import { JsonEditor } from './CodeInput'
8
+ import { Field, type FieldShell } from './Field'
9
+ import { use } from '@weft-js/warp-core/weft'
10
+ import '@weft-js/warp-core/styles/rich/body.css'
11
+ import '@weft-js/warp-core/styles/rich/key-value.css'
12
+
13
+ /** One row of a KeyValueEditor. */
14
+ export type KeyValue = { key: string; value: string }
15
+
16
+ /** Props for KeyValueEditor. */
17
+ export type KeyValueEditorProps = FieldShell & Bindable<KeyValue[]> & {
18
+ keyLabel?: string
19
+ valueLabel?: string
20
+ /** A Form ⇄ JSON switch; both keep the value. */
21
+ json?: boolean
22
+ }
23
+
24
+ const toJson = (rows: KeyValue[]) => JSON.stringify(Object.fromEntries(rows.filter(r => r.key).map(r => [r.key, r.value])), null, 2)
25
+
26
+ /**
27
+ * A table of `key | value | remove` rows with Add row (04 KeyValueEditor). A duplicate key is a caution, not a block.
28
+ * With `json`, a Form ⇄ JSON switch shows the same value as a JsonEditor. Rows keep their identity, so typing never
29
+ * loses focus; adding focuses the new key, removing the row before.
30
+ */
31
+ export function KeyValueEditor({ value, defaultValue, onValueChange, keyLabel = 'Key', valueLabel = 'Value', json, hint, ...rest }: KeyValueEditorProps) {
32
+ const held = bindable<KeyValue[]>(() => value, () => defaultValue, [], () => onValueChange)
33
+ let serial = 0
34
+ const ids = state(untrack(() => held.value.map(() => ++serial)))
35
+ const mode = state<'form' | 'json'>('form')
36
+ const text = state('')
37
+ let body: HTMLElement | undefined
38
+ let timer: ReturnType<typeof setTimeout> | undefined
39
+ onCleanup(() => clearTimeout(timer))
40
+ const dup = (rows: KeyValue[], at: number) => !!rows[at]!.key && rows.findIndex(r => r.key === rows[at]!.key) !== at
41
+ const hasDup = () => held.value.some((_, at) => dup(held.value, at))
42
+ const edit = (at: number, part: 'key' | 'value', v: string) => held.set(held.value.map((r, n) => (n === at ? { ...r, [part]: v } : r)), 'keyboard')
43
+ const add = () => {
44
+ held.set([...held.value, { key: '', value: '' }], 'pointer')
45
+ ids.value = [...ids.value, ++serial]
46
+ flush()
47
+ body?.querySelector<HTMLElement>('tbody tr:last-child input')?.focus()
48
+ }
49
+ const remove = (at: number) => {
50
+ held.set(held.value.filter((_, n) => n !== at), 'pointer')
51
+ ids.value = ids.value.filter((_, n) => n !== at)
52
+ flush()
53
+ const left = body?.querySelectorAll<HTMLElement>('[data-part="remove"]')
54
+ ;(left?.[Math.min(at, (left?.length ?? 1) - 1)] ?? body?.querySelector<HTMLElement>('[data-part="add"]'))?.focus()
55
+ }
56
+ const flipMode = (m: 'form' | 'json') => {
57
+ if (m === 'json') text.value = toJson(held.value)
58
+ mode.value = m
59
+ }
60
+ const fromJson = (v: string) => {
61
+ text.value = v
62
+ clearTimeout(timer)
63
+ timer = setTimeout(() => {
64
+ try {
65
+ const o = JSON.parse(v)
66
+ if (!o || typeof o !== 'object' || Array.isArray(o)) return
67
+ const rows = Object.entries(o).map(([key, val]) => ({ key, value: typeof val === 'string' ? val : JSON.stringify(val) }))
68
+ held.set(rows, 'keyboard')
69
+ ids.value = rows.map(() => ++serial)
70
+ } catch { /* the editor shows the error; the last good value stands */ }
71
+ }, 250)
72
+ }
73
+ const switcher = untrack(() => json) ? <Segmented label={`${rest.label} format`} size="sm" value={mode.value} options={[{ value: 'form', label: 'Form' }, { value: 'json', label: 'JSON' }]} onChange={m => flipMode(m)} /> : undefined
74
+ let root: HTMLDivElement | undefined
75
+ use(formReset, () => root, () => () => {
76
+ clearTimeout(timer)
77
+ held.reset()
78
+ ids.value = held.value.map(() => ++serial)
79
+ text.value = toJson(held.value)
80
+ })
81
+ return (
82
+ <div ref={n => (root = n)} data-scope="kv">
83
+ {switcher}
84
+ {mode.value === 'json'
85
+ ? <JsonEditor {...rest} value={text.value} onValueChange={fromJson} />
86
+ : (
87
+ <Field
88
+ {...rest}
89
+ group
90
+ filled
91
+ hint={hasDup() ? 'That key is already here: the last one wins.' : hint}
92
+ attrs={{ 'data-multiline': '' }}
93
+ control={() => (
94
+ <div ref={n => (body = n)}>
95
+ <table>
96
+ <thead><tr><th scope="col">{keyLabel}</th><th scope="col">{valueLabel}</th><th scope="col"><span data-part="sr-only">Remove</span></th></tr></thead>
97
+ <tbody>
98
+ {ids.value.map((rowId, at) => (
99
+ <tr key={rowId}>
100
+ <td><input aria-label={`${keyLabel}, row ${at + 1}`} value={held.value[at]?.key ?? ''} data-duplicate={held.value[at] && dup(held.value, at) ? '' : undefined} onInput={(e: Event) => edit(at, 'key', (e.currentTarget as HTMLInputElement).value)} /></td>
101
+ <td><input aria-label={`${valueLabel}, row ${at + 1}`} value={held.value[at]?.value ?? ''} onInput={(e: Event) => edit(at, 'value', (e.currentTarget as HTMLInputElement).value)} /></td>
102
+ <td><button type="button" data-part="remove" aria-label={`Remove row ${at + 1}`} onClick={() => remove(at)}><XIcon /></button></td>
103
+ </tr>
104
+ ))}
105
+ </tbody>
106
+ </table>
107
+ <Button emphasis="ghost" tone="neutral" size="sm" data-part="add" onClick={add}>Add row</Button>
108
+ </div>
109
+ )}
110
+ />
111
+ )}
112
+ </div>
113
+ )
114
+ }
package/src/Layout.tsx ADDED
@@ -0,0 +1,109 @@
1
+ // Layout primitives (02 §3, PLAN §6): each is the spec's `data-scope` box and nothing else. Relationships, not pixels:
2
+ // `gap` is the closed Space scale, widths are named, and there is no breakpoint prop, since the boxes answer to their
3
+ // container. `as` picks the element from the tags below; weft has no dynamic tag, so each is one compiled template.
4
+ import { untrack } from '@weft-js/core'
5
+ import type { Space } from '@weft-js/core/ds'
6
+ import './styles'
7
+ import '@weft-js/warp-core/styles/layout/tokens.css'
8
+ import '@weft-js/warp-core/styles/layout/base.css'
9
+ import '@weft-js/warp-core/styles/layout/stack.css'
10
+ import '@weft-js/warp-core/styles/layout/row.css'
11
+ import '@weft-js/warp-core/styles/layout/grid.css'
12
+ import '@weft-js/warp-core/styles/layout/split.css'
13
+ import '@weft-js/warp-core/styles/layout/center.css'
14
+ import '@weft-js/warp-core/styles/layout/cover.css'
15
+ import '@weft-js/warp-core/styles/layout/frame.css'
16
+ import '@weft-js/warp-core/styles/layout/spacer.css'
17
+ import '@weft-js/warp-core/styles/layout/inset.css'
18
+ import './layout.css'
19
+
20
+ type Open = { children?: unknown; [attr: string]: unknown }
21
+ type Tag = keyof typeof TAGS
22
+ /** A breakpoint a layout can switch at. */
23
+ export type Step = 'tablet' | 'desktop'
24
+ type Box = Open & { as?: Tag | undefined; gap?: Space | undefined }
25
+
26
+ function Div({ children, ...rest }: Open) {
27
+ return <div {...rest}>{children}</div>
28
+ }
29
+ function Section({ children, ...rest }: Open) {
30
+ return <section {...rest}>{children}</section>
31
+ }
32
+ function Article({ children, ...rest }: Open) {
33
+ return <article {...rest}>{children}</article>
34
+ }
35
+ function Main({ children, ...rest }: Open) {
36
+ return <main {...rest}>{children}</main>
37
+ }
38
+ function Header({ children, ...rest }: Open) {
39
+ return <header {...rest}>{children}</header>
40
+ }
41
+ function Footer({ children, ...rest }: Open) {
42
+ return <footer {...rest}>{children}</footer>
43
+ }
44
+ function Nav({ children, ...rest }: Open) {
45
+ return <nav {...rest}>{children}</nav>
46
+ }
47
+ function Aside({ children, ...rest }: Open) {
48
+ return <aside {...rest}>{children}</aside>
49
+ }
50
+ function Form({ children, ...rest }: Open) {
51
+ return <form {...rest}>{children}</form>
52
+ }
53
+ function Ul({ children, ...rest }: Open) {
54
+ return <ul {...rest}>{children}</ul>
55
+ }
56
+ function Ol({ children, ...rest }: Open) {
57
+ return <ol {...rest}>{children}</ol>
58
+ }
59
+ const TAGS = { div: Div, section: Section, article: Article, main: Main, header: Header, footer: Footer, nav: Nav, aside: Aside, form: Form, ul: Ul, ol: Ol }
60
+
61
+ function Element({ as, ...rest }: Box) {
62
+ const Tag = TAGS[untrack(() => as) ?? 'div']
63
+ return <Tag {...rest} />
64
+ }
65
+
66
+ /** Vertical flow; the gap is the relationship between the children. */
67
+ export function Stack({ gap, align, divided, ...rest }: Box & { align?: 'start' | 'center' | 'end' | undefined; divided?: boolean | undefined }) {
68
+ return <Element data-scope="stack" data-gap={gap} data-align={align} data-divided={divided ? '' : undefined} {...rest} />
69
+ }
70
+
71
+ /** A horizontal cluster that wraps. `reflow` makes it a Stack below that step; `wrap={false}` keeps it on one line. */
72
+ export function Row({ gap, justify, align, wrap, reflow, ...rest }: Box & { justify?: 'start' | 'between' | 'end' | 'center' | undefined; align?: 'start' | 'baseline' | undefined; wrap?: boolean | undefined; reflow?: Step | undefined }) {
73
+ return <Element data-scope="row" data-gap={gap} data-justify={justify === 'start' ? undefined : justify} data-align={align} data-wrap={wrap === false ? 'none' : undefined} data-reflow={reflow} {...rest} />
74
+ }
75
+
76
+ /** Intrinsic columns: as many as fit at `min`, never more than `cols`. A child with `data-span="full"` takes the row. */
77
+ export function Grid({ gap, min, cols, ...rest }: Box & { min?: 'xs' | 'sm' | 'md' | 'lg' | undefined; cols?: 2 | 3 | 4 | 6 | undefined }) {
78
+ return <Element data-scope="grid" data-gap={gap} data-min={min} data-cols={cols} {...rest} />
79
+ }
80
+
81
+ /** A fixed side and a fluid main, stacked below its step. Each child is a container. */
82
+ export function Split({ gap, side, width, at, ...rest }: Box & { side?: 'start' | 'end' | undefined; width?: 'rail' | 'nav' | 'list' | 'half' | undefined; at?: Step | undefined }) {
83
+ return <Element data-scope="split" data-gap={gap} data-side={side === 'start' ? undefined : side} data-width={width} data-at={at === 'tablet' ? undefined : at} {...rest} />
84
+ }
85
+
86
+ /** A measured column, centred, with page gutters when `gutter` is set. */
87
+ export function Center({ max, gutter, intrinsic, ...rest }: Box & { max?: 'measure' | 'narrow' | 'content' | 'wide' | undefined; gutter?: boolean | undefined; intrinsic?: boolean | undefined }) {
88
+ return <Element data-scope="center" data-max={max === 'content' ? undefined : max} data-gutter={gutter ? '' : undefined} data-intrinsic={intrinsic ? '' : undefined} {...rest} />
89
+ }
90
+
91
+ /** Fills the screen: the child with `data-part="center"` is centred, a header and footer hug the edges (auth, error pages). */
92
+ export function Cover({ gap, min, ...rest }: Box & { min?: 'screen' | 'fill' | undefined }) {
93
+ return <Element data-scope="cover" data-gap={gap} data-min={min === 'screen' ? undefined : min} {...rest} />
94
+ }
95
+
96
+ /** A fixed-ratio media box with no chrome of its own. */
97
+ export function Frame({ ratio, ...rest }: Box & { ratio?: 'square' | '4:3' | '3:2' | '16:9' | '21:9' | '3:4' | '9:16' | undefined }) {
98
+ return <Element data-scope="frame" data-ratio={ratio === '16:9' ? undefined : ratio} {...rest} />
99
+ }
100
+
101
+ /** Takes the free space in a Row or Stack, so what follows sits at the far end. */
102
+ export function Spacer(rest: Open) {
103
+ return <div data-scope="spacer" {...rest}></div>
104
+ }
105
+
106
+ /** A sunken, padded region. */
107
+ export function Inset({ pad, ...rest }: Box & { pad?: 'group' | 'section' | undefined }) {
108
+ return <Element data-scope="inset" data-pad={pad === 'section' ? pad : undefined} {...rest} />
109
+ }
@@ -0,0 +1,63 @@
1
+ import { markdownToHtml, nextIndex, uid } from '@weft-js/warp-core'
2
+ import { derived, flush, state, untrack } from '@weft-js/core'
3
+ import { bindable, type Bindable } from './bind'
4
+ import { Textarea, type FieldShell } from './Field'
5
+ import './styles'
6
+ import '@weft-js/warp-core/styles/rich/markdown.css'
7
+ import '@weft-js/warp-core/styles/rich/md-editor.css'
8
+
9
+ /** Markdown, rendered (04 Markdown): a small, safe renderer. Raw HTML is escaped and shows as text. */
10
+ export function Markdown({ source, linkTarget, ...rest }: { source: string; linkTarget?: '_self' | '_blank' | undefined; [attr: string]: unknown }) {
11
+ return <article data-scope="markdown" {...rest}
12
+ // oxlint-disable-next-line weft/no-html-sink -- markdownToHtml sanitizes
13
+ innerHTML={markdownToHtml(source, { linkTarget })}></article>
14
+ }
15
+
16
+ /** Props for MarkdownEditor. */
17
+ export type MarkdownEditorProps = FieldShell & Bindable<string> & { rows?: number }
18
+
19
+ const WRAPS: Record<string, [string, string]> = { b: ['**', '**'], i: ['_', '_'], k: ['[', '](https://)'] }
20
+
21
+ /**
22
+ * Source and preview (04 MarkdownEditor; inputs-rich.html:2299): side by side from 640px of its own width, tabs below
23
+ * that. ⌘B, ⌘I and ⌘K wrap the selection, and stay undoable.
24
+ */
25
+ export function MarkdownEditor({ value, defaultValue, onValueChange, rows = 12, ...rest }: MarkdownEditorProps) {
26
+ const id = uid('mde')
27
+ const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
28
+ const tab = state<'write' | 'preview'>('write')
29
+ const html = derived(() => markdownToHtml(held.value))
30
+ const onKey = (e: KeyboardEvent) => {
31
+ const wrap = (e.metaKey || e.ctrlKey) && WRAPS[e.key.toLowerCase()]
32
+ if (!wrap) return
33
+ e.preventDefault()
34
+ const a = e.currentTarget as HTMLTextAreaElement
35
+ const { selectionStart: from, selectionEnd: to } = a
36
+ const text = wrap[0] + a.value.slice(from, to) + wrap[1]
37
+ if (!document.execCommand?.('insertText', false, text)) a.setRangeText(text, from, to, 'end')
38
+ a.setSelectionRange(from + wrap[0].length, to + wrap[0].length)
39
+ held.set(a.value, 'keyboard')
40
+ }
41
+ const onTabKey = (e: KeyboardEvent) => {
42
+ const to = nextIndex(e.key, tab.value === 'write' ? 0 : 1, 2)
43
+ if (to < 0) return
44
+ e.preventDefault()
45
+ tab.value = to ? 'preview' : 'write'
46
+ flush()
47
+ ;(e.currentTarget as Element).querySelector<HTMLElement>('[aria-selected="true"]')?.focus()
48
+ }
49
+ const button = (name: 'write' | 'preview', text: string) => (
50
+ <button type="button" role="tab" id={`${id}-${name}`} aria-selected={tab.value === name ? 'true' : 'false'} aria-controls={`${id}-${name}-p`} tabindex={tab.value === name ? '0' : '-1'} onClick={() => (tab.value = name)}>{text}</button>
51
+ )
52
+ return (
53
+ <div data-scope="md-editor" data-tab={tab.value}>
54
+ <div data-part="tabs" role="tablist" aria-label={untrack(() => rest.label)} onKeyDown={onTabKey}>{button('write', 'Write')}{button('preview', 'Preview')}</div>
55
+ <div data-part="source" role="tabpanel" id={`${id}-write-p`} aria-labelledby={`${id}-write`}>
56
+ <Textarea {...rest} reset={held.reset} rows={rows} value={held.value} spellcheck={false} onInput={(e: Event) => held.set((e.currentTarget as HTMLInputElement).value, 'keyboard')} onKeyDown={onKey} />
57
+ </div>
58
+ <div data-scope="markdown" role="tabpanel" id={`${id}-preview-p`} aria-labelledby={`${id}-preview`} tabindex="0"
59
+ // oxlint-disable-next-line weft/no-html-sink -- the preview comes from markdownToHtml, which sanitizes
60
+ innerHTML={html.value}></div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,150 @@
1
+ import { caretPoint, comboPopup, findTrigger, mentionToken, parseMentions, serializeMentions, shiftMarks, uid, type MentionItem, type MentionMark } from '@weft-js/warp-core'
2
+ import { derived, flush, onCleanup, state, untrack, watch } from '@weft-js/core'
3
+ import { bindable, type Bindable } from './bind'
4
+ import { Field, type FieldShell } from './Field'
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
+ import '@weft-js/warp-core/styles/rich/helpers.css'
9
+
10
+ /** Props for MentionsInput. */
11
+ export type MentionsInputProps = FieldShell & Bindable<string> & {
12
+ /** Trigger character → its search. Superseded calls are aborted. */
13
+ triggers: Record<string, (q: string, signal: AbortSignal) => Promise<MentionItem[]>>
14
+ rows?: number
15
+ name?: string | undefined
16
+ }
17
+
18
+ const initials = (s: string) => s.split(/\s+/).map(w => w[0]).join('').slice(0, 2)
19
+
20
+ /**
21
+ * A plain textarea with trigger characters (04 MentionsInput; inputs-rich.html:2367-2401), so paste, autofill and
22
+ * spellcheck still work. The list opens at the caret: ↑/↓ then Enter or Tab inserts, Esc closes, and an insertion is
23
+ * announced. The value keeps tokens as `@[Ada Lovelace](user:ada)`; the textarea shows the name, and editing inside a
24
+ * name turns it back into plain text.
25
+ */
26
+ export function MentionsInput({ triggers, rows = 3, value, defaultValue, onValueChange, name, hint, ...rest }: MentionsInputProps) {
27
+ const id = uid('men')
28
+ const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
29
+ const start = untrack(() => parseMentions(held.value))
30
+ const text = state(start.text)
31
+ let marks: MentionMark[] = start.marks
32
+ let written = held.value
33
+ const hits = state<MentionItem[]>([])
34
+ const active = state(0)
35
+ const open = state(false)
36
+ const said = state('')
37
+ const empty = state('')
38
+ let at: { trigger: string; query: string; at: number } | undefined
39
+ let area: HTMLTextAreaElement | undefined
40
+ let pop: HTMLDivElement | undefined
41
+ let mark: HTMLSpanElement | undefined
42
+ let timer: ReturnType<typeof setTimeout> | undefined
43
+ let stop: AbortController | undefined
44
+ const cancel = () => { clearTimeout(timer); stop?.abort(); stop = undefined }
45
+ onCleanup(cancel)
46
+ use(comboPopup, () => pop, () => ({ open: open.value, anchor: mark!, onClose: () => (open.value = false) }))
47
+ // a value set from outside: a write of our own compares equal
48
+ watch(() => held.value, v => {
49
+ if (v === written) return
50
+ const next = parseMentions(v)
51
+ written = v
52
+ marks = next.marks
53
+ text.value = next.text
54
+ })
55
+ const publish = (reason: 'keyboard' | 'pointer') => { written = serializeMentions(area!.value, marks); held.set(written, reason) }
56
+ const place = () => {
57
+ const a = area!
58
+ const p = caretPoint(a, at!.at)
59
+ mark!.style.insetInlineStart = `${a.offsetLeft + Math.min(p.x, a.clientWidth - 220)}px`
60
+ mark!.style.insetBlockStart = `${a.offsetTop + p.y}px`
61
+ }
62
+ const scan = () => {
63
+ const a = area!
64
+ at = findTrigger(a.value, a.selectionStart, Object.keys(triggers), marks)
65
+ cancel()
66
+ if (!at) { open.value = false; return }
67
+ const found = at
68
+ // 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
69
+ timer = setTimeout(async () => {
70
+ const me = (stop = new AbortController())
71
+ try {
72
+ const r = await triggers[found.trigger]!(found.query, me.signal)
73
+ if (me !== stop) return
74
+ hits.value = r
75
+ active.value = 0
76
+ empty.value = r.length ? '' : `No matches for ${found.trigger}${found.query}`
77
+ place()
78
+ open.value = true
79
+ } catch { if (me === stop) open.value = false }
80
+ }, 120)
81
+ }
82
+ const pick = (n: number, reason: 'keyboard' | 'pointer') => {
83
+ const a = area!, it = hits.value[n]
84
+ if (!it || !at) return
85
+ const shown = at.trigger + it.label
86
+ const end = a.selectionStart
87
+ const was = a.value
88
+ a.setRangeText(`${shown} `, at.at, end, 'end')
89
+ marks = [...shiftMarks(marks, was, a.value), { start: at.at, end: at.at + shown.length, token: mentionToken(at.trigger, it) }]
90
+ open.value = false
91
+ text.value = a.value
92
+ publish(reason)
93
+ a.focus()
94
+ said.value = `${it.label} inserted`
95
+ }
96
+ const onInput = () => {
97
+ const a = area!
98
+ marks = shiftMarks(marks, text.value, a.value)
99
+ text.value = a.value
100
+ publish('keyboard')
101
+ scan()
102
+ }
103
+ const onKey = (e: KeyboardEvent) => {
104
+ if (!open.value || e.isComposing) return
105
+ if (e.key === 'Escape') { e.preventDefault(); open.value = false; return }
106
+ const n = hits.value.length
107
+ if (!n) return
108
+ const by = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
109
+ if (by) {
110
+ e.preventDefault()
111
+ active.value = (active.value + by + n) % n
112
+ flush()
113
+ pop?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' })
114
+ } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pick(active.value, 'keyboard') }
115
+ }
116
+ const person = derived(() => at?.trigger === '@')
117
+ return (
118
+ <Field
119
+ {...rest}
120
+ hint={hint}
121
+ attrs={{ 'data-multiline': '' }}
122
+ filled={text.value.length > 0}
123
+ reset={held.reset}
124
+ hold={open.value}
125
+ control={p => (
126
+ <textarea ref={n => (area = n)} {...p.rest} data-part="input" id={p.id} rows={rows} placeholder=" " aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} aria-autocomplete="list" aria-controls={id} aria-activedescendant={open.value && hits.value.length ? `${id}-${active.value}` : undefined} value={text.value} onInput={onInput} onKeyDown={onKey} onClick={scan}>{start.text}</textarea>
127
+ )}
128
+ end={
129
+ <>
130
+ <span ref={n => (mark = n)} data-part="caret-anchor" aria-hidden="true"></span>
131
+ <div ref={n => (pop = n)} data-scope="popover" data-kind="combo" data-sweep="" popover="manual" role="presentation" onPointerDown={e => e.preventDefault()}>
132
+ <span data-part="sweep" aria-hidden="true"></span>
133
+ <ul data-part="listbox" role="listbox" id={id} aria-label="Suggestions">
134
+ {hits.value.map((it, n) => (
135
+ <li key={it.id} role="option" id={`${id}-${n}`} aria-selected={n === active.value ? 'true' : 'false'} data-active={n === active.value ? '' : undefined} onClick={() => pick(n, 'pointer')}>
136
+ {person.value && <span class="av" aria-hidden="true">{initials(it.label)}</span>}
137
+ <span>{it.label}</span>
138
+ {it.meta && <span data-part="opt-meta">{it.meta}</span>}
139
+ </li>
140
+ ))}
141
+ {!hits.value.length && <li role="option" aria-disabled="true" aria-selected="false">{empty.value}</li>}
142
+ </ul>
143
+ </div>
144
+ <span data-part="sr-only" role="status">{said.value}</span>
145
+ {name && <input type="hidden" name={name} value={held.value} />}
146
+ </>
147
+ }
148
+ />
149
+ )
150
+ }