@annondeveloper/ui-kit 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +1463 -127
  2. package/dist/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
  3. package/dist/chunk-2DWZVHZS.js.map +1 -0
  4. package/dist/form.d.ts +6 -6
  5. package/dist/form.js +2 -3
  6. package/dist/form.js.map +1 -1
  7. package/dist/index.d.ts +510 -52
  8. package/dist/index.js +2996 -15
  9. package/dist/index.js.map +1 -1
  10. package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
  11. package/package.json +24 -26
  12. package/src/components/animated-counter.tsx +8 -5
  13. package/src/components/avatar.tsx +2 -1
  14. package/src/components/badge.tsx +3 -2
  15. package/src/components/button.tsx +3 -2
  16. package/src/components/card.tsx +13 -12
  17. package/src/components/checkbox.tsx +3 -2
  18. package/src/components/color-input.tsx +427 -0
  19. package/src/components/command-bar.tsx +435 -0
  20. package/src/components/confidence-bar.tsx +115 -0
  21. package/src/components/confirm-dialog.tsx +2 -1
  22. package/src/components/copy-block.tsx +224 -0
  23. package/src/components/data-table.tsx +9 -13
  24. package/src/components/diff-viewer.tsx +340 -0
  25. package/src/components/dropdown-menu.tsx +2 -1
  26. package/src/components/empty-state.tsx +2 -1
  27. package/src/components/filter-pill.tsx +2 -1
  28. package/src/components/form-input.tsx +5 -4
  29. package/src/components/heatmap-calendar.tsx +218 -0
  30. package/src/components/infinite-scroll.tsx +248 -0
  31. package/src/components/kanban-column.tsx +198 -0
  32. package/src/components/live-feed.tsx +222 -0
  33. package/src/components/log-viewer.tsx +4 -1
  34. package/src/components/metric-card.tsx +2 -1
  35. package/src/components/notification-stack.tsx +233 -0
  36. package/src/components/pipeline-stage.tsx +2 -1
  37. package/src/components/popover.tsx +2 -1
  38. package/src/components/port-status-grid.tsx +2 -1
  39. package/src/components/progress.tsx +2 -1
  40. package/src/components/radio-group.tsx +2 -1
  41. package/src/components/realtime-value.tsx +283 -0
  42. package/src/components/select.tsx +2 -1
  43. package/src/components/severity-timeline.tsx +2 -1
  44. package/src/components/sheet.tsx +2 -1
  45. package/src/components/skeleton.tsx +4 -3
  46. package/src/components/slider.tsx +2 -1
  47. package/src/components/smart-table.tsx +383 -0
  48. package/src/components/sortable-list.tsx +272 -0
  49. package/src/components/sparkline.tsx +2 -1
  50. package/src/components/status-badge.tsx +2 -1
  51. package/src/components/status-pulse.tsx +2 -1
  52. package/src/components/step-wizard.tsx +380 -0
  53. package/src/components/streaming-text.tsx +160 -0
  54. package/src/components/success-checkmark.tsx +2 -1
  55. package/src/components/tabs.tsx +2 -1
  56. package/src/components/threshold-gauge.tsx +2 -1
  57. package/src/components/time-range-selector.tsx +2 -1
  58. package/src/components/toast.tsx +2 -1
  59. package/src/components/toggle-switch.tsx +2 -1
  60. package/src/components/tooltip.tsx +2 -1
  61. package/src/components/truncated-text.tsx +2 -1
  62. package/src/components/typing-indicator.tsx +123 -0
  63. package/src/components/uptime-tracker.tsx +2 -1
  64. package/src/components/utilization-bar.tsx +2 -1
  65. package/src/theme.css +6 -0
  66. package/src/utils.ts +1 -1
  67. package/dist/chunk-5OKSXPWK.js.map +0 -1
@@ -1,11 +1,12 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { cn } from '../utils'
4
5
 
5
6
  // ── Shared class constants ──────────────────────────────────────────────────
6
7
  // Import these in any page that needs raw class strings (e.g. for <textarea>)
7
8
 
