@a4ui/core 0.33.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.
Files changed (58) hide show
  1. package/dist/elements.css +299 -0
  2. package/dist/full.css +299 -0
  3. package/dist/index.d.ts +27 -1
  4. package/dist/index.js +9140 -5065
  5. package/dist/ui/ActivityFeed.d.ts +49 -0
  6. package/dist/ui/AudioWaveform.d.ts +22 -0
  7. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  8. package/dist/ui/CodeEditor.d.ts +25 -0
  9. package/dist/ui/CouponField.d.ts +33 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/FollowingPointer.d.ts +24 -0
  13. package/dist/ui/GanttChart.d.ts +37 -0
  14. package/dist/ui/InteractiveMap.d.ts +50 -0
  15. package/dist/ui/JsonViewer.d.ts +24 -0
  16. package/dist/ui/Kanban.d.ts +49 -0
  17. package/dist/ui/Lamp.d.ts +22 -0
  18. package/dist/ui/Lightbox.d.ts +41 -0
  19. package/dist/ui/LocationPicker.d.ts +25 -0
  20. package/dist/ui/MaskedInput.d.ts +26 -0
  21. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  22. package/dist/ui/OtpInput.d.ts +29 -0
  23. package/dist/ui/PivotTable.d.ts +37 -0
  24. package/dist/ui/PresenceAvatars.d.ts +54 -0
  25. package/dist/ui/QueryBuilder.d.ts +56 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/SignaturePad.d.ts +19 -0
  28. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  29. package/dist/ui/TreeTable.d.ts +52 -0
  30. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  31. package/package.json +1 -1
  32. package/src/index.ts +60 -1
  33. package/src/ui/ActivityFeed.tsx +178 -0
  34. package/src/ui/AudioWaveform.tsx +190 -0
  35. package/src/ui/AvailabilityPicker.tsx +163 -0
  36. package/src/ui/CodeEditor.tsx +277 -0
  37. package/src/ui/CouponField.tsx +120 -0
  38. package/src/ui/EmojiPicker.tsx +424 -0
  39. package/src/ui/EventScheduler.tsx +280 -0
  40. package/src/ui/FollowingPointer.tsx +112 -0
  41. package/src/ui/GanttChart.tsx +283 -0
  42. package/src/ui/InteractiveMap.tsx +349 -0
  43. package/src/ui/JsonViewer.tsx +189 -0
  44. package/src/ui/Kanban.tsx +250 -0
  45. package/src/ui/Lamp.tsx +88 -0
  46. package/src/ui/Lightbox.tsx +261 -0
  47. package/src/ui/LocationPicker.tsx +96 -0
  48. package/src/ui/MaskedInput.tsx +108 -0
  49. package/src/ui/OnboardingChecklist.tsx +163 -0
  50. package/src/ui/OtpInput.tsx +153 -0
  51. package/src/ui/PivotTable.tsx +0 -0
  52. package/src/ui/PresenceAvatars.tsx +142 -0
  53. package/src/ui/QueryBuilder.tsx +280 -0
  54. package/src/ui/SheetSnap.tsx +264 -0
  55. package/src/ui/SignaturePad.tsx +221 -0
  56. package/src/ui/SpreadsheetGrid.tsx +304 -0
  57. package/src/ui/TreeTable.tsx +172 -0
  58. package/src/ui/VideoPlayerShell.tsx +282 -0
