@gunjo/ui 0.0.1-alpha.1 → 0.0.1-alpha.2

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 (224) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +90 -0
  3. package/README.md +52 -91
  4. package/package.json +47 -6
  5. package/src/components/display/Accordion.tsx +185 -0
  6. package/src/components/display/AccordionGroup.tsx +155 -0
  7. package/src/components/display/ActionDataTable.tsx +413 -0
  8. package/src/components/display/ActivityTimelineCard.tsx +483 -0
  9. package/src/components/display/AnalyticsCard.tsx +167 -0
  10. package/src/components/display/AssetCard.tsx +242 -0
  11. package/src/components/display/AssetGrid.tsx +164 -0
  12. package/src/components/display/Avatar.tsx +127 -0
  13. package/src/components/display/AvatarGroup.tsx +131 -0
  14. package/src/components/{atoms → display}/Badge.tsx +3 -3
  15. package/src/components/display/BarChart.tsx +247 -0
  16. package/src/components/{molecules → display}/Card.tsx +1 -1
  17. package/src/components/display/Carousel.tsx +593 -0
  18. package/src/components/display/ChartLegend.tsx +124 -0
  19. package/src/components/display/ChatMessage.tsx +382 -0
  20. package/src/components/display/ChoroplethMap.tsx +613 -0
  21. package/src/components/display/Code.tsx +42 -0
  22. package/src/components/display/CodeBlock.tsx +338 -0
  23. package/src/components/display/ColorSwatch.tsx +71 -0
  24. package/src/components/display/ConcentricProgressCard.tsx +545 -0
  25. package/src/components/display/DataTable.tsx +522 -0
  26. package/src/components/display/DistributionBar.tsx +102 -0
  27. package/src/components/display/DocNote.tsx +36 -0
  28. package/src/components/display/DonutChart.tsx +257 -0
  29. package/src/components/display/EmptyState.tsx +44 -0
  30. package/src/components/display/FileTree.tsx +180 -0
  31. package/src/components/display/GaugeChart.tsx +219 -0
  32. package/src/components/display/HeatmapChart.tsx +266 -0
  33. package/src/components/display/Icon.tsx +66 -0
  34. package/src/components/display/ImagePreview.tsx +140 -0
  35. package/src/components/{atoms → display}/Img.tsx +46 -12
  36. package/src/components/display/LabeledDonutCard.tsx +475 -0
  37. package/src/components/display/LineChart.tsx +464 -0
  38. package/src/components/{molecules → display}/List.tsx +20 -13
  39. package/src/components/display/MarkdownRenderer.tsx +157 -0
  40. package/src/components/display/MetadataList.tsx +81 -0
  41. package/src/components/display/MiniDistributionBarCard.tsx +314 -0
  42. package/src/components/display/PieChart.tsx +234 -0
  43. package/src/components/display/QuadrantMatrix.tsx +330 -0
  44. package/src/components/display/RadarChart.tsx +335 -0
  45. package/src/components/display/RadialBarChart.tsx +264 -0
  46. package/src/components/display/RetentionCohortCard.tsx +350 -0
  47. package/src/components/display/RibbonChart.tsx +618 -0
  48. package/src/components/display/SearchableAccordion.tsx +270 -0
  49. package/src/components/display/SegmentTimelineCard.tsx +452 -0
  50. package/src/components/display/SegmentedGaugeCard.tsx +607 -0
  51. package/src/components/display/Spacer.tsx +51 -0
  52. package/src/components/display/SparklineChart.tsx +394 -0
  53. package/src/components/display/StackedBarChart.tsx +393 -0
  54. package/src/components/display/Statistic.tsx +70 -0
  55. package/src/components/{molecules → display}/Table.tsx +22 -7
  56. package/src/components/display/Tag.tsx +80 -0
  57. package/src/components/display/TagEditor.tsx +141 -0
  58. package/src/components/display/Timeline.tsx +121 -0
  59. package/src/components/{atoms → display}/ToolPill.tsx +42 -18
  60. package/src/components/display/TreeView.tsx +226 -0
  61. package/src/components/display/chart-tooltip.tsx +423 -0
  62. package/src/components/display/chart-utils.ts +71 -0
  63. package/src/components/display/circular-chart-utils.ts +147 -0
  64. package/src/components/display/generated/default-variant-keys.ts +90 -0
  65. package/src/components/display/generated/variant-keys.ts +169 -0
  66. package/src/components/{atoms → feedback}/Alert.tsx +12 -5
  67. package/src/components/feedback/Banner.tsx +90 -0
  68. package/src/components/{molecules → feedback}/NotificationCenter.tsx +64 -31
  69. package/src/components/feedback/ProgressWidget.tsx +44 -0
  70. package/src/components/{atoms → feedback}/Spinner.tsx +2 -2
  71. package/src/components/{molecules → feedback}/StatusBar.tsx +4 -4
  72. package/src/components/feedback/StatusScreen.tsx +148 -0
  73. package/src/components/{molecules → feedback}/Stepper.tsx +10 -5
  74. package/src/components/feedback/Toast.tsx +108 -0
  75. package/src/components/feedback/ToastProvider.tsx +78 -0
  76. package/src/components/feedback/generated/default-variant-keys.ts +16 -0
  77. package/src/components/feedback/generated/variant-keys.ts +21 -0
  78. package/src/components/generated/component-manifest.ts +1568 -454
  79. package/src/components/generated/component-style-hints.ts +1958 -718
  80. package/src/components/{atoms → inputs}/ButtonVariants.ts +13 -3
  81. package/src/components/inputs/Calendar.tsx +212 -0
  82. package/src/components/inputs/ChatComposer.tsx +75 -0
  83. package/src/components/inputs/ChatInput.tsx +528 -0
  84. package/src/components/{atoms → inputs}/Checkbox.tsx +2 -2
  85. package/src/components/inputs/Combobox.tsx +175 -0
  86. package/src/components/inputs/CopyButton.tsx +187 -0
  87. package/src/components/inputs/DatePicker.tsx +519 -0
  88. package/src/components/inputs/DateRangePicker.tsx +878 -0
  89. package/src/components/inputs/EditableField.tsx +182 -0
  90. package/src/components/{organisms → inputs}/FileUploader.tsx +24 -9
  91. package/src/components/inputs/FilterButton.tsx +163 -0
  92. package/src/components/{molecules → inputs}/Form.tsx +20 -3
  93. package/src/components/{atoms → inputs}/Input.tsx +2 -0
  94. package/src/components/inputs/InputOTP.tsx +75 -0
  95. package/src/components/inputs/Mention.tsx +279 -0
  96. package/src/components/inputs/NumberInput.tsx +109 -0
  97. package/src/components/inputs/PasswordGroup.tsx +138 -0
  98. package/src/components/inputs/PasswordInput.tsx +74 -0
  99. package/src/components/inputs/PasswordRequirementList.tsx +96 -0
  100. package/src/components/inputs/PasswordStrengthMeter.tsx +93 -0
  101. package/src/components/inputs/PhoneInput.tsx +99 -0
  102. package/src/components/inputs/PostalCodeInput.tsx +98 -0
  103. package/src/components/inputs/RangeSlider.tsx +129 -0
  104. package/src/components/inputs/SearchInput.tsx +76 -0
  105. package/src/components/inputs/Select.tsx +39 -0
  106. package/src/components/{atoms → inputs}/Slider.tsx +18 -5
  107. package/src/components/{molecules → inputs}/SortButton.tsx +5 -2
  108. package/src/components/{atoms → inputs}/Switch.tsx +15 -4
  109. package/src/components/inputs/TagInput.tsx +114 -0
  110. package/src/components/{atoms → inputs}/Textarea.tsx +1 -0
  111. package/src/components/inputs/TimePicker.tsx +150 -0
  112. package/src/components/inputs/Toggle.tsx +48 -0
  113. package/src/components/{atoms → inputs}/ToggleGroup.tsx +2 -2
  114. package/src/components/inputs/TooltipButton.tsx +148 -0
  115. package/src/components/inputs/VoiceInputButton.tsx +317 -0
  116. package/src/components/inputs/calendar-holidays.ts +56 -0
  117. package/src/components/inputs/generated/default-variant-keys.ts +32 -0
  118. package/src/components/{atoms → inputs}/generated/variant-keys.ts +19 -27
  119. package/src/components/layout/AspectRatio.tsx +12 -0
  120. package/src/components/layout/AssetInspectorPanel.tsx +416 -0
  121. package/src/components/layout/Cluster.tsx +56 -0
  122. package/src/components/layout/CollapsiblePanelToggle.tsx +94 -0
  123. package/src/components/layout/Container.tsx +43 -0
  124. package/src/components/layout/DeviceFrame.tsx +227 -0
  125. package/src/components/layout/Grid.tsx +65 -0
  126. package/src/components/layout/HStack.tsx +73 -0
  127. package/src/components/{organisms → layout}/InspectorPanel.tsx +6 -5
  128. package/src/components/layout/MarqueeFrame.tsx +158 -0
  129. package/src/components/layout/Resizable.tsx +94 -0
  130. package/src/components/layout/ScrollArea.tsx +71 -0
  131. package/src/components/{organisms → layout}/SpatialCanvas.tsx +12 -7
  132. package/src/components/layout/VStack.tsx +69 -0
  133. package/src/components/layout/generated/default-variant-keys.ts +16 -0
  134. package/src/components/layout/generated/variant-keys.ts +21 -0
  135. package/src/components/{molecules → navigation}/Breadcrumb.tsx +5 -4
  136. package/src/components/navigation/Command.tsx +266 -0
  137. package/src/components/navigation/CommandPalette.tsx +83 -0
  138. package/src/components/navigation/DocumentPager.tsx +171 -0
  139. package/src/components/navigation/Footer.tsx +88 -0
  140. package/src/components/navigation/Header.tsx +80 -0
  141. package/src/components/{molecules → navigation}/Menubar.tsx +45 -12
  142. package/src/components/navigation/NavigationMenu.tsx +128 -0
  143. package/src/components/navigation/PageAside.tsx +84 -0
  144. package/src/components/{molecules → navigation}/Pagination.tsx +60 -7
  145. package/src/components/{organisms → navigation}/RightRail.tsx +1 -1
  146. package/src/components/navigation/Sidebar.tsx +223 -0
  147. package/src/components/navigation/SidebarItem.tsx +160 -0
  148. package/src/components/{molecules → navigation}/Tabs.tsx +2 -2
  149. package/src/components/navigation/TextLink.tsx +71 -0
  150. package/src/components/navigation/generated/default-variant-keys.ts +12 -0
  151. package/src/components/navigation/generated/variant-keys.ts +13 -0
  152. package/src/components/overlay/AIChatInput.tsx +5 -0
  153. package/src/components/overlay/AIChatMessage.tsx +6 -0
  154. package/src/components/overlay/AlertDialog.tsx +145 -0
  155. package/src/components/overlay/ChatPanel.tsx +180 -0
  156. package/src/components/{molecules → overlay}/ContextMenu.tsx +65 -29
  157. package/src/components/{molecules → overlay}/Dialog.tsx +21 -13
  158. package/src/components/overlay/Drawer.tsx +131 -0
  159. package/src/components/{molecules → overlay}/DropdownMenu.tsx +52 -17
  160. package/src/components/overlay/FloatingPanel.tsx +90 -0
  161. package/src/components/overlay/HoverCard.tsx +36 -0
  162. package/src/components/overlay/MediaLightbox.tsx +403 -0
  163. package/src/components/overlay/MediaPickerDialog.tsx +198 -0
  164. package/src/components/overlay/Modal.tsx +103 -0
  165. package/src/components/overlay/OnboardingFlow.tsx +172 -0
  166. package/src/components/overlay/Popover.tsx +36 -0
  167. package/src/components/overlay/ShareModal.tsx +324 -0
  168. package/src/components/{molecules → overlay}/Sheet.tsx +76 -19
  169. package/src/components/overlay/Tooltip.tsx +130 -0
  170. package/src/components/overlay/generated/default-variant-keys.ts +14 -0
  171. package/src/components/overlay/generated/variant-keys.ts +17 -0
  172. package/src/components/patterns/BlogTemplate.tsx +46 -0
  173. package/src/components/{templates → patterns}/DashboardTemplate.tsx +2 -2
  174. package/src/components/patterns/DocsTemplate.tsx +41 -0
  175. package/src/components/{templates → patterns}/MediaLibraryTemplate.tsx +1 -1
  176. package/src/components/patterns/OnboardingTemplate.tsx +32 -0
  177. package/src/components/patterns/PricingTemplate.tsx +106 -0
  178. package/src/globals.css +173 -22
  179. package/src/index.ts +177 -76
  180. package/tailwind-theme-extend.cjs +48 -3
  181. package/design/atoms-metadata.json +0 -82
  182. package/design/molecules-metadata.json +0 -130
  183. package/design/organisms-metadata.json +0 -38
  184. package/design/templates-metadata.json +0 -38
  185. package/src/components/atoms/Avatar.tsx +0 -57
  186. package/src/components/atoms/Select.tsx +0 -28
  187. package/src/components/atoms/generated/default-variant-keys.ts +0 -36
  188. package/src/components/molecules/AIChatInput.tsx +0 -140
  189. package/src/components/molecules/AIChatMessage.tsx +0 -109
  190. package/src/components/molecules/Accordion.tsx +0 -99
  191. package/src/components/molecules/Calendar.tsx +0 -60
  192. package/src/components/molecules/Carousel.tsx +0 -261
  193. package/src/components/molecules/Command.tsx +0 -152
  194. package/src/components/molecules/FilterButton.tsx +0 -133
  195. package/src/components/molecules/HoverCard.tsx +0 -29
  196. package/src/components/molecules/Modal.tsx +0 -66
  197. package/src/components/molecules/Popover.tsx +0 -31
  198. package/src/components/molecules/ProgressWidget.tsx +0 -40
  199. package/src/components/molecules/Resizable.tsx +0 -47
  200. package/src/components/molecules/ScrollArea.tsx +0 -48
  201. package/src/components/molecules/SidebarItem.tsx +0 -134
  202. package/src/components/molecules/Toast.tsx +0 -57
  203. package/src/components/molecules/Tooltip.tsx +0 -30
  204. package/src/components/molecules/generated/default-variant-keys.ts +0 -22
  205. package/src/components/molecules/generated/variant-keys.ts +0 -33
  206. package/src/components/organisms/CommandPalette.tsx +0 -58
  207. package/src/components/organisms/FloatingPanel.tsx +0 -46
  208. package/src/components/organisms/ShareModal.tsx +0 -182
  209. package/src/components/organisms/ToastProvider.tsx +0 -49
  210. /package/src/components/{atoms → display}/Kbd.tsx +0 -0
  211. /package/src/components/{atoms → display}/Separator.tsx +0 -0
  212. /package/src/components/{atoms → display}/Skeleton.tsx +0 -0
  213. /package/src/components/{atoms → feedback}/Progress.tsx +0 -0
  214. /package/src/components/{atoms → inputs}/Button.tsx +0 -0
  215. /package/src/components/{atoms → inputs}/Label.tsx +0 -0
  216. /package/src/components/{atoms → inputs}/RadioGroup.tsx +0 -0
  217. /package/src/components/{organisms → navigation}/AppRail.tsx +0 -0
  218. /package/src/components/{templates → patterns}/AuthTemplate.tsx +0 -0
  219. /package/src/components/{templates → patterns}/BannalyzeTemplate.tsx +0 -0
  220. /package/src/components/{templates → patterns}/ChatTemplate.tsx +0 -0
  221. /package/src/components/{templates → patterns}/EditorTemplate.tsx +0 -0
  222. /package/src/components/{templates → patterns}/KanbanTemplate.tsx +0 -0
  223. /package/src/components/{templates → patterns}/LandingTemplate.tsx +0 -0
  224. /package/src/components/{templates → patterns}/SettingsTemplate.tsx +0 -0