8
- export const INPUT_CLS = cn(
9
+ export const INPUT_CLS: string = cn(
9
10
  'w-full rounded-lg border border-[hsl(var(--border-default))]',
10
11
  'bg-[hsl(var(--bg-base))] px-3 py-2 text-sm',
11
12
  'text-[hsl(var(--text-primary))] placeholder:text-[hsl(var(--text-tertiary))]',
@@ -13,12 +14,12 @@ export const INPUT_CLS = cn(
13
14
  'disabled:opacity-50 disabled:cursor-not-allowed',
14
15
  )
15
16
 
16
- export const LABEL_CLS = cn(
17
+ export const LABEL_CLS: string = cn(
17
18
  'mb-1.5 block text-xs font-medium uppercase tracking-wider',
18
19
  'text-[hsl(var(--text-secondary))]',
19
20
  )
20
21
 
21
- export const TEXTAREA_CLS = cn(
22
+ export const TEXTAREA_CLS: string = cn(
22
23
  INPUT_CLS,
23
24
  'resize-none font-mono text-xs leading-relaxed',
24
25
  )
@@ -52,7 +53,7 @@ export interface FormInputProps {
52
53
  export function FormInput({
53
54
  label, value, onChange, type = 'text',
54
55
  placeholder, required, disabled, hint, className, autoComplete,
55
- }: FormInputProps) {
56
+ }: FormInputProps): React.JSX.Element {
56
57
  return (
57
58
  <div className={cn('space-y-1.5', className)}>
58
59
  <label className={LABEL_CLS}>
@@ -0,0 +1,218 @@
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
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
51
+ return new Date()
52
+ }
53
+ const [y, m, d] = s.split('-').map(Number)
54
+ const date = new Date(y, m - 1, d)
55
+ if (isNaN(date.getTime())) return new Date()
56
+ return date
57
+ }
58
+
59
+ /**
60
+ * @description A GitHub-style contribution heatmap calendar with configurable color scale,
61
+ * hover tooltips, month/day labels, and click handlers.
62
+ */
63
+ export function HeatmapCalendar({
64
+ data,
65
+ startDate,
66
+ endDate,
67
+ colorScale = DEFAULT_COLORS,
68
+ onDayClick,
69
+ showMonthLabels = true,
70
+ showDayLabels = true,
71
+ tooltipFormat,
72
+ className,
73
+ }: HeatmapCalendarProps): React.JSX.Element {
74
+ const [hoveredDay, setHoveredDay] = useState<DayValue | null>(null)
75
+
76
+ const { weeks, months, maxValue } = useMemo(() => {
77
+ const end = endDate ? parseDate(endDate) : new Date()
78
+ const start = startDate
79
+ ? parseDate(startDate)
80
+ : new Date(end.getFullYear() - 1, end.getMonth(), end.getDate() + 1)
81
+
82
+ // Build value lookup
83
+ const lookup = new Map<string, number>()
84
+ let maxVal = 0
85
+ for (const d of data) {
86
+ lookup.set(d.date, d.value)
87
+ if (d.value > maxVal) maxVal = d.value
88
+ }
89
+
90
+ // Generate weeks (columns) starting from the start date's week
91
+ const weeks: (DayValue | null)[][] = []
92
+ const monthChanges: { col: number; label: string }[] = []
93
+
94
+ // Align start to the beginning of its week (Sunday)
95
+ const cursor = new Date(start)
96
+ cursor.setDate(cursor.getDate() - cursor.getDay())
97
+
98
+ let prevMonth = -1
99
+
100
+ while (cursor <= end || weeks.length === 0) {
101
+ const week: (DayValue | null)[] = []
102
+ for (let dow = 0; dow < 7; dow++) {
103
+ const key = toDateKey(cursor)
104
+ if (cursor >= start && cursor <= end) {
105
+ week.push({ date: key, value: lookup.get(key) ?? 0 })
106
+ } else {
107
+ week.push(null)
108
+ }
109
+
110
+ // Track month boundaries
111
+ if (cursor.getMonth() !== prevMonth && cursor >= start && cursor <= end) {
112
+ if (dow === 0) {
113
+ monthChanges.push({ col: weeks.length, label: MONTH_NAMES[cursor.getMonth()] })
114
+ }
115
+ prevMonth = cursor.getMonth()
116
+ }
117
+
118
+ cursor.setDate(cursor.getDate() + 1)
119
+ }
120
+ weeks.push(week)
121
+ }
122
+
123
+ return { weeks, months: monthChanges, maxValue: maxVal }
124
+ }, [data, startDate, endDate])
125
+
126
+ const getColorClass = (value: number): string => {
127
+ if (value === 0 || maxValue === 0) return colorScale[0]
128
+ const idx = Math.min(
129
+ colorScale.length - 1,
130
+ Math.ceil((value / maxValue) * (colorScale.length - 1))
131
+ )
132
+ return colorScale[idx]
133
+ }
134
+
135
+ const defaultTooltip = (day: DayValue): string =>
136
+ `${day.date}: ${day.value}`
137
+
138
+ const formatTooltip = tooltipFormat ?? defaultTooltip
139
+
140
+ return (
141
+ <div className={cn('overflow-x-auto', className)}>
142
+ <div className="inline-flex flex-col gap-0.5">
143
+ {/* Month labels */}
144
+ {showMonthLabels && (
145
+ <div className="flex" style={{ marginLeft: showDayLabels ? '2rem' : 0 }}>
146
+ {weeks.map((_, col) => {
147
+ const monthEntry = months.find((m) => m.col === col)
148
+ return (
149
+ <div
150
+ key={`m-${col}`}
151
+ className="text-[10px] text-[hsl(var(--text-tertiary))]"
152
+ style={{ width: 13, minWidth: 13 }}
153
+ >
154
+ {monthEntry?.label ?? ''}
155
+ </div>
156
+ )
157
+ })}
158
+ </div>
159
+ )}
160
+
161
+ {/* Grid rows (7 days) */}
162
+ <div className="flex gap-0">
163
+ {/* Day labels column */}
164
+ {showDayLabels && (
165
+ <div className="flex flex-col gap-[2px] mr-1">
166
+ {DAY_LABELS.map((label, i) => (
167
+ <div
168
+ key={i}
169
+ className="text-[10px] text-[hsl(var(--text-tertiary))] h-[11px] flex items-center justify-end pr-1"
170
+ style={{ width: '1.75rem' }}
171
+ >
172
+ {label}
173
+ </div>
174
+ ))}
175
+ </div>
176
+ )}
177
+
178
+ {/* Week columns */}
179
+ {weeks.map((week, wIdx) => (
180
+ <div key={wIdx} className="flex flex-col gap-[2px]">
181
+ {week.map((day, dIdx) => (
182
+ <div
183
+ key={dIdx}
184
+ className={cn(
185
+ 'w-[11px] h-[11px] rounded-sm',
186
+ day ? getColorClass(day.value) : 'bg-transparent',
187
+ day && onDayClick && 'cursor-pointer',
188
+ day && 'hover:ring-1 hover:ring-[hsl(var(--text-tertiary))]',
189
+ )}
190
+ onClick={day && onDayClick ? () => onDayClick(day) : undefined}
191
+ onMouseEnter={day ? () => setHoveredDay(day) : undefined}
192
+ onMouseLeave={() => setHoveredDay(null)}
193
+ title={day ? formatTooltip(day) : undefined}
194
+ role={day && onDayClick ? 'button' : undefined}
195
+ tabIndex={day && onDayClick ? 0 : undefined}
196
+ onKeyDown={
197
+ day && onDayClick
198
+ ? (e) => { if (e.key === 'Enter' || e.key === ' ') onDayClick(day) }
199
+ : undefined
200
+ }
201
+ />
202
+ ))}
203
+ </div>
204
+ ))}
205
+ </div>
206
+
207
+ {/* Legend */}
208
+ <div className="flex items-center gap-1 mt-1" style={{ marginLeft: showDayLabels ? '2rem' : 0 }}>
209
+ <span className="text-[10px] text-[hsl(var(--text-tertiary))] mr-1">Less</span>
210
+ {colorScale.map((color, i) => (
211
+ <div key={i} className={cn('w-[11px] h-[11px] rounded-sm', color)} />
212
+ ))}
213
+ <span className="text-[10px] text-[hsl(var(--text-tertiary))] ml-1">More</span>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ )
218
+ }
@@ -0,0 +1,248 @@
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
+ /** Function to derive a stable key for each item. Falls back to index. */
32
+ getItemKey?: (item: T, index: number) => string | number
33
+ /** Additional class name for the scroll container. */
34
+ className?: string
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // InfiniteScroll
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * @description A virtualized infinite-scroll list using IntersectionObserver.
43
+ * Supports optional height-based virtualization, loading indicators, scroll-to-top,
44
+ * empty states, and skeleton placeholders. No scroll event listeners used.
45
+ */
46
+ export function InfiniteScroll<T>({
47
+ items,
48
+ renderItem,
49
+ loadMore,
50
+ hasMore,
51
+ isLoading = false,
52
+ threshold = 200,
53
+ itemHeight,
54
+ getItemKey,
55
+ emptyState,
56
+ className,
57
+ }: InfiniteScrollProps<T>): React.JSX.Element {
58
+ const prefersReducedMotion = useReducedMotion()
59
+ const containerRef = useRef<HTMLDivElement>(null)
60
+ const sentinelRef = useRef<HTMLDivElement>(null)
61
+ const [showScrollTop, setShowScrollTop] = useState(false)
62
+ const loadingRef = useRef(false)
63
+ const loadMoreRef = useRef(loadMore)
64
+ useEffect(() => { loadMoreRef.current = loadMore }, [loadMore])
65
+
66
+ // IntersectionObserver for infinite load trigger
67
+ useEffect(() => {
68
+ const sentinel = sentinelRef.current
69
+ if (!sentinel) return
70
+
71
+ const observer = new IntersectionObserver(
72
+ (entries) => {
73
+ const entry = entries[0]
74
+ if (entry?.isIntersecting && hasMore && !isLoading && !loadingRef.current) {
75
+ loadingRef.current = true
76
+ const result = loadMoreRef.current()
77
+ if (result && typeof result.then === 'function') {
78
+ result.then(() => { loadingRef.current = false }).catch(() => { loadingRef.current = false })
79
+ } else {
80
+ loadingRef.current = false
81
+ }
82
+ }
83
+ },
84
+ {
85
+ root: containerRef.current,
86
+ rootMargin: `0px 0px ${threshold}px 0px`,
87
+ },
88
+ )
89
+
90
+ observer.observe(sentinel)
91
+ return () => observer.disconnect()
92
+ }, [hasMore, isLoading, threshold])
93
+
94
+ // Update loadingRef when isLoading changes
95
+ useEffect(() => {
96
+ if (!isLoading) loadingRef.current = false
97
+ }, [isLoading])
98
+
99
+ // Scroll position tracking for scroll-to-top button
100
+ useEffect(() => {
101
+ const container = containerRef.current
102
+ if (!container) return
103
+
104
+ const handler = () => {
105
+ setShowScrollTop(container.scrollTop > 400)
106
+ }
107
+ container.addEventListener('scroll', handler, { passive: true })
108
+ return () => container.removeEventListener('scroll', handler)
109
+ }, [])
110
+
111
+ const scrollToTop = useCallback(() => {
112
+ containerRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'instant' : 'smooth' })
113
+ }, [prefersReducedMotion])
114
+
115
+ // Virtualization
116
+ const [scrollTop, setScrollTop] = useState(0)
117
+ const containerHeight = useRef(0)
118
+
119
+ useEffect(() => {
120
+ if (!itemHeight) return
121
+ const container = containerRef.current
122
+ if (!container) return
123
+
124
+ containerHeight.current = container.clientHeight
125
+ const handler = () => {
126
+ setScrollTop(container.scrollTop)
127
+ containerHeight.current = container.clientHeight
128
+ }
129
+ container.addEventListener('scroll', handler, { passive: true })
130
+ return () => container.removeEventListener('scroll', handler)
131
+ }, [itemHeight])
132
+
133
+ const virtualizedContent = useMemo(() => {
134
+ if (!itemHeight) return null
135
+
136
+ const visibleHeight = containerHeight.current || 600
137
+ const buffer = 5
138
+ const startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer)
139
+ const endIdx = Math.min(
140
+ items.length,
141
+ Math.ceil((scrollTop + visibleHeight) / itemHeight) + buffer,
142
+ )
143
+
144
+ const totalHeight = items.length * itemHeight
145
+ const offsetTop = startIdx * itemHeight
146
+
147
+ return {
148
+ totalHeight,
149
+ offsetTop,
150
+ visibleItems: items.slice(startIdx, endIdx),
151
+ startIdx,
152
+ }
153
+ }, [items, itemHeight, scrollTop])
154
+
155
+ // Empty state
156
+ if (items.length === 0 && !isLoading && !hasMore) {
157
+ return (
158
+ <div className={cn('flex items-center justify-center min-h-[200px]', className)}>
159
+ {emptyState ?? (
160
+ <div className="text-center py-12">
161
+ <p className="text-sm text-[hsl(var(--text-tertiary))]">No items to display.</p>
162
+ </div>
163
+ )}
164
+ </div>
165
+ )
166
+ }
167
+
168
+ return (
169
+ <div
170
+ ref={containerRef}
171
+ className={cn('relative overflow-y-auto', className)}
172
+ >
173
+ {/* Virtualized rendering */}
174
+ {virtualizedContent ? (
175
+ <div style={{ height: virtualizedContent.totalHeight, position: 'relative' }}>
176
+ <div style={{ position: 'absolute', top: virtualizedContent.offsetTop, left: 0, right: 0 }}>
177
+ {virtualizedContent.visibleItems.map((item, i) => (
178
+ <div key={virtualizedContent.startIdx + i} style={{ height: itemHeight }}>
179
+ {renderItem(item, virtualizedContent.startIdx + i)}
180
+ </div>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ ) : (
185
+ /* Non-virtualized rendering */
186
+ items.map((item, index) => (
187
+ <div key={getItemKey ? getItemKey(item, index) : index}>
188
+ {renderItem(item, index)}
189
+ </div>
190
+ ))
191
+ )}
192
+
193
+ {/* Loading indicator */}
194
+ {isLoading && (
195
+ <div className="flex items-center justify-center py-6 gap-2">
196
+ <Loader2 className="h-5 w-5 text-[hsl(var(--brand-primary))] animate-spin" />
197
+ <span className="text-sm text-[hsl(var(--text-tertiary))]">Loading more...</span>
198
+ </div>
199
+ )}
200
+
201
+ {/* Skeleton placeholders while loading with no items yet */}
202
+ {isLoading && items.length === 0 && (
203
+ <div className="space-y-3 p-4">
204
+ {Array.from({ length: 6 }).map((_, i) => (
205
+ <div key={i} className="skeleton-shimmer h-16 rounded-xl" />
206
+ ))}
207
+ </div>
208
+ )}
209
+
210
+ {/* No more items */}
211
+ {!hasMore && items.length > 0 && (
212
+ <div className="py-4 text-center">
213
+ <span className="text-[11px] text-[hsl(var(--text-tertiary))]">
214
+ All {items.length} item{items.length !== 1 ? 's' : ''} loaded
215
+ </span>
216
+ </div>
217
+ )}
218
+
219
+ {/* Sentinel for IntersectionObserver */}
220
+ <div ref={sentinelRef} className="h-px w-full" aria-hidden="true" />
221
+
222
+ {/* Scroll to top button */}
223
+ <AnimatePresence>
224
+ {showScrollTop && (
225
+ <motion.button
226
+ initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.8 }}
227
+ animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1 }}
228
+ exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.8 }}
229
+ transition={{ duration: 0.15 }}
230
+ onClick={scrollToTop}
231
+ className={cn(
232
+ 'sticky bottom-4 left-1/2 -translate-x-1/2 z-10',
233
+ 'inline-flex items-center gap-1.5 rounded-full',
234
+ 'px-3 py-2 text-[11px] font-medium',
235
+ 'bg-[hsl(var(--bg-elevated))] border border-[hsl(var(--border-default))]',
236
+ 'text-[hsl(var(--text-secondary))] shadow-lg',
237
+ 'hover:bg-[hsl(var(--bg-overlay))] hover:text-[hsl(var(--text-primary))] transition-colors',
238
+ 'cursor-pointer',
239
+ )}
240
+ >
241
+ <ArrowUp className="h-3.5 w-3.5" />
242
+ Back to top
243
+ </motion.button>
244
+ )}
245
+ </AnimatePresence>
246
+ </div>
247
+ )
248
+ }
@@ -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
+ }