@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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import { cn } from "../../lib/utils"
5
- import { Image as ImageIcon } from "lucide-react"
5
+ import { IconPhoto as ImageIcon } from "@tabler/icons-react"
6
6
  import type { ImgVariantKey } from "./generated/variant-keys"
7
7
  import { imgDefaultVariantKey } from "./generated/default-variant-keys"
8
8
 
@@ -13,21 +13,42 @@ const imgOpacityClasses: Record<ImgVariantKey, string> = {
13
13
  }
14
14
 
15
15
  const imgVariantClasses: Record<ImgVariantKey, string> = {
16
- error: "inline-flex items-center w-[256px] h-[256px] gap-2 rounded-lg text-xs font-normal",
17
- loaded: "w-[256px] h-[256px] rounded-lg",
18
- loading: "w-[256px] h-[256px] rounded-lg",
16
+ error: "h-[256px] w-[256px] gap-2 rounded-lg text-xs font-normal",
17
+ loaded: "h-[256px] w-[256px] rounded-lg",
18
+ loading: "h-[256px] w-[256px] rounded-lg",
19
19
  }
20
20
 
21
21
  export interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {
22
22
  fallback?: React.ReactNode
23
+ errorLabel?: string
23
24
  aspectRatio?: "square" | "video" | "auto" | "portrait" | "wide"
24
25
  objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down"
25
26
  showSkeleton?: boolean
26
27
  }
27
28
 
