@annondeveloper/ui-kit 0.1.0 → 0.2.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/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
- package/dist/chunk-2DWZVHZS.js.map +1 -0
- package/dist/form.d.ts +6 -6
- package/dist/form.js +1 -1
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +508 -52
- package/dist/index.js +2927 -4
- package/dist/index.js.map +1 -1
- package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
- package/package.json +1 -1
- package/src/components/animated-counter.tsx +2 -1
- package/src/components/avatar.tsx +2 -1
- package/src/components/badge.tsx +3 -2
- package/src/components/button.tsx +3 -2
- package/src/components/card.tsx +13 -12
- package/src/components/checkbox.tsx +3 -2
- package/src/components/color-input.tsx +414 -0
- package/src/components/command-bar.tsx +434 -0
- package/src/components/confidence-bar.tsx +115 -0
- package/src/components/confirm-dialog.tsx +2 -1
- package/src/components/copy-block.tsx +229 -0
- package/src/components/data-table.tsx +2 -1
- package/src/components/diff-viewer.tsx +319 -0
- package/src/components/dropdown-menu.tsx +2 -1
- package/src/components/empty-state.tsx +2 -1
- package/src/components/filter-pill.tsx +2 -1
- package/src/components/form-input.tsx +5 -4
- package/src/components/heatmap-calendar.tsx +213 -0
- package/src/components/infinite-scroll.tsx +243 -0
- package/src/components/kanban-column.tsx +198 -0
- package/src/components/live-feed.tsx +220 -0
- package/src/components/log-viewer.tsx +2 -1
- package/src/components/metric-card.tsx +2 -1
- package/src/components/notification-stack.tsx +226 -0
- package/src/components/pipeline-stage.tsx +2 -1
- package/src/components/popover.tsx +2 -1
- package/src/components/port-status-grid.tsx +2 -1
- package/src/components/progress.tsx +2 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/realtime-value.tsx +283 -0
- package/src/components/select.tsx +2 -1
- package/src/components/severity-timeline.tsx +2 -1
- package/src/components/sheet.tsx +2 -1
- package/src/components/skeleton.tsx +4 -3
- package/src/components/slider.tsx +2 -1
- package/src/components/smart-table.tsx +383 -0
- package/src/components/sortable-list.tsx +268 -0
- package/src/components/sparkline.tsx +2 -1
- package/src/components/status-badge.tsx +2 -1
- package/src/components/status-pulse.tsx +2 -1
- package/src/components/step-wizard.tsx +372 -0
- package/src/components/streaming-text.tsx +163 -0
- package/src/components/success-checkmark.tsx +2 -1
- package/src/components/tabs.tsx +2 -1
- package/src/components/threshold-gauge.tsx +2 -1
- package/src/components/time-range-selector.tsx +2 -1
- package/src/components/toast.tsx +2 -1
- package/src/components/toggle-switch.tsx +2 -1
- package/src/components/tooltip.tsx +2 -1
- package/src/components/truncated-text.tsx +2 -1
- package/src/components/typing-indicator.tsx +123 -0
- package/src/components/uptime-tracker.tsx +2 -1
- package/src/components/utilization-bar.tsx +2 -1
- package/src/utils.ts +1 -1
- package/dist/chunk-5OKSXPWK.js.map +0 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
export interface DayValue {
|
|
8
|
+
/** Date in YYYY-MM-DD format. */
|
|
9
|
+
date: string
|
|
10
|
+
/** Numeric value for this day. */
|
|
11
|
+
value: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HeatmapCalendarProps {
|
|
15
|
+
/** Array of day values. */
|
|
16
|
+
data: DayValue[]
|
|
17
|
+
/** Start date (YYYY-MM-DD). Defaults to 365 days ago. */
|
|
18
|
+
startDate?: string
|
|
19
|
+
/** End date (YYYY-MM-DD). Defaults to today. */
|
|
20
|
+
endDate?: string
|
|
21
|
+
/** 5 colors from lightest to darkest. Defaults to green scale. */
|
|
22
|
+
colorScale?: string[]
|
|
23
|
+
/** Callback when a day cell is clicked. */
|
|
24
|
+
onDayClick?: (day: DayValue) => void
|
|
25
|
+
/** Show month labels at top. */
|
|
26
|
+
showMonthLabels?: boolean
|
|
27
|
+
/** Show day-of-week labels (Mon, Wed, Fri) on the left. */
|
|
28
|
+
showDayLabels?: boolean
|
|
29
|
+
/** Custom tooltip format function. */
|
|
30
|
+
tooltipFormat?: (day: DayValue) => string
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_COLORS = [
|
|
35
|
+
'bg-[hsl(var(--bg-overlay))]',
|
|
36
|
+
'bg-[hsl(var(--status-ok))]/20',
|
|
37
|
+
'bg-[hsl(var(--status-ok))]/40',
|
|
38
|
+
'bg-[hsl(var(--status-ok))]/65',
|
|
39
|
+
'bg-[hsl(var(--status-ok))]',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
43
|
+
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
|
|
44
|
+
|
|
45
|
+
function toDateKey(d: Date): string {
|
|
46
|
+
return d.toISOString().slice(0, 10)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseDate(s: string): Date {
|
|
50
|
+
const [y, m, d] = s.split('-').map(Number)
|
|
51
|
+
return new Date(y, m - 1, d)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @description A GitHub-style contribution heatmap calendar with configurable color scale,
|
|
56
|
+
* hover tooltips, month/day labels, and click handlers.
|
|
57
|
+
*/
|
|
58
|
+
export function HeatmapCalendar({
|
|
59
|
+
data,
|
|
60
|
+
startDate,
|
|
61
|
+
endDate,
|
|
62
|
+
colorScale = DEFAULT_COLORS,
|
|
63
|
+
onDayClick,
|
|
64
|
+
showMonthLabels = true,
|
|
65
|
+
showDayLabels = true,
|
|
66
|
+
tooltipFormat,
|
|
67
|
+
className,
|
|
68
|
+
}: HeatmapCalendarProps): React.JSX.Element {
|
|
69
|
+
const [hoveredDay, setHoveredDay] = useState<DayValue | null>(null)
|
|
70
|
+
|
|
71
|
+
const { weeks, months, maxValue } = useMemo(() => {
|
|
72
|
+
const end = endDate ? parseDate(endDate) : new Date()
|
|
73
|
+
const start = startDate
|
|
74
|
+
? parseDate(startDate)
|
|
75
|
+
: new Date(end.getFullYear() - 1, end.getMonth(), end.getDate() + 1)
|
|
76
|
+
|
|
77
|
+
// Build value lookup
|
|
78
|
+
const lookup = new Map<string, number>()
|
|
79
|
+
let maxVal = 0
|
|
80
|
+
for (const d of data) {
|
|
81
|
+
lookup.set(d.date, d.value)
|
|
82
|
+
if (d.value > maxVal) maxVal = d.value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Generate weeks (columns) starting from the start date's week
|
|
86
|
+
const weeks: (DayValue | null)[][] = []
|
|
87
|
+
const monthChanges: { col: number; label: string }[] = []
|
|
88
|
+
|
|
89
|
+
// Align start to the beginning of its week (Sunday)
|
|
90
|
+
const cursor = new Date(start)
|
|
91
|
+
cursor.setDate(cursor.getDate() - cursor.getDay())
|
|
92
|
+
|
|
93
|
+
let prevMonth = -1
|
|
94
|
+
|
|
95
|
+
while (cursor <= end || weeks.length === 0) {
|
|
96
|
+
const week: (DayValue | null)[] = []
|
|
97
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
98
|
+
const key = toDateKey(cursor)
|
|
99
|
+
if (cursor >= start && cursor <= end) {
|
|
100
|
+
week.push({ date: key, value: lookup.get(key) ?? 0 })
|
|
101
|
+
} else {
|
|
102
|
+
week.push(null)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Track month boundaries
|
|
106
|
+
if (cursor.getMonth() !== prevMonth && cursor >= start && cursor <= end) {
|
|
107
|
+
if (dow === 0) {
|
|
108
|
+
monthChanges.push({ col: weeks.length, label: MONTH_NAMES[cursor.getMonth()] })
|
|
109
|
+
}
|
|
110
|
+
prevMonth = cursor.getMonth()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
cursor.setDate(cursor.getDate() + 1)
|
|
114
|
+
}
|
|
115
|
+
weeks.push(week)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { weeks, months: monthChanges, maxValue: maxVal }
|
|
119
|
+
}, [data, startDate, endDate])
|
|
120
|
+
|
|
121
|
+
const getColorClass = (value: number): string => {
|
|
122
|
+
if (value === 0 || maxValue === 0) return colorScale[0]
|
|
123
|
+
const idx = Math.min(
|
|
124
|
+
colorScale.length - 1,
|
|
125
|
+
Math.ceil((value / maxValue) * (colorScale.length - 1))
|
|
126
|
+
)
|
|
127
|
+
return colorScale[idx]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const defaultTooltip = (day: DayValue): string =>
|
|
131
|
+
`${day.date}: ${day.value}`
|
|
132
|
+
|
|
133
|
+
const formatTooltip = tooltipFormat ?? defaultTooltip
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn('overflow-x-auto', className)}>
|
|
137
|
+
<div className="inline-flex flex-col gap-0.5">
|
|
138
|
+
{/* Month labels */}
|
|
139
|
+
{showMonthLabels && (
|
|
140
|
+
<div className="flex" style={{ marginLeft: showDayLabels ? '2rem' : 0 }}>
|
|
141
|
+
{weeks.map((_, col) => {
|
|
142
|
+
const monthEntry = months.find((m) => m.col === col)
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
key={`m-${col}`}
|
|
146
|
+
className="text-[10px] text-[hsl(var(--text-tertiary))]"
|
|
147
|
+
style={{ width: 13, minWidth: 13 }}
|
|
148
|
+
>
|
|
149
|
+
{monthEntry?.label ?? ''}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{/* Grid rows (7 days) */}
|
|
157
|
+
<div className="flex gap-0">
|
|
158
|
+
{/* Day labels column */}
|
|
159
|
+
{showDayLabels && (
|
|
160
|
+
<div className="flex flex-col gap-[2px] mr-1">
|
|
161
|
+
{DAY_LABELS.map((label, i) => (
|
|
162
|
+
<div
|
|
163
|
+
key={i}
|
|
164
|
+
className="text-[10px] text-[hsl(var(--text-tertiary))] h-[11px] flex items-center justify-end pr-1"
|
|
165
|
+
style={{ width: '1.75rem' }}
|
|
166
|
+
>
|
|
167
|
+
{label}
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* Week columns */}
|
|
174
|
+
{weeks.map((week, wIdx) => (
|
|
175
|
+
<div key={wIdx} className="flex flex-col gap-[2px]">
|
|
176
|
+
{week.map((day, dIdx) => (
|
|
177
|
+
<div
|
|
178
|
+
key={dIdx}
|
|
179
|
+
className={cn(
|
|
180
|
+
'w-[11px] h-[11px] rounded-sm',
|
|
181
|
+
day ? getColorClass(day.value) : 'bg-transparent',
|
|
182
|
+
day && onDayClick && 'cursor-pointer',
|
|
183
|
+
day && 'hover:ring-1 hover:ring-[hsl(var(--text-tertiary))]',
|
|
184
|
+
)}
|
|
185
|
+
onClick={day && onDayClick ? () => onDayClick(day) : undefined}
|
|
186
|
+
onMouseEnter={day ? () => setHoveredDay(day) : undefined}
|
|
187
|
+
onMouseLeave={() => setHoveredDay(null)}
|
|
188
|
+
title={day ? formatTooltip(day) : undefined}
|
|
189
|
+
role={day && onDayClick ? 'button' : undefined}
|
|
190
|
+
tabIndex={day && onDayClick ? 0 : undefined}
|
|
191
|
+
onKeyDown={
|
|
192
|
+
day && onDayClick
|
|
193
|
+
? (e) => { if (e.key === 'Enter' || e.key === ' ') onDayClick(day) }
|
|
194
|
+
: undefined
|
|
195
|
+
}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Legend */}
|
|
203
|
+
<div className="flex items-center gap-1 mt-1" style={{ marginLeft: showDayLabels ? '2rem' : 0 }}>
|
|
204
|
+
<span className="text-[10px] text-[hsl(var(--text-tertiary))] mr-1">Less</span>
|
|
205
|
+
{colorScale.map((color, i) => (
|
|
206
|
+
<div key={i} className={cn('w-[11px] h-[11px] rounded-sm', color)} />
|
|
207
|
+
))}
|
|
208
|
+
<span className="text-[10px] text-[hsl(var(--text-tertiary))] ml-1">More</span>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
5
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import { ArrowUp, Loader2 } from 'lucide-react'
|
|
7
|
+
import { cn } from '../utils'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Types
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Props for the InfiniteScroll component. */
|
|
14
|
+
export interface InfiniteScrollProps<T> {
|
|
15
|
+
/** Array of loaded items. */
|
|
16
|
+
items: T[]
|
|
17
|
+
/** Render function for each item. */
|
|
18
|
+
renderItem: (item: T, index: number) => React.JSX.Element
|
|
19
|
+
/** Called when more items should be loaded. */
|
|
20
|
+
loadMore: () => void | Promise<void>
|
|
21
|
+
/** Whether more items are available. */
|
|
22
|
+
hasMore: boolean
|
|
23
|
+
/** Whether a load operation is in progress. */
|
|
24
|
+
isLoading?: boolean
|
|
25
|
+
/** Pixels from bottom to trigger loadMore. Default 200. */
|
|
26
|
+
threshold?: number
|
|
27
|
+
/** Fixed item height for virtualization. If omitted, all items render (no virtualization). */
|
|
28
|
+
itemHeight?: number
|
|
29
|
+
/** Content to display when items array is empty. */
|
|
30
|
+
emptyState?: React.ReactNode
|
|
31
|
+
/** Additional class name for the scroll container. */
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// InfiniteScroll
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @description A virtualized infinite-scroll list using IntersectionObserver.
|
|
41
|
+
* Supports optional height-based virtualization, loading indicators, scroll-to-top,
|
|
42
|
+
* empty states, and skeleton placeholders. No scroll event listeners used.
|
|
43
|
+
*/
|
|
44
|
+
export function InfiniteScroll<T>({
|
|
45
|
+
items,
|
|
46
|
+
renderItem,
|
|
47
|
+
loadMore,
|
|
48
|
+
hasMore,
|
|
49
|
+
isLoading = false,
|
|
50
|
+
threshold = 200,
|
|
51
|
+
itemHeight,
|
|
52
|
+
emptyState,
|
|
53
|
+
className,
|
|
54
|
+
}: InfiniteScrollProps<T>): React.JSX.Element {
|
|
55
|
+
const prefersReducedMotion = useReducedMotion()
|
|
56
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
57
|
+
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
58
|
+
const [showScrollTop, setShowScrollTop] = useState(false)
|
|
59
|
+
const loadingRef = useRef(false)
|
|
60
|
+
|
|
61
|
+
// IntersectionObserver for infinite load trigger
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const sentinel = sentinelRef.current
|
|
64
|
+
if (!sentinel) return
|
|
65
|
+
|
|
66
|
+
const observer = new IntersectionObserver(
|
|
67
|
+
(entries) => {
|
|
68
|
+
const entry = entries[0]
|
|
69
|
+
if (entry?.isIntersecting && hasMore && !isLoading && !loadingRef.current) {
|
|
70
|
+
loadingRef.current = true
|
|
71
|
+
const result = loadMore()
|
|
72
|
+
if (result && typeof result.then === 'function') {
|
|
73
|
+
result.then(() => { loadingRef.current = false }).catch(() => { loadingRef.current = false })
|
|
74
|
+
} else {
|
|
75
|
+
loadingRef.current = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
root: containerRef.current,
|
|
81
|
+
rootMargin: `0px 0px ${threshold}px 0px`,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
observer.observe(sentinel)
|
|
86
|
+
return () => observer.disconnect()
|
|
87
|
+
}, [hasMore, isLoading, loadMore, threshold])
|
|
88
|
+
|
|
89
|
+
// Update loadingRef when isLoading changes
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!isLoading) loadingRef.current = false
|
|
92
|
+
}, [isLoading])
|
|
93
|
+
|
|
94
|
+
// Scroll position tracking for scroll-to-top button
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const container = containerRef.current
|
|
97
|
+
if (!container) return
|
|
98
|
+
|
|
99
|
+
const handler = () => {
|
|
100
|
+
setShowScrollTop(container.scrollTop > 400)
|
|
101
|
+
}
|
|
102
|
+
container.addEventListener('scroll', handler, { passive: true })
|
|
103
|
+
return () => container.removeEventListener('scroll', handler)
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
const scrollToTop = useCallback(() => {
|
|
107
|
+
containerRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'instant' : 'smooth' })
|
|
108
|
+
}, [prefersReducedMotion])
|
|
109
|
+
|
|
110
|
+
// Virtualization
|
|
111
|
+
const [scrollTop, setScrollTop] = useState(0)
|
|
112
|
+
const containerHeight = useRef(0)
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!itemHeight) return
|
|
116
|
+
const container = containerRef.current
|
|
117
|
+
if (!container) return
|
|
118
|
+
|
|
119
|
+
containerHeight.current = container.clientHeight
|
|
120
|
+
const handler = () => {
|
|
121
|
+
setScrollTop(container.scrollTop)
|
|
122
|
+
containerHeight.current = container.clientHeight
|
|
123
|
+
}
|
|
124
|
+
container.addEventListener('scroll', handler, { passive: true })
|
|
125
|
+
return () => container.removeEventListener('scroll', handler)
|
|
126
|
+
}, [itemHeight])
|
|
127
|
+
|
|
128
|
+
const virtualizedContent = useMemo(() => {
|
|
129
|
+
if (!itemHeight) return null
|
|
130
|
+
|
|
131
|
+
const visibleHeight = containerHeight.current || 600
|
|
132
|
+
const buffer = 5
|
|
133
|
+
const startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer)
|
|
134
|
+
const endIdx = Math.min(
|
|
135
|
+
items.length,
|
|
136
|
+
Math.ceil((scrollTop + visibleHeight) / itemHeight) + buffer,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const totalHeight = items.length * itemHeight
|
|
140
|
+
const offsetTop = startIdx * itemHeight
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
totalHeight,
|
|
144
|
+
offsetTop,
|
|
145
|
+
visibleItems: items.slice(startIdx, endIdx),
|
|
146
|
+
startIdx,
|
|
147
|
+
}
|
|
148
|
+
}, [items, itemHeight, scrollTop])
|
|
149
|
+
|
|
150
|
+
// Empty state
|
|
151
|
+
if (items.length === 0 && !isLoading && !hasMore) {
|
|
152
|
+
return (
|
|
153
|
+
<div className={cn('flex items-center justify-center min-h-[200px]', className)}>
|
|
154
|
+
{emptyState ?? (
|
|
155
|
+
<div className="text-center py-12">
|
|
156
|
+
<p className="text-sm text-[hsl(var(--text-tertiary))]">No items to display.</p>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
ref={containerRef}
|
|
166
|
+
className={cn('relative overflow-y-auto', className)}
|
|
167
|
+
>
|
|
168
|
+
{/* Virtualized rendering */}
|
|
169
|
+
{virtualizedContent ? (
|
|
170
|
+
<div style={{ height: virtualizedContent.totalHeight, position: 'relative' }}>
|
|
171
|
+
<div style={{ position: 'absolute', top: virtualizedContent.offsetTop, left: 0, right: 0 }}>
|
|
172
|
+
{virtualizedContent.visibleItems.map((item, i) => (
|
|
173
|
+
<div key={virtualizedContent.startIdx + i} style={{ height: itemHeight }}>
|
|
174
|
+
{renderItem(item, virtualizedContent.startIdx + i)}
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
) : (
|
|
180
|
+
/* Non-virtualized rendering */
|
|
181
|
+
items.map((item, index) => (
|
|
182
|
+
<div key={index}>
|
|
183
|
+
{renderItem(item, index)}
|
|
184
|
+
</div>
|
|
185
|
+
))
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Loading indicator */}
|
|
189
|
+
{isLoading && (
|
|
190
|
+
<div className="flex items-center justify-center py-6 gap-2">
|
|
191
|
+
<Loader2 className="h-5 w-5 text-[hsl(var(--brand-primary))] animate-spin" />
|
|
192
|
+
<span className="text-sm text-[hsl(var(--text-tertiary))]">Loading more...</span>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Skeleton placeholders while loading with no items yet */}
|
|
197
|
+
{isLoading && items.length === 0 && (
|
|
198
|
+
<div className="space-y-3 p-4">
|
|
199
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
200
|
+
<div key={i} className="skeleton-shimmer h-16 rounded-xl" />
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* No more items */}
|
|
206
|
+
{!hasMore && items.length > 0 && (
|
|
207
|
+
<div className="py-4 text-center">
|
|
208
|
+
<span className="text-[11px] text-[hsl(var(--text-tertiary))]">
|
|
209
|
+
All {items.length} item{items.length !== 1 ? 's' : ''} loaded
|
|
210
|
+
</span>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Sentinel for IntersectionObserver */}
|
|
215
|
+
<div ref={sentinelRef} className="h-px w-full" aria-hidden="true" />
|
|
216
|
+
|
|
217
|
+
{/* Scroll to top button */}
|
|
218
|
+
<AnimatePresence>
|
|
219
|
+
{showScrollTop && (
|
|
220
|
+
<motion.button
|
|
221
|
+
initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.8 }}
|
|
222
|
+
animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1 }}
|
|
223
|
+
exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.8 }}
|
|
224
|
+
transition={{ duration: 0.15 }}
|
|
225
|
+
onClick={scrollToTop}
|
|
226
|
+
className={cn(
|
|
227
|
+
'sticky bottom-4 left-1/2 -translate-x-1/2 z-10',
|
|
228
|
+
'inline-flex items-center gap-1.5 rounded-full',
|
|
229
|
+
'px-3 py-2 text-[11px] font-medium',
|
|
230
|
+
'bg-[hsl(var(--bg-elevated))] border border-[hsl(var(--border-default))]',
|
|
231
|
+
'text-[hsl(var(--text-secondary))] shadow-lg',
|
|
232
|
+
'hover:bg-[hsl(var(--bg-overlay))] hover:text-[hsl(var(--text-primary))] transition-colors',
|
|
233
|
+
'cursor-pointer',
|
|
234
|
+
)}
|
|
235
|
+
>
|
|
236
|
+
<ArrowUp className="h-3.5 w-3.5" />
|
|
237
|
+
Back to top
|
|
238
|
+
</motion.button>
|
|
239
|
+
)}
|
|
240
|
+
</AnimatePresence>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
import { Plus } from 'lucide-react'
|
|
7
|
+
|
|
8
|
+
export type KanbanBadgeColor =
|
|
9
|
+
| 'brand' | 'blue' | 'green' | 'yellow' | 'red' | 'orange'
|
|
10
|
+
| 'purple' | 'pink' | 'teal' | 'gray'
|
|
11
|
+
|
|
12
|
+
export interface KanbanItem {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
tags?: { label: string; color: KanbanBadgeColor }[]
|
|
17
|
+
assignee?: { name: string; avatar?: string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KanbanColumnProps {
|
|
21
|
+
/** Column title. */
|
|
22
|
+
title: string
|
|
23
|
+
/** Items to display in this column. */
|
|
24
|
+
items: KanbanItem[]
|
|
25
|
+
/** Optional override for the count badge. */
|
|
26
|
+
count?: number
|
|
27
|
+
/** Column header accent color (CSS value or token). */
|
|
28
|
+
color?: string
|
|
29
|
+
/** Callback when a card is clicked. */
|
|
30
|
+
onItemClick?: (item: KanbanItem) => void
|
|
31
|
+
/** Callback when the add button is clicked. */
|
|
32
|
+
onAddItem?: () => void
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TAG_COLOR_MAP: Record<KanbanBadgeColor, string> = {
|
|
37
|
+
brand: 'bg-[hsl(var(--brand-primary))]/15 text-[hsl(var(--brand-primary))]',
|
|
38
|
+
blue: 'bg-[hsl(var(--brand-secondary))]/15 text-[hsl(var(--brand-secondary))]',
|
|
39
|
+
green: 'bg-[hsl(var(--status-ok))]/15 text-[hsl(var(--status-ok))]',
|
|
40
|
+
yellow: 'bg-[hsl(var(--status-warning))]/15 text-[hsl(var(--status-warning))]',
|
|
41
|
+
red: 'bg-[hsl(var(--status-critical))]/15 text-[hsl(var(--status-critical))]',
|
|
42
|
+
orange: 'bg-[hsl(var(--status-warning))]/20 text-[hsl(var(--status-warning))]',
|
|
43
|
+
purple: 'bg-[hsl(270,60%,60%)]/15 text-[hsl(270,60%,65%)]',
|
|
44
|
+
pink: 'bg-[hsl(330,60%,60%)]/15 text-[hsl(330,60%,65%)]',
|
|
45
|
+
teal: 'bg-[hsl(180,60%,40%)]/15 text-[hsl(180,60%,55%)]',
|
|
46
|
+
gray: 'bg-[hsl(var(--bg-overlay))] text-[hsl(var(--text-secondary))]',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @description A kanban board column with title, count badge, scrollable card list,
|
|
51
|
+
* hover states, staggered Framer Motion entrance, and empty state.
|
|
52
|
+
*/
|
|
53
|
+
export function KanbanColumn({
|
|
54
|
+
title,
|
|
55
|
+
items,
|
|
56
|
+
count,
|
|
57
|
+
color,
|
|
58
|
+
onItemClick,
|
|
59
|
+
onAddItem,
|
|
60
|
+
className,
|
|
61
|
+
}: KanbanColumnProps): React.JSX.Element {
|
|
62
|
+
const reduced = useReducedMotion()
|
|
63
|
+
const displayCount = count ?? items.length
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
'flex flex-col w-72 min-w-[18rem] rounded-2xl',
|
|
69
|
+
'bg-[hsl(var(--bg-surface))] border border-[hsl(var(--border-subtle))]',
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{/* Header */}
|
|
74
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[hsl(var(--border-subtle))]">
|
|
75
|
+
<div className="flex items-center gap-2">
|
|
76
|
+
{color && (
|
|
77
|
+
<span
|
|
78
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
79
|
+
style={{ backgroundColor: color }}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
<span className="text-sm font-semibold text-[hsl(var(--text-primary))]">
|
|
83
|
+
{title}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[10px] font-medium tabular-nums bg-[hsl(var(--bg-overlay))] text-[hsl(var(--text-secondary))]">
|
|
86
|
+
{displayCount}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
{onAddItem && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={onAddItem}
|
|
93
|
+
className={cn(
|
|
94
|
+
'p-1 rounded-lg cursor-pointer',
|
|
95
|
+
'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-primary))]',
|
|
96
|
+
'hover:bg-[hsl(var(--bg-overlay))] transition-colors duration-150',
|
|
97
|
+
)}
|
|
98
|
+
aria-label={`Add item to ${title}`}
|
|
99
|
+
>
|
|
100
|
+
<Plus className="size-4" />
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Card list */}
|
|
106
|
+
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
|
107
|
+
{items.length === 0 ? (
|
|
108
|
+
<div className="flex items-center justify-center py-8 text-xs text-[hsl(var(--text-tertiary))]">
|
|
109
|
+
No items
|
|
110
|
+
</div>
|
|
111
|
+
) : (
|
|
112
|
+
items.map((item, idx) => (
|
|
113
|
+
<motion.div
|
|
114
|
+
key={item.id}
|
|
115
|
+
initial={reduced ? undefined : { opacity: 0, y: 8 }}
|
|
116
|
+
animate={{ opacity: 1, y: 0 }}
|
|
117
|
+
transition={reduced ? { duration: 0 } : { delay: idx * 0.04, duration: 0.2 }}
|
|
118
|
+
>
|
|
119
|
+
<KanbanCard item={item} onClick={onItemClick} />
|
|
120
|
+
</motion.div>
|
|
121
|
+
))
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function KanbanCard({
|
|
129
|
+
item,
|
|
130
|
+
onClick,
|
|
131
|
+
}: {
|
|
132
|
+
item: KanbanItem
|
|
133
|
+
onClick?: (item: KanbanItem) => void
|
|
134
|
+
}): React.JSX.Element {
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
role={onClick ? 'button' : undefined}
|
|
138
|
+
tabIndex={onClick ? 0 : undefined}
|
|
139
|
+
onClick={onClick ? () => onClick(item) : undefined}
|
|
140
|
+
onKeyDown={
|
|
141
|
+
onClick
|
|
142
|
+
? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick(item) }
|
|
143
|
+
: undefined
|
|
144
|
+
}
|
|
145
|
+
className={cn(
|
|
146
|
+
'rounded-xl p-3 border border-[hsl(var(--border-subtle))]',
|
|
147
|
+
'bg-[hsl(var(--bg-base))]',
|
|
148
|
+
onClick && 'cursor-pointer hover:bg-[hsl(var(--bg-elevated))] hover:shadow-sm',
|
|
149
|
+
'transition-all duration-150',
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
<p className="text-sm font-medium text-[hsl(var(--text-primary))] line-clamp-2">
|
|
153
|
+
{item.title}
|
|
154
|
+
</p>
|
|
155
|
+
{item.description && (
|
|
156
|
+
<p className="mt-1 text-xs text-[hsl(var(--text-secondary))] line-clamp-2">
|
|
157
|
+
{item.description}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Tags + Assignee row */}
|
|
162
|
+
{(item.tags?.length || item.assignee) && (
|
|
163
|
+
<div className="flex items-center justify-between mt-2.5 gap-2">
|
|
164
|
+
{item.tags && item.tags.length > 0 && (
|
|
165
|
+
<div className="flex flex-wrap gap-1 min-w-0">
|
|
166
|
+
{item.tags.map((tag, i) => (
|
|
167
|
+
<span
|
|
168
|
+
key={i}
|
|
169
|
+
className={cn(
|
|
170
|
+
'inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap',
|
|
171
|
+
TAG_COLOR_MAP[tag.color],
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{tag.label}
|
|
175
|
+
</span>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
{item.assignee && (
|
|
180
|
+
<div className="shrink-0" title={item.assignee.name}>
|
|
181
|
+
{item.assignee.avatar ? (
|
|
182
|
+
<img
|
|
183
|
+
src={item.assignee.avatar}
|
|
184
|
+
alt={item.assignee.name}
|
|
185
|
+
className="size-6 rounded-full object-cover"
|
|
186
|
+
/>
|
|
187
|
+
) : (
|
|
188
|
+
<div className="size-6 rounded-full bg-[hsl(var(--bg-overlay))] flex items-center justify-center text-[10px] font-semibold text-[hsl(var(--text-secondary))]">
|
|
189
|
+
{item.assignee.name.slice(0, 2).toUpperCase()}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|