@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.
- package/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|