@gunjo/ui 0.0.1-alpha.0 → 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,607 @@
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
+ clamp,
17
+ defaultChartValueFormatter,
18
+ getChartColor,
19
+ normalizeChartValue,
20
+ } from "./chart-utils"
21
+ import { ChartFloatingTooltip, ChartTooltip } from "./chart-tooltip"
22
+ import type { SegmentedGaugeCardVariantKey } from "./generated/variant-keys"
23
+ import { segmentedGaugeCardDefaultVariantKey } from "./generated/default-variant-keys"
24
+
25
+ export interface SegmentedGaugeCardSegment extends ChartDataPoint {
26
+ rangeLabel?: React.ReactNode
27
+ description?: React.ReactNode
28
+ }
29
+
30
+ export interface SegmentedGaugeCardProps
31
+ extends Omit<React.ComponentPropsWithoutRef<typeof Card>, "title"> {
32
+ segments: SegmentedGaugeCardSegment[]
33
+ title?: React.ReactNode
34
+ description?: React.ReactNode
35
+ value?: number
36
+ valueLabel?: React.ReactNode
37
+ centerLabel?: React.ReactNode
38
+ delta?: React.ReactNode
39
+ deltaDescription?: React.ReactNode
40
+ targetValue?: number
41
+ targetLabel?: React.ReactNode
42
+ caption?: React.ReactNode
43
+ variant?: SegmentedGaugeCardVariantKey
44
+ selectedIndex?: number
45
+ min?: number
46
+ max?: number
47
+ thickness?: number
48
+ showLegend?: boolean
49
+ formatValue?: (value: number) => React.ReactNode
50
+ totalLabel?: React.ReactNode
51
+ onSegmentSelect?: (segment: SegmentedGaugeCardSegment, index: number) => void
52
+ }
53
+
54
+ interface NormalizedGaugeSegment {
55
+ label?: React.ReactNode
56
+ value: number
57
+ color: string
58
+ start: number
59
+ end: number
60
+ }
61
+
62
+ type SegmentedGaugeCardClassNames = {
63
+ card: string
64
+ header: string
65
+ content: string
66
+ chart: string
67
+ title: string
68
+ }
69
+
70
+ const variantClasses: Record<SegmentedGaugeCardVariantKey, SegmentedGaugeCardClassNames> = {
71
+ compact: {
72
+ card: "rounded-md",
73
+ header: "p-4 pb-3",
74
+ content: "px-4 pb-4",
75
+ chart: "max-w-56",
76
+ title: "text-sm",
77
+ },
78
+ default: {
79
+ card: "rounded-lg",
80
+ header: "p-5 pb-3",
81
+ content: "px-5 pb-5",
82
+ chart: "max-w-72",
83
+ title: "text-base",
84
+ },
85
+ }
86
+
87
+ function getPositiveValue(value: number) {
88
+ return Number.isFinite(value) ? Math.max(value, 0) : 0
89
+ }
90
+
91
+ function getSegmentTotal(segments: SegmentedGaugeCardSegment[]) {
92
+ return segments.reduce((sum, segment) => sum + getPositiveValue(segment.value), 0)
93
+ }
94
+
95
+ function normalizeGaugeSegments(
96
+ segments: SegmentedGaugeCardSegment[],
97
+ max: number
98
+ ): NormalizedGaugeSegment[] {
99
+ let cursor = 0
100
+ return segments.map((segment, index) => {
101
+ const value = getPositiveValue(segment.value)
102
+ const size = normalizeChartValue(value, max)
103
+ const normalized = {
104
+ label: segment.label,
105
+ value,
106
+ color: getChartColor(segment.color, index),
107
+ start: cursor,
108
+ end: Math.min(100, cursor + size),
109
+ }
110
+ cursor = normalized.end
111
+ return normalized
112
+ })
113
+ }
114
+
115
+ function buildGaugeGradient(segments: NormalizedGaugeSegment[]) {
116
+ const trackColor = "hsl(var(--muted))"
117
+ const stops = segments
118
+ .filter((segment) => segment.end > segment.start)
119
+ .map(
120
+ (segment) =>
121
+ `${segment.color} ${segment.start * 1.8}deg ${segment.end * 1.8}deg`
122
+ )
123
+ const lastStop = Math.max(...segments.map((segment) => segment.end), 0)
124
+
125
+ return `conic-gradient(from 270deg, ${
126
+ stops.length > 0 ? `${stops.join(", ")}, ` : ""
127
+ }${trackColor} ${lastStop * 1.8}deg 180deg, transparent 180deg 360deg)`
128
+ }
129
+
130
+ function buildSelectedSegmentGradient(
131
+ segment: NormalizedGaugeSegment | undefined,
132
+ color: string
133
+ ) {
134
+ if (!segment || segment.end <= segment.start) return undefined
135
+
136
+ return `conic-gradient(from 270deg, transparent 0deg ${segment.start * 1.8}deg, ${color} ${segment.start * 1.8}deg ${segment.end * 1.8}deg, transparent ${segment.end * 1.8}deg 360deg)`
137
+ }
138
+
139
+ function getSegmentAtPercent(segments: NormalizedGaugeSegment[], percent: number) {
140
+ return (
141
+ segments.find(
142
+ (segment) =>
143
+ segment.end > segment.start &&
144
+ percent >= segment.start &&
145
+ percent <= segment.end
146
+ ) ?? segments.find((segment) => segment.end > segment.start)
147
+ )
148
+ }
149
+
150
+ function getSegmentPosition(segment: NormalizedGaugeSegment | undefined) {
151
+ if (!segment) return { x: 50, y: 18 }
152
+ const percent = (segment.start + segment.end) / 2
153
+ return getGaugeArcPosition(percent)
154
+ }
155
+
156
+ function getGaugeArcPosition(percent: number) {
157
+ const radians = (clamp(percent) / 100) * Math.PI
158
+ return {
159
+ x: clamp(50 - Math.cos(radians) * 42, 8, 92),
160
+ y: clamp(100 - Math.sin(radians) * 82, 12, 88),
161
+ }
162
+ }
163
+
164
+ function getGaugeTargetGuide(percent: number, thickness: number) {
165
+ const radians = (clamp(percent) / 100) * Math.PI
166
+ const position = getGaugeArcPosition(percent)
167
+ const tangentX = 42 * Math.sin(radians)
168
+ const tangentY = -82 * Math.cos(radians)
169
+ return {
170
+ ...position,
171
+ rotation: (Math.atan2(tangentY, tangentX) * 180) / Math.PI,
172
+ length: Math.max(28, Math.min(42, thickness + 14)),
173
+ }
174
+ }
175
+
176
+ function getSegmentSource(
177
+ segments: SegmentedGaugeCardSegment[],
178
+ normalized: NormalizedGaugeSegment | undefined
179
+ ) {
180
+ if (!normalized) return undefined
181
+ return segments.find((segment) => segment.label === normalized.label)
182
+ }
183
+
184
+ const SegmentedGaugeCard = React.forwardRef<
185
+ HTMLDivElement,
186
+ SegmentedGaugeCardProps
187
+ >(
188
+ (
189
+ {
190
+ className,
191
+ segments,
192
+ title = "Segmented gauge",
193
+ description,
194
+ value,
195
+ valueLabel,
196
+ centerLabel,
197
+ delta,
198
+ deltaDescription,
199
+ targetValue,
200
+ targetLabel = "Target",
201
+ caption,
202
+ variant = segmentedGaugeCardDefaultVariantKey,
203
+ selectedIndex,
204
+ min = 0,
205
+ max,
206
+ thickness = variant === "compact" ? 18 : 24,
207
+ showLegend = true,
208
+ formatValue = defaultChartValueFormatter,
209
+ totalLabel = "Total",
210
+ onSegmentSelect,
211
+ ...props
212
+ },
213
+ ref
214
+ ) => {
215
+ const styles = variantClasses[variant]
216
+ const segmentTotal = getSegmentTotal(segments)
217
+ const resolvedMax = Math.max(max ?? segmentTotal, segmentTotal, 1)
218
+ const normalizedSegments = React.useMemo(
219
+ () => normalizeGaugeSegments(segments, resolvedMax),
220
+ [segments, resolvedMax]
221
+ )
222
+ const selectedSegment =
223
+ selectedIndex === undefined ? undefined : normalizedSegments[selectedIndex]
224
+ const [activeSegment, setActiveSegment] = React.useState<
225
+ NormalizedGaugeSegment | undefined
226
+ >(selectedSegment ?? normalizedSegments[0])
227
+ const [tooltipOpen, setTooltipOpen] = React.useState(false)
228
+ const chartRef = React.useRef<HTMLDivElement | null>(null)
229
+ const touchTooltipStickyRef = React.useRef(false)
230
+ const [tooltipPosition, setTooltipPosition] = React.useState(
231
+ getSegmentPosition(selectedSegment ?? normalizedSegments[0])
232
+ )
233
+ const activeSource = getSegmentSource(segments, activeSegment)
234
+ const activeShare =
235
+ activeSegment === undefined
236
+ ? undefined
237
+ : `${defaultChartValueFormatter(
238
+ normalizeChartValue(activeSegment.value, resolvedMax)
239
+ )}%`
240
+ const normalizedValue =
241
+ value === undefined ? undefined : clamp(value, min, resolvedMax)
242
+ const displayValue =
243
+ valueLabel ??
244
+ (normalizedValue === undefined ? undefined : formatValue(normalizedValue))
245
+ const resolvedCenterLabel = centerLabel ?? totalLabel
246
+ const targetPercent =
247
+ targetValue === undefined
248
+ ? undefined
249
+ : normalizeChartValue(clamp(targetValue, min, resolvedMax) - min, resolvedMax - min)
250
+ const targetGuide =
251
+ targetPercent === undefined
252
+ ? undefined
253
+ : getGaugeTargetGuide(targetPercent, thickness)
254
+ const background = buildGaugeGradient(normalizedSegments)
255
+ const selectedFillBackground = buildSelectedSegmentGradient(
256
+ selectedSegment,
257
+ selectedSegment?.color ?? "transparent"
258
+ )
259
+ const selectedOutlineBackground = buildSelectedSegmentGradient(
260
+ selectedSegment,
261
+ "hsl(var(--foreground))"
262
+ )
263
+ React.useEffect(() => {
264
+ const nextSegment = selectedSegment ?? normalizedSegments[0]
265
+ setActiveSegment(nextSegment)
266
+ setTooltipPosition(getSegmentPosition(nextSegment))
267
+ }, [normalizedSegments, selectedSegment])
268
+
269
+ const updateTooltipAtPoint = (
270
+ element: HTMLDivElement,
271
+ clientX: number,
272
+ clientY: number
273
+ ) => {
274
+ const rect = element.getBoundingClientRect()
275
+ const radius = rect.width / 2
276
+ const innerRadius = Math.max(radius - thickness, 0)
277
+ const centerX = rect.left + radius
278
+ const centerY = rect.top + radius
279
+ const dx = clientX - centerX
280
+ const dy = clientY - centerY
281
+ const distance = Math.sqrt(dx * dx + dy * dy)
282
+ const angle = (Math.atan2(dy, dx) * 180) / Math.PI
283
+ const cssAngle = (((angle + 90) % 360) + 360) % 360
284
+ const sweep = (cssAngle - 270 + 360) % 360
285
+ const percent = (sweep / 180) * 100
286
+ const nextSegment = getSegmentAtPercent(normalizedSegments, percent)
287
+ const isOnSegment =
288
+ distance >= innerRadius &&
289
+ distance <= radius &&
290
+ sweep <= 180 &&
291
+ nextSegment !== undefined &&
292
+ percent >= nextSegment.start &&
293
+ percent <= nextSegment.end
294
+
295
+ if (!isOnSegment) {
296
+ setTooltipOpen(false)
297
+ return
298
+ }
299
+
300
+ setActiveSegment(nextSegment)
301
+ setTooltipPosition({
302
+ x: clamp(((clientX - rect.left) / rect.width) * 100),
303
+ y: clamp(((clientY - rect.top) / rect.height) * 100),
304
+ })
305
+ setTooltipOpen(true)
306
+ }
307
+ const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
308
+ if (event.pointerType !== "touch") {
309
+ touchTooltipStickyRef.current = false
310
+ }
311
+ updateTooltipAtPoint(event.currentTarget, event.clientX, event.clientY)
312
+ }
313
+ const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
314
+ touchTooltipStickyRef.current = event.pointerType === "touch"
315
+ if (event.pointerType === "touch") {
316
+ event.preventDefault()
317
+ }
318
+ event.currentTarget.focus({ preventScroll: true })
319
+ updateTooltipAtPoint(event.currentTarget, event.clientX, event.clientY)
320
+ }
321
+ const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
322
+ const touch = event.touches[0]
323
+ if (!touch) return
324
+ touchTooltipStickyRef.current = true
325
+ event.preventDefault()
326
+ event.currentTarget.focus({ preventScroll: true })
327
+ updateTooltipAtPoint(event.currentTarget, touch.clientX, touch.clientY)
328
+ }
329
+
330
+ const handleActiveSegmentSelect = () => {
331
+ if (!tooltipOpen || !activeSegment) return
332
+
333
+ const nextIndex = normalizedSegments.indexOf(activeSegment)
334
+ const sourceSegment = segments[nextIndex]
335
+ if (nextIndex < 0 || !sourceSegment) return
336
+
337
+ onSegmentSelect?.(sourceSegment, nextIndex)
338
+ }
339
+
340
+ return (
341
+ <Card
342
+ ref={ref}
343
+ className={cn("w-full min-w-0 overflow-hidden p-0", styles.card, className)}
344
+ {...props}
345
+ >
346
+ <CardHeader className={styles.header}>
347
+ <div className="flex min-w-0 items-start justify-between gap-3">
348
+ <div className="min-w-0 space-y-1">
349
+ <CardTitle className={cn("truncate", styles.title)}>
350
+ {title}
351
+ </CardTitle>
352
+ {description ? (
353
+ <CardDescription className="text-xs">
354
+ {description}
355
+ </CardDescription>
356
+ ) : null}
357
+ </div>
358
+ {delta !== undefined && delta !== null ? (
359
+ <div
360
+ className="shrink-0 text-right text-sm font-semibold text-success-strong tabular-nums"
361
+ title={chartLabelToString(
362
+ deltaDescription,
363
+ "Delta description"
364
+ )}
365
+ >
366
+ {delta}
367
+ </div>
368
+ ) : null}
369
+ </div>
370
+ </CardHeader>
371
+ <CardContent className={cn("space-y-4", styles.content)}>
372
+ <div className="flex min-w-0 justify-center">
373
+ <div
374
+ ref={chartRef}
375
+ className={cn(
376
+ "relative aspect-[2/1] w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
377
+ onSegmentSelect && "cursor-pointer",
378
+ styles.chart
379
+ )}
380
+ role="img"
381
+ aria-label={
382
+ activeSegment
383
+ ? `${chartLabelToString(activeSegment.label)}: ${formatValue(activeSegment.value)}`
384
+ : props["aria-label"]
385
+ }
386
+ tabIndex={0}
387
+ onFocus={() => {
388
+ const nextSegment =
389
+ selectedSegment ?? normalizedSegments[0]
390
+ setActiveSegment(nextSegment)
391
+ setTooltipPosition(getSegmentPosition(nextSegment))
392
+ setTooltipOpen(true)
393
+ }}
394
+ onBlur={() => {
395
+ touchTooltipStickyRef.current = false
396
+ setTooltipOpen(false)
397
+ }}
398
+ onPointerDown={handlePointerDown}
399
+ onPointerMove={handlePointerMove}
400
+ onTouchStart={handleTouchStart}
401
+ onPointerLeave={() => {
402
+ if (touchTooltipStickyRef.current) return
403
+ const nextSegment =
404
+ selectedSegment ?? normalizedSegments[0]
405
+ setActiveSegment(nextSegment)
406
+ setTooltipPosition(getSegmentPosition(nextSegment))
407
+ setTooltipOpen(false)
408
+ }}
409
+ onPointerCancel={() => {
410
+ touchTooltipStickyRef.current = false
411
+ setTooltipOpen(false)
412
+ }}
413
+ onClick={handleActiveSegmentSelect}
414
+ onKeyDown={(event) => {
415
+ if (event.key !== "Enter" && event.key !== " ") return
416
+ event.preventDefault()
417
+ handleActiveSegmentSelect()
418
+ }}
419
+ >
420
+ <div
421
+ className="pointer-events-none absolute inset-0 overflow-hidden"
422
+ aria-hidden="true"
423
+ >
424
+ <div
425
+ className="absolute inset-x-0 top-0 aspect-square rounded-full"
426
+ style={{ background }}
427
+ />
428
+ {selectedFillBackground ? (
429
+ <div
430
+ className="absolute inset-x-0 top-0 aspect-square rounded-full opacity-40"
431
+ style={{
432
+ background: selectedFillBackground,
433
+ mask: `radial-gradient(farthest-side, transparent calc(100% - ${thickness + 7}px), hsl(var(--palette-black)) calc(100% - ${thickness + 7}px) calc(100% - 1px), transparent calc(100% - 1px))`,
434
+ WebkitMask: `radial-gradient(farthest-side, transparent calc(100% - ${thickness + 7}px), hsl(var(--palette-black)) calc(100% - ${thickness + 7}px) calc(100% - 1px), transparent calc(100% - 1px))`,
435
+ }}
436
+ />
437
+ ) : null}
438
+ {selectedOutlineBackground ? (
439
+ <div
440
+ className="absolute inset-x-0 top-0 aspect-square rounded-full"
441
+ style={{
442
+ background: selectedOutlineBackground,
443
+ mask: "radial-gradient(farthest-side, transparent calc(100% - 5px), hsl(var(--palette-black)) calc(100% - 5px) calc(100% - 1px), transparent calc(100% - 1px))",
444
+ WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 5px), hsl(var(--palette-black)) calc(100% - 5px) calc(100% - 1px), transparent calc(100% - 1px))",
445
+ }}
446
+ />
447
+ ) : null}
448
+ <div
449
+ className="absolute rounded-full bg-card"
450
+ style={{
451
+ insetInline: thickness,
452
+ top: thickness,
453
+ width: `calc(100% - ${thickness * 2}px)`,
454
+ aspectRatio: "1 / 1",
455
+ }}
456
+ />
457
+ </div>
458
+
459
+ {targetGuide ? (
460
+ <ChartTooltip
461
+ label={targetLabel}
462
+ value={formatValue(targetValue ?? 0)}
463
+ description={chartLabelToString(
464
+ targetLabel,
465
+ "Target"
466
+ )}
467
+ >
468
+ <span
469
+ className="absolute z-30 h-16 w-16 -translate-x-1/2 -translate-y-1/2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
470
+ style={{
471
+ left: `${targetGuide.x}%`,
472
+ top: `${targetGuide.y}%`,
473
+ }}
474
+ tabIndex={0}
475
+ aria-label={`${chartLabelToString(targetLabel, "Target")}: ${chartLabelToString(formatValue(targetValue ?? 0), "Value")}`}
476
+ >
477
+ <span
478
+ className="absolute left-1/2 top-1/2 block h-px rounded-full border-t border-dashed border-foreground/80"
479
+ style={{
480
+ width: targetGuide.length,
481
+ transform: `translate(-50%, -50%) rotate(${targetGuide.rotation}deg)`,
482
+ }}
483
+ aria-hidden="true"
484
+ />
485
+ </span>
486
+ </ChartTooltip>
487
+ ) : null}
488
+
489
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center px-4 text-center">
490
+ {displayValue !== undefined ? (
491
+ <span className="text-3xl font-semibold tracking-tight tabular-nums">
492
+ {displayValue}
493
+ </span>
494
+ ) : null}
495
+ {resolvedCenterLabel !== undefined ? (
496
+ <span className="text-xs text-muted-foreground">
497
+ {resolvedCenterLabel}
498
+ </span>
499
+ ) : null}
500
+ </div>
501
+
502
+ <ChartFloatingTooltip
503
+ label={activeSegment?.label}
504
+ value={
505
+ activeSegment
506
+ ? formatValue(activeSegment.value)
507
+ : undefined
508
+ }
509
+ description={activeSource?.description ?? activeShare}
510
+ position={tooltipPosition}
511
+ open={tooltipOpen}
512
+ anchorRef={chartRef}
513
+ onOpenChange={setTooltipOpen}
514
+ />
515
+ </div>
516
+ </div>
517
+
518
+ {showLegend ? (
519
+ <div className="grid min-w-0 gap-2">
520
+ {segments.map((segment, index) => {
521
+ const normalizedSegment = normalizedSegments[index]
522
+ const isActive = selectedSegment
523
+ ? selectedSegment === normalizedSegment
524
+ : activeSegment === normalizedSegment
525
+ const share = `${defaultChartValueFormatter(
526
+ normalizeChartValue(
527
+ getPositiveValue(segment.value),
528
+ resolvedMax
529
+ )
530
+ )}%`
531
+
532
+ return (
533
+ <ChartTooltip
534
+ key={`${chartLabelToString(segment.label, "Segment")}-${index}`}
535
+ label={segment.label}
536
+ value={formatValue(segment.value)}
537
+ description={
538
+ segment.description ?? [
539
+ totalLabel,
540
+ ": ",
541
+ share,
542
+ ]
543
+ }
544
+ >
545
+ <button
546
+ type="button"
547
+ className={cn(
548
+ "grid min-w-0 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",
549
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
550
+ isActive && "border-foreground shadow-sm"
551
+ )}
552
+ onPointerEnter={() => {
553
+ setActiveSegment(normalizedSegment)
554
+ setTooltipPosition(
555
+ getSegmentPosition(normalizedSegment)
556
+ )
557
+ }}
558
+ onFocus={() => {
559
+ setActiveSegment(normalizedSegment)
560
+ setTooltipPosition(
561
+ getSegmentPosition(normalizedSegment)
562
+ )
563
+ }}
564
+ onClick={() => onSegmentSelect?.(segment, index)}
565
+ >
566
+ <span
567
+ className="row-span-2 h-3 w-3 rounded-full"
568
+ style={{
569
+ backgroundColor:
570
+ normalizedSegment?.color,
571
+ }}
572
+ aria-hidden="true"
573
+ />
574
+ <span className="min-w-0 truncate text-sm font-medium">
575
+ {segment.label}
576
+ </span>
577
+ <span className="text-sm font-semibold tabular-nums">
578
+ {share}
579
+ </span>
580
+ <span className="min-w-0 truncate text-xs text-muted-foreground">
581
+ {formatValue(segment.value)}
582
+ </span>
583
+ {segment.rangeLabel ? (
584
+ <span className="truncate text-xs text-muted-foreground">
585
+ {segment.rangeLabel}
586
+ </span>
587
+ ) : null}
588
+ </button>
589
+ </ChartTooltip>
590
+ )
591
+ })}
592
+ </div>
593
+ ) : null}
594
+
595
+ {caption ? (
596
+ <div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
597
+ {caption}
598
+ </div>
599
+ ) : null}
600
+ </CardContent>
601
+ </Card>
602
+ )
603
+ }
604
+ )
605
+ SegmentedGaugeCard.displayName = "SegmentedGaugeCard"
606
+
607
+ export { SegmentedGaugeCard }
@@ -0,0 +1,51 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "../../lib/utils"
4
+
5
+ const SIZE_MAP = {
6
+ 1: "h-1 w-1",
7
+ 2: "h-2 w-2",
8
+ 3: "h-3 w-3",
9
+ 4: "h-4 w-4",
10
+ 6: "h-6 w-6",
11
+ 8: "h-8 w-8",
12
+ 12: "h-12 w-12",
13
+ 16: "h-16 w-16",
14
+ 24: "h-24 w-24",
15
+ } as const
16
+
17
+ export interface SpacerProps extends React.HTMLAttributes<HTMLDivElement> {
18
+ /**
19
+ * Fixed size (px scale 1=4px, 4=16px, etc.).
20
+ * If omitted, Spacer flex-grows to fill remaining space (use inside flex containers).
21
+ */
22
+ size?: keyof typeof SIZE_MAP
23
+ axis?: "x" | "y" | "both"
24
+ }
25
+
26
+ const Spacer = React.forwardRef<HTMLDivElement, SpacerProps>(
27
+ ({ className, size, axis = "both", ...props }, ref) => {
28
+ const fixed = size !== undefined
29
+ const sizeClass = fixed ? SIZE_MAP[size] : ""
30
+
31
+ const flexGrowClass = !fixed
32
+ ? axis === "x"
33
+ ? "flex-grow"
34
+ : axis === "y"
35
+ ? "flex-grow"
36
+ : "flex-1"
37
+ : ""
38
+
39
+ return (
40
+ <div
41
+ ref={ref}
42
+ aria-hidden
43
+ className={cn(sizeClass, flexGrowClass, className)}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+ )
49
+ Spacer.displayName = "Spacer"
50
+
51
+ export { Spacer }