28
29
  const Img = React.forwardRef<HTMLImageElement, ImgProps>(
29
- ({ className, src, alt, fallback, aspectRatio = "auto", objectFit = "cover", showSkeleton = true, onLoad, onError, ...props }, ref) => {
30
- const [status, setStatus] = React.useState<ImgVariantKey>(imgDefaultVariantKey)
30
+ ({ className, src, alt, fallback, errorLabel = "Image not found", aspectRatio = "auto", objectFit = "cover", showSkeleton = true, onLoad, onError, ...props }, ref) => {
31
+ const [status, setStatus] = React.useState<ImgVariantKey>(() => (src ? imgDefaultVariantKey : "error"))
32
+ const imageRef = React.useRef<HTMLImageElement | null>(null)
33
+
34
+ const syncImageStatus = React.useCallback((node: HTMLImageElement | null) => {
35
+ if (!src) {
36
+ setStatus("error")
37
+ return
38
+ }
39
+ if (!node?.complete) return
40
+ setStatus(node.naturalWidth > 0 ? "loaded" : "error")
41
+ }, [src])
42
+
43
+ const setImageRef = React.useCallback((node: HTMLImageElement | null) => {
44
+ imageRef.current = node
45
+ if (typeof ref === "function") {
46
+ ref(node)
47
+ } else if (ref) {
48
+ ref.current = node
49
+ }
50
+ syncImageStatus(node)
51
+ }, [ref, syncImageStatus])
31
52
 
32
53
  const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
33
54
  setStatus("loaded")
@@ -39,8 +60,18 @@ const Img = React.forwardRef<HTMLImageElement, ImgProps>(
39
60
  onError?.(e)
40
61
  }
41
62
 
42
- // Reset status if src changes
43
63
  React.useEffect(() => {
64
+ if (!src) {
65
+ setStatus("error")
66
+ return
67
+ }
68
+
69
+ const node = imageRef.current
70
+ if (node?.complete) {
71
+ setStatus(node.naturalWidth > 0 ? "loaded" : "error")
72
+ return
73
+ }
74
+
44
75
  setStatus(imgDefaultVariantKey)
45
76
  }, [src])
46
77
 
@@ -59,11 +90,13 @@ const Img = React.forwardRef<HTMLImageElement, ImgProps>(
59
90
  "none": "object-none",
60
91
  "scale-down": "object-scale-down"
61
92
  }[objectFit]
93
+ const imageSizeClass = aspectRatio === "auto" ? "h-auto w-full" : "h-full w-full"
94
+ const fallbackHeightClass = aspectRatio === "auto" && status !== "loaded" ? "min-h-40" : undefined
62
95
 
63
96
  return (
64
- <div className={cn("relative overflow-hidden bg-secondary", imgVariantClasses[status], aspectRatioClass, className)}>
97
+ <div className={cn("relative w-full overflow-hidden bg-secondary", imgVariantClasses[status], aspectRatioClass, fallbackHeightClass, className)}>
65
98
  {showSkeleton && status === "loading" && (
66
- <div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-secondary">
99
+ <div className="absolute inset-0 z-0 flex animate-pulse items-center justify-center bg-secondary">
67
100
  {/* Optional spinner or just pulse */}
68
101
  {/* <Loader2 className="w-6 h-6 text-muted-foreground animate-spin" /> */}
69
102
  </div>
@@ -74,19 +107,20 @@ const Img = React.forwardRef<HTMLImageElement, ImgProps>(
74
107
  {fallback || (
75
108
  <>
76
109
  <ImageIcon className="mb-2 h-8 w-8 opacity-80" />
77
- <span className="text-xs">Image not found</span>
110
+ <span className="text-xs">{errorLabel}</span>
78
111
  </>
79
112
  )}
80
113
  </div>
81
114
  ) : (
82
115
  <img
83
- ref={ref}
116
+ ref={setImageRef}
84
117
  src={src}
85
118
  alt={alt}
86
119
  onLoad={handleLoad}
87
120
  onError={handleError}
88
121
  className={cn(
89
- "w-full h-full transition-opacity duration-300",
122
+ "transition-opacity duration-300",
123
+ imageSizeClass,
90
124
  objectFitClass,
91
125
  imgOpacityClasses[status]
92
126
  )}
@@ -0,0 +1,475 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../../lib/utils"
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from "./Card"
13
+ import type { ChartDataPoint } from "./chart-utils"
14
+ import {
15
+ chartLabelToString,
16
+ defaultChartValueFormatter,
17
+ normalizeChartValue,
18
+ } from "./chart-utils"
19
+ import { ChartFloatingTooltip, ChartTooltip } from "./chart-tooltip"
20
+ import {
21
+ buildActiveCircularSegmentGradient,
22
+ buildConicGradient,
23
+ getCircularSegmentAtPercent,
24
+ getCircularSegmentPosition,
25
+ getCircularSegmentShare,
26
+ normalizeCircularSegments,
27
+ type NormalizedCircularSegment,
28
+ } from "./circular-chart-utils"
29
+ import type { LabeledDonutCardVariantKey } from "./generated/variant-keys"
30
+ import { labeledDonutCardDefaultVariantKey } from "./generated/default-variant-keys"
31
+
32
+ export interface LabeledDonutCardSegment extends ChartDataPoint {
33
+ calloutLabel?: React.ReactNode
34
+ comparison?: React.ReactNode
35
+ }
36
+
37
+ export interface LabeledDonutCardProps
38
+ extends Omit<React.ComponentPropsWithoutRef<typeof Card>, "title"> {
39
+ segments: LabeledDonutCardSegment[]
40
+ title?: React.ReactNode
41
+ description?: React.ReactNode
42
+ centerValue?: React.ReactNode
43
+ centerLabel?: React.ReactNode
44
+ delta?: React.ReactNode
45
+ deltaDescription?: React.ReactNode
46
+ caption?: React.ReactNode
47
+ variant?: LabeledDonutCardVariantKey
48
+ selectedIndex?: number
49
+ thickness?: number
50
+ showCallouts?: boolean
51
+ formatValue?: (value: number) => React.ReactNode
52
+ totalLabel?: React.ReactNode
53
+ onSegmentSelect?: (segment: LabeledDonutCardSegment, index: number) => void
54
+ }
55
+
56
+ type LabeledDonutCardClassNames = {
57
+ card: string
58
+ header: string
59
+ content: string
60
+ chart: string
61
+ title: string
62
+ }
63
+
64
+ const variantClasses: Record<LabeledDonutCardVariantKey, LabeledDonutCardClassNames> = {
65
+ compact: {
66
+ card: "rounded-md",
67
+ header: "p-4 pb-3",
68
+ content: "px-4 pb-4",
69
+ chart: "max-w-40",
70
+ title: "text-sm",
71
+ },
72
+ default: {
73
+ card: "rounded-lg",
74
+ header: "p-5 pb-3",
75
+ content: "px-5 pb-5",
76
+ chart: "max-w-52",
77
+ title: "text-base",
78
+ },
79
+ }
80
+
81
+ function getPositiveValue(value: number) {
82
+ return Number.isFinite(value) ? Math.max(value, 0) : 0
83
+ }
84
+
85
+ function getSegmentByNormalizedSegment(
86
+ segments: LabeledDonutCardSegment[],
87
+ normalized: NormalizedCircularSegment | undefined
88
+ ) {
89
+ if (!normalized) return undefined
90
+ return segments.find((segment) => segment.label === normalized.label)
91
+ }
92
+
93
+ const LabeledDonutCard = React.forwardRef<
94
+ HTMLDivElement,
95
+ LabeledDonutCardProps
96
+ >(
97
+ (
98
+ {
99
+ className,
100
+ segments,
101
+ title = "Sales by platform",
102
+ description,
103
+ centerValue,
104
+ centerLabel,
105
+ delta,
106
+ deltaDescription,
107
+ caption,
108
+ variant = labeledDonutCardDefaultVariantKey,
109
+ selectedIndex,
110
+ thickness = variant === "compact" ? 18 : 24,
111
+ showCallouts = true,
112
+ formatValue = defaultChartValueFormatter,
113
+ totalLabel = "Total",
114
+ onSegmentSelect,
115
+ ...props
116
+ },
117
+ ref
118
+ ) => {
119
+ const styles = variantClasses[variant]
120
+ const normalizedSegments = React.useMemo(
121
+ () => normalizeCircularSegments(segments),
122
+ [segments]
123
+ )
124
+ const selectedSegment =
125
+ selectedIndex === undefined ? undefined : normalizedSegments[selectedIndex]
126
+ const [activeSegment, setActiveSegment] = React.useState<
127
+ NormalizedCircularSegment | undefined
128
+ >(selectedSegment ?? normalizedSegments[0])
129
+ const [tooltipOpen, setTooltipOpen] = React.useState(false)
130
+ const chartRef = React.useRef<HTMLDivElement | null>(null)
131
+ const touchTooltipStickyRef = React.useRef(false)
132
+ const [tooltipPosition, setTooltipPosition] = React.useState(
133
+ getCircularSegmentPosition(selectedSegment ?? normalizedSegments[0])
134
+ )
135
+ const total = Math.max(
136
+ segments.reduce((sum, segment) => sum + getPositiveValue(segment.value), 0),
137
+ 1
138
+ )
139
+ const tooltipSegment = activeSegment ?? selectedSegment ?? normalizedSegments[0]
140
+ const tooltipSource = getSegmentByNormalizedSegment(segments, tooltipSegment)
141
+ const tooltipShare = getCircularSegmentShare(tooltipSegment)
142
+ const tooltipShareLabel =
143
+ tooltipShare === undefined
144
+ ? undefined
145
+ : `${defaultChartValueFormatter(tooltipShare)}%`
146
+ const activeSegmentLabel = chartLabelToString(tooltipSegment?.label, "")
147
+ const background = buildConicGradient(normalizedSegments)
148
+ const activeOverlay = tooltipOpen
149
+ ? buildActiveCircularSegmentGradient(tooltipSegment)
150
+ : undefined
151
+
152
+ React.useEffect(() => {
153
+ const nextSegment = selectedSegment ?? normalizedSegments[0]
154
+ setActiveSegment(nextSegment)
155
+ setTooltipPosition(getCircularSegmentPosition(nextSegment))
156
+ }, [normalizedSegments, selectedSegment])
157
+
158
+ const updateTooltipAtPoint = (
159
+ element: HTMLDivElement,
160
+ clientX: number,
161
+ clientY: number
162
+ ) => {
163
+ const rect = element.getBoundingClientRect()
164
+ const radius = rect.width / 2
165
+ const centerX = rect.left + radius
166
+ const centerY = rect.top + radius
167
+ const dx = clientX - centerX
168
+ const dy = clientY - centerY
169
+ const distance = Math.sqrt(dx * dx + dy * dy)
170
+ const innerRadius = Math.max(radius - thickness, 0)
171
+
172
+ if (distance < innerRadius || distance > radius) {
173
+ setTooltipOpen(false)
174
+ return
175
+ }
176
+
177
+ const angle = (Math.atan2(dy, dx) * 180) / Math.PI
178
+ const percent = ((((angle + 90) % 360) + 360) % 360) / 360 * 100
179
+ setActiveSegment(getCircularSegmentAtPercent(normalizedSegments, percent))
180
+ setTooltipPosition({
181
+ x: Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100)),
182
+ y: Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100)),
183
+ })
184
+ setTooltipOpen(true)
185
+ }
186
+ const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
187
+ if (event.pointerType !== "touch") {
188
+ touchTooltipStickyRef.current = false
189
+ }
190
+ updateTooltipAtPoint(event.currentTarget, event.clientX, event.clientY)
191
+ }
192
+ const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
193
+ touchTooltipStickyRef.current = event.pointerType === "touch"
194
+ if (event.pointerType === "touch") {
195
+ event.preventDefault()
196
+ }
197
+ event.currentTarget.focus({ preventScroll: true })
198
+ updateTooltipAtPoint(event.currentTarget, event.clientX, event.clientY)
199
+ }
200
+ const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
201
+ const touch = event.touches[0]
202
+ if (!touch) return
203
+ touchTooltipStickyRef.current = true
204
+ event.preventDefault()
205
+ event.currentTarget.focus({ preventScroll: true })
206
+ updateTooltipAtPoint(event.currentTarget, touch.clientX, touch.clientY)
207
+ }
208
+ const handleSegmentSelect = () => {
209
+ if (!onSegmentSelect || !activeSegment) return
210
+
211
+ const segmentIndex = normalizedSegments.findIndex(
212
+ (segment) => segment === activeSegment
213
+ )
214
+ const fallbackIndex = normalizedSegments.findIndex(
215
+ (segment) => segment.label === activeSegment.label
216
+ )
217
+ const index = segmentIndex >= 0 ? segmentIndex : fallbackIndex
218
+ const sourceSegment = segments[index]
219
+
220
+ if (!sourceSegment || index < 0) return
221
+ onSegmentSelect(sourceSegment, index)
222
+ }
223
+
224
+ return (
225
+ <Card
226
+ ref={ref}
227
+ className={cn("w-full min-w-0 overflow-hidden p-0", styles.card, className)}
228
+ {...props}
229
+ >
230
+ <CardHeader className={styles.header}>
231
+ <div className="flex min-w-0 items-start justify-between gap-3">
232
+ <div className="min-w-0 space-y-1">
233
+ <CardTitle className={cn("truncate", styles.title)}>
234
+ {title}
235
+ </CardTitle>
236
+ {description ? (
237
+ <CardDescription className="text-xs">
238
+ {description}
239
+ </CardDescription>
240
+ ) : null}
241
+ </div>
242
+ {delta !== undefined && delta !== null ? (
243
+ <div
244
+ className="shrink-0 text-right text-sm font-semibold text-success-strong tabular-nums"
245
+ title={chartLabelToString(
246
+ deltaDescription,
247
+ "Delta description"
248
+ )}
249
+ >
250
+ {delta}
251
+ </div>
252
+ ) : null}
253
+ </div>
254
+ </CardHeader>
255
+ <CardContent className={cn("space-y-4", styles.content)}>
256
+ <div
257
+ className={cn(
258
+ "grid min-w-0 gap-4",
259
+ showCallouts
260
+ ? "sm:grid-cols-[minmax(9rem,0.85fr)_minmax(0,1fr)]"
261
+ : "justify-items-center"
262
+ )}
263
+ >
264
+ <div
265
+ className={cn(
266
+ "flex min-w-0 items-center justify-center",
267
+ !showCallouts && "w-full max-w-xs"
268
+ )}
269
+ >
270
+ <div
271
+ ref={chartRef}
272
+ className={cn(
273
+ "relative aspect-square w-full rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
274
+ onSegmentSelect && "cursor-pointer",
275
+ styles.chart
276
+ )}
277
+ style={{ background }}
278
+ aria-label={
279
+ tooltipSegment
280
+ ? `${chartLabelToString(tooltipSegment.label)}: ${formatValue(tooltipSegment.value)}${
281
+ tooltipShareLabel
282
+ ? ` (${tooltipShareLabel})`
283
+ : ""
284
+ }`
285
+ : props["aria-label"]
286
+ }
287
+ role={onSegmentSelect ? "button" : "img"}
288
+ tabIndex={0}
289
+ onClick={handleSegmentSelect}
290
+ onFocus={() => {
291
+ const nextSegment = selectedSegment ?? normalizedSegments[0]
292
+ setActiveSegment(nextSegment)
293
+ setTooltipPosition(
294
+ getCircularSegmentPosition(nextSegment)
295
+ )
296
+ setTooltipOpen(true)
297
+ }}
298
+ onBlur={() => {
299
+ touchTooltipStickyRef.current = false
300
+ setTooltipOpen(false)
301
+ }}
302
+ onPointerDown={handlePointerDown}
303
+ onPointerMove={handlePointerMove}
304
+ onTouchStart={handleTouchStart}
305
+ onPointerLeave={() => {
306
+ if (touchTooltipStickyRef.current) return
307
+ setActiveSegment(selectedSegment ?? normalizedSegments[0])
308
+ setTooltipPosition(
309
+ getCircularSegmentPosition(
310
+ selectedSegment ?? normalizedSegments[0]
311
+ )
312
+ )
313
+ setTooltipOpen(false)
314
+ }}
315
+ onPointerCancel={() => {
316
+ touchTooltipStickyRef.current = false
317
+ setTooltipOpen(false)
318
+ }}
319
+ onKeyDown={(event) => {
320
+ if (
321
+ event.key !== "Enter" &&
322
+ event.key !== " "
323
+ ) {
324
+ return
325
+ }
326
+
327
+ event.preventDefault()
328
+ handleSegmentSelect()
329
+ }}
330
+ >
331
+ {activeOverlay ? (
332
+ <span
333
+ className="pointer-events-none absolute inset-0 rounded-full mix-blend-multiply dark:mix-blend-screen"
334
+ style={{
335
+ background: activeOverlay,
336
+ filter: "drop-shadow(0 0 8px hsl(var(--foreground) / 0.32)) drop-shadow(0 0 14px hsl(var(--ring) / 0.26))",
337
+ }}
338
+ data-chart-active-overlay="true"
339
+ aria-hidden="true"
340
+ />
341
+ ) : null}
342
+ <div
343
+ className="absolute rounded-full bg-card"
344
+ style={{ inset: thickness }}
345
+ aria-hidden="true"
346
+ />
347
+ {centerValue !== undefined ||
348
+ centerLabel !== undefined ? (
349
+ <div className="absolute inset-0 flex flex-col items-center justify-center px-4 text-center">
350
+ {centerValue !== undefined ? (
351
+ <span className="text-2xl font-semibold tracking-tight tabular-nums">
352
+ {centerValue}
353
+ </span>
354
+ ) : null}
355
+ {centerLabel !== undefined ? (
356
+ <span className="text-xs text-muted-foreground">
357
+ {centerLabel}
358
+ </span>
359
+ ) : null}
360
+ </div>
361
+ ) : null}
362
+ <ChartFloatingTooltip
363
+ label={tooltipSegment?.label}
364
+ value={
365
+ tooltipSegment
366
+ ? formatValue(tooltipSegment.value)
367
+ : undefined
368
+ }
369
+ description={
370
+ tooltipSource?.comparison ?? tooltipShareLabel
371
+ }
372
+ position={tooltipPosition}
373
+ open={tooltipOpen}
374
+ anchorRef={chartRef}
375
+ onOpenChange={setTooltipOpen}
376
+ />
377
+ </div>
378
+ </div>
379
+
380
+ {showCallouts ? (
381
+ <div className="grid min-w-0 gap-2">
382
+ {segments.map((segment, index) => {
383
+ const normalizedSegment = normalizedSegments[index]
384
+ const share = normalizeChartValue(
385
+ getPositiveValue(segment.value),
386
+ total
387
+ )
388
+ const shareLabel = `${defaultChartValueFormatter(share)}%`
389
+ const isActive =
390
+ activeSegment === normalizedSegment ||
391
+ chartLabelToString(segment.label, "") === activeSegmentLabel
392
+
393
+ return (
394
+ <ChartTooltip
395
+ key={`${chartLabelToString(segment.label, "Segment")}-${index}`}
396
+ label={segment.calloutLabel ?? segment.label}
397
+ value={formatValue(segment.value)}
398
+ description={
399
+ segment.comparison ?? [
400
+ totalLabel,
401
+ ": ",
402
+ shareLabel,
403
+ ]
404
+ }
405
+ >
406
+ <button
407
+ type="button"
408
+ className={cn(
409
+ "grid min-w-0 cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 rounded-md border bg-card px-3 py-2 text-left transition-colors",
410
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
411
+ isActive &&
412
+ "border-foreground bg-muted/60 shadow-sm"
413
+ )}
414
+ aria-current={isActive ? "true" : undefined}
415
+ onPointerEnter={() => {
416
+ setActiveSegment(normalizedSegment)
417
+ setTooltipPosition(
418
+ getCircularSegmentPosition(
419
+ normalizedSegment
420
+ )
421
+ )
422
+ }}
423
+ onFocus={() => {
424
+ setActiveSegment(normalizedSegment)
425
+ setTooltipPosition(
426
+ getCircularSegmentPosition(
427
+ normalizedSegment
428
+ )
429
+ )
430
+ }}
431
+ onClick={() => onSegmentSelect?.(segment, index)}
432
+ >
433
+ <span
434
+ className="row-span-2 h-3 w-3 rounded-full"
435
+ style={{
436
+ backgroundColor:
437
+ normalizedSegment?.color,
438
+ }}
439
+ aria-hidden="true"
440
+ />
441
+ <span className="min-w-0 truncate text-sm font-medium">
442
+ {segment.calloutLabel ?? segment.label}
443
+ </span>
444
+ <span className="text-right text-sm font-semibold tabular-nums">
445
+ {shareLabel}
446
+ </span>
447
+ <span className="min-w-0 truncate text-xs text-muted-foreground">
448
+ {formatValue(segment.value)}
449
+ </span>
450
+ {segment.comparison ? (
451
+ <span className="truncate text-right text-xs text-muted-foreground">
452
+ {segment.comparison}
453
+ </span>
454
+ ) : null}
455
+ </button>
456
+ </ChartTooltip>
457
+ )
458
+ })}
459
+ </div>
460
+ ) : null}
461
+ </div>
462
+
463
+ {caption ? (
464
+ <div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
465
+ {caption}
466
+ </div>
467
+ ) : null}
468
+ </CardContent>
469
+ </Card>
470
+ )
471
+ }
472
+ )
473
+ LabeledDonutCard.displayName = "LabeledDonutCard"
474
+
475
+ export { LabeledDonutCard }