@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.
- package/dist/elements.css +116 -0
- package/dist/full.css +116 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8276 -5549
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/package.json +1 -1
- package/src/index.ts +36 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
|
@@ -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, '&').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
|
+
}
|