@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,280 @@
|
|
|
1
|
+
// Time-grid calendar: day or week view of hour bands (startHour..endHour) with
|
|
2
|
+
// events absolutely positioned by their start/end minutes, split side-by-side on
|
|
3
|
+
// overlap. Shares its time-slot idiom with AvailabilityPicker (native `Date`,
|
|
4
|
+
// ISO/'HH:mm' strings, no date library) — this one draws a positioned grid
|
|
5
|
+
// instead of a button list. Plain Solid + theme tokens (works in light/dark).
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { createMemo, For, Show } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
|
|
11
|
+
/** A single scheduled event; `start`/`end` are local ISO datetimes ('YYYY-MM-DDTHH:mm'). */
|
|
12
|
+
export interface SchedulerEvent {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
start: string
|
|
16
|
+
end: string
|
|
17
|
+
tone?: 'primary' | 'accent'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EventSchedulerProps {
|
|
21
|
+
events: SchedulerEvent[]
|
|
22
|
+
/** Day to show (day view) or the week containing it (week view). ISO 'YYYY-MM-DD'. @default today */
|
|
23
|
+
date?: string
|
|
24
|
+
/** @default 'day' */
|
|
25
|
+
view?: 'day' | 'week'
|
|
26
|
+
/** First hour band shown (0–23). @default 7 */
|
|
27
|
+
startHour?: number
|
|
28
|
+
/** Last hour band shown, exclusive (0–23). @default 21 */
|
|
29
|
+
endHour?: number
|
|
30
|
+
class?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HOUR_PX = 56
|
|
34
|
+
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
|
|
35
|
+
|
|
36
|
+
const TONE_CLASSES: Record<'primary' | 'accent', string> = {
|
|
37
|
+
primary: 'bg-primary text-primary-foreground',
|
|
38
|
+
accent: 'bg-accent text-accent-foreground',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Local-time `YYYY-MM-DD` key for a Date. */
|
|
42
|
+
function isoKey(d: Date): string {
|
|
43
|
+
const y = d.getFullYear()
|
|
44
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
45
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
46
|
+
return `${y}-${m}-${day}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse a local ISO datetime ('YYYY-MM-DDTHH:mm') into a `Date`, no timezone shifting. */
|
|
50
|
+
function parseLocal(dt: string): Date {
|
|
51
|
+
const [datePart, timePart = '00:00'] = dt.split('T')
|
|
52
|
+
const [y, m, d] = datePart.split('-').map(Number)
|
|
53
|
+
const [hh, mm] = timePart.split(':').map(Number)
|
|
54
|
+
return new Date(y, m - 1, d, hh, mm)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function minutesOfDay(d: Date): number {
|
|
58
|
+
return d.getHours() * 60 + d.getMinutes()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 'HH:mm'-since-midnight minutes rendered as a 12h clock label, e.g. 570 -> '9:30 AM'. */
|
|
62
|
+
function fmtTime(min: number): string {
|
|
63
|
+
const h = Math.floor(min / 60)
|
|
64
|
+
const m = min % 60
|
|
65
|
+
const period = h < 12 ? 'AM' : 'PM'
|
|
66
|
+
const h12 = h % 12 === 0 ? 12 : h % 12
|
|
67
|
+
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PositionedEvent {
|
|
71
|
+
event: SchedulerEvent
|
|
72
|
+
startMin: number
|
|
73
|
+
endMin: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface LaidOutEvent extends PositionedEvent {
|
|
77
|
+
col: number
|
|
78
|
+
colCount: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Greedy interval-graph coloring: overlapping events in a day column get
|
|
83
|
+
* side-by-side sub-columns (like most calendar UIs). Non-overlapping events
|
|
84
|
+
* each get the full column width.
|
|
85
|
+
*/
|
|
86
|
+
function layoutColumn(events: PositionedEvent[]): LaidOutEvent[] {
|
|
87
|
+
const sorted = [...events].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin)
|
|
88
|
+
const results: LaidOutEvent[] = []
|
|
89
|
+
let cluster: (PositionedEvent & { col: number })[] = []
|
|
90
|
+
let colEnds: number[] = []
|
|
91
|
+
let clusterMaxEnd = -Infinity
|
|
92
|
+
|
|
93
|
+
const flush = () => {
|
|
94
|
+
if (cluster.length === 0) return
|
|
95
|
+
const colCount = Math.max(...cluster.map((e) => e.col)) + 1
|
|
96
|
+
for (const e of cluster) results.push({ ...e, colCount })
|
|
97
|
+
cluster = []
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const ev of sorted) {
|
|
101
|
+
if (ev.startMin >= clusterMaxEnd) {
|
|
102
|
+
flush()
|
|
103
|
+
colEnds = []
|
|
104
|
+
clusterMaxEnd = -Infinity
|
|
105
|
+
}
|
|
106
|
+
let col = colEnds.findIndex((end) => end <= ev.startMin)
|
|
107
|
+
if (col === -1) {
|
|
108
|
+
col = colEnds.length
|
|
109
|
+
colEnds.push(ev.endMin)
|
|
110
|
+
} else {
|
|
111
|
+
colEnds[col] = ev.endMin
|
|
112
|
+
}
|
|
113
|
+
clusterMaxEnd = Math.max(clusterMaxEnd, ev.endMin)
|
|
114
|
+
cluster.push({ ...ev, col })
|
|
115
|
+
}
|
|
116
|
+
flush()
|
|
117
|
+
return results
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Time-grid calendar: one column per visible day, hour bands from `startHour` to
|
|
122
|
+
* `endHour`, events absolutely positioned by their start/end minutes and split
|
|
123
|
+
* side-by-side when they overlap. Draws a "now" line through today's column
|
|
124
|
+
* when it's in view. Read-only positioning (drag-to-create/resize is out of
|
|
125
|
+
* scope). Scrolls vertically when the hour range is tall.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```tsx
|
|
129
|
+
* <EventScheduler
|
|
130
|
+
* view="week"
|
|
131
|
+
* date="2026-07-21"
|
|
132
|
+
* events={[
|
|
133
|
+
* { id: '1', title: 'Standup', start: '2026-07-21T09:00', end: '2026-07-21T09:30', tone: 'primary' },
|
|
134
|
+
* { id: '2', title: 'Design review', start: '2026-07-21T09:15', end: '2026-07-21T10:00', tone: 'accent' },
|
|
135
|
+
* ]}
|
|
136
|
+
* />
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function EventScheduler(props: EventSchedulerProps): JSX.Element {
|
|
140
|
+
const startHour = () => props.startHour ?? 7
|
|
141
|
+
const endHour = () => props.endHour ?? 21
|
|
142
|
+
const hours = createMemo(() => {
|
|
143
|
+
const out: number[] = []
|
|
144
|
+
for (let h = startHour(); h < endHour(); h++) out.push(h)
|
|
145
|
+
return out
|
|
146
|
+
})
|
|
147
|
+
const gridHeight = () => (endHour() - startHour()) * HOUR_PX
|
|
148
|
+
const rangeStart = () => startHour() * 60
|
|
149
|
+
const rangeEnd = () => endHour() * 60
|
|
150
|
+
const pxFor = (min: number) => ((min - rangeStart()) / (rangeEnd() - rangeStart())) * gridHeight()
|
|
151
|
+
|
|
152
|
+
const baseDate = createMemo(() => (props.date ? parseLocal(props.date) : new Date()))
|
|
153
|
+
|
|
154
|
+
const days = createMemo(() => {
|
|
155
|
+
const base = baseDate()
|
|
156
|
+
if ((props.view ?? 'day') === 'day') {
|
|
157
|
+
return [new Date(base.getFullYear(), base.getMonth(), base.getDate())]
|
|
158
|
+
}
|
|
159
|
+
const sunday = new Date(base.getFullYear(), base.getMonth(), base.getDate() - base.getDay())
|
|
160
|
+
return Array.from(
|
|
161
|
+
{ length: 7 },
|
|
162
|
+
(_, i) => new Date(sunday.getFullYear(), sunday.getMonth(), sunday.getDate() + i),
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const eventsByDay = createMemo(() => {
|
|
167
|
+
const map = new Map<string, PositionedEvent[]>()
|
|
168
|
+
for (const event of props.events) {
|
|
169
|
+
const start = parseLocal(event.start)
|
|
170
|
+
const end = parseLocal(event.end)
|
|
171
|
+
const key = isoKey(start)
|
|
172
|
+
const startMin = minutesOfDay(start)
|
|
173
|
+
const list = map.get(key) ?? []
|
|
174
|
+
list.push({ event, startMin, endMin: Math.max(minutesOfDay(end), startMin + 15) })
|
|
175
|
+
map.set(key, list)
|
|
176
|
+
}
|
|
177
|
+
return map
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const today = new Date()
|
|
181
|
+
const nowMin = today.getHours() * 60 + today.getMinutes()
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div class={cn('flex flex-col overflow-hidden rounded-xl border border-border bg-card', props.class)}>
|
|
185
|
+
{/* Header row: gutter + one label per visible day */}
|
|
186
|
+
<div class="flex border-b border-border">
|
|
187
|
+
<div class="w-14 shrink-0" />
|
|
188
|
+
<For each={days()}>
|
|
189
|
+
{(day) => (
|
|
190
|
+
<div class="flex-1 border-l border-border py-2 text-center">
|
|
191
|
+
<div class="text-xs text-muted-foreground">{DAY_LABELS[day.getDay()]}</div>
|
|
192
|
+
<div
|
|
193
|
+
class={cn(
|
|
194
|
+
'mx-auto mt-0.5 grid h-6 w-6 place-items-center rounded-full text-sm text-foreground',
|
|
195
|
+
isoKey(day) === isoKey(today) && 'bg-primary font-semibold text-primary-foreground',
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
{day.getDate()}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</For>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Body: scrolls vertically; gutter of hour labels + day columns */}
|
|
206
|
+
<div class="flex overflow-y-auto" style={{ 'max-height': '32rem' }}>
|
|
207
|
+
<div class="w-14 shrink-0" style={{ height: `${gridHeight()}px` }}>
|
|
208
|
+
<For each={hours()}>
|
|
209
|
+
{(h) => (
|
|
210
|
+
<div
|
|
211
|
+
class="relative text-right text-xs text-muted-foreground"
|
|
212
|
+
style={{ height: `${HOUR_PX}px` }}
|
|
213
|
+
>
|
|
214
|
+
<span class="absolute -top-2 right-2">{fmtTime(h * 60)}</span>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</For>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<For each={days()}>
|
|
221
|
+
{(day) => {
|
|
222
|
+
const laidOut = createMemo(() => layoutColumn(eventsByDay().get(isoKey(day)) ?? []))
|
|
223
|
+
const isToday = isoKey(day) === isoKey(today)
|
|
224
|
+
return (
|
|
225
|
+
<div class="relative flex-1 border-l border-border" style={{ height: `${gridHeight()}px` }}>
|
|
226
|
+
{/* Hour gridlines */}
|
|
227
|
+
<For each={hours()}>
|
|
228
|
+
{(h) => (
|
|
229
|
+
<div
|
|
230
|
+
class="absolute inset-x-0 border-t border-border"
|
|
231
|
+
style={{ top: `${pxFor(h * 60)}px` }}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</For>
|
|
235
|
+
|
|
236
|
+
{/* "Now" line, today's column only */}
|
|
237
|
+
<Show when={isToday && nowMin >= rangeStart() && nowMin <= rangeEnd()}>
|
|
238
|
+
<div class="absolute inset-x-0 z-20 h-px bg-accent" style={{ top: `${pxFor(nowMin)}px` }}>
|
|
239
|
+
<span class="absolute -left-1 -top-1 h-2 w-2 rounded-full bg-accent" />
|
|
240
|
+
</div>
|
|
241
|
+
</Show>
|
|
242
|
+
|
|
243
|
+
{/* Events */}
|
|
244
|
+
<For each={laidOut()}>
|
|
245
|
+
{(item) => {
|
|
246
|
+
const clampedStart = Math.max(item.startMin, rangeStart())
|
|
247
|
+
const clampedEnd = Math.min(item.endMin, rangeEnd())
|
|
248
|
+
const top = pxFor(clampedStart)
|
|
249
|
+
const height = Math.max(pxFor(clampedEnd) - top, 18)
|
|
250
|
+
const widthPct = 100 / item.colCount
|
|
251
|
+
return (
|
|
252
|
+
<Show when={clampedEnd > rangeStart() && clampedStart < rangeEnd()}>
|
|
253
|
+
<div
|
|
254
|
+
class={cn(
|
|
255
|
+
'absolute z-10 overflow-hidden rounded-md px-1.5 py-1 text-left text-xs shadow-sm',
|
|
256
|
+
TONE_CLASSES[item.event.tone ?? 'primary'],
|
|
257
|
+
)}
|
|
258
|
+
style={{
|
|
259
|
+
top: `${top}px`,
|
|
260
|
+
height: `${height}px`,
|
|
261
|
+
left: `${item.col * widthPct}%`,
|
|
262
|
+
width: `calc(${widthPct}% - 2px)`,
|
|
263
|
+
}}
|
|
264
|
+
title={`${item.event.title} · ${fmtTime(item.startMin)}–${fmtTime(item.endMin)}`}
|
|
265
|
+
>
|
|
266
|
+
<div class="truncate font-medium">{item.event.title}</div>
|
|
267
|
+
<div class="truncate opacity-80">{fmtTime(item.startMin)}</div>
|
|
268
|
+
</div>
|
|
269
|
+
</Show>
|
|
270
|
+
)
|
|
271
|
+
}}
|
|
272
|
+
</For>
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}}
|
|
276
|
+
</For>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// FollowingPointer — hides the native cursor over `children` and renders a
|
|
2
|
+
// custom arrow + label chip that follows the mouse instead. Position is
|
|
3
|
+
// written straight to the DOM node's `transform` on every pointermove (same
|
|
4
|
+
// "skip the signal, touch the ref directly" idiom as Magnetic.tsx) rather
|
|
5
|
+
// than through a reactive signal, so the follow is jank-free. No-op (native
|
|
6
|
+
// cursor kept, nothing custom rendered) under motionReduced().
|
|
7
|
+
import { onCleanup, onMount, Show, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface FollowingPointerProps {
|
|
13
|
+
label?: JSX.Element
|
|
14
|
+
/** Tone of the custom cursor + label chip. @default 'primary' */
|
|
15
|
+
color?: 'primary' | 'accent'
|
|
16
|
+
children: JSX.Element
|
|
17
|
+
class?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wraps `children` in a `relative` container that hides the native cursor
|
|
22
|
+
* and renders a custom pointer — a small arrow glyph plus a rounded label
|
|
23
|
+
* chip in the given tone — that follows the mouse within the container.
|
|
24
|
+
* Hidden until the pointer enters, hidden again on leave. Under
|
|
25
|
+
* {@link motionReduced}, the native cursor is kept and nothing custom is
|
|
26
|
+
* rendered. Listeners are cleaned up on unmount.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <FollowingPointer label="Alex" color="accent">
|
|
31
|
+
* <div class="grid h-48 place-items-center rounded-lg border">Hover me</div>
|
|
32
|
+
* </FollowingPointer>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function FollowingPointer(props: FollowingPointerProps): JSX.Element {
|
|
36
|
+
let root: HTMLDivElement | undefined
|
|
37
|
+
let pointerEl: HTMLDivElement | undefined
|
|
38
|
+
|
|
39
|
+
onMount(() => {
|
|
40
|
+
if (motionReduced() || !root || !pointerEl) return
|
|
41
|
+
|
|
42
|
+
const handlePointerMove = (event: PointerEvent): void => {
|
|
43
|
+
const rect = root!.getBoundingClientRect()
|
|
44
|
+
const x = event.clientX - rect.left
|
|
45
|
+
const y = event.clientY - rect.top
|
|
46
|
+
pointerEl!.style.transform = `translate(${x}px, ${y}px)`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const handlePointerEnter = (event: PointerEvent): void => {
|
|
50
|
+
pointerEl!.style.opacity = '1'
|
|
51
|
+
handlePointerMove(event)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handlePointerLeave = (): void => {
|
|
55
|
+
pointerEl!.style.opacity = '0'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
root.addEventListener('pointermove', handlePointerMove)
|
|
59
|
+
root.addEventListener('pointerenter', handlePointerEnter)
|
|
60
|
+
root.addEventListener('pointerleave', handlePointerLeave)
|
|
61
|
+
|
|
62
|
+
onCleanup(() => {
|
|
63
|
+
root!.removeEventListener('pointermove', handlePointerMove)
|
|
64
|
+
root!.removeEventListener('pointerenter', handlePointerEnter)
|
|
65
|
+
root!.removeEventListener('pointerleave', handlePointerLeave)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const tone = (): 'primary' | 'accent' => (props.color === 'accent' ? 'accent' : 'primary')
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div ref={root} class={cn('relative', !motionReduced() && 'cursor-none', props.class)}>
|
|
73
|
+
{props.children}
|
|
74
|
+
<Show when={!motionReduced()}>
|
|
75
|
+
<div
|
|
76
|
+
ref={pointerEl}
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
class="pointer-events-none absolute left-0 top-0 z-50 flex items-center opacity-0 transition-opacity duration-150 will-change-transform"
|
|
79
|
+
>
|
|
80
|
+
<svg
|
|
81
|
+
width="20"
|
|
82
|
+
height="20"
|
|
83
|
+
viewBox="0 0 20 20"
|
|
84
|
+
class={cn(
|
|
85
|
+
'-translate-x-0.5 -translate-y-0.5 drop-shadow',
|
|
86
|
+
tone() === 'accent' ? 'text-accent' : 'text-primary',
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<path
|
|
90
|
+
d="M2 2 L18 9 L10 11 L8 18 Z"
|
|
91
|
+
fill="currentColor"
|
|
92
|
+
stroke="hsl(var(--background))"
|
|
93
|
+
stroke-width="1"
|
|
94
|
+
/>
|
|
95
|
+
</svg>
|
|
96
|
+
<Show when={props.label}>
|
|
97
|
+
<div
|
|
98
|
+
class={cn(
|
|
99
|
+
'ml-2 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium shadow-lg',
|
|
100
|
+
tone() === 'accent'
|
|
101
|
+
? 'bg-accent text-accent-foreground'
|
|
102
|
+
: 'bg-primary text-primary-foreground',
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{props.label}
|
|
106
|
+
</div>
|
|
107
|
+
</Show>
|
|
108
|
+
</div>
|
|
109
|
+
</Show>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// GanttChart — a read-only project schedule chart built from plain flex/grid
|
|
2
|
+
// `div`s (no SVG, no date library). Each task is a row with its name in a
|
|
3
|
+
// fixed-width left column and a bar positioned on a proportional time axis
|
|
4
|
+
// (left%/width% derived from the task's start/end within the overall date
|
|
5
|
+
// range), mirroring BarChart's percentage-of-max layout idiom. Dependency
|
|
6
|
+
// arrows are drawn as right-angle elbow connectors using bordered divs so
|
|
7
|
+
// the whole chart stays in the same box-model coordinate space as the bars.
|
|
8
|
+
import { createMemo, For, Show, type JSX } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
13
|
+
/** Header height (`h-8`) in px — the coordinate origin for row overlays. */
|
|
14
|
+
const HEADER_HEIGHT = 32
|
|
15
|
+
/** Per-task row height (`h-10`) in px. */
|
|
16
|
+
const ROW_HEIGHT = 40
|
|
17
|
+
/** Candidate tick spacings (days); the smallest that keeps ticks readable wins. */
|
|
18
|
+
const TICK_STEP_DAYS = [1, 2, 3, 7, 14, 30, 60, 90, 180]
|
|
19
|
+
|
|
20
|
+
/** A single bar rendered by {@link GanttChart}. */
|
|
21
|
+
export interface GanttTask {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
/** ISO date, `'YYYY-MM-DD'`. */
|
|
25
|
+
start: string
|
|
26
|
+
/** ISO date, `'YYYY-MM-DD'`. */
|
|
27
|
+
end: string
|
|
28
|
+
/** Ids of tasks that must finish before this one starts; drawn as elbow connectors. */
|
|
29
|
+
dependencies?: string[]
|
|
30
|
+
/** Bar color. Defaults to `'primary'`. */
|
|
31
|
+
tone?: 'primary' | 'accent'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GanttChartProps {
|
|
35
|
+
tasks: GanttTask[]
|
|
36
|
+
class?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Tick {
|
|
40
|
+
label: string
|
|
41
|
+
percent: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Connector {
|
|
45
|
+
id: string
|
|
46
|
+
/** Elbow's vertical run: x of the dependency's end, from its row to the dependent's row. */
|
|
47
|
+
xFrom: number
|
|
48
|
+
yFrom: number
|
|
49
|
+
xTo: number
|
|
50
|
+
yTo: number
|
|
51
|
+
arrowRight: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pickStepDays(totalDays: number): number {
|
|
55
|
+
const target = totalDays / 8
|
|
56
|
+
return TICK_STEP_DAYS.find((step) => step >= target) ?? TICK_STEP_DAYS[TICK_STEP_DAYS.length - 1]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatTick(date: Date): string {
|
|
60
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read-only Gantt chart: a header row of date ticks over a proportional time
|
|
65
|
+
* axis, one row per task with a colored bar spanning `start`–`end`, elbow
|
|
66
|
+
* connectors for `dependencies`, and a "today" marker when today falls
|
|
67
|
+
* within the overall range. Horizontal scroll kicks in when the range is
|
|
68
|
+
* wide relative to the container.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```tsx
|
|
72
|
+
* <GanttChart
|
|
73
|
+
* tasks={[
|
|
74
|
+
* { id: 'design', name: 'Design', start: '2026-01-05', end: '2026-01-16', tone: 'accent' },
|
|
75
|
+
* { id: 'build', name: 'Build', start: '2026-01-19', end: '2026-02-06', dependencies: ['design'] },
|
|
76
|
+
* { id: 'launch', name: 'Launch', start: '2026-02-09', end: '2026-02-10', dependencies: ['build'] },
|
|
77
|
+
* ]}
|
|
78
|
+
* />
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function GanttChart(props: GanttChartProps): JSX.Element {
|
|
82
|
+
const range = createMemo(() => {
|
|
83
|
+
if (props.tasks.length === 0) return null
|
|
84
|
+
let min = Number.POSITIVE_INFINITY
|
|
85
|
+
let max = Number.NEGATIVE_INFINITY
|
|
86
|
+
for (const task of props.tasks) {
|
|
87
|
+
const start = new Date(task.start).getTime()
|
|
88
|
+
const end = new Date(task.end).getTime()
|
|
89
|
+
if (start < min) min = start
|
|
90
|
+
if (end > max) max = end
|
|
91
|
+
}
|
|
92
|
+
// Guard a zero-width range (single task, start === end) so percentages stay finite.
|
|
93
|
+
const totalMs = Math.max(max - min, DAY_MS)
|
|
94
|
+
return { min, max: min + totalMs, totalMs }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const percentFor = (isoOrTime: string | number): number => {
|
|
98
|
+
const r = range()
|
|
99
|
+
if (!r) return 0
|
|
100
|
+
const time = typeof isoOrTime === 'string' ? new Date(isoOrTime).getTime() : isoOrTime
|
|
101
|
+
return Math.min(100, Math.max(0, ((time - r.min) / r.totalMs) * 100))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ticks = createMemo<Tick[]>(() => {
|
|
105
|
+
const r = range()
|
|
106
|
+
if (!r) return []
|
|
107
|
+
const totalDays = Math.max(1, Math.round(r.totalMs / DAY_MS))
|
|
108
|
+
const step = pickStepDays(totalDays)
|
|
109
|
+
const out: Tick[] = []
|
|
110
|
+
for (let d = 0; d <= totalDays; d += step) {
|
|
111
|
+
out.push({ label: formatTick(new Date(r.min + d * DAY_MS)), percent: (d / totalDays) * 100 })
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const todayPercent = createMemo<number | null>(() => {
|
|
117
|
+
const r = range()
|
|
118
|
+
if (!r) return null
|
|
119
|
+
const now = new Date()
|
|
120
|
+
const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())
|
|
121
|
+
if (today < r.min || today > r.max) return null
|
|
122
|
+
return percentFor(today)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const rowIndex = createMemo(() => {
|
|
126
|
+
const map = new Map<string, number>()
|
|
127
|
+
props.tasks.forEach((task, i) => map.set(task.id, i))
|
|
128
|
+
return map
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const connectors = createMemo<Connector[]>(() => {
|
|
132
|
+
const byId = new Map(props.tasks.map((t) => [t.id, t]))
|
|
133
|
+
const index = rowIndex()
|
|
134
|
+
const out: Connector[] = []
|
|
135
|
+
for (const task of props.tasks) {
|
|
136
|
+
for (const depId of task.dependencies ?? []) {
|
|
137
|
+
const dep = byId.get(depId)
|
|
138
|
+
if (!dep || dep.id === task.id) continue
|
|
139
|
+
const iDep = index.get(dep.id)
|
|
140
|
+
const iTask = index.get(task.id)
|
|
141
|
+
if (iDep === undefined || iTask === undefined) continue
|
|
142
|
+
const xFrom = percentFor(dep.end)
|
|
143
|
+
const xTo = percentFor(task.start)
|
|
144
|
+
out.push({
|
|
145
|
+
id: `${dep.id}->${task.id}`,
|
|
146
|
+
xFrom,
|
|
147
|
+
yFrom: iDep * ROW_HEIGHT + ROW_HEIGHT / 2,
|
|
148
|
+
xTo,
|
|
149
|
+
yTo: iTask * ROW_HEIGHT + ROW_HEIGHT / 2,
|
|
150
|
+
arrowRight: xTo >= xFrom,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div class={cn('overflow-x-auto rounded-lg border border-border bg-card', props.class)}>
|
|
159
|
+
<Show
|
|
160
|
+
when={props.tasks.length > 0}
|
|
161
|
+
fallback={<p class="p-4 text-sm text-muted-foreground">No tasks to display.</p>}
|
|
162
|
+
>
|
|
163
|
+
<div class="flex min-w-[640px]">
|
|
164
|
+
{/* Names column */}
|
|
165
|
+
<div class="w-40 shrink-0 border-r border-border">
|
|
166
|
+
<div class="flex h-8 items-center px-3 text-xs font-medium text-muted-foreground">Task</div>
|
|
167
|
+
<For each={props.tasks}>
|
|
168
|
+
{(task, i) => (
|
|
169
|
+
<div
|
|
170
|
+
class={cn(
|
|
171
|
+
'flex h-10 items-center border-t border-border px-3',
|
|
172
|
+
i() % 2 === 1 && 'bg-muted',
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
<span class="truncate text-sm text-foreground" title={task.name}>
|
|
176
|
+
{task.name}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</For>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Chart column: header ticks, rows, connectors and the today marker all
|
|
184
|
+
share this box as their coordinate space (percent for x, px for y). */}
|
|
185
|
+
<div class="relative flex-1">
|
|
186
|
+
{/* Vertical gridlines, one per tick, spanning the whole chart column. */}
|
|
187
|
+
<div class="pointer-events-none absolute inset-0">
|
|
188
|
+
<For each={ticks()}>
|
|
189
|
+
{(tick) => (
|
|
190
|
+
<div
|
|
191
|
+
class="absolute inset-y-0 border-l border-border/40"
|
|
192
|
+
style={{ left: `${tick.percent}%` }}
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
</For>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div class="relative h-8">
|
|
199
|
+
<For each={ticks()}>
|
|
200
|
+
{(tick) => (
|
|
201
|
+
<span
|
|
202
|
+
class="absolute top-1/2 -translate-y-1/2 whitespace-nowrap pl-1 text-[11px] text-muted-foreground"
|
|
203
|
+
style={{ left: `${tick.percent}%` }}
|
|
204
|
+
>
|
|
205
|
+
{tick.label}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
</For>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<For each={props.tasks}>
|
|
212
|
+
{(task, i) => {
|
|
213
|
+
const left = () => percentFor(task.start)
|
|
214
|
+
const width = () => Math.max(percentFor(task.end) - left(), 1)
|
|
215
|
+
return (
|
|
216
|
+
<div class={cn('relative h-10 border-t border-border', i() % 2 === 1 && 'bg-muted')}>
|
|
217
|
+
<div
|
|
218
|
+
title={`${task.name}: ${task.start} → ${task.end}`}
|
|
219
|
+
class={cn(
|
|
220
|
+
'absolute inset-y-2 rounded',
|
|
221
|
+
task.tone === 'accent' ? 'bg-accent' : 'bg-primary',
|
|
222
|
+
)}
|
|
223
|
+
style={{ left: `${left()}%`, width: `${width()}%` }}
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}}
|
|
228
|
+
</For>
|
|
229
|
+
|
|
230
|
+
{/* Dependency connectors: elbow (vertical run at the predecessor's end,
|
|
231
|
+
horizontal run into the dependent's start) as bordered divs. */}
|
|
232
|
+
<div
|
|
233
|
+
class="pointer-events-none absolute inset-x-0 bottom-0"
|
|
234
|
+
style={{ top: `${HEADER_HEIGHT}px` }}
|
|
235
|
+
>
|
|
236
|
+
<For each={connectors()}>
|
|
237
|
+
{(c) => (
|
|
238
|
+
<>
|
|
239
|
+
<div
|
|
240
|
+
class="absolute border-l border-border"
|
|
241
|
+
style={{
|
|
242
|
+
left: `${c.xFrom}%`,
|
|
243
|
+
top: `${Math.min(c.yFrom, c.yTo)}px`,
|
|
244
|
+
height: `${Math.abs(c.yTo - c.yFrom)}px`,
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
<div
|
|
248
|
+
class="absolute border-t border-border"
|
|
249
|
+
style={{
|
|
250
|
+
top: `${c.yTo}px`,
|
|
251
|
+
left: `${Math.min(c.xFrom, c.xTo)}%`,
|
|
252
|
+
width: `${Math.abs(c.xTo - c.xFrom)}%`,
|
|
253
|
+
}}
|
|
254
|
+
/>
|
|
255
|
+
<div
|
|
256
|
+
class={cn(
|
|
257
|
+
'absolute h-0 w-0 border-y-4 border-y-transparent',
|
|
258
|
+
c.arrowRight ? 'border-l-8 border-l-border' : 'border-r-8 border-r-border',
|
|
259
|
+
)}
|
|
260
|
+
style={{
|
|
261
|
+
top: `${c.yTo - 4}px`,
|
|
262
|
+
left: c.arrowRight ? `calc(${c.xTo}% - 8px)` : `${c.xTo}%`,
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
</For>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* "Today" marker, spanning the header + every row. */}
|
|
271
|
+
<Show when={todayPercent() !== null}>
|
|
272
|
+
<div
|
|
273
|
+
class="pointer-events-none absolute inset-y-0 border-l border-primary"
|
|
274
|
+
style={{ left: `${todayPercent()}%` }}
|
|
275
|
+
title="Today"
|
|
276
|
+
/>
|
|
277
|
+
</Show>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</Show>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|