@@ -0,0 +1,423 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { createPortal } from "react-dom"
5
+
6
+ type ChartTooltipSide = "top" | "right" | "bottom" | "left"
7
+
8
+ export interface ChartTooltipProps {
9
+ label?: React.ReactNode
10
+ value?: React.ReactNode
11
+ description?: React.ReactNode
12
+ children: React.ReactElement
13
+ side?: ChartTooltipSide
14
+ }
15
+
16
+ interface ChartTooltipPosition {
17
+ left: number
18
+ top: number
19
+ translateX: string
20
+ translateY: string
21
+ }
22
+
23
+ function hasTooltipContent(value: React.ReactNode) {
24
+ return value !== undefined && value !== null && value !== ""
25
+ }
26
+
27
+ function clampTooltipPercent(value: number, min = 12, max = 88) {
28
+ if (!Number.isFinite(value)) return 50
29
+ return Math.min(max, Math.max(min, value))
30
+ }
31
+
32
+ function clampTooltipPixel(value: number, viewportSize: number, edgeOffset = 16) {
33
+ if (!Number.isFinite(value)) return edgeOffset
34
+ return Math.min(viewportSize - edgeOffset, Math.max(edgeOffset, value))
35
+ }
36
+
37
+ function getPortalTooltipPosition(
38
+ rect: DOMRect,
39
+ side: ChartTooltipSide
40
+ ): ChartTooltipPosition {
41
+ const viewportWidth = window.innerWidth
42
+ const viewportHeight = window.innerHeight
43
+ const resolvedSide =
44
+ side === "top" && rect.top < 96
45
+ ? "bottom"
46
+ : side === "bottom" && viewportHeight - rect.bottom < 96
47
+ ? "top"
48
+ : side === "left" && rect.left < 192
49
+ ? "right"
50
+ : side === "right" && viewportWidth - rect.right < 192
51
+ ? "left"
52
+ : side
53
+ const centerX = rect.left + rect.width / 2
54
+ const centerY = rect.top + rect.height / 2
55
+ const left =
56
+ resolvedSide === "left"
57
+ ? rect.left - 8
58
+ : resolvedSide === "right"
59
+ ? rect.right + 8
60
+ : centerX
61
+ const top =
62
+ resolvedSide === "bottom"
63
+ ? rect.bottom + 8
64
+ : resolvedSide === "left" || resolvedSide === "right"
65
+ ? centerY
66
+ : rect.top - 8
67
+ const clampedLeft = clampTooltipPixel(left, viewportWidth)
68
+ const clampedTop = clampTooltipPixel(top, viewportHeight)
69
+ const translateX =
70
+ resolvedSide === "left"
71
+ ? "-100%"
72
+ : resolvedSide === "right"
73
+ ? "0"
74
+ : clampedLeft < 144
75
+ ? "0"
76
+ : clampedLeft > viewportWidth - 144
77
+ ? "-100%"
78
+ : "-50%"
79
+ const translateY =
80
+ resolvedSide === "bottom"
81
+ ? "0"
82
+ : resolvedSide === "left" || resolvedSide === "right"
83
+ ? "-50%"
84
+ : "-100%"
85
+
86
+ return {
87
+ left: clampedLeft,
88
+ top: clampedTop,
89
+ translateX,
90
+ translateY,
91
+ }
92
+ }
93
+
94
+ function composeEventHandlers<Event>(
95
+ userHandler: ((event: Event) => void) | undefined,
96
+ internalHandler: (event: Event) => void
97
+ ) {
98
+ return (event: Event) => {
99
+ userHandler?.(event)
100
+ internalHandler(event)
101
+ }
102
+ }
103
+
104
+ function ChartTooltipBody({
105
+ label,
106
+ value,
107
+ description,
108
+ }: Pick<ChartTooltipProps, "label" | "value" | "description">) {
109
+ return (
110
+ <div className="grid max-w-48 gap-1">
111
+ {hasTooltipContent(label) ? (
112
+ <div className="text-xs font-medium text-popover-foreground">
113
+ {label}
114
+ </div>
115
+ ) : null}
116
+ {hasTooltipContent(value) ? (
117
+ <div className="text-sm font-semibold tabular-nums text-popover-foreground">
118
+ {value}
119
+ </div>
120
+ ) : null}
121
+ {hasTooltipContent(description) ? (
122
+ <div className="text-xs text-muted-foreground">
123
+ {description}
124
+ </div>
125
+ ) : null}
126
+ </div>
127
+ )
128
+ }
129
+
130
+ export function ChartTooltip({
131
+ label,
132
+ value,
133
+ description,
134
+ children,
135
+ side = "top",
136
+ }: ChartTooltipProps) {
137
+ if (
138
+ !hasTooltipContent(label) &&
139
+ !hasTooltipContent(value) &&
140
+ !hasTooltipContent(description)
141
+ ) {
142
+ return children
143
+ }
144
+
145
+ const tooltipId = React.useId()
146
+ const [open, setOpen] = React.useState(false)
147
+ const [position, setPosition] = React.useState<ChartTooltipPosition | null>(
148
+ null
149
+ )
150
+ const lastPointerTypeRef = React.useRef<string | null>(null)
151
+ const triggerElementRef = React.useRef<HTMLElement | null>(null)
152
+ const child =
153
+ children as React.ReactElement<
154
+ React.HTMLAttributes<HTMLElement> & {
155
+ "aria-describedby"?: string
156
+ "data-chart-tooltip-open"?: string
157
+ }
158
+ >
159
+
160
+ const updatePosition = React.useCallback(
161
+ (element: HTMLElement) => {
162
+ setPosition(getPortalTooltipPosition(element.getBoundingClientRect(), side))
163
+ },
164
+ [side]
165
+ )
166
+
167
+ const showTooltip = React.useCallback(
168
+ (event: React.PointerEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
169
+ if ("pointerType" in event) {
170
+ lastPointerTypeRef.current = event.pointerType
171
+ }
172
+ triggerElementRef.current = event.currentTarget
173
+ updatePosition(event.currentTarget)
174
+ setOpen(true)
175
+ },
176
+ [updatePosition]
177
+ )
178
+
179
+ const hideTooltip = React.useCallback((event?: React.SyntheticEvent<HTMLElement>) => {
180
+ const pointerType =
181
+ event && "pointerType" in event ? event.pointerType : undefined
182
+ if (pointerType === "touch" || lastPointerTypeRef.current === "touch") {
183
+ return
184
+ }
185
+ setOpen(false)
186
+ }, [])
187
+
188
+ const handlePointerDown = React.useCallback(
189
+ (event: React.PointerEvent<HTMLElement>) => {
190
+ lastPointerTypeRef.current = event.pointerType
191
+ if (event.pointerType === "touch" || event.pointerType === "pen") {
192
+ triggerElementRef.current = event.currentTarget
193
+ updatePosition(event.currentTarget)
194
+ setOpen(true)
195
+ }
196
+ },
197
+ [updatePosition]
198
+ )
199
+
200
+ React.useEffect(() => {
201
+ if (!open) return undefined
202
+
203
+ const closeTooltip = () => {
204
+ lastPointerTypeRef.current = null
205
+ triggerElementRef.current = null
206
+ setOpen(false)
207
+ }
208
+ const closeTooltipOnOutsidePointerDown = (event: PointerEvent) => {
209
+ const target = event.target
210
+ if (
211
+ target instanceof Node &&
212
+ triggerElementRef.current?.contains(target)
213
+ ) {
214
+ return
215
+ }
216
+
217
+ closeTooltip()
218
+ }
219
+
220
+ window.addEventListener("pointerdown", closeTooltipOnOutsidePointerDown, true)
221
+ window.addEventListener("scroll", closeTooltip, true)
222
+ window.addEventListener("touchmove", closeTooltip, { passive: true })
223
+ window.addEventListener("wheel", closeTooltip, { passive: true })
224
+ window.addEventListener("resize", closeTooltip)
225
+ window.visualViewport?.addEventListener("scroll", closeTooltip)
226
+ window.visualViewport?.addEventListener("resize", closeTooltip)
227
+
228
+ return () => {
229
+ window.removeEventListener("pointerdown", closeTooltipOnOutsidePointerDown, true)
230
+ window.removeEventListener("scroll", closeTooltip, true)
231
+ window.removeEventListener("touchmove", closeTooltip)
232
+ window.removeEventListener("wheel", closeTooltip)
233
+ window.removeEventListener("resize", closeTooltip)
234
+ window.visualViewport?.removeEventListener("scroll", closeTooltip)
235
+ window.visualViewport?.removeEventListener("resize", closeTooltip)
236
+ }
237
+ }, [open])
238
+
239
+ const tooltip =
240
+ open && position && typeof document !== "undefined"
241
+ ? createPortal(
242
+ <div
243
+ id={tooltipId}
244
+ role="tooltip"
245
+ className="pointer-events-none fixed z-50 w-max overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-left text-sm text-popover-foreground shadow-md"
246
+ style={{
247
+ left: position.left,
248
+ top: position.top,
249
+ transform: `translate(${position.translateX}, ${position.translateY})`,
250
+ maxWidth: "min(20rem, calc(100vw - 2rem))",
251
+ }}
252
+ >
253
+ <ChartTooltipBody
254
+ label={label}
255
+ value={value}
256
+ description={description}
257
+ />
258
+ </div>,
259
+ document.body
260
+ )
261
+ : null
262
+
263
+ return (
264
+ <>
265
+ {React.cloneElement(child, {
266
+ "aria-describedby": open ? tooltipId : child.props["aria-describedby"],
267
+ "data-chart-tooltip-open": open ? "true" : undefined,
268
+ className: [
269
+ child.props.className,
270
+ "data-[chart-tooltip-open=true]:ring-2 data-[chart-tooltip-open=true]:ring-ring data-[chart-tooltip-open=true]:ring-offset-2 data-[chart-tooltip-open=true]:ring-offset-background",
271
+ ]
272
+ .filter(Boolean)
273
+ .join(" "),
274
+ onBlur: composeEventHandlers(child.props.onBlur, hideTooltip),
275
+ onFocus: composeEventHandlers(child.props.onFocus, showTooltip),
276
+ onPointerDown: composeEventHandlers(
277
+ child.props.onPointerDown,
278
+ handlePointerDown
279
+ ),
280
+ onPointerEnter: composeEventHandlers(
281
+ child.props.onPointerEnter,
282
+ showTooltip
283
+ ),
284
+ onPointerLeave: composeEventHandlers(
285
+ child.props.onPointerLeave,
286
+ hideTooltip
287
+ ),
288
+ onPointerMove: composeEventHandlers(
289
+ child.props.onPointerMove,
290
+ showTooltip
291
+ ),
292
+ })}
293
+ {tooltip}
294
+ </>
295
+ )
296
+ }
297
+
298
+ export interface ChartFloatingTooltipProps {
299
+ label?: React.ReactNode
300
+ value?: React.ReactNode
301
+ description?: React.ReactNode
302
+ position?: { x: number; y: number }
303
+ open?: boolean
304
+ anchorRef?: React.RefObject<HTMLElement | null>
305
+ onOpenChange?: (open: boolean) => void
306
+ showMarker?: boolean
307
+ }
308
+
309
+ export function ChartFloatingTooltip({
310
+ label,
311
+ value,
312
+ description,
313
+ position = { x: 50, y: 20 },
314
+ open = false,
315
+ anchorRef,
316
+ onOpenChange,
317
+ showMarker = true,
318
+ }: ChartFloatingTooltipProps) {
319
+ React.useEffect(() => {
320
+ if (!open || !onOpenChange) return
321
+
322
+ const closeTooltip = () => onOpenChange(false)
323
+ window.addEventListener("scroll", closeTooltip, true)
324
+ window.addEventListener("touchmove", closeTooltip, { passive: true })
325
+ window.addEventListener("wheel", closeTooltip, { passive: true })
326
+ window.addEventListener("resize", closeTooltip)
327
+ window.visualViewport?.addEventListener("scroll", closeTooltip)
328
+ window.visualViewport?.addEventListener("resize", closeTooltip)
329
+
330
+ return () => {
331
+ window.removeEventListener("scroll", closeTooltip, true)
332
+ window.removeEventListener("touchmove", closeTooltip)
333
+ window.removeEventListener("wheel", closeTooltip)
334
+ window.removeEventListener("resize", closeTooltip)
335
+ window.visualViewport?.removeEventListener("scroll", closeTooltip)
336
+ window.visualViewport?.removeEventListener("resize", closeTooltip)
337
+ }
338
+ }, [onOpenChange, open])
339
+
340
+ if (
341
+ !open ||
342
+ (!hasTooltipContent(label) &&
343
+ !hasTooltipContent(value) &&
344
+ !hasTooltipContent(description))
345
+ ) {
346
+ return null
347
+ }
348
+
349
+ const x = clampTooltipPercent(position.x)
350
+ const y = clampTooltipPercent(position.y)
351
+ const showBelow = y < 24
352
+ const translateX = x < 24 ? "0" : x > 76 ? "-100%" : "-50%"
353
+ const translateY = showBelow ? "0.5rem" : "calc(-100% - 0.5rem)"
354
+ const anchor = anchorRef?.current
355
+
356
+ if (anchor && typeof document !== "undefined") {
357
+ const rect = anchor.getBoundingClientRect()
358
+ const viewportWidth = window.innerWidth
359
+ const viewportHeight = window.innerHeight
360
+ const left = clampTooltipPixel(rect.left + (rect.width * x) / 100, viewportWidth)
361
+ const top = clampTooltipPixel(rect.top + (rect.height * y) / 100, viewportHeight)
362
+
363
+ return createPortal(
364
+ <>
365
+ {showMarker ? (
366
+ <span
367
+ className="pointer-events-none fixed z-40 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-ring bg-background shadow-[0_0_0_3px_hsl(var(--background)),0_0_0_6px_hsl(var(--ring)/0.2)]"
368
+ style={{ left, top }}
369
+ aria-hidden="true"
370
+ />
371
+ ) : null}
372
+ <div
373
+ role="tooltip"
374
+ className="pointer-events-none fixed z-50 w-max overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-left text-sm text-popover-foreground shadow-md"
375
+ style={{
376
+ left,
377
+ top,
378
+ transform: `translate(${translateX}, ${translateY})`,
379
+ maxWidth: "min(20rem, calc(100vw - 2rem))",
380
+ }}
381
+ >
382
+ <ChartTooltipBody
383
+ label={label}
384
+ value={value}
385
+ description={description}
386
+ />
387
+ </div>
388
+ </>,
389
+ document.body
390
+ )
391
+ }
392
+
393
+ return (
394
+ <>
395
+ {showMarker ? (
396
+ <span
397
+ className="pointer-events-none absolute z-40 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-ring bg-background shadow-[0_0_0_3px_hsl(var(--background)),0_0_0_6px_hsl(var(--ring)/0.2)]"
398
+ style={{
399
+ left: `${x}%`,
400
+ top: `${y}%`,
401
+ }}
402
+ aria-hidden="true"
403
+ />
404
+ ) : null}
405
+ <div
406
+ role="tooltip"
407
+ className="pointer-events-none absolute z-50 w-max overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-left text-sm text-popover-foreground shadow-md"
408
+ style={{
409
+ left: `${x}%`,
410
+ top: `${y}%`,
411
+ transform: `translate(${translateX}, ${translateY})`,
412
+ maxWidth: "min(20rem, calc(100vw - 2rem))",
413
+ }}
414
+ >
415
+ <ChartTooltipBody
416
+ label={label}
417
+ value={value}
418
+ description={description}
419
+ />
420
+ </div>
421
+ </>
422
+ )
423
+ }
@@ -0,0 +1,71 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ export type ChartTone =
4
+ | "primary"
5
+ | "success"
6
+ | "warning"
7
+ | "info"
8
+ | "accent"
9
+ | "destructive"
10
+ | "muted"
11
+
12
+ export type ChartColor = ChartTone | (string & {})
13
+
14
+ export interface ChartDataPoint {
15
+ label?: ReactNode
16
+ value: number
17
+ color?: ChartColor
18
+ }
19
+
20
+ export const chartToneOrder: ChartTone[] = [
21
+ "primary",
22
+ "success",
23
+ "warning",
24
+ "info",
25
+ "accent",
26
+ "destructive",
27
+ ]
28
+
29
+ const chartToneValues: Record<ChartTone, string> = {
30
+ primary: "hsl(var(--primary))",
31
+ success: "hsl(var(--success))",
32
+ warning: "hsl(var(--warning))",
33
+ info: "hsl(var(--info))",
34
+ accent: "hsl(var(--accent))",
35
+ destructive: "hsl(var(--destructive))",
36
+ muted: "hsl(var(--muted-foreground))",
37
+ }
38
+
39
+ export function clamp(value: number, min = 0, max = 100) {
40
+ return Math.min(max, Math.max(min, value))
41
+ }
42
+
43
+ export function getChartColor(color: ChartColor | undefined, index: number) {
44
+ const resolvedColor = color ?? chartToneOrder[index % chartToneOrder.length]
45
+ return chartToneValues[resolvedColor as ChartTone] ?? resolvedColor
46
+ }
47
+
48
+ export function getChartValue(point: ChartDataPoint | number) {
49
+ return typeof point === "number" ? point : point.value
50
+ }
51
+
52
+ export function getChartLabel(point: ChartDataPoint | number, index: number) {
53
+ if (typeof point === "number") return `#${index + 1}`
54
+ return point.label ?? `#${index + 1}`
55
+ }
56
+
57
+ export function chartLabelToString(label: ReactNode, fallback = "Data") {
58
+ if (typeof label === "string" || typeof label === "number") return String(label)
59
+ return fallback
60
+ }
61
+
62
+ export function normalizeChartValue(value: number, max: number) {
63
+ if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return 0
64
+ return clamp((value / max) * 100)
65
+ }
66
+
67
+ export function defaultChartValueFormatter(value: number) {
68
+ return new Intl.NumberFormat("en-US", {
69
+ maximumFractionDigits: value % 1 === 0 ? 0 : 1,
70
+ }).format(value)
71
+ }
@@ -0,0 +1,147 @@
1
+ import type { PointerEvent, ReactNode } from "react"
2
+
3
+ import type { ChartDataPoint } from "./chart-utils"
4
+ import {
5
+ clamp,
6
+ defaultChartValueFormatter,
7
+ getChartColor,
8
+ normalizeChartValue,
9
+ } from "./chart-utils"
10
+
11
+ export interface NormalizedCircularSegment {
12
+ label?: ReactNode
13
+ value: number
14
+ color: string
15
+ start: number
16
+ end: number
17
+ }
18
+
19
+ export function normalizeCircularSegments(segments: ChartDataPoint[]) {
20
+ const positiveSegments = segments.map((segment, index) => ({
21
+ label: segment.label,
22
+ value: Math.max(segment.value, 0),
23
+ color: getChartColor(segment.color, index),
24
+ }))
25
+ const total = Math.max(
26
+ positiveSegments.reduce((sum, segment) => sum + segment.value, 0),
27
+ 1
28
+ )
29
+ let cursor = 0
30
+
31
+ return positiveSegments.map((segment) => {
32
+ const size = normalizeChartValue(segment.value, total)
33
+ const normalizedSegment = {
34
+ ...segment,
35
+ start: cursor,
36
+ end: cursor + size,
37
+ }
38
+ cursor += size
39
+ return normalizedSegment
40
+ })
41
+ }
42
+
43
+ export function buildConicGradient(
44
+ segments: NormalizedCircularSegment[],
45
+ fallback = "hsl(var(--muted))"
46
+ ) {
47
+ if (segments.every((segment) => segment.value <= 0)) {
48
+ return fallback
49
+ }
50
+
51
+ return `conic-gradient(${segments
52
+ .map((segment) => `${segment.color} ${segment.start}% ${segment.end}%`)
53
+ .join(", ")})`
54
+ }
55
+
56
+ export function buildActiveCircularSegmentGradient(
57
+ segment: NormalizedCircularSegment | undefined,
58
+ color = "hsl(var(--foreground) / 0.3)"
59
+ ) {
60
+ if (!segment) return undefined
61
+
62
+ return `conic-gradient(transparent 0% ${segment.start}%, ${color} ${segment.start}% ${segment.end}%, transparent ${segment.end}% 100%)`
63
+ }
64
+
65
+ export function getCircularSegmentAtPercent(
66
+ segments: NormalizedCircularSegment[],
67
+ percent: number
68
+ ) {
69
+ return (
70
+ segments.find(
71
+ (segment) => percent >= segment.start && percent <= segment.end
72
+ ) ?? segments[segments.length - 1]
73
+ )
74
+ }
75
+
76
+ export function getCircularPointerPercent(event: PointerEvent<HTMLElement>) {
77
+ const rect = event.currentTarget.getBoundingClientRect()
78
+ const centerX = rect.left + rect.width / 2
79
+ const centerY = rect.top + rect.height / 2
80
+ const dx = event.clientX - centerX
81
+ const dy = event.clientY - centerY
82
+ const angle = (Math.atan2(dy, dx) * 180) / Math.PI
83
+ return (((angle + 90) % 360) + 360) % 360 / 360 * 100
84
+ }
85
+
86
+ export function getCircularPointerPosition(event: PointerEvent<HTMLElement>) {
87
+ const rect = event.currentTarget.getBoundingClientRect()
88
+ return {
89
+ x: clamp(((event.clientX - rect.left) / rect.width) * 100),
90
+ y: clamp(((event.clientY - rect.top) / rect.height) * 100),
91
+ }
92
+ }
93
+
94
+ export function getCircularPointerDistance(event: PointerEvent<HTMLElement>) {
95
+ const rect = event.currentTarget.getBoundingClientRect()
96
+ const radius = rect.width / 2
97
+ const centerX = rect.left + radius
98
+ const centerY = rect.top + radius
99
+ const dx = event.clientX - centerX
100
+ const dy = event.clientY - centerY
101
+
102
+ return {
103
+ distance: Math.sqrt(dx * dx + dy * dy),
104
+ radius,
105
+ }
106
+ }
107
+
108
+ export function getCircularSegmentPosition(
109
+ segment: NormalizedCircularSegment | undefined,
110
+ radius = 38
111
+ ) {
112
+ if (!segment) return { x: 50, y: 18 }
113
+ const percent = (segment.start + segment.end) / 2
114
+ const angle = (percent / 100) * 360 - 90
115
+ const radians = (angle * Math.PI) / 180
116
+ return {
117
+ x: clamp(50 + Math.cos(radians) * radius),
118
+ y: clamp(50 + Math.sin(radians) * radius),
119
+ }
120
+ }
121
+
122
+ export function getCircularSegmentShare(
123
+ segment: NormalizedCircularSegment | undefined
124
+ ) {
125
+ if (!segment) return undefined
126
+ return Math.max(0, segment.end - segment.start)
127
+ }
128
+
129
+ export function buildLegendItems(
130
+ segments: ChartDataPoint[],
131
+ formatValue: (value: number) => ReactNode = defaultChartValueFormatter,
132
+ totalLabel: ReactNode = "Total"
133
+ ) {
134
+ const total = Math.max(
135
+ segments.reduce((sum, segment) => sum + Math.max(segment.value, 0), 0),
136
+ 1
137
+ )
138
+
139
+ return segments.map((segment, index) => ({
140
+ label: segment.label,
141
+ value: `${defaultChartValueFormatter(
142
+ normalizeChartValue(Math.max(segment.value, 0), total)
143
+ )}%`,
144
+ color: segment.color ?? getChartColor(undefined, index),
145
+ description: [totalLabel, ": ", formatValue(segment.value)],
146
+ }))
147
+ }