@srcroot/ui 0.0.55 → 0.0.58

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 (107) hide show
  1. package/README.md +151 -151
  2. package/dist/index.d.ts +0 -0
  3. package/dist/index.js +120 -93
  4. package/package.json +7 -2
  5. package/src/registry/analytics/google-analytics.tsx +36 -39
  6. package/src/registry/analytics/google-tag-manager.tsx +62 -65
  7. package/src/registry/analytics/meta-pixel.tsx +44 -47
  8. package/src/registry/analytics/microsoft-clarity.tsx +31 -34
  9. package/src/registry/analytics/tiktok-pixel.tsx +34 -37
  10. package/src/registry/lib/utils.ts +0 -0
  11. package/src/registry/themes/v3/blue.css +157 -157
  12. package/src/registry/themes/v3/glass.css +153 -153
  13. package/src/registry/themes/v3/gray.css +157 -157
  14. package/src/registry/themes/v3/green.css +157 -157
  15. package/src/registry/themes/v3/neutral.css +157 -157
  16. package/src/registry/themes/v3/orange.css +157 -157
  17. package/src/registry/themes/v3/rose.css +157 -157
  18. package/src/registry/themes/v3/slate.css +157 -157
  19. package/src/registry/themes/v3/stone.css +157 -157
  20. package/src/registry/themes/v3/violet.css +186 -186
  21. package/src/registry/themes/v3/zinc.css +157 -157
  22. package/src/registry/themes/v4/blue.css +184 -184
  23. package/src/registry/themes/v4/glass.css +180 -180
  24. package/src/registry/themes/v4/gray.css +184 -184
  25. package/src/registry/themes/v4/green.css +184 -184
  26. package/src/registry/themes/v4/neutral.css +184 -184
  27. package/src/registry/themes/v4/orange.css +184 -184
  28. package/src/registry/themes/v4/rose.css +184 -184
  29. package/src/registry/themes/v4/slate.css +184 -184
  30. package/src/registry/themes/v4/stone.css +184 -184
  31. package/src/registry/themes/v4/violet.css +184 -184
  32. package/src/registry/themes/v4/zinc.css +184 -184
  33. package/src/registry/ui/accordion.tsx +164 -165
  34. package/src/registry/ui/alert-dialog.tsx +213 -214
  35. package/src/registry/ui/alert.tsx +73 -76
  36. package/src/registry/ui/aspect-ratio.tsx +44 -47
  37. package/src/registry/ui/avatar.tsx +96 -97
  38. package/src/registry/ui/badge.tsx +52 -55
  39. package/src/registry/ui/breadcrumb.tsx +147 -150
  40. package/src/registry/ui/button-group.tsx +64 -67
  41. package/src/registry/ui/button.tsx +71 -72
  42. package/src/registry/ui/calendar.tsx +514 -515
  43. package/src/registry/ui/card.tsx +88 -91
  44. package/src/registry/ui/carousel.tsx +214 -214
  45. package/src/registry/ui/chart.tsx +373 -373
  46. package/src/registry/ui/chatbot.tsx +86 -13
  47. package/src/registry/ui/checkbox.tsx +93 -94
  48. package/src/registry/ui/collapsible.tsx +107 -108
  49. package/src/registry/ui/combobox.tsx +171 -171
  50. package/src/registry/ui/command.tsx +300 -300
  51. package/src/registry/ui/container.tsx +44 -47
  52. package/src/registry/ui/context-menu.tsx +221 -221
  53. package/src/registry/ui/date-picker.tsx +228 -228
  54. package/src/registry/ui/dialog.tsx +269 -270
  55. package/src/registry/ui/drawer.tsx +10 -4
  56. package/src/registry/ui/dropdown-menu.tsx +529 -530
  57. package/src/registry/ui/empty-state.tsx +0 -2
  58. package/src/registry/ui/file-upload.tsx +0 -0
  59. package/src/registry/ui/floating-dock.tsx +0 -0
  60. package/src/registry/ui/form-field.tsx +91 -94
  61. package/src/registry/ui/google-analytics.tsx +38 -0
  62. package/src/registry/ui/google-tag-manager.tsx +64 -0
  63. package/src/registry/ui/hover-card.tsx +223 -223
  64. package/src/registry/ui/image.tsx +144 -147
  65. package/src/registry/ui/input-group.tsx +82 -85
  66. package/src/registry/ui/input.tsx +125 -125
  67. package/src/registry/ui/kbd.tsx +60 -63
  68. package/src/registry/ui/label.tsx +36 -37
  69. package/src/registry/ui/loading-spinner.tsx +108 -111
  70. package/src/registry/ui/map.tsx +0 -0
  71. package/src/registry/ui/marquee.tsx +2 -0
  72. package/src/registry/ui/menubar.tsx +246 -246
  73. package/src/registry/ui/meta-pixel.tsx +46 -0
  74. package/src/registry/ui/microsoft-clarity.tsx +33 -0
  75. package/src/registry/ui/native-select.tsx +49 -52
  76. package/src/registry/ui/otp-input.tsx +163 -155
  77. package/src/registry/ui/pagination.tsx +149 -152
  78. package/src/registry/ui/patterns.tsx +28 -0
  79. package/src/registry/ui/popover.tsx +226 -227
  80. package/src/registry/ui/progress.tsx +51 -52
  81. package/src/registry/ui/radio.tsx +99 -102
  82. package/src/registry/ui/resizable.tsx +314 -314
  83. package/src/registry/ui/scroll-animation.tsx +45 -0
  84. package/src/registry/ui/scroll-area.tsx +121 -122
  85. package/src/registry/ui/scroll-to-top.tsx +0 -0
  86. package/src/registry/ui/search.tsx +162 -150
  87. package/src/registry/ui/select.tsx +292 -293
  88. package/src/registry/ui/separator.tsx +46 -47
  89. package/src/registry/ui/sheet.tsx +6 -3
  90. package/src/registry/ui/sidebar.tsx +628 -628
  91. package/src/registry/ui/skeleton.tsx +26 -29
  92. package/src/registry/ui/slider.tsx +196 -197
  93. package/src/registry/ui/slot.tsx +69 -72
  94. package/src/registry/ui/star-rating.tsx +146 -134
  95. package/src/registry/ui/switch.tsx +72 -73
  96. package/src/registry/ui/table-of-contents.tsx +96 -96
  97. package/src/registry/ui/table.tsx +138 -139
  98. package/src/registry/ui/tabs.tsx +124 -125
  99. package/src/registry/ui/text.tsx +61 -64
  100. package/src/registry/ui/textarea.tsx +41 -42
  101. package/src/registry/ui/theme-switcher.tsx +66 -66
  102. package/src/registry/ui/tiktok-pixel.tsx +36 -0
  103. package/src/registry/ui/toast.tsx +97 -98
  104. package/src/registry/ui/toggle-group.tsx +129 -129
  105. package/src/registry/ui/toggle.tsx +72 -72
  106. package/src/registry/ui/tooltip.tsx +143 -144
  107. package/src/registry/ui/whatsapp.tsx +0 -0
