@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
@@ -0,0 +1,243 @@
1
+ import { DownIcon, GlobeIcon, SearchIcon } from '@weft-js/warp-icons'
2
+ import { caretAfter, currencyCodes, currencySymbol, digitsOf, displayName, formatPhone, inferCountry, languageCodes, listKey, localZone, matchZone, nationalLength, PHONE_COUNTRIES, regionCodes, regionOf, uid, zoneCity, zoneRows, type ChangeReason, type CountryCode, type E164, type IsoDate, type PhoneCountry, type TimeZoneId } from '@weft-js/warp-core'
3
+ import { derived, flush, onMount, state, untrack } from '@weft-js/core'
4
+ import { Field } from './Field'
5
+ import type { IconRef } from './Icon'
6
+ import { bindable, Composite, Hidden, Panel, useOpen, usePickerEnv, type Bindable, type Openable, type RichFieldProps } from './Picker'
7
+ import { use } from '@weft-js/warp-core/weft'
8
+ import '@weft-js/warp-core/styles/rich/listbox.css'
9
+ import '@weft-js/warp-core/styles/rich/timezone.css'
10
+ import '@weft-js/warp-core/styles/rich/phone.css'
11
+
12
+ export type Row = { key: string; main: string; sub?: string | undefined; meta?: string | undefined; code?: string; pin?: string | undefined; search: string }
13
+
14
+ const SHOWN = 80
15
+
16
+ /**
17
+ * The search list in a picker's panel (APG Combobox with a listbox popup): focus stays in the input, ↑/↓ move the
18
+ * active option, Enter picks. Long lists render their first rows; typing narrows them.
19
+ */
20
+ function SearchList({ label, placeholder, rows, value, match, onPick, count }: { label: string; placeholder: string; rows: () => Row[]; value: string | null; match?: ((r: Row, q: string) => boolean) | undefined; onPick: (key: string, r: ChangeReason) => void; count?: ((n: number, q: string) => string) | undefined }) {
21
+ const id = uid('list')
22
+ const q = state('')
23
+ const all = derived(() => rows())
24
+ const hits = derived(() => {
25
+ const s = q.value.trim().toLowerCase()
26
+ return s ? all.value.filter(r => (match ? match(r, s) : r.search.includes(s))) : all.value
27
+ })
28
+ const shown = derived(() => hits.value.slice(0, SHOWN))
29
+ const active = state(untrack(() => Math.max(0, all.value.slice(0, SHOWN).findIndex(r => r.key === value))))
30
+ let list: HTMLUListElement | undefined
31
+ const show = () => list?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' })
32
+ const onKey = (e: KeyboardEvent) => {
33
+ const n = shown.value.length
34
+ if (!n) return
35
+ const m = listKey(e.key, active.value, n, { page: 8, wrap: true, typing: true })
36
+ if (!m.handled) return
37
+ e.preventDefault()
38
+ if (m.pick) return onPick(shown.value[active.value]!.key, 'keyboard')
39
+ active.value = m.to
40
+ flush()
41
+ show()
42
+ }
43
+ onMount(show)
44
+ return (
45
+ <div class="tz-pop">
46
+ <label data-part="search">
47
+ <SearchIcon />
48
+ <span class="sr">{label}</span>
49
+ <input type="search" role="combobox" data-initial="" aria-expanded="true" aria-controls={id} aria-autocomplete="list" aria-activedescendant={shown.value.length ? `${id}-${active.value}` : undefined} placeholder={placeholder} onInput={e => { q.value = (e.currentTarget).value; active.value = 0 }} onKeyDown={onKey} />
50
+ </label>
51
+ <ul ref={n => (list = n)} data-part="listbox" role="listbox" id={id} aria-label={label}>
52
+ {shown.value.map((r, n) => <Option key={r.key} id={`${id}-${n}`} row={r} selected={r.key === value} active={n === active.value} onPick={onPick} />)}
53
+ </ul>
54
+ {!shown.value.length && <p class="tz-empty">No match for “{q.value}”.</p>}
55
+ {count && <p class="tz-count" aria-live="polite">{count(hits.value.length, q.value)}</p>}
56
+ </div>
57
+ )
58
+ }
59
+
60
+ const Option = ({ id, row, selected, active, onPick }: { id: string; row: Row; selected: boolean; active: boolean; onPick: (k: string, r: ChangeReason) => void }) => (
61
+ <li role="option" id={id} aria-selected={selected ? 'true' : 'false'} data-active={active ? '' : undefined} onClick={() => onPick(row.key, 'pointer')}>
62
+ {row.pin && <span class="tz-pin">{row.pin}</span>}
63
+ {row.code && <span data-part="code">{row.code}</span>}
64
+ <span>{row.main}{row.sub && <small> {row.sub}</small>}</span>
65
+ {row.meta && <span data-part="opt-meta">{row.meta}</span>}
66
+ </li>
67
+ )
68
+
69
+ /** A field whose control is one value button over a search list: the frame of TimeZoneSelect and the Intl selects. */
70
+ function ListField({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, icon, text, title, search, rows, match, count, hint, label, name, presentation, disabled, readonly, locale, today, ...rest }: RichFieldProps & Bindable<string | null> & Openable & { icon?: IconRef | undefined; text: (v: string, locale: string) => string; title: string; search: string; rows: (locale: string, today: string) => Row[]; match?: ((r: Row, q: string) => boolean) | undefined; count?: ((n: number, q: string) => string) | undefined; hint?: unknown }) {
71
+ const env = usePickerEnv(() => locale, () => today)
72
+ const v = bindable<string | null>(() => value, () => defaultValue, null, () => onValueChange)
73
+ const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
74
+ const shown = derived(() => (v.value ? text(v.value, env.locale.value) : ''))
75
+ let trigger: HTMLButtonElement | undefined
76
+ return (
77
+ <Composite
78
+ {...rest}
79
+ icon={icon}
80
+ label={label}
81
+ hint={hint}
82
+ disabled={disabled}
83
+ readonly={readonly}
84
+ bound={v}
85
+ filled={!!v.value}
86
+ hold={pop.value}
87
+ control={() => (
88
+ <>
89
+ <button ref={n => (trigger = n)} type="button" data-part="pick" aria-haspopup="dialog" aria-expanded={pop.value ? 'true' : 'false'} aria-label={`${label}, ${shown.value || 'none'}`} aria-disabled={readonly ? 'true' : undefined} disabled={disabled} onClick={() => { if (!readonly) pop.set(!pop.value) }}>
90
+ <span>{shown.value}</span><DownIcon />
91
+ </button>
92
+ <Hidden name={name} value={() => v.value ?? ''} />
93
+ </>
94
+ )}
95
+ end={
96
+ <Panel label={title} open={pop} trigger={() => trigger!} presentation={presentation}>
97
+ {() => <SearchList label={search} placeholder={search} rows={() => rows(env.locale.value, env.today.value)} match={match} value={v.value} count={count} onPick={(k, r) => { v.set(k, r); pop.set(false) }} />}
98
+ </Panel>
99
+ }
100
+ />
101
+ )
102
+ }
103
+
104
+ /** Props for TimeZoneSelect. */
105
+ export type TimeZoneSelectProps = RichFieldProps & Bindable<TimeZoneId | null> & Openable & { referenceDate?: string | undefined; zones?: TimeZoneId[]; showOffset?: boolean }
106
+
107
+ /** TimeZoneSelect (04 §2): every IANA zone with its offset at `referenceDate`; Local is pinned first. */
108
+ export function TimeZoneSelect({ referenceDate, zones, showOffset = true, ...props }: TimeZoneSelectProps) {
109
+ const local = untrack(localZone)
110
+ const matches = new Map<string, ReturnType<typeof zoneRows>[number]>()
111
+ const rows = (locale: string, today: string): Row[] => {
112
+ const list = zoneRows(locale, (referenceDate ?? today) as IsoDate, zones)
113
+ for (const z of list) matches.set(z.zone, z)
114
+ const row = (z: (typeof list)[number]): Row => ({ key: z.zone, main: z.city, sub: z.region, meta: showOffset ? z.offset : undefined, pin: z.zone === local ? 'Local' : undefined, search: z.search })
115
+ return [...list.filter(z => z.zone === local), ...list.filter(z => z.zone !== local)].map(row)
116
+ }
117
+ return <ListField icon={GlobeIcon} title="Choose a time zone" search="City, zone or +5:30" text={v => zoneCity(v)} rows={rows} match={(r, q) => matchZone(matches.get(r.key)!, q)} count={(n, q) => (q ? `${n} zone${n === 1 ? '' : 's'}` : `${n} zones · offsets for today`)} {...props} />
118
+ }
119
+
120
+ export type CodeSelectProps = RichFieldProps & Bindable<string | null> & Openable & { options?: string[] | undefined; preferred?: string[] }
121
+ const pinned = (list: string[], first?: string[]) => (first ? [...first, ...list.filter(c => !first.includes(c))] : list)
122
+
123
+ /** CountrySelect (04 §3): names from Intl.DisplayNames in the UI locale; `preferred` pins items. */
124
+ export function CountrySelect({ options, preferred, ...props }: CodeSelectProps) {
125
+ const rows = (locale: string): Row[] => pinned(options ?? regionCodes(), preferred).map(c => { const main = displayName('region', c, locale); return { key: c, code: c, main, search: `${main} ${c}`.toLowerCase() } })
126
+ return <ListField icon={GlobeIcon} title="Choose a country" search="Search countries" text={(v, l) => displayName('region', v, l)} rows={rows} {...props} />
127
+ }
128
+
129
+ /** LanguageSelect (04 §3): each language with its endonym, because people look for their own language in their own script. */
130
+ export function LanguageSelect({ options, preferred, endonyms = true, ...props }: CodeSelectProps & { endonyms?: boolean }) {
131
+ const rows = (locale: string): Row[] => pinned(options ?? languageCodes(), preferred).map(c => { const main = displayName('language', c, locale), own = displayName('language', c, c); return { key: c, main, sub: endonyms && own !== main ? `· ${own}` : undefined, search: `${main} ${own} ${c}`.toLowerCase() } })
132
+ return <ListField icon={GlobeIcon} title="Choose a language" search="Search languages" text={(v, l) => displayName('language', v, l)} rows={rows} {...props} />
133
+ }
134
+
135
+ /** CurrencySelect (04 §3): "Euro · EUR · €". */
136
+ export function CurrencySelect({ options, preferred, ...props }: CodeSelectProps) {
137
+ const rows = (locale: string): Row[] => pinned(options ?? currencyCodes(), preferred).map(c => { const main = displayName('currency', c, locale); return { key: c, main, sub: `· ${c}`, meta: currencySymbol(c, locale), search: `${main} ${c}`.toLowerCase() } })
138
+ return <ListField title="Choose a currency" search="Search currencies" text={(v, l) => `${displayName('currency', v, l)} · ${v}`} rows={rows} {...props} />
139
+ }
140
+
141
+ /** A prefix moves where the label rests: onto the input, past the country button (specimen, `restOn`). */
142
+ function restOnInput(el: HTMLInputElement) {
143
+ const control = el.closest<HTMLElement>('[data-part="control"]')!
144
+ const measure = () => {
145
+ if (!control.style.getPropertyValue('--_inset')) control.style.setProperty('--_inset', getComputedStyle(el.parentElement!).paddingInlineStart)
146
+ control.style.setProperty('--_lead', `${Math.round(el.getBoundingClientRect().left - control.getBoundingClientRect().left)}px`)
147
+ }
148
+ measure()
149
+ const size = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
150
+ size?.observe(control)
151
+ return { update() {}, destroy() { size?.disconnect() } }
152
+ }
153
+
154
+ /** Props for PhoneField. */
155
+ export type PhoneFieldProps = RichFieldProps & Bindable<E164 | null> & {
156
+ country?: CountryCode
157
+ defaultCountry?: CountryCode
158
+ countries?: CountryCode[] | undefined
159
+ onCountryChange?: (c: CountryCode) => void
160
+ }
161
+
162
+ /**
163
+ * PhoneField (04 §3): a country button, then a native tel input (the OS keypad and SMS autofill beat anything custom).
164
+ * Digits format as you type and the caret stays behind the same digit; a pasted `+…` infers the country. The value is
165
+ * E.164, and only once the number is the country's length. The country list is a search list.
166
+ */
167
+ export function PhoneField({ value, defaultValue, onValueChange, country, defaultCountry, countries, onCountryChange, locale, today, name, presentation, label, hint, error, disabled, readonly, ...rest }: PhoneFieldProps) {
168
+ const env = usePickerEnv(() => locale, () => today)
169
+ const v = bindable<E164 | null>(() => value, () => defaultValue, null, () => onValueChange)
170
+ const list = untrack(() => (countries ? PHONE_COUNTRIES.filter(c => countries.includes(c.code)) : PHONE_COUNTRIES))
171
+ const find = (code?: string) => list.find(c => c.code === code)
172
+ const code = state<PhoneCountry>(untrack(() => find(country) ?? find(defaultCountry) ?? find(regionOf(env.locale.value)) ?? list[0]!))
173
+ const text = state(untrack(() => {
174
+ const e = v.value
175
+ if (!e) return ''
176
+ const c = inferCountry(e, code.value, list) ?? code.value
177
+ code.value = c
178
+ return formatPhone(e.slice(c.dial.length + 1), c).shown
179
+ }))
180
+ const pop = useOpen(() => undefined, () => false, () => undefined)
181
+ const own = state<string | undefined>(undefined)
182
+ const name_ = derived(() => displayName('region', code.value.code, env.locale.value))
183
+ const placeholder = derived(() => { const c = code.value; return c.pattern.replace(/#/g, '0').replace(/^0/, c.trunk ? '0' : '5') })
184
+ let button: HTMLButtonElement | undefined
185
+ let input: HTMLInputElement | undefined
186
+ const sync = (raw: string, caret: number | null) => {
187
+ const c = code.value
188
+ const before = digitsOf(raw.slice(0, caret ?? raw.length)).length
189
+ const { shown, national } = formatPhone(raw, c)
190
+ text.value = shown
191
+ if (input) { input.value = shown; if (input === input.ownerDocument.activeElement) { const at = caretAfter(shown, before); input.setSelectionRange(at, at) } }
192
+ const need = nationalLength(c)
193
+ own.value = national.length > need ? `${name_.value} numbers have ${need} digits after the country code.` : undefined
194
+ const e = national.length === need ? `+${c.dial}${national}` as E164 : null
195
+ if (e !== untrack(() => v.value)) v.set(e, 'keyboard')
196
+ }
197
+ const choose = (c: PhoneCountry) => { code.value = c; untrack(() => onCountryChange)?.(c.code); pop.set(false); input?.focus(); sync(text.value, null) }
198
+ const onBlur = () => {
199
+ const c = code.value
200
+ const n = formatPhone(text.value, c).national.length, need = nationalLength(c)
201
+ if (n && n < need) own.value = `That's ${n} digits. ${name_.value} numbers have ${need}.`
202
+ }
203
+ use(restOnInput, () => input)
204
+ const onPaste = (e: ClipboardEvent) => {
205
+ if (readonly) return
206
+ const t = e.clipboardData?.getData('text').trim() ?? ''
207
+ if (!t.startsWith('+')) return
208
+ const m = inferCountry(t, code.value, list)
209
+ if (!m) return
210
+ e.preventDefault()
211
+ code.value = m
212
+ sync((m.trunk ?? '') + digitsOf(t).slice(m.dial.length), null)
213
+ }
214
+ return (
215
+ <Field
216
+ {...rest}
217
+ label={label}
218
+ hint={hint}
219
+ error={error ?? own.value}
220
+ disabled={disabled}
221
+ readonly={readonly}
222
+ filled={text.value.length > 0}
223
+ hold={pop.value}
224
+ attrs={{ 'data-phone': '' }}
225
+ reset={() => { v.reset(); own.value = undefined; code.value = inferCountry(v.value ?? '', code.value, list) ?? code.value; text.value = v.value ? formatPhone(v.value.slice(code.value.dial.length + 1), code.value).shown : ''; if (input) input.value = text.value }}
226
+ control={p => (
227
+ <div data-part="body">
228
+ <button ref={n => (button = n)} type="button" data-part="country" aria-haspopup="dialog" aria-expanded={pop.value ? 'true' : 'false'} aria-disabled={readonly ? 'true' : undefined} disabled={disabled} onClick={() => { if (!readonly) pop.set(!pop.value) }}>
229
+ <span aria-hidden="true"><b>{code.value.code}</b>+{code.value.dial}</span><span class="sr">Country: {name_.value}, +{code.value.dial}</span><DownIcon />
230
+ </button>
231
+ <input ref={n => (input = n)} data-part="input" id={p.id} type="tel" inputmode="tel" autocomplete="tel-national" placeholder={placeholder.value} aria-describedby={p.describedby} value={text.value} required={p.rest.required} disabled={disabled} readonly={readonly} onInput={e => { const el = e.currentTarget; sync(el.value, el.selectionStart) }} onBlur={onBlur} onPaste={onPaste} />
232
+ <Hidden name={name} value={() => v.value ?? ''} />
233
+ </div>
234
+ )}
235
+ end={
236
+ <Panel label="Country" open={pop} trigger={() => button!} presentation={presentation}>
237
+ {() => <SearchList label="Search countries" placeholder="Country or code" value={code.value.code} rows={() => list.map(c => { const main = displayName('region', c.code, env.locale.value); return { key: c.code, code: c.code, main, meta: `+${c.dial}`, search: `${main} ${c.code} ${c.dial}`.toLowerCase() } })} match={(r, q) => r.search.includes(q.replace(/^\+/, ''))} onPick={k => choose(find(k)!)} />}
238
+ </Panel>
239
+ }
240
+ />
241
+ )
242
+ }
243
+
@@ -0,0 +1,207 @@
1
+ import { CodeIcon, CodeBlockIcon, LinkIcon, ListIcon, ListOrderedIcon, QuoteIcon, UndoIcon } from '@weft-js/warp-icons'
2
+ import { comboPopup, htmlToMarkdown, keyboardDock, markdownToHtml, nextIndex, richText, sanitizeHtml, uid, type MentionItem, type RichTextHandle, type RichTextOptions } from '@weft-js/warp-core'
3
+ import { derived, flush, onCleanup, state, untrack, watch } from '@weft-js/core'
4
+ import { bindable, type Bindable } from './bind'
5
+ import { Button } from './Button'
6
+ import { Field, type FieldShell } from './Field'
7
+ import { Tooltip } from './Tooltip'
8
+ import { use } from '@weft-js/warp-core/weft'
9
+ import '@weft-js/warp-core/styles/rich/body.css'
10
+ import '@weft-js/warp-core/styles/rich/rich-text.css'
11
+ import '@weft-js/warp-core/styles/rich/listbox.css'
12
+ import '@weft-js/warp-core/styles/rich/popover.css'
13
+ import '@weft-js/warp-core/styles/rich/helpers.css'
14
+
15
+ /** An entry in the editor's slash menu. */
16
+ export type SlashCommand = { id: string; label: string; hint?: string; shortcut?: string; run: (editor: RichTextHandle) => void }
17
+ type Mark = 'bold' | 'italic' | 'strike' | 'code' | 'link'
18
+ type Block = 'h2' | 'h3' | 'ul' | 'ol' | 'quote' | 'codeblock'
19
+
20
+ /** Props for RichTextEditor. */
21
+ export type RichTextEditorProps = FieldShell & Bindable<string> & {
22
+ format?: 'html' | 'markdown' | undefined
23
+ /** fixed (default) in the frame, bubble over a selection, or none. A phone docks either above the keyboard. */
24
+ toolbar?: 'fixed' | 'bubble' | 'none'
25
+ marks?: Mark[]
26
+ blocks?: Block[]
27
+ commands?: SlashCommand[]
28
+ mentions?: { search: (q: string, signal: AbortSignal) => Promise<MentionItem[]> }
29
+ placeholder?: string
30
+ minRows?: number
31
+ maxRows?: number
32
+ name?: string | undefined
33
+ }
34
+
35
+ type Tool = { cmd: string; label: string; tip: string; glyph: () => unknown; style?: string; toggles?: boolean }
36
+ const TOOLS: Record<Mark | Block, Tool> = {
37
+ h2: { cmd: 'h2', label: 'H2 heading', tip: 'Heading · # ', glyph: () => 'H2', toggles: true },
38
+ h3: { cmd: 'h3', label: 'H3 subheading', tip: 'Subheading · ### ', glyph: () => 'H3', toggles: true },
39
+ bold: { cmd: 'bold', label: 'Bold', tip: 'Bold · ⌘B', glyph: () => 'B', style: 'font-weight:700', toggles: true },
40
+ italic: { cmd: 'italic', label: 'Italic', tip: 'Italic · ⌘I', glyph: () => 'I', style: 'font-style:italic;font-family:Georgia,serif;font-weight:400;font-size:16px', toggles: true },
41
+ strike: { cmd: 'strikeThrough', label: 'Strikethrough', tip: 'Strikethrough', glyph: () => 'S', style: 'text-decoration:line-through;font-weight:500', toggles: true },
42
+ code: { cmd: 'code', label: 'Inline code', tip: 'Code · `text`', glyph: () => <CodeIcon />, toggles: true },
43
+ link: { cmd: 'link', label: 'Link', tip: 'Link · ⌘K', glyph: () => <LinkIcon />, toggles: true },
44
+ ul: { cmd: 'insertUnorderedList', label: 'Bulleted list', tip: 'Bullets · - ', glyph: () => <ListIcon />, toggles: true },
45
+ ol: { cmd: 'insertOrderedList', label: 'Numbered list', tip: 'Numbers · 1. ', glyph: () => <ListOrderedIcon />, toggles: true },
46
+ quote: { cmd: 'blockquote', label: 'Quote', tip: 'Quote · > ', glyph: () => <QuoteIcon />, toggles: true },
47
+ codeblock: { cmd: 'pre', label: 'Code block', tip: 'Code block · ```', glyph: () => <CodeBlockIcon />, toggles: true },
48
+ }
49
+ const SLASH: SlashCommand[] = [
50
+ { id: 'h2', label: 'Heading', hint: '#', run: e => e.run('h2') },
51
+ { id: 'h3', label: 'Subheading', hint: '###', run: e => e.run('h3') },
52
+ { id: 'ul', label: 'Bulleted list', hint: '-', run: e => e.run('insertUnorderedList') },
53
+ { id: 'ol', label: 'Numbered list', hint: '1.', run: e => e.run('insertOrderedList') },
54
+ { id: 'quote', label: 'Quote', hint: '>', run: e => e.run('blockquote') },
55
+ { id: 'code', label: 'Code block', hint: '```', run: e => e.run('pre') },
56
+ { id: 'divider', label: 'Divider', hint: '---', run: e => e.run('insertHorizontalRule') },
57
+ ]
58
+ type MenuRow = { id: string; main: string; meta?: string | undefined; run: () => void }
59
+
60
+ /**
61
+ * A rich text field (04 RichTextEditor; inputs-rich.html:1522): the editor frame is the field. The toolbar is an APG
62
+ * Toolbar, one tab stop with ←/→ inside. Markdown shortcuts open a block at the start of a line; `/` on an empty line and
63
+ * `@` open a list at the caret. Everything that enters the editor, a value or a paste, goes through warp-core's allow-list.
64
+ */
65
+ export function RichTextEditor({ value, defaultValue, onValueChange, format = 'html', toolbar = 'fixed', marks = ['bold', 'italic', 'strike', 'code', 'link'], blocks = ['h2', 'ul', 'ol', 'quote'], commands = SLASH, mentions, placeholder = 'Write, or type / for blocks…', minRows, maxRows, name, hint, ...rest }: RichTextEditorProps) {
66
+ const id = uid('rte')
67
+ const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
68
+ const toHtml = (v: string) => (format === 'markdown' ? sanitizeHtml(markdownToHtml(v)) : sanitizeHtml(v))
69
+ const fromEditor = (html: string) => (format === 'markdown' ? htmlToMarkdown(ed!) : html)
70
+ const start = untrack(() => toHtml(held.value))
71
+ let written = untrack(() => held.value)
72
+ const pressed = state<Record<string, boolean>>({})
73
+ const stop = state(0)
74
+ const linking = state(false)
75
+ const url = state('')
76
+ const items = state<MenuRow[]>([])
77
+ const active = state(0)
78
+ const menuOpen = state(false)
79
+ const said = state('')
80
+ const tools = derived(() => [blocks.filter(b => b === 'h2' || b === 'h3').map(b => TOOLS[b]), marks.map(m => TOOLS[m]), blocks.filter(b => b !== 'h2' && b !== 'h3').map(b => TOOLS[b])].filter(g => g.length))
81
+ const flat = derived(() => tools.value.flat())
82
+ let ed: HTMLDivElement | undefined
83
+ let handle: RichTextHandle | undefined
84
+ let bar: HTMLDivElement | undefined
85
+ let pop: HTMLDivElement | undefined
86
+ let mark: HTMLSpanElement | undefined
87
+ let link: HTMLInputElement | undefined
88
+ let lookup: AbortController | undefined
89
+ onCleanup(() => lookup?.abort())
90
+ const close = () => { menuOpen.value = false; lookup?.abort() }
91
+ const openMenu = (rows: MenuRow[], rect: DOMRect) => {
92
+ items.value = rows
93
+ active.value = 0
94
+ mark!.style.insetBlockStart = `${rect.bottom}px`
95
+ mark!.style.insetInlineStart = `${rect.left}px`
96
+ menuOpen.value = rows.length > 0
97
+ }
98
+ const onQuery = async (q: { trigger: string; query: string; rect: DOMRect } | null) => {
99
+ lookup?.abort()
100
+ if (!q) return void (menuOpen.value = false)
101
+ const drop = () => handle!.replaceQuery(q.query.length + 1)
102
+ if (q.trigger === '/') {
103
+ const low = q.query.toLowerCase()
104
+ return openMenu(commands.filter(c => c.label.toLowerCase().includes(low)).map(c => ({ id: c.id, main: c.label, meta: c.shortcut ?? c.hint, run: () => { drop(); c.run(handle!) } })), q.rect)
105
+ }
106
+ if (!mentions) return
107
+ const me = (lookup = new AbortController())
108
+ try {
109
+ const found = await mentions.search(q.query, me.signal)
110
+ if (me !== lookup) return
111
+ openMenu(found.map(m => ({ id: m.id, main: m.label, meta: m.meta, run: () => {
112
+ drop()
113
+ handle!.insertMention(m.label)
114
+ said.value = `Mention: ${m.label}`
115
+ } })), q.rect)
116
+ } catch { /* superseded */ }
117
+ }
118
+ const choose = (at: number) => { const row = items.value[at]; close(); row?.run() }
119
+ const startLink = () => { url.value = ''; linking.value = true; flush(); link?.focus() }
120
+ const endLink = (apply: boolean) => {
121
+ linking.value = false
122
+ if (apply && url.value.trim()) handle!.run('link', url.value.trim())
123
+ else { ed!.focus(); handle!.restore() }
124
+ }
125
+ use(
126
+ (el: HTMLDivElement, o: RichTextOptions) => (handle = richText(el, o)),
127
+ () => ed,
128
+ () => ({
129
+ onChange: (html: string) => { written = fromEditor(html); held.set(written, 'keyboard') },
130
+ onState: (on: Record<string, boolean>) => (pressed.value = on),
131
+ onQuery, onLink: startLink,
132
+ bubble: toolbar === 'bubble' ? bar : undefined,
133
+ }),
134
+ )
135
+ if (untrack(() => toolbar) !== 'none') use(keyboardDock, () => bar, () => ({ editor: () => ed }))
136
+ use(comboPopup, () => pop, () => ({ open: menuOpen.value, anchor: mark!, onClose: close }))
137
+ watch(() => held.value, v => {
138
+ if (v === written) return
139
+ written = v
140
+ // oxlint-disable-next-line weft/no-html-sink -- toHtml sanitizes
141
+ if (ed) ed.innerHTML = toHtml(v)
142
+ })
143
+ const onToolKey = (e: KeyboardEvent) => {
144
+ const to = nextIndex(e.key, stop.value, flat.value.length, { rtl: getComputedStyle(e.currentTarget as Element).direction === 'rtl' })
145
+ if (to < 0) return
146
+ e.preventDefault()
147
+ stop.value = to
148
+ flush()
149
+ ;(e.currentTarget as Element).querySelector<HTMLElement>('[tabindex="0"]')?.focus()
150
+ }
151
+ const onMenuKey = (e: KeyboardEvent) => {
152
+ if (!menuOpen.value || e.isComposing) return
153
+ const n = items.value.length
154
+ const by = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
155
+ if (by) { e.preventDefault(); active.value = (active.value + by + n) % n }
156
+ else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); choose(active.value) }
157
+ else if (e.key === 'Escape') { e.preventDefault(); close() }
158
+ }
159
+ const Tool = ({ tool: t }: { tool: Tool }) => (
160
+ <Tooltip content={t.tip}><button type="button" data-part="tool" tabindex={flat.value[stop.value] === t ? '0' : '-1'} aria-label={t.label} aria-pressed={pressed.get(t.cmd) ? 'true' : 'false'} style={t.style} onClick={() => (t.cmd === 'link' ? (handle!.save(), startLink()) : handle!.run(t.cmd))}>{t.glyph()}</button></Tooltip>
161
+ )
162
+ const rows = () => [minRows && `min-block-size:${minRows * 1.6}em`, maxRows && `max-block-size:${maxRows * 1.6}em`].filter(Boolean).join(';') || undefined
163
+ return (
164
+ <Field
165
+ {...rest}
166
+ hint={hint ?? 'Markdown shortcuts work at the start of a line. Esc closes menus.'}
167
+ group
168
+ filled
169
+ reset={held.reset}
170
+ hold={menuOpen.value}
171
+ attrs={{ 'data-multiline': '' }}
172
+ control={p => (
173
+ <div data-scope="rte">
174
+ <div data-part="frame">
175
+ {toolbar !== 'none' && (
176
+ <div ref={n => (bar = n)} role="toolbar" aria-label="Formatting" aria-controls={`${id}-c`} popover={toolbar === 'bubble' ? 'manual' : undefined} data-bubble={toolbar === 'bubble' ? '' : undefined} onKeyDown={onToolKey} onPointerDown={e => { if ((e.target as Element).closest?.('[data-part="tool"]')) e.preventDefault() }}>
177
+ {tools.value.map((g, i) => <span key={g[0]!.cmd} style="display:contents">{i > 0 && <span data-part="sep"></span>}{g.map(t => <Tool key={t.cmd} tool={t} />)}</span>)}
178
+ <span data-part="sep"></span>
179
+ <Tooltip content="Undo" kbd="⌘Z"><button type="button" data-part="tool" tabindex="-1" aria-label="Undo" onClick={() => handle!.run('undo')}><UndoIcon /></button></Tooltip>
180
+ </div>
181
+ )}
182
+ {linking.value && (
183
+ <div data-part="linkbar">
184
+ <label data-part="sr-only" for={`${id}-url`}>Link address</label>
185
+ <input ref={n => (link = n)} type="url" id={`${id}-url`} placeholder="https://" value={url.value} onInput={(e: Event) => (url.value = (e.currentTarget as HTMLInputElement).value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); endLink(true) } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); endLink(false) } }} />
186
+ <Button size="sm" onClick={() => endLink(true)}>Apply</Button>
187
+ <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => endLink(false)}>Cancel</Button>
188
+ </div>
189
+ )}
190
+ <div ref={n => (ed = n)} data-part="content" id={`${id}-c`} contenteditable="true" tabindex="0" role="textbox" aria-multiline="true" aria-labelledby={`${p.id}-l`} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} aria-autocomplete="list" aria-controls={`${id}-menu`} aria-activedescendant={menuOpen.value ? `${id}-${active.value}` : undefined} data-placeholder={placeholder} spellcheck style={rows()} onKeyDown={onMenuKey}
191
+ // oxlint-disable-next-line weft/no-html-sink -- `start` went through toHtml, which sanitizes
192
+ innerHTML={start}></div>
193
+ </div>
194
+ <span ref={n => (mark = n)} data-part="caret-anchor" style="position:fixed" aria-hidden="true"></span>
195
+ <div ref={n => (pop = n)} data-scope="popover" data-kind="combo" data-sweep="" popover="manual" role="presentation" onPointerDown={e => e.preventDefault()}>
196
+ <span data-part="sweep" aria-hidden="true"></span>
197
+ <ul data-part="listbox" role="listbox" id={`${id}-menu`} aria-label="Insert">
198
+ {items.value.map((r, n) => <li key={r.id} role="option" id={`${id}-${n}`} aria-selected={n === active.value ? 'true' : 'false'} data-active={n === active.value ? '' : undefined} onClick={() => choose(n)}><span>{r.main}</span>{r.meta && <span data-part="opt-meta">{r.meta}</span>}</li>)}
199
+ </ul>
200
+ </div>
201
+ <span data-part="sr-only" role="status">{said.value}</span>
202
+ {name && <input type="hidden" name={name} value={held.value} />}
203
+ </div>
204
+ )}
205
+ />
206
+ )
207
+ }
@@ -0,0 +1,190 @@
1
+ import { derived, onCleanup, onMount, state, untrack } from '@weft-js/core'
2
+ import { measureEngine, type MeasureHandle, type MeasureProvider } from '@weft-js/core/text'
3
+ import { slices, uid, virtualizer, type Virtualizer } from '@weft-js/warp-core'
4
+ import './styles'
5
+ import '@weft-js/warp-core/styles/data/scroll-grid.css'
6
+
7
+ /** One ScrollGrid column. */
8
+ export interface GridColumn {
9
+ id: string
10
+ header: string
11
+ /** Pixels. Default 104. */
12
+ width?: number | undefined
13
+ /** The text wraps, and rows are as tall as it needs. */
14
+ wrap?: boolean
15
+ }
16
+ /** Props for ScrollGrid. */
17
+ export interface ScrollGridProps {
18
+ label: string
19
+ rows: number
20
+ columns: readonly GridColumn[]
21
+ /** The cell at a row and column. */
22
+ cell: (row: number, column: GridColumn) => unknown
23
+ /** The first column, the row's name. */
24
+ rowHeader: (row: number) => string
25
+ /** The corner cell's text. */
26
+ corner?: string
27
+ /** Default `480px`. */
28
+ height?: string
29
+ /** Each row's height, in pixels. Default 34. */
30
+ rowHeight?: number
31
+ /** The pinned first column's width, in pixels. Default 132. */
32
+ headerWidth?: number
33
+ /** Marks a cell critical (a negative value). */
34
+ negative?: (row: number, column: GridColumn) => boolean
35
+ }
36
+
37
+ const RH = 34, CW = 104, HW = 132, LEADING = 1.4, PAD = 12, INSET = 20
38
+ const HELP = 'Arrow keys move one cell, Page Up and Page Down one screen, Home and End to the row ends, Control with Home or End to the grid ends.'
39
+
40
+ /** The two-axis grid (06 §6): rows and columns are each a window on one scroller, plus the active cell's row and column. */
41
+ export function ScrollGrid(props: ScrollGridProps) {
42
+ const id = uid('grid')
43
+ let el: HTMLElement | undefined, canvas: HTMLElement | undefined
44
+ let ys: Virtualizer | undefined, xs: Virtualizer | undefined
45
+ let engine: MeasureProvider | null = null
46
+ let stop: (() => void) | undefined
47
+ const rh = untrack(() => props.rowHeight ?? RH), hw = untrack(() => props.headerWidth ?? HW)
48
+ const columns = derived(() => props.columns)
49
+ const rows = state<number[]>(Array.from({ length: Math.min(untrack(() => props.rows), 12) }, (_, i) => i))
50
+ const cols = state<number[]>(Array.from({ length: Math.min(untrack(() => columns.value.length), 12) }, (_, i) => i))
51
+ const active = state<[number, number]>([0, 0])
52
+ const shownCols = derived(() => cols.value.flatMap(c => (columns.value[c] ? [{ c, col: columns.value[c] }] : [])))
53
+ const handles = new Map<string, MeasureHandle>()
54
+ let font = '', line = 0
55
+ const wrapped = derived(() => columns.value.flatMap((c, i) => (c.wrap ? [{ c, i }] : [])))
56
+ const cellId = (r: number, c: number) => `${id}-r${r}-c${c}`
57
+
58
+ const handle = (r: number, c: GridColumn) => {
59
+ const key = `${r}\0${c.id}`
60
+ let h = handles.get(key)
61
+ if (!h) { h = engine!.prepare(String((props.cell(r, c) as string | number | null | undefined) ?? ''), font); handles.set(key, h) }
62
+ return h
63
+ }
64
+ const size = (r: number) => {
65
+ if (!engine || !wrapped.value.length) return rh
66
+ let most = rh
67
+ for (const { c } of wrapped.value) most = Math.max(most, engine.layout(handle(r, c), (c.width ?? CW) - INSET, line).height + PAD)
68
+ return most
69
+ }
70
+
71
+ function focusCell([r, c]: [number, number], align = true) {
72
+ active.value = [r, c]
73
+ ys?.update({})
74
+ xs?.update({})
75
+ if (align) reveal(r, c)
76
+ el!.setAttribute('aria-activedescendant', cellId(r, c))
77
+ }
78
+
79
+ function reveal(r: number, c: number) {
80
+ const top = el!.scrollTop, y = ys!.sizes.offset(r), h = ys!.sizes.get(r), room = el!.clientHeight - rh
81
+ if (y < top) el!.scrollTop = y
82
+ else if (y + h! > top + room) el!.scrollTop = y + h! - room
83
+ const left = el!.scrollLeft, x = xs!.sizes.offset(c), w = xs!.sizes.get(c), wide = el!.clientWidth - hw
84
+ if (x < left) el!.scrollLeft = x
85
+ else if (x + w! > left + wide) el!.scrollLeft = x + w! - wide
86
+ }
87
+
88
+ function onKeyDown(e: KeyboardEvent) {
89
+ const [r, c] = active.value, lastR = props.rows - 1, lastC = columns.value.length - 1, ctrl = e.ctrlKey || e.metaKey
90
+ const page = Math.max(1, Math.floor((el!.clientHeight - rh) / rh) - 1)
91
+ const to: Record<string, [number, number]> = {
92
+ ArrowDown: [r + 1, c], ArrowUp: [r - 1, c], ArrowRight: [r, c + 1], ArrowLeft: [r, c - 1], PageDown: [r + page, c], PageUp: [r - page, c],
93
+ Home: ctrl ? [0, 0] : [r, 0], End: ctrl ? [lastR, lastC] : [r, lastC],
94
+ }
95
+ const next = to[e.key]
96
+ if (!next) return
97
+ e.preventDefault()
98
+ focusCell([Math.max(0, Math.min(lastR, next[0])), Math.max(0, Math.min(lastC, next[1]))])
99
+ }
100
+
101
+ onMount(() => {
102
+ queueMicrotask(() => {
103
+ if (!el) return
104
+ const allRows = Array.from({ length: untrack(() => props.rows) }, (_, i) => i)
105
+ ys = virtualizer(el, { keys: allRows, spacer: canvas!, estimate: rh, size, head: rh, sized: true, pinned: () => [active.value[0]], overscan: 200, onRange: i => (rows.value = i) })
106
+ xs = virtualizer(el, { keys: untrack(() => columns.value.map((_, i) => i)), spacer: canvas!, axis: 'x', estimate: i => untrack(() => columns.value[i]!.width ?? CW), head: hw, sized: true, pinned: () => [active.value[1]], overscan: 200, onRange: i => (cols.value = i) })
107
+ for (const [row, key] of held.splice(0)) row.set(ys.attach(row.node, key))
108
+ for (const [cell, key] of heldCols.splice(0)) cell.set(xs.attach(cell.node, key))
109
+ if (!untrack(() => wrapped.value.length)) return
110
+ void measureEngine().then(e => {
111
+ if (!e || !el) return
112
+ const c = getComputedStyle(el)
113
+ font = `${c.fontWeight} ${c.fontSize} ${c.fontFamily}`
114
+ line = parseFloat(c.fontSize) * LEADING
115
+ engine = e
116
+ ys!.refresh(rows.value[0] ?? 0, (rows.value.at(-1) ?? 0) + 1)
117
+ let at = 0, lo = 0
118
+ stop = slices(() => {
119
+ if (at >= allRows.length) { ys!.refresh(lo, at); return false }
120
+ size(at++)
121
+ if (at - lo >= 256) { ys!.refresh(lo, at); lo = at }
122
+ return true
123
+ })
124
+ })
125
+ })
126
+ onCleanup(() => { stop?.(); ys?.destroy(); xs?.destroy() })
127
+ })
128
+
129
+ type Held = { node: HTMLElement; set: (undo: () => void) => void }
130
+ const held: [Held, number][] = [], heldCols: [Held, number][] = []
131
+ const watch = (node: () => HTMLElement, axis: 'x' | 'y', key: number) => onMount(() => {
132
+ let undo: (() => void) | undefined
133
+ const v = axis === 'y' ? ys : xs
134
+ if (v) undo = v.attach(node(), key)
135
+ else (axis === 'y' ? held : heldCols).push([{ node: node(), set: u => (undo = u) }, key])
136
+ onCleanup(() => undo?.())
137
+ })
138
+
139
+ function Cell(p: { r: number; entry: { c: number; col: GridColumn } }) {
140
+ let node: HTMLElement | undefined
141
+ const c = untrack(() => p.entry.c)
142
+ watch(() => node!, 'x', c)
143
+ const on = () => active.value[0] === p.r && active.value[1] === c
144
+ const wrap = () => p.entry.col.wrap
145
+ return (
146
+ <div
147
+ ref={n => (node = n)} id={cellId(p.r, c)} data-part="cell" role="gridcell" aria-colindex={c + 2} aria-selected={on() ? 'true' : undefined} data-active={on() ? '' : undefined}
148
+ data-neg={props.negative?.(p.r, p.entry.col) ? '' : undefined}
149
+ style={`inline-size:${p.entry.col.width ?? CW}px;block-size:100%;transform:translateX(${hw + c * CW}px)${wrap() ? `;white-space:normal;line-height:${LEADING};justify-content:start;align-items:start;padding-block:${PAD / 2}px` : ''}`}
150
+ onPointerDown={() => focusCell([p.r, c], false)}
151
+ >{props.cell(p.r, p.entry.col)}</div>
152
+ )
153
+ }
154
+
155
+ function Head(p: { entry: { c: number; col: GridColumn } }) {
156
+ let node: HTMLElement | undefined
157
+ const c = untrack(() => p.entry.c)
158
+ watch(() => node!, 'x', c)
159
+ return <div ref={n => (node = n)} data-part="col-head" role="columnheader" aria-colindex={c + 2} style={`inline-size:${p.entry.col.width ?? CW}px;transform:translateX(${hw + c * CW}px)`}>{p.entry.col.header}</div>
160
+ }
161
+
162
+ function Row(p: { r: number }) {
163
+ let node: HTMLElement | undefined
164
+ const r = untrack(() => p.r)
165
+ watch(() => node!, 'y', r)
166
+ return (
167
+ <div ref={n => (node = n)} data-part="row" role="row" aria-rowindex={r + 2} style={`inline-size:${hw}px;min-inline-size:100%;transform:translateY(${rh + r * rh}px)`}>
168
+ <div data-part="row-head" role="rowheader" aria-colindex="1" style={`position:sticky;inset-inline-start:0;inline-size:${hw}px;block-size:100%`}>{props.rowHeader(r)}</div>
169
+ {shownCols.value.map(e => <Cell key={e.c} r={r} entry={e} />)}
170
+ </div>
171
+ )
172
+ }
173
+
174
+ return (
175
+ <div
176
+ ref={n => (el = n)} data-scope="scroll-grid" role="grid" id={id} aria-label={props.label} aria-describedby={`${id}-help`} aria-readonly="true"
177
+ aria-rowcount={props.rows + 1} aria-colcount={columns.value.length + 1} aria-activedescendant={cellId(0, 0)} tabindex="0" onKeyDown={onKeyDown}
178
+ style={props.height ? `block-size:${props.height}` : undefined}
179
+ >
180
+ <div ref={n => (canvas = n)} data-part="canvas" role="presentation">
181
+ <div data-part="head-row" role="row" aria-rowindex="1" style="position:sticky;inset-block-start:0">
182
+ <div data-part="corner" role="columnheader" aria-colindex="1" style={`position:sticky;inset-inline-start:0;inline-size:${hw}px`}>{props.corner ?? props.label}</div>
183
+ {shownCols.value.map(e => <Head key={e.c} entry={e} />)}
184
+ </div>
185
+ {rows.value.map(r => <Row key={r} r={r} />)}
186
+ </div>
187
+ <p id={`${id}-help`} class="vh">{HELP}</p>
188
+ </div>
189
+ )
190
+ }