@@ -0,0 +1,190 @@
1
+ // Bar-style audio waveform over a hidden native <audio> element — no media
2
+ // library. When no real `peaks` are supplied, a deterministic placeholder
3
+ // waveform is derived from the bar index (abs(sin(...)) blend) so the shape
4
+ // is stable across renders instead of reshuffling on every re-render like
5
+ // Math.random() would.
6
+ import { Pause, Play } from 'lucide-solid'
7
+ import type { JSX } from 'solid-js'
8
+ import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { motionReduced } from '../lib/motion'
12
+
13
+ export interface AudioWaveformProps {
14
+ src?: string
15
+ peaks?: number[]
16
+ /** Bar row height in pixels. @default 64 */
17
+ height?: number
18
+ class?: string
19
+ }
20
+
21
+ const PLACEHOLDER_BAR_COUNT = 48
22
+ // Demo progress rate (fraction of the fake "duration" per animation frame
23
+ // tick) used only when there's no `src` to drive real audio playback.
24
+ const DEMO_TICK_MS = 100
25
+ const DEMO_STEP = 0.01
26
+
27
+ /** Deterministic placeholder bar heights (0..1), no Math.random. */
28
+ function placeholderPeaks(count: number): number[] {
29
+ return Array.from({ length: count }, (_, i) => {
30
+ const a = Math.abs(Math.sin(i * 0.35))
31
+ const b = Math.abs(Math.sin(i * 0.09 + 1.2))
32
+ return 0.15 + 0.85 * (0.65 * a + 0.35 * b)
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Waveform of vertical bars driven by an optional hidden `<audio>` element.
38
+ * Pass `peaks` (numbers in `0..1`) for a real waveform, or omit it for a
39
+ * deterministic placeholder shape. The played portion (up to
40
+ * `currentTime / duration`) is highlighted; clicking a bar seeks there. With
41
+ * no `src`, playback is simulated on a timer for demo purposes.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <AudioWaveform src="/media/track.mp3" />
46
+ * <AudioWaveform peaks={[0.2, 0.8, 0.4, 0.9, 0.3]} height={48} />
47
+ * ```
48
+ */
49
+ export function AudioWaveform(props: AudioWaveformProps): JSX.Element {
50
+ let audioRef: HTMLAudioElement | undefined
51
+
52
+ const [playing, setPlaying] = createSignal(false)
53
+ const [currentTime, setCurrentTime] = createSignal(0)
54
+ const [duration, setDuration] = createSignal(0)
55
+
56
+ const bars = createMemo(() => props.peaks ?? placeholderPeaks(PLACEHOLDER_BAR_COUNT))
57
+ const progress = createMemo(() => (duration() > 0 ? currentTime() / duration() : 0))
58
+ const barHeight = () => props.height ?? 64
59
+
60
+ const togglePlay = (): void => {
61
+ if (props.src) {
62
+ const audio = audioRef
63
+ if (!audio) return
64
+ if (audio.paused) void audio.play()
65
+ else audio.pause()
66
+ return
67
+ }
68
+ // No source: drive a fake demo progress instead of real playback.
69
+ setPlaying((p) => !p)
70
+ }
71
+
72
+ const seekToRatio = (ratio: number): void => {
73
+ const clamped = Math.min(Math.max(ratio, 0), 1)
74
+ if (props.src) {
75
+ const audio = audioRef
76
+ if (!audio || !duration()) return
77
+ audio.currentTime = clamped * duration()
78
+ return
79
+ }
80
+ setCurrentTime(clamped * (duration() || 1))
81
+ }
82
+
83
+ const handleBarClick = (index: number): void => {
84
+ seekToRatio((index + 0.5) / bars().length)
85
+ }
86
+
87
+ onMount(() => {
88
+ if (props.src) {
89
+ const audio = audioRef
90
+ if (!audio) return
91
+
92
+ const onPlay = (): void => {
93
+ setPlaying(true)
94
+ }
95
+ const onPause = (): void => {
96
+ setPlaying(false)
97
+ }
98
+ const onTimeUpdate = (): void => {
99
+ setCurrentTime(audio.currentTime)
100
+ }
101
+ const onLoadedMetadata = (): void => {
102
+ setDuration(audio.duration || 0)
103
+ }
104
+
105
+ audio.addEventListener('play', onPlay)
106
+ audio.addEventListener('pause', onPause)
107
+ audio.addEventListener('timeupdate', onTimeUpdate)
108
+ audio.addEventListener('loadedmetadata', onLoadedMetadata)
109
+
110
+ onCleanup(() => {
111
+ audio.removeEventListener('play', onPlay)
112
+ audio.removeEventListener('pause', onPause)
113
+ audio.removeEventListener('timeupdate', onTimeUpdate)
114
+ audio.removeEventListener('loadedmetadata', onLoadedMetadata)
115
+ })
116
+ return
117
+ }
118
+
119
+ // Demo mode: fake a duration and advance currentTime on an interval while playing.
120
+ setDuration(30)
121
+ const interval = setInterval(() => {
122
+ if (!playing() || motionReduced()) return
123
+ setCurrentTime((t) => {
124
+ const next = t + DEMO_STEP * duration()
125
+ if (next >= duration()) {
126
+ setPlaying(false)
127
+ return 0
128
+ }
129
+ return next
130
+ })
131
+ }, DEMO_TICK_MS)
132
+ onCleanup(() => clearInterval(interval))
133
+ })
134
+
135
+ return (
136
+ <div class={cn('card flex items-center gap-3 p-3', props.class)}>
137
+ <Show when={props.src}>
138
+ <audio ref={audioRef} src={props.src} />
139
+ </Show>
140
+
141
+ <button
142
+ type="button"
143
+ aria-label={playing() ? 'Pause' : 'Play'}
144
+ onClick={togglePlay}
145
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform duration-150 hover:scale-105 active:scale-95"
146
+ >
147
+ <Show when={playing()} fallback={<Play class="h-4 w-4 translate-x-0.5" fill="currentColor" />}>
148
+ <Pause class="h-4 w-4" fill="currentColor" />
149
+ </Show>
150
+ </button>
151
+
152
+ <div
153
+ role="slider"
154
+ aria-label="Waveform position"
155
+ aria-valuemin={0}
156
+ aria-valuemax={100}
157
+ aria-valuenow={Math.round(progress() * 100)}
158
+ tabIndex={0}
159
+ class="flex flex-1 cursor-pointer items-center gap-[2px]"
160
+ style={{ height: `${barHeight()}px` }}
161
+ onClick={(event) => {
162
+ const rect = event.currentTarget.getBoundingClientRect()
163
+ seekToRatio((event.clientX - rect.left) / rect.width)
164
+ }}
165
+ >
166
+ <For each={bars()}>
167
+ {(peak, index) => {
168
+ const played = createMemo(() => index() / bars().length <= progress())
169
+ return (
170
+ <button
171
+ type="button"
172
+ aria-hidden="true"
173
+ tabIndex={-1}
174
+ onClick={(event) => {
175
+ event.stopPropagation()
176
+ handleBarClick(index())
177
+ }}
178
+ class={cn(
179
+ 'w-full min-w-[2px] flex-1 rounded-full transition-colors duration-150',
180
+ played() ? 'bg-primary' : 'bg-muted',
181
+ )}
182
+ style={{ height: `${Math.max(peak, 0.06) * 100}%` }}
183
+ />
184
+ )
185
+ }}
186
+ </For>
187
+ </div>
188
+ </div>
189
+ )
190
+ }
@@ -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
+ }