@a4ui/core 0.34.0 → 0.35.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.
@@ -0,0 +1,178 @@
1
+ // Chronological audit-trail list, newest first, grouped by day with a
2
+ // Timeline-style connector line down the left. Distinct from
3
+ // TransactionFeed (dense finance rows, no day grouping/connector) — this is
4
+ // generic activity/audit history ("who did what, when").
5
+ import { Activity } from 'lucide-solid'
6
+ import type { JSX } from 'solid-js'
7
+ import { For, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { Avatar } from './Avatar'
11
+
12
+ /** A single audit-trail entry rendered by {@link ActivityFeed}. */
13
+ export interface ActivityItem {
14
+ id: string
15
+ /** Name of the user/system that performed the action. */
16
+ actor: string
17
+ /** What happened, rendered right after `actor` (e.g. `"commented on Invoice #42"`). */
18
+ action: JSX.Element
19
+ /** Leading icon shown when `avatar` is omitted. Defaults to a generic activity icon. */
20
+ icon?: JSX.Element
21
+ /** ISO 8601 timestamp of the event. */
22
+ timestamp: string
23
+ avatar?: string
24
+ }
25
+
26
+ export interface ActivityFeedProps {
27
+ items: ActivityItem[]
28
+ class?: string
29
+ }
30
+
31
+ interface ActivityGroup {
32
+ label: string
33
+ items: ActivityItem[]
34
+ }
35
+
36
+ const DAY_MS = 86_400_000
37
+
38
+ function startOfDay(date: Date): number {
39
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
40
+ }
41
+
42
+ /** `"Today"` / `"Yesterday"` / a locale date, based on calendar-day distance from now. */
43
+ function dayLabel(timestamp: string): string {
44
+ const date = new Date(timestamp)
45
+ const diffDays = Math.round((startOfDay(new Date()) - startOfDay(date)) / DAY_MS)
46
+ if (diffDays === 0) return 'Today'
47
+ if (diffDays === 1) return 'Yesterday'
48
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
49
+ }
50
+
51
+ /** Compact relative time (`"2h ago"`, `"just now"`) computed from an ISO timestamp. */
52
+ function relativeTime(timestamp: string): string {
53
+ const diffSeconds = Math.round((Date.now() - new Date(timestamp).getTime()) / 1000)
54
+ if (diffSeconds < 60) return 'just now'
55
+ const diffMinutes = Math.round(diffSeconds / 60)
56
+ if (diffMinutes < 60) return `${diffMinutes}m ago`
57
+ const diffHours = Math.round(diffMinutes / 60)
58
+ if (diffHours < 24) return `${diffHours}h ago`
59
+ const diffDays = Math.round(diffHours / 24)
60
+ if (diffDays < 7) return `${diffDays}d ago`
61
+ const diffWeeks = Math.round(diffDays / 7)
62
+ if (diffWeeks < 5) return `${diffWeeks}w ago`
63
+ const diffMonths = Math.round(diffDays / 30)
64
+ if (diffMonths < 12) return `${diffMonths}mo ago`
65
+ return `${Math.round(diffDays / 365)}y ago`
66
+ }
67
+
68
+ function initials(name: string): string {
69
+ return name
70
+ .split(' ')
71
+ .filter(Boolean)
72
+ .slice(0, 2)
73
+ .map((part) => part[0]?.toUpperCase())
74
+ .join('')
75
+ }
76
+
77
+ /** Groups items newest-first by calendar day, preserving first-seen group order. */
78
+ function groupByDay(items: ActivityItem[]): ActivityGroup[] {
79
+ const sorted = [...items].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
80
+ const groups: ActivityGroup[] = []
81
+ const byLabel = new Map<string, ActivityGroup>()
82
+ for (const item of sorted) {
83
+ const label = dayLabel(item.timestamp)
84
+ let group = byLabel.get(label)
85
+ if (!group) {
86
+ group = { label, items: [] }
87
+ byLabel.set(label, group)
88
+ groups.push(group)
89
+ }
90
+ group.items.push(item)
91
+ }
92
+ return groups
93
+ }
94
+
95
+ /**
96
+ * Chronological audit-trail list (newest first), grouped under subtle day
97
+ * headers (`"Today"` / `"Yesterday"` / a locale date) with a continuous
98
+ * connector line down the left, in the same spirit as {@link Timeline}. Each
99
+ * row shows an avatar (or a fallback icon), `"{actor} {action}"`, and a
100
+ * relative timestamp whose `title` carries the absolute time. Distinct from
101
+ * {@link TransactionFeed}: this is generic activity/audit history, not a
102
+ * financial ledger.
103
+ *
104
+ * @example
105
+ * ```tsx
106
+ * <ActivityFeed
107
+ * items={[
108
+ * {
109
+ * id: '1',
110
+ * actor: 'Ada Lovelace',
111
+ * action: <>commented on <strong>Invoice #42</strong></>,
112
+ * timestamp: '2026-07-21T09:15:00Z',
113
+ * avatar: ada.avatarUrl,
114
+ * },
115
+ * {
116
+ * id: '2',
117
+ * actor: 'System',
118
+ * action: 'archived the project',
119
+ * timestamp: '2026-07-20T18:02:00Z',
120
+ * },
121
+ * ]}
122
+ * />
123
+ * ```
124
+ */
125
+ export function ActivityFeed(props: ActivityFeedProps): JSX.Element {
126
+ const groups = () => groupByDay(props.items)
127
+
128
+ return (
129
+ <div class={cn('flex flex-col gap-6', props.class)}>
130
+ <For each={groups()}>
131
+ {(group) => (
132
+ <div>
133
+ <p class="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
134
+ {group.label}
135
+ </p>
136
+ <ol class="relative flex flex-col gap-5">
137
+ <For each={group.items}>
138
+ {(item, index) => (
139
+ <li class="relative flex gap-3 pl-1">
140
+ <Show when={index() < group.items.length - 1}>
141
+ <span
142
+ aria-hidden="true"
143
+ class="absolute left-[1.375rem] top-9 -bottom-5 w-px -translate-x-1/2 bg-border"
144
+ />
145
+ </Show>
146
+ <Show
147
+ when={item.avatar}
148
+ fallback={
149
+ <span class="relative z-[1] grid h-9 w-9 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
150
+ {item.icon ?? <Activity class="h-4 w-4" />}
151
+ </span>
152
+ }
153
+ >
154
+ <span class="relative z-[1] shrink-0">
155
+ <Avatar src={item.avatar} alt={item.actor} fallback={initials(item.actor)} />
156
+ </span>
157
+ </Show>
158
+ <div class="min-w-0 flex-1 pt-1.5">
159
+ <p class="text-sm text-foreground">
160
+ <span class="font-medium">{item.actor}</span> {item.action}
161
+ </p>
162
+ <p
163
+ class="mt-0.5 text-xs text-muted-foreground"
164
+ title={new Date(item.timestamp).toLocaleString()}
165
+ >
166
+ {relativeTime(item.timestamp)}
167
+ </p>
168
+ </div>
169
+ </li>
170
+ )}
171
+ </For>
172
+ </ol>
173
+ </div>
174
+ )}
175
+ </For>
176
+ </div>
177
+ )
178
+ }
@@ -0,0 +1,163 @@
1
+ // Booking-style time-slot picker: a compact scrollable list of bookable dates on
2
+ // the left (from the keys of `slotsByDate`), open 'HH:mm' slots for the selected
3
+ // date as a button grid on the right. Shares its time-slot idiom with
4
+ // EventScheduler (native `Date`, ISO/'HH:mm' strings, no date library) — this one
5
+ // lays slots out as buttons instead of a positioned grid. Plain Solid + theme
6
+ // tokens (works in light/dark).
7
+ import type { JSX } from 'solid-js'
8
+ import { createMemo, createSignal, For, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ export interface AvailabilityPickerProps {
13
+ /** Open slots per date, keyed by local ISO 'YYYY-MM-DD'; each value is a list of 'HH:mm' times. */
14
+ slotsByDate: Record<string, string[]>
15
+ /** Controlled selection; omit to manage selection internally. */
16
+ value?: { date: string; time: string }
17
+ onChange?: (selection: { date: string; time: string }) => void
18
+ /** Label shown near the slots. @default the browser's Intl timezone */
19
+ timezone?: string
20
+ class?: string
21
+ }
22
+
23
+ const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
24
+ const MONTH_SHORT = [
25
+ 'Jan',
26
+ 'Feb',
27
+ 'Mar',
28
+ 'Apr',
29
+ 'May',
30
+ 'Jun',
31
+ 'Jul',
32
+ 'Aug',
33
+ 'Sep',
34
+ 'Oct',
35
+ 'Nov',
36
+ 'Dec',
37
+ ] as const
38
+
39
+ /** Parse a `YYYY-MM-DD` key into a local `Date` at midnight, no timezone shifting. */
40
+ function parseDateKey(key: string): Date {
41
+ const [y, m, d] = key.split('-').map(Number)
42
+ return new Date(y, m - 1, d)
43
+ }
44
+
45
+ /** Format an 'HH:mm' 24h slot as a 12h label, e.g. '09:30' -> '9:30 AM'. */
46
+ function formatSlot(time: string): string {
47
+ const [hh, mm] = time.split(':').map(Number)
48
+ const period = hh < 12 ? 'AM' : 'PM'
49
+ const h12 = hh % 12 === 0 ? 12 : hh % 12
50
+ return `${h12}:${String(mm).padStart(2, '0')} ${period}`
51
+ }
52
+
53
+ /**
54
+ * Booking-style availability picker: a scrollable list of bookable dates on the
55
+ * left (built from the keys of `slotsByDate`) and a scrollable grid of open
56
+ * 'HH:mm' slots for the selected date on the right, with a timezone label.
57
+ * Controlled via `value`/`onChange`, or self-managed when `value` is omitted.
58
+ * Fully keyboard accessible (native buttons, `aria-pressed` on the current pick).
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const [selection, setSelection] = createSignal<{ date: string; time: string }>()
63
+ * <AvailabilityPicker
64
+ * slotsByDate={{ '2026-07-22': ['09:00', '09:30', '14:00'], '2026-07-23': ['10:00'] }}
65
+ * value={selection()}
66
+ * onChange={setSelection}
67
+ * />
68
+ * ```
69
+ */
70
+ export function AvailabilityPicker(props: AvailabilityPickerProps): JSX.Element {
71
+ const dates = createMemo(() => Object.keys(props.slotsByDate).sort())
72
+
73
+ const [internalValue, setInternalValue] = createSignal<{ date: string; time: string } | undefined>(
74
+ undefined,
75
+ )
76
+ // eslint-disable-next-line solid/reactivity -- seed once; navigation from here is user-driven
77
+ const [viewingDate, setViewingDate] = createSignal<string | undefined>(props.value?.date ?? dates()[0])
78
+
79
+ const selection = () => props.value ?? internalValue()
80
+ const activeDate = () => props.value?.date ?? viewingDate()
81
+ const slots = createMemo(() => props.slotsByDate[activeDate() ?? ''] ?? [])
82
+ const timezone = () => props.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
83
+
84
+ const pick = (date: string, time: string) => {
85
+ setViewingDate(date)
86
+ setInternalValue({ date, time })
87
+ props.onChange?.({ date, time })
88
+ }
89
+
90
+ return (
91
+ <div class={cn('flex overflow-hidden rounded-xl border border-border bg-card', props.class)}>
92
+ {/* Date list */}
93
+ <div class="flex w-32 shrink-0 flex-col gap-1 overflow-y-auto border-r border-border p-2">
94
+ <For each={dates()} fallback={<p class="p-2 text-xs text-muted-foreground">No dates</p>}>
95
+ {(dateKey) => {
96
+ const d = parseDateKey(dateKey)
97
+ const isActive = () => activeDate() === dateKey
98
+ const count = () => props.slotsByDate[dateKey]?.length ?? 0
99
+ return (
100
+ <button
101
+ type="button"
102
+ aria-pressed={isActive()}
103
+ disabled={count() === 0}
104
+ onClick={() => setViewingDate(dateKey)}
105
+ class={cn(
106
+ 'flex flex-col items-start rounded-md px-2 py-1.5 text-left transition-colors',
107
+ isActive() ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-muted',
108
+ count() === 0 && !isActive() && 'text-muted-foreground/50',
109
+ )}
110
+ >
111
+ <span class="text-[10px] uppercase opacity-80">{WEEKDAY_SHORT[d.getDay()]}</span>
112
+ <span class="text-sm font-medium">
113
+ {MONTH_SHORT[d.getMonth()]} {d.getDate()}
114
+ </span>
115
+ </button>
116
+ )
117
+ }}
118
+ </For>
119
+ </div>
120
+
121
+ {/* Slots */}
122
+ <div class="flex flex-1 flex-col">
123
+ <div class="flex items-center justify-between border-b border-border px-3 py-2">
124
+ <span class="text-sm font-medium text-foreground">
125
+ <Show when={activeDate()} fallback="Select a date">
126
+ {(d) => {
127
+ const date = parseDateKey(d())
128
+ return `${WEEKDAY_SHORT[date.getDay()]}, ${MONTH_SHORT[date.getMonth()]} ${date.getDate()}`
129
+ }}
130
+ </Show>
131
+ </span>
132
+ <span class="text-xs text-muted-foreground">{timezone()}</span>
133
+ </div>
134
+
135
+ <div class="grid grid-cols-3 gap-2 overflow-y-auto p-3" style={{ 'max-height': '20rem' }}>
136
+ <For
137
+ each={slots()}
138
+ fallback={<p class="col-span-3 text-sm text-muted-foreground">No open times</p>}
139
+ >
140
+ {(time) => {
141
+ const isSelected = () => selection()?.date === activeDate() && selection()?.time === time
142
+ return (
143
+ <button
144
+ type="button"
145
+ aria-pressed={isSelected()}
146
+ onClick={() => pick(activeDate() ?? '', time)}
147
+ class={cn(
148
+ 'rounded-md border px-2 py-1.5 text-sm transition-colors',
149
+ isSelected()
150
+ ? 'border-primary bg-primary text-primary-foreground'
151
+ : 'border-border text-foreground hover:bg-muted',
152
+ )}
153
+ >
154
+ {formatSlot(time)}
155
+ </button>
156
+ )
157
+ }}
158
+ </For>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ )
163
+ }
@@ -0,0 +1,277 @@
1
+ // Lightweight code textarea — line numbers + approximate syntax highlighting,
2
+ // no parser, no syntax-highlight dependency. A transparent <textarea> is
3
+ // layered exactly over a <pre> highlight overlay; the textarea stays the
4
+ // real input (caret, selection, a11y), the overlay is purely decorative.
5
+ import type { JSX } from 'solid-js'
6
+ import { createMemo, For } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface CodeEditorProps {
11
+ value: string
12
+ /** Called with the new source string on every input event. */
13
+ onInput?: (v: string) => void
14
+ /** Language used to pick the keyword set for highlighting. Default `'ts'`. */
15
+ language?: 'ts' | 'js' | 'json' | 'html' | 'css'
16
+ /** Hides the caret and blocks editing; the overlay still highlights. */
17
+ readOnly?: boolean
18
+ class?: string
19
+ }
20
+
21
+ const KEYWORDS: Record<NonNullable<CodeEditorProps['language']>, string[]> = {
22
+ ts: [
23
+ 'const',
24
+ 'let',
25
+ 'var',
26
+ 'function',
27
+ 'return',
28
+ 'if',
29
+ 'else',
30
+ 'for',
31
+ 'while',
32
+ 'switch',
33
+ 'case',
34
+ 'break',
35
+ 'continue',
36
+ 'class',
37
+ 'extends',
38
+ 'implements',
39
+ 'interface',
40
+ 'type',
41
+ 'enum',
42
+ 'import',
43
+ 'export',
44
+ 'from',
45
+ 'default',
46
+ 'new',
47
+ 'this',
48
+ 'super',
49
+ 'try',
50
+ 'catch',
51
+ 'finally',
52
+ 'throw',
53
+ 'async',
54
+ 'await',
55
+ 'typeof',
56
+ 'instanceof',
57
+ 'in',
58
+ 'of',
59
+ 'null',
60
+ 'undefined',
61
+ 'true',
62
+ 'false',
63
+ 'void',
64
+ 'public',
65
+ 'private',
66
+ 'protected',
67
+ 'readonly',
68
+ 'static',
69
+ 'as',
70
+ ],
71
+ js: [
72
+ 'const',
73
+ 'let',
74
+ 'var',
75
+ 'function',
76
+ 'return',
77
+ 'if',
78
+ 'else',
79
+ 'for',
80
+ 'while',
81
+ 'switch',
82
+ 'case',
83
+ 'break',
84
+ 'continue',
85
+ 'class',
86
+ 'extends',
87
+ 'import',
88
+ 'export',
89
+ 'from',
90
+ 'default',
91
+ 'new',
92
+ 'this',
93
+ 'super',
94
+ 'try',
95
+ 'catch',
96
+ 'finally',
97
+ 'throw',
98
+ 'async',
99
+ 'await',
100
+ 'typeof',
101
+ 'instanceof',
102
+ 'in',
103
+ 'of',
104
+ 'null',
105
+ 'undefined',
106
+ 'true',
107
+ 'false',
108
+ 'void',
109
+ ],
110
+ json: ['true', 'false', 'null'],
111
+ html: ['DOCTYPE', 'html', 'head', 'body', 'div', 'span', 'script', 'style', 'link', 'meta'],
112
+ css: [
113
+ 'color',
114
+ 'background',
115
+ 'display',
116
+ 'flex',
117
+ 'grid',
118
+ 'position',
119
+ 'width',
120
+ 'height',
121
+ 'margin',
122
+ 'padding',
123
+ 'border',
124
+ 'font',
125
+ 'transition',
126
+ 'transform',
127
+ ],
128
+ }
129
+
130
+ /** Escapes text that will be inserted as literal `<pre>` content. */
131
+ function escapeHtml(text: string): string {
132
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
133
+ }
134
+
135
+ /**
136
+ * Tokenizes a single line into highlighted HTML. Approximate on purpose —
137
+ * regex-based, not a real parser — good enough for readability, not for
138
+ * correctness-sensitive tooling.
139
+ */
140
+ function highlightLine(line: string, keywords: string[]): string {
141
+ // Comments win outright: once matched, the rest of the line is dimmed as-is.
142
+ const lineCommentIndex = line.indexOf('//')
143
+ const htmlCommentIndex = line.indexOf('<!--')
144
+ const commentIndex =
145
+ lineCommentIndex === -1
146
+ ? htmlCommentIndex
147
+ : htmlCommentIndex === -1
148
+ ? lineCommentIndex
149
+ : Math.min(lineCommentIndex, htmlCommentIndex)
150
+
151
+ const code = commentIndex === -1 ? line : line.slice(0, commentIndex)
152
+ const comment = commentIndex === -1 ? '' : line.slice(commentIndex)
153
+
154
+ const keywordPattern = keywords.length > 0 ? `\\b(${keywords.join('|')})\\b` : ''
155
+ const tokenPattern = new RegExp(
156
+ [
157
+ /"(?:[^"\\]|\\.)*"/.source, // double-quoted string
158
+ /'(?:[^'\\]|\\.)*'/.source, // single-quoted string
159
+ /`(?:[^`\\]|\\.)*`/.source, // template string
160
+ /\b\d+(?:\.\d+)?\b/.source, // number
161
+ ...(keywordPattern ? [keywordPattern] : []),
162
+ ].join('|'),
163
+ 'g',
164
+ )
165
+
166
+ let highlighted = ''
167
+ let lastIndex = 0
168
+ for (const match of code.matchAll(tokenPattern)) {
169
+ const token = match[0]
170
+ const start = match.index ?? 0
171
+ highlighted += escapeHtml(code.slice(lastIndex, start))
172
+ if (token[0] === '"' || token[0] === "'" || token[0] === '`') {
173
+ highlighted += `<span class="text-accent">${escapeHtml(token)}</span>`
174
+ } else if (/^\d/.test(token)) {
175
+ highlighted += `<span class="text-primary">${escapeHtml(token)}</span>`
176
+ } else {
177
+ highlighted += `<span class="text-primary font-medium">${escapeHtml(token)}</span>`
178
+ }
179
+ lastIndex = start + token.length
180
+ }
181
+ highlighted += escapeHtml(code.slice(lastIndex))
182
+
183
+ if (comment) {
184
+ highlighted += `<span class="text-muted-foreground italic">${escapeHtml(comment)}</span>`
185
+ }
186
+
187
+ return highlighted || ' ' // keep empty lines at full line-height
188
+ }
189
+
190
+ const SHARED_TEXT = 'whitespace-pre font-mono text-sm leading-6 px-3 py-2 [tab-size:2]' as const
191
+
192
+ /**
193
+ * A minimal code editor: line numbers in a gutter, a transparent textarea for
194
+ * input, and a `<pre>` overlay underneath it that renders approximate syntax
195
+ * highlighting from simple regexes (keywords, strings, numbers, comments).
196
+ * Not a full parser — good for READMEs, config editors, and demo panes, not
197
+ * a replacement for a real editor component (CodeMirror/Monaco).
198
+ *
199
+ * @example
200
+ * ```tsx
201
+ * const [src, setSrc] = createSignal('const x: number = 1\n')
202
+ * <CodeEditor value={src()} onInput={setSrc} language="ts" />
203
+ * ```
204
+ */
205
+ export function CodeEditor(props: CodeEditorProps): JSX.Element {
206
+ const local = props
207
+
208
+ let textareaRef: HTMLTextAreaElement | undefined
209
+ let overlayRef: HTMLPreElement | undefined
210
+
211
+ const lines = createMemo(() => local.value.split('\n'))
212
+ const keywords = createMemo(() => KEYWORDS[local.language ?? 'ts'])
213
+ const highlightedLines = createMemo(() => lines().map((line) => highlightLine(line, keywords())))
214
+
215
+ const syncScroll = () => {
216
+ if (!textareaRef || !overlayRef) return
217
+ overlayRef.scrollTop = textareaRef.scrollTop
218
+ overlayRef.scrollLeft = textareaRef.scrollLeft
219
+ }
220
+
221
+ const handleKeyDown: JSX.EventHandlerUnion<HTMLTextAreaElement, KeyboardEvent> = (ev) => {
222
+ if (ev.key !== 'Tab' || local.readOnly) return
223
+ ev.preventDefault()
224
+ const el = ev.currentTarget
225
+ const start = el.selectionStart
226
+ const end = el.selectionEnd
227
+ const next = `${local.value.slice(0, start)} ${local.value.slice(end)}`
228
+ local.onInput?.(next)
229
+ // Restore caret after the inserted spaces once the value commits.
230
+ queueMicrotask(() => {
231
+ el.selectionStart = el.selectionEnd = start + 2
232
+ })
233
+ }
234
+
235
+ return (
236
+ <div class={cn('relative flex overflow-hidden rounded-md border border-border bg-card', local.class)}>
237
+ <div
238
+ aria-hidden="true"
239
+ class={cn(
240
+ SHARED_TEXT,
241
+ 'select-none border-r border-border bg-muted text-right text-muted-foreground',
242
+ )}
243
+ >
244
+ <For each={lines()}>{(_line, index) => <div>{index() + 1}</div>}</For>
245
+ </div>
246
+ <div class="relative flex-1 overflow-hidden">
247
+ <pre
248
+ ref={overlayRef}
249
+ aria-hidden="true"
250
+ class={cn(SHARED_TEXT, 'pointer-events-none absolute inset-0 overflow-auto text-foreground')}
251
+ >
252
+ <For each={highlightedLines()}>
253
+ {(html) => (
254
+ // eslint-disable-next-line solid/no-innerhtml -- highlightLine() HTML-escapes every code slice via escapeHtml(); only our own token <span>s are injected, so this cannot inject markup from `value`.
255
+ <div innerHTML={html} />
256
+ )}
257
+ </For>
258
+ </pre>
259
+ <textarea
260
+ ref={textareaRef}
261
+ aria-label="Code editor"
262
+ class={cn(
263
+ SHARED_TEXT,
264
+ 'absolute inset-0 resize-none overflow-auto bg-transparent text-transparent caret-foreground outline-none',
265
+ local.readOnly && 'caret-transparent',
266
+ )}
267
+ spellcheck={false}
268
+ readOnly={local.readOnly}
269
+ value={local.value}
270
+ onInput={(ev) => local.onInput?.(ev.currentTarget.value)}
271
+ onScroll={syncScroll}
272
+ onKeyDown={handleKeyDown}
273
+ />
274
+ </div>
275
+ </div>
276
+ )
277
+ }