@@ -1,134 +1,146 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { cn } from "@/lib/utils"
5
-
6
- interface StarRatingProps {
7
- /** Current rating value */
8
- value?: number
9
- /** Max number of stars */
10
- max?: number
11
- /** Callback when rating changes */
12
- onValueChange?: (value: number) => void
13
- /** Whether rating is readonly */
14
- readonly?: boolean
15
- /** Size of stars */
16
- size?: "sm" | "default" | "lg"
17
- className?: string
18
- }
19
-
20
- const sizeClasses = {
21
- sm: "h-4 w-4",
22
- default: "h-5 w-5",
23
- lg: "h-6 w-6",
24
- }
25
-
26
- /**
27
- * Star rating component
28
- *
29
- * @example
30
- * const [rating, setRating] = useState(0)
31
- * <StarRating value={rating} onValueChange={setRating} />
32
- *
33
- * @example
34
- * <StarRating value={4.5} readonly />
35
- */
36
- const StarRating = React.forwardRef<HTMLDivElement, StarRatingProps>(
37
- (
38
- {
39
- value = 0,
40
- max = 5,
41
- onValueChange,
42
- readonly = false,
43
- size = "default",
44
- className,
45
- },
46
- ref
47
- ) => {
48
- const [hoverValue, setHoverValue] = React.useState<number | null>(null)
49
-
50
- const displayValue = hoverValue !== null ? hoverValue : value
51
-
52
- const handleClick = (starValue: number) => {
53
- if (!readonly) {
54
- onValueChange?.(starValue)
55
- }
56
- }
57
-
58
- const handleKeyDown = (e: React.KeyboardEvent, starValue: number) => {
59
- if (readonly) return
60
-
61
- if (e.key === "Enter" || e.key === " ") {
62
- e.preventDefault()
63
- onValueChange?.(starValue)
64
- } else if (e.key === "ArrowRight") {
65
- e.preventDefault()
66
- onValueChange?.(Math.min(value + 1, max))
67
- } else if (e.key === "ArrowLeft") {
68
- e.preventDefault()
69
- onValueChange?.(Math.max(value - 1, 0))
70
- }
71
- }
72
-
73
- return (
74
- <div
75
- ref={ref}
76
- role="radiogroup"
77
- aria-label={`Rating: ${value} out of ${max} stars`}
78
- className={cn("inline-flex gap-0.5", className)}
79
- onMouseLeave={() => setHoverValue(null)}
80
- >
81
- {Array.from({ length: max }).map((_, index) => {
82
- const starValue = index + 1
83
- const isFilled = displayValue >= starValue
84
- const isHalfFilled = !isFilled && displayValue > index && displayValue < starValue
85
-
86
- return (
87
- <button
88
- key={index}
89
- type="button"
90
- role="radio"
91
- aria-checked={value >= starValue}
92
- aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
93
- disabled={readonly}
94
- tabIndex={readonly ? -1 : starValue === Math.ceil(value) || (value === 0 && starValue === 1) ? 0 : -1}
95
- className={cn(
96
- "relative focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
97
- !readonly && "cursor-pointer hover:scale-110 transition-transform"
98
- )}
99
- onClick={() => handleClick(starValue)}
100
- onMouseEnter={() => !readonly && setHoverValue(starValue)}
101
- onKeyDown={(e) => handleKeyDown(e, starValue)}
102
- >
103
- {/* Empty star */}
104
- <svg
105
- className={cn(sizeClasses[size], "text-muted-foreground/30")}
106
- fill="currentColor"
107
- viewBox="0 0 24 24"
108
- >
109
- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
110
- </svg>
111
-
112
- {/* Filled star overlay */}
113
- <svg
114
- className={cn(
115
- sizeClasses[size],
116
- "absolute inset-0 text-yellow-400 transition-opacity",
117
- isFilled ? "opacity-100" : isHalfFilled ? "opacity-50" : "opacity-0"
118
- )}
119
- fill="currentColor"
120
- viewBox="0 0 24 24"
121
- >
122
- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
123
- </svg>
124
- </button>
125
- )
126
- })}
127
- </div>
128
- )
129
- }
130
- )
131
- StarRating.displayName = "StarRating"
132
-
133
- export { StarRating }
134
-
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface StarRatingProps {
7
+ /** Current rating value */
8
+ value?: number;
9
+ /** Max number of stars */
10
+ max?: number;
11
+ /** Callback when rating changes */
12
+ onValueChange?: (value: number) => void;
13
+ /** Whether rating is readonly */
14
+ readonly?: boolean;
15
+ /** Size of stars */
16
+ size?: "sm" | "default" | "lg";
17
+ className?: string;
18
+ }
19
+
20
+ const sizeClasses = {
21
+ sm: "h-4 w-4",
22
+ default: "h-5 w-5",
23
+ lg: "h-6 w-6",
24
+ };
25
+
26
+ /**
27
+ * Star rating component
28
+ *
29
+ * @example
30
+ * const [rating, setRating] = useState(0)
31
+ * <StarRating value={rating} onValueChange={setRating} />
32
+ *
33
+ * @example
34
+ * <StarRating value={4.5} readonly />
35
+ */
36
+ const StarRating = React.forwardRef<HTMLDivElement, StarRatingProps>(
37
+ (
38
+ {
39
+ value = 0,
40
+ max = 5,
41
+ onValueChange,
42
+ readonly = false,
43
+ size = "default",
44
+ className,
45
+ },
46
+ ref,
47
+ ) => {
48
+ const [hoverValue, setHoverValue] = React.useState<number | null>(null);
49
+
50
+ const displayValue = hoverValue !== null ? hoverValue : value;
51
+
52
+ const handleClick = (starValue: number) => {
53
+ if (!readonly) {
54
+ onValueChange?.(starValue);
55
+ }
56
+ };
57
+
58
+ const handleKeyDown = (e: React.KeyboardEvent, starValue: number) => {
59
+ if (readonly) return;
60
+
61
+ if (e.key === "Enter" || e.key === " ") {
62
+ e.preventDefault();
63
+ onValueChange?.(starValue);
64
+ } else if (e.key === "ArrowRight") {
65
+ e.preventDefault();
66
+ onValueChange?.(Math.min(value + 1, max));
67
+ } else if (e.key === "ArrowLeft") {
68
+ e.preventDefault();
69
+ onValueChange?.(Math.max(value - 1, 0));
70
+ }
71
+ };
72
+
73
+ return (
74
+ <div
75
+ ref={ref}
76
+ role="radiogroup"
77
+ aria-label={`Rating: ${value} out of ${max} stars`}
78
+ className={cn("inline-flex gap-0.5", className)}
79
+ onMouseLeave={() => setHoverValue(null)}
80
+ >
81
+ {Array.from({ length: max }).map((_, index) => {
82
+ const starValue = index + 1;
83
+ const isFilled = displayValue >= starValue;
84
+ const isHalfFilled =
85
+ !isFilled && displayValue > index && displayValue < starValue;
86
+
87
+ return (
88
+ <button
89
+ key={index}
90
+ type="button"
91
+ role="radio"
92
+ aria-checked={value >= starValue}
93
+ aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
94
+ disabled={readonly}
95
+ tabIndex={
96
+ readonly
97
+ ? -1
98
+ : starValue === Math.ceil(value) ||
99
+ (value === 0 && starValue === 1)
100
+ ? 0
101
+ : -1
102
+ }
103
+ className={cn(
104
+ "relative focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
105
+ !readonly &&
106
+ "cursor-pointer hover:scale-110 transition-transform",
107
+ )}
108
+ onClick={() => handleClick(starValue)}
109
+ onMouseEnter={() => !readonly && setHoverValue(starValue)}
110
+ onKeyDown={(e) => handleKeyDown(e, starValue)}
111
+ >
112
+ {/* Empty star */}
113
+ <svg
114
+ className={cn(sizeClasses[size], "text-muted-foreground/30")}
115
+ fill="currentColor"
116
+ viewBox="0 0 24 24"
117
+ >
118
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
119
+ </svg>
120
+
121
+ {/* Filled star overlay */}
122
+ <svg
123
+ className={cn(
124
+ sizeClasses[size],
125
+ "absolute inset-0 text-yellow-400 transition-opacity",
126
+ isFilled
127
+ ? "opacity-100"
128
+ : isHalfFilled
129
+ ? "opacity-50"
130
+ : "opacity-0",
131
+ )}
132
+ fill="currentColor"
133
+ viewBox="0 0 24 24"
134
+ >
135
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
136
+ </svg>
137
+ </button>
138
+ );
139
+ })}
140
+ </div>
141
+ );
142
+ },
143
+ );
144
+ StarRating.displayName = "StarRating";
145
+
146
+ export { StarRating };
@@ -1,73 +1,72 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { cn } from "@/lib/utils"
5
-
6
- interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
7
- /**
8
- * Whether the switch is on
9
- */
10
- checked?: boolean
11
- /**
12
- * Callback when the switch state changes
13
- */
14
- onCheckedChange?: (checked: boolean) => void
15
- /**
16
- * Whether the switch is disabled
17
- */
18
- disabled?: boolean
19
- }
20
-
21
- /**
22
- * Switch/Toggle component with keyboard accessibility
23
- *
24
- * @example
25
- * const [enabled, setEnabled] = useState(false)
26
- * <Switch checked={enabled} onCheckedChange={setEnabled} />
27
- */
28
- const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
29
- ({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
30
- const handleClick = () => {
31
- if (!disabled) {
32
- onCheckedChange?.(!checked)
33
- }
34
- }
35
-
36
- const handleKeyDown = (e: React.KeyboardEvent) => {
37
- if (e.key === " " || e.key === "Enter") {
38
- e.preventDefault()
39
- handleClick()
40
- }
41
- }
42
-
43
- return (
44
- <button
45
- type="button"
46
- role="switch"
47
- aria-checked={checked}
48
- aria-disabled={disabled}
49
- disabled={disabled}
50
- ref={ref}
51
- className={cn(
52
- "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
53
- checked ? "bg-primary" : "bg-input",
54
- className
55
- )}
56
- onClick={handleClick}
57
- onKeyDown={handleKeyDown}
58
- {...props}
59
- >
60
- <span
61
- className={cn(
62
- "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
63
- checked ? "translate-x-4" : "translate-x-0"
64
- )}
65
- />
66
- </button>
67
- )
68
- }
69
- )
70
- Switch.displayName = "Switch"
71
-
72
- export { Switch }
73
-
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
7
+ /**
8
+ * Whether the switch is on
9
+ */
10
+ checked?: boolean
11
+ /**
12
+ * Callback when the switch state changes
13
+ */
14
+ onCheckedChange?: (checked: boolean) => void
15
+ /**
16
+ * Whether the switch is disabled
17
+ */
18
+ disabled?: boolean
19
+ }
20
+
21
+ /**
22
+ * Switch/Toggle component with keyboard accessibility
23
+ *
24
+ * @example
25
+ * const [enabled, setEnabled] = useState(false)
26
+ * <Switch checked={enabled} onCheckedChange={setEnabled} />
27
+ */
28
+ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
29
+ ({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
30
+ const handleClick = () => {
31
+ if (!disabled) {
32
+ onCheckedChange?.(!checked)
33
+ }
34
+ }
35
+
36
+ const handleKeyDown = (e: React.KeyboardEvent) => {
37
+ if (e.key === " " || e.key === "Enter") {
38
+ e.preventDefault()
39
+ handleClick()
40
+ }
41
+ }
42
+
43
+ return (
44
+ <button
45
+ type="button"
46
+ role="switch"
47
+ aria-checked={checked}
48
+ aria-disabled={disabled}
49
+ disabled={disabled}
50
+ ref={ref}
51
+ className={cn(
52
+ "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
53
+ checked ? "bg-primary" : "bg-input",
54
+ className
55
+ )}
56
+ onClick={handleClick}
57
+ onKeyDown={handleKeyDown}
58
+ {...props}
59
+ >
60
+ <span
61
+ className={cn(
62
+ "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
63
+ checked ? "translate-x-4" : "translate-x-0"
64
+ )}
65
+ />
66
+ </button>
67
+ )
68
+ }
69
+ )
70
+ Switch.displayName = "Switch"
71
+
72
+ export { Switch }
@@ -1,96 +1,96 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { cn } from "@/lib/utils"
5
-
6
- export interface HeadingInfo {
7
- id: string
8
- text: string
9
- level: number
10
- children?: HeadingInfo[]
11
- }
12
-
13
- interface TableOfContentsProps {
14
- headings?: HeadingInfo[]
15
- activeSection?: string | null
16
- className?: string
17
- onSectionClick?: (id: string) => void
18
- }
19
-
20
- export function TableOfContents({
21
- headings = [],
22
- activeSection,
23
- className,
24
- onSectionClick,
25
- }: TableOfContentsProps) {
26
-
27
- const scrollToHeading = React.useCallback((headingId: string) => {
28
- if (onSectionClick) {
29
- onSectionClick(headingId)
30
- return
31
- }
32
-
33
- const element = document.getElementById(headingId)
34
- if (element) {
35
- const offset = 80
36
- const elementPosition =
37
- element.getBoundingClientRect().top + window.pageYOffset
38
- window.scrollTo({
39
- top: elementPosition - offset,
40
- behavior: "smooth",
41
- })
42
- }
43
- }, [onSectionClick])
44
-
45
- // Flatten all headings without hierarchy for simplicity
46
- const flattenedHeadings = React.useMemo(() => {
47
- const flatten = (headings: HeadingInfo[]): HeadingInfo[] => {
48
- return headings.flatMap((heading) => [
49
- heading,
50
- ...(heading.children ? flatten(heading.children) : []),
51
- ])
52
- }
53
- return flatten(headings)
54
- }, [headings])
55
-
56
- if (flattenedHeadings.length === 0) return null
57
-
58
- return (
59
- <div
60
- className={cn(
61
- "hidden lg:block sticky top-16 right-0 h-[calc(100vh-4rem)] w-full bg-background justify-self-end lg:max-w-64",
62
- className
63
- )}
64
- >
65
- <div className="flex flex-col h-full">
66
- <div className="flex items-center justify-between p-4">
67
- <span className="font-medium text-foreground text-sm">
68
- On this page
69
- </span>
70
- </div>
71
-
72
- <nav className="flex-1 overflow-y-auto p-4 scrollbar-thin">
73
- <div className="space-y-1">
74
- {flattenedHeadings.map((heading) => (
75
- <button
76
- key={heading.id}
77
- onClick={() => scrollToHeading(heading.id)}
78
- className={cn(
79
- "w-full text-left px-2 py-1.5 rounded text-xs transition-colors duration-200",
80
- activeSection === heading.id
81
- ? "text-primary bg-accent font-medium"
82
- : "text-muted-foreground hover:text-foreground",
83
- )}
84
- style={{
85
- paddingLeft: `${(heading.level - 1) * 12 + 8}px`,
86
- }}
87
- >
88
- {heading.text}
89
- </button>
90
- ))}
91
- </div>
92
- </nav>
93
- </div>
94
- </div>
95
- )
96
- }
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ export interface HeadingInfo {
7
+ id: string
8
+ text: string
9
+ level: number
10
+ children?: HeadingInfo[]
11
+ }
12
+
13
+ interface TableOfContentsProps {
14
+ headings?: HeadingInfo[]
15
+ activeSection?: string | null
16
+ className?: string
17
+ onSectionClick?: (id: string) => void
18
+ }
19
+
20
+ export function TableOfContents({
21
+ headings = [],
22
+ activeSection,
23
+ className,
24
+ onSectionClick,
25
+ }: TableOfContentsProps) {
26
+
27
+ const scrollToHeading = React.useCallback((headingId: string) => {
28
+ if (onSectionClick) {
29
+ onSectionClick(headingId)
30
+ return
31
+ }
32
+
33
+ const element = document.getElementById(headingId)
34
+ if (element) {
35
+ const offset = 80
36
+ const elementPosition =
37
+ element.getBoundingClientRect().top + window.pageYOffset
38
+ window.scrollTo({
39
+ top: elementPosition - offset,
40
+ behavior: "smooth",
41
+ })
42
+ }
43
+ }, [onSectionClick])
44
+
45
+ // Flatten all headings without hierarchy for simplicity
46
+ const flattenedHeadings = React.useMemo(() => {
47
+ const flatten = (headings: HeadingInfo[]): HeadingInfo[] => {
48
+ return headings.flatMap((heading) => [
49
+ heading,
50
+ ...(heading.children ? flatten(heading.children) : []),
51
+ ])
52
+ }
53
+ return flatten(headings)
54
+ }, [headings])
55
+
56
+ if (flattenedHeadings.length === 0) return null
57
+
58
+ return (
59
+ <div
60
+ className={cn(
61
+ "hidden lg:block sticky top-16 right-0 h-[calc(100vh-4rem)] w-full bg-background justify-self-end lg:max-w-64",
62
+ className
63
+ )}
64
+ >
65
+ <div className="flex flex-col h-full">
66
+ <div className="flex items-center justify-between p-4">
67
+ <span className="font-medium text-foreground text-sm">
68
+ On this page
69
+ </span>
70
+ </div>
71
+
72
+ <nav className="flex-1 overflow-y-auto p-4 scrollbar-thin">
73
+ <div className="space-y-1">
74
+ {flattenedHeadings.map((heading) => (
75
+ <button
76
+ key={heading.id}
77
+ onClick={() => scrollToHeading(heading.id)}
78
+ className={cn(
79
+ "w-full text-left px-2 py-1.5 rounded text-xs transition-colors duration-200",
80
+ activeSection === heading.id
81
+ ? "text-primary bg-accent font-medium"
82
+ : "text-muted-foreground hover:text-foreground",
83
+ )}
84
+ style={{
85
+ paddingLeft: `${(heading.level - 1) * 12 + 8}px`,
86
+ }}
87
+ >
88
+ {heading.text}
89
+ </button>
90
+ ))}
91
+ </div>
92
+ </nav>
93
+ </div>
94
+ </div>
95
+ )
96
+ }