@nuxtseo/design-system 0.0.0-stage → 0.1.0

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 (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +232 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +125 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +55 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +68 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +29 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,457 @@
1
+ <script setup lang="ts" generic="T = unknown, R extends number | string = number">
2
+ import { defaultWindow, useEventListener, useMounted, useMutationObserver } from '@vueuse/core'
3
+ import { nextTick, ref, shallowRef, useId, useTemplateRef, watch, watchPostEffect } from 'vue'
4
+ /**
5
+ * UiTooltipGrid
6
+ *
7
+ * One shared tooltip + pointer-event delegation on a wrapper element.
8
+ *
9
+ * Use this for heatmaps / dense grids where mounting one TooltipRoot per cell
10
+ * is wasteful (state churn, focus-ring conflicts, "safe-polygon" flicker
11
+ * between adjacent triggers). Industry precedent: GitHub contribution graph,
12
+ * Observable Plot, Recharts, Grafana panels — all use a single tooltip
13
+ * element repositioned to whichever cell is under the cursor.
14
+ *
15
+ * Cells inside the slot must carry data attributes:
16
+ * data-tooltip-row="0" (or a string id, e.g. "site|clicks")
17
+ * data-tooltip-col="3"
18
+ * data-tooltip-value="42" (optional, forwarded to resolve())
19
+ *
20
+ * Positioning uses `position: fixed` + manual `getBoundingClientRect` math
21
+ * with viewport clamping. No external deps; flips automatically on the
22
+ * preferred side when there isn't room.
23
+ */
24
+
25
+ interface ResolvedTooltip {
26
+ title?: string
27
+ /**
28
+ * Detail rows. The first row becomes the hero value (oversized monospace
29
+ * number + uppercase label). Remaining rows render as key/value pairs.
30
+ */
31
+ details?: { label: string, value?: string, color?: 'default' | 'muted' | 'success' | 'error' | 'warning' }[]
32
+ /** Semantic level — drives the accent bar color and header dot. */
33
+ level?: 'success' | 'warning' | 'error' | 'info' | 'neutral'
34
+ }
35
+
36
+ interface Props {
37
+ /**
38
+ * Called per cell to compute tooltip content. Return null/undefined to hide.
39
+ * `row` is a number when `data-tooltip-row` is numeric, else the string id.
40
+ */
41
+ resolve: (cell: { row: R, col: number, value: T, el: HTMLElement }) => ResolvedTooltip | null | undefined
42
+ /** Selector identifying a cell inside the wrapper. */
43
+ selector?: string
44
+ side?: 'top' | 'bottom' | 'left' | 'right'
45
+ sideOffset?: number
46
+ /** Override max-width for the tooltip body. */
47
+ maxWidth?: string
48
+ }
49
+
50
+ const {
51
+ resolve,
52
+ selector = '[data-tooltip-row]',
53
+ side = 'top',
54
+ sideOffset = 6,
55
+ maxWidth = '250px',
56
+ } = defineProps<Props>()
57
+
58
+ const wrapperEl = useTemplateRef<HTMLElement>('wrapperEl')
59
+ const tooltipEl = useTemplateRef<HTMLElement>('tooltipEl')
60
+ const mounted = useMounted()
61
+ const tooltipId = useId()
62
+ const data = shallowRef<ResolvedTooltip | null>(null)
63
+ const open = ref(false)
64
+
65
+ // The hovered cell is reactive so a refresh can re-resolve or close it. Hot
66
+ // pointer state below stays off Vue's reactivity system: a pointermove onto
67
+ // the same cell triggers no render.
68
+ const currentTarget = shallowRef<HTMLElement | null>(null)
69
+ let tipWidth = 0
70
+ let tipHeight = 0
71
+ let rafPending = false
72
+
73
+ function addDescription(target: HTMLElement) {
74
+ const descriptions = new Set(target.getAttribute('aria-describedby')?.split(/\s+/).filter(Boolean) ?? [])
75
+ descriptions.add(tooltipId)
76
+ target.setAttribute('aria-describedby', [...descriptions].join(' '))
77
+ }
78
+
79
+ function removeDescription(target: HTMLElement) {
80
+ const descriptions = target.getAttribute('aria-describedby')?.split(/\s+/).filter(id => id && id !== tooltipId) ?? []
81
+ if (descriptions.length)
82
+ target.setAttribute('aria-describedby', descriptions.join(' '))
83
+ else
84
+ target.removeAttribute('aria-describedby')
85
+ }
86
+
87
+ function applyTransform(refRect: DOMRect) {
88
+ if (!tooltipEl.value || !tipWidth || !tipHeight)
89
+ return
90
+ const vw = window.innerWidth
91
+ const vh = window.innerHeight
92
+ const pad = 8
93
+ const offset = sideOffset
94
+
95
+ let top = 0
96
+ let left = 0
97
+ if (side === 'top') {
98
+ top = refRect.top - tipHeight - offset
99
+ left = refRect.left + refRect.width / 2 - tipWidth / 2
100
+ }
101
+ else if (side === 'bottom') {
102
+ top = refRect.bottom + offset
103
+ left = refRect.left + refRect.width / 2 - tipWidth / 2
104
+ }
105
+ else if (side === 'left') {
106
+ top = refRect.top + refRect.height / 2 - tipHeight / 2
107
+ left = refRect.left - tipWidth - offset
108
+ }
109
+ else {
110
+ top = refRect.top + refRect.height / 2 - tipHeight / 2
111
+ left = refRect.right + offset
112
+ }
113
+
114
+ if (side === 'top' && top < pad)
115
+ top = refRect.bottom + offset
116
+ else if (side === 'bottom' && top + tipHeight > vh - pad)
117
+ top = refRect.top - tipHeight - offset
118
+
119
+ if (left < pad)
120
+ left = pad
121
+ if (left + tipWidth > vw - pad)
122
+ left = vw - tipWidth - pad
123
+ if (top < pad)
124
+ top = pad
125
+ if (top + tipHeight > vh - pad)
126
+ top = vh - tipHeight - pad
127
+
128
+ // Direct DOM write — bypasses Vue reactivity and uses GPU-composite-only transform.
129
+ tooltipEl.value.style.transform = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
130
+ }
131
+
132
+ function measureTip() {
133
+ if (!tooltipEl.value)
134
+ return
135
+ const r = tooltipEl.value.getBoundingClientRect()
136
+ tipWidth = r.width
137
+ tipHeight = r.height
138
+ }
139
+
140
+ function scheduleReposition() {
141
+ if (rafPending)
142
+ return
143
+ rafPending = true
144
+ requestAnimationFrame(() => {
145
+ rafPending = false
146
+ if (!currentTarget.value)
147
+ return
148
+ applyTransform(currentTarget.value.getBoundingClientRect())
149
+ })
150
+ }
151
+
152
+ // A row attribute that `Number()` reads is a row index, as it always was.
153
+ // Anything else is a string row id, e.g. "site|clicks".
154
+ function parseRow(raw: string | undefined): number | string | undefined {
155
+ if (raw === undefined)
156
+ return undefined
157
+ const n = Number(raw)
158
+ return Number.isNaN(n) ? raw : n
159
+ }
160
+
161
+ function activate(target: HTMLElement) {
162
+ const row = parseRow(target.dataset.tooltipRow)
163
+ const col = Number(target.dataset.tooltipCol)
164
+ if (row === undefined || Number.isNaN(col))
165
+ return false
166
+ const raw = target.dataset.tooltipValue
167
+ let value: unknown
168
+ if (raw === undefined) {
169
+ value = undefined
170
+ }
171
+ else {
172
+ const n = Number(raw)
173
+ value = (Number.isFinite(n) && raw.trim() !== '') ? n : raw
174
+ }
175
+ const resolved = resolve({ row: row as R, col, value: value as T, el: target })
176
+ if (!resolved)
177
+ return false
178
+
179
+ const wasOpen = open.value
180
+ // Stamp active state on the new cell, clear it on the previous one.
181
+ if (currentTarget.value && currentTarget.value !== target) {
182
+ currentTarget.value.removeAttribute('data-tooltip-active')
183
+ removeDescription(currentTarget.value)
184
+ }
185
+ target.setAttribute('data-tooltip-active', 'true')
186
+ addDescription(target)
187
+
188
+ currentTarget.value = target
189
+ data.value = resolved
190
+ if (!wasOpen) {
191
+ // Suppress the transform transition on the very first show so the
192
+ // tooltip doesn't fly in from (0,0). We measure, place, then re-enable.
193
+ tooltipEl.value?.setAttribute('data-first-show', 'true')
194
+ open.value = true
195
+ }
196
+
197
+ // After content renders, measure once then place. For subsequent swaps
198
+ // (same content shape, mostly) we can skip remeasure and just translate.
199
+ nextTick(() => {
200
+ if (!wasOpen)
201
+ measureTip()
202
+ if (currentTarget.value)
203
+ applyTransform(currentTarget.value.getBoundingClientRect())
204
+ if (!wasOpen) {
205
+ // Re-enable transform transition on the next frame so subsequent
206
+ // cell-to-cell swaps glide smoothly.
207
+ requestAnimationFrame(() => {
208
+ tooltipEl.value?.removeAttribute('data-first-show')
209
+ })
210
+ }
211
+ })
212
+ return true
213
+ }
214
+
215
+ function deactivate() {
216
+ if (!open.value && !currentTarget.value)
217
+ return
218
+ open.value = false
219
+ if (currentTarget.value) {
220
+ currentTarget.value.removeAttribute('data-tooltip-active')
221
+ removeDescription(currentTarget.value)
222
+ }
223
+ currentTarget.value = null
224
+ }
225
+
226
+ function handlePointerMove(e: PointerEvent) {
227
+ const target = (e.target as HTMLElement | null)?.closest(selector) as HTMLElement | null
228
+ if (!target) {
229
+ deactivate()
230
+ return
231
+ }
232
+ if (target === currentTarget.value)
233
+ return
234
+ if (!activate(target))
235
+ deactivate()
236
+ }
237
+
238
+ function handlePointerLeave() {
239
+ deactivate()
240
+ }
241
+
242
+ function handleFocusIn(e: FocusEvent) {
243
+ const target = (e.target as HTMLElement | null)?.closest(selector) as HTMLElement | null
244
+ if (!target)
245
+ return
246
+ activate(target)
247
+ }
248
+
249
+ function handleFocusOut() {
250
+ deactivate()
251
+ }
252
+
253
+ function handleKey(e: KeyboardEvent) {
254
+ if (e.key === 'Escape' && open.value)
255
+ deactivate()
256
+ }
257
+
258
+ // Keep glued on scroll/resize while open. rAF-batched.
259
+ function onScrollOrResize() {
260
+ if (currentTarget.value)
261
+ scheduleReposition()
262
+ }
263
+
264
+ // When content swaps the body height may change — re-measure on next data tick.
265
+ watch(data, () => {
266
+ if (!open.value)
267
+ return
268
+ nextTick(() => {
269
+ measureTip()
270
+ if (currentTarget.value)
271
+ applyTransform(currentTarget.value.getBoundingClientRect())
272
+ })
273
+ })
274
+
275
+ function refreshActive() {
276
+ const target = currentTarget.value
277
+ if (target && (target.isConnected === false || !activate(target)))
278
+ deactivate()
279
+ }
280
+
281
+ // A refresh can remove a cell while its resolver still returns content.
282
+ useMutationObserver(wrapperEl, () => {
283
+ const target = currentTarget.value
284
+ if (target && (target.isConnected === false || !wrapperEl.value?.contains(target)))
285
+ deactivate()
286
+ }, { childList: true, subtree: true })
287
+
288
+ // Refresh resolved content when its reactive inputs or the active cell change.
289
+ watchPostEffect(refreshActive)
290
+ useMutationObserver(currentTarget, refreshActive, {
291
+ attributes: true,
292
+ attributeFilter: ['data-tooltip-row', 'data-tooltip-col', 'data-tooltip-value'],
293
+ })
294
+
295
+ useEventListener(defaultWindow, 'scroll', onScrollOrResize, { passive: true, capture: true })
296
+ useEventListener(defaultWindow, 'resize', onScrollOrResize, { passive: true })
297
+ </script>
298
+
299
+ <template>
300
+ <div
301
+ ref="wrapperEl"
302
+ class="contents"
303
+ @pointermove="handlePointerMove"
304
+ @pointerleave="handlePointerLeave"
305
+ @focusin="handleFocusIn"
306
+ @focusout="handleFocusOut"
307
+ @keydown="handleKey"
308
+ >
309
+ <slot />
310
+ <Teleport v-if="mounted" to="body">
311
+ <div
312
+ v-show="open && data"
313
+ :id="tooltipId"
314
+ ref="tooltipEl"
315
+ role="tooltip"
316
+ class="ui-tooltip-grid-content"
317
+ :data-open="open"
318
+ :aria-hidden="!open"
319
+ >
320
+ <div
321
+ v-if="data"
322
+ class="text-left font-normal leading-normal flex items-stretch"
323
+ :style="{ maxWidth }"
324
+ :data-level="data.level || 'neutral'"
325
+ data-ui="UiTooltipGrid"
326
+ >
327
+ <!-- Vertical accent bar — semantic-coded -->
328
+ <span class="ui-tooltip-grid-accent" aria-hidden="true" />
329
+ <div class="flex-1 min-w-0 pl-2.5">
330
+ <!-- Header: status dot + uppercase title -->
331
+ <div v-if="data.title" class="flex items-center gap-1.5 mb-1.5">
332
+ <span class="ui-tooltip-grid-dot" aria-hidden="true" />
333
+ <span class="text-label">
334
+ {{ data.title }}
335
+ </span>
336
+ </div>
337
+ <template v-if="data.details?.length">
338
+ <!-- Hero value: first detail row, oversized monospace -->
339
+ <div class="flex items-baseline gap-1.5 mb-0.5">
340
+ <span
341
+ class="font-mono tabular-nums font-semibold text-base leading-none"
342
+ :class="{
343
+ 'text-success': data.details[0]!.color === 'success',
344
+ 'text-error': data.details[0]!.color === 'error',
345
+ 'text-warning': data.details[0]!.color === 'warning',
346
+ }"
347
+ >{{ data.details[0]!.value ?? '—' }}</span>
348
+ <span class="text-label">{{ data.details[0]!.label }}</span>
349
+ </div>
350
+ <!-- Secondary rows: stacked key/value -->
351
+ <div v-if="data.details.length > 1" class="mt-2 pt-2 border-t border-default space-y-0.5">
352
+ <div
353
+ v-for="(d, i) in data.details.slice(1)"
354
+ :key="i"
355
+ class="flex items-center justify-between gap-4 text-mini"
356
+ >
357
+ <span class="text-muted">{{ d.label }}</span>
358
+ <span
359
+ v-if="d.value"
360
+ class="font-mono tabular-nums"
361
+ :class="{
362
+ 'text-success': d.color === 'success',
363
+ 'text-error': d.color === 'error',
364
+ 'text-warning': d.color === 'warning',
365
+ 'text-muted': d.color === 'muted',
366
+ 'text-default': !d.color || d.color === 'default',
367
+ }"
368
+ >{{ d.value }}</span>
369
+ </div>
370
+ </div>
371
+ </template>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </Teleport>
376
+ </div>
377
+ </template>
378
+
379
+ <style>
380
+ .ui-tooltip-grid-content {
381
+ position: fixed;
382
+ top: 0;
383
+ left: 0;
384
+ z-index: 50;
385
+ background-color: var(--ui-bg-elevated);
386
+ /* Popover-tier chrome — shares the exact tokens UiTooltip uses. */
387
+ background-image: var(--surface-raised);
388
+ box-shadow: var(--elevation-popover);
389
+ color: var(--ui-text);
390
+ border: 1px solid var(--ui-border);
391
+ border-radius: 0.5rem;
392
+ padding: 0.625rem 0.75rem;
393
+ pointer-events: none;
394
+ letter-spacing: -0.005em;
395
+ -webkit-font-smoothing: antialiased;
396
+ will-change: transform, opacity;
397
+ /* GPU-composite-only transition — no layout, no paint. */
398
+ transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), opacity 120ms ease-out;
399
+ opacity: 0;
400
+ }
401
+
402
+ /* Suppress transform transition on the very first reveal so the tooltip
403
+ doesn't fly in from (0,0). The opacity fade still plays. */
404
+ .ui-tooltip-grid-content[data-first-show="true"] {
405
+ transition: opacity 120ms ease-out;
406
+ }
407
+
408
+ .ui-tooltip-grid-content[data-open="true"] {
409
+ opacity: 1;
410
+ }
411
+
412
+ /* ─── Body ─── */
413
+ .ui-tooltip-grid-accent {
414
+ flex: 0 0 2px;
415
+ align-self: stretch;
416
+ border-radius: 999px;
417
+ background: var(--accent, var(--ui-text));
418
+ opacity: 0.55;
419
+ }
420
+
421
+ [data-ui="UiTooltipGrid"][data-level="success"] { --accent: var(--color-success-500); }
422
+ [data-ui="UiTooltipGrid"][data-level="warning"] { --accent: var(--color-warning-500); }
423
+ [data-ui="UiTooltipGrid"][data-level="error"] { --accent: var(--color-error-500); }
424
+ [data-ui="UiTooltipGrid"][data-level="info"] { --accent: var(--color-info-500); }
425
+ [data-ui="UiTooltipGrid"][data-level="neutral"] { --accent: var(--ui-text); }
426
+
427
+ .ui-tooltip-grid-dot {
428
+ width: 0.375rem;
429
+ height: 0.375rem;
430
+ border-radius: 999px;
431
+ background: var(--accent, var(--ui-text));
432
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, var(--ui-text)) 25%, transparent);
433
+ }
434
+
435
+ @media (prefers-reduced-motion: reduce) {
436
+ .ui-tooltip-grid-content {
437
+ transition: opacity 80ms linear;
438
+ }
439
+ }
440
+
441
+ /* ─── Active cell state — applied to whichever cell is currently hovered ─── */
442
+ [data-tooltip-active="true"] {
443
+ position: relative;
444
+ z-index: 1;
445
+ outline: 1.5px solid var(--ui-border-accented);
446
+ outline-offset: 1px;
447
+ transform: scale(1.08);
448
+ transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1), outline-color 100ms ease-out;
449
+ }
450
+
451
+ @media (prefers-reduced-motion: reduce) {
452
+ [data-tooltip-active="true"] {
453
+ transform: none;
454
+ transition: outline-color 100ms ease-out;
455
+ }
456
+ }
457
+ </style>
@@ -0,0 +1,184 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { UiIcon } from '#components'
4
+ import { clamp } from '../../utils/number'
5
+
6
+ const {
7
+ value = 0,
8
+ size = 'xs',
9
+ iconOnly = false,
10
+ format = 'number',
11
+ showSign = false,
12
+ inverted = false,
13
+ colored = false,
14
+ isNew = false,
15
+ isLost = false,
16
+ autoLost = true,
17
+ clamp: shouldClamp = true,
18
+ precision = 1,
19
+ } = defineProps<{
20
+ value?: number | string
21
+ size?: keyof typeof sizes
22
+ iconOnly?: boolean
23
+ /** 'points' names an absolute rate difference; 'percent' names a relative change. */
24
+ format?: 'number' | 'percent' | 'points'
25
+ /** Show +/- sign before value (auto-enabled for percent and points formats) */
26
+ showSign?: boolean
27
+ /** Invert color logic — positive value shows red, negative shows green (e.g. position where lower is better) */
28
+ inverted?: boolean
29
+ /**
30
+ * Trend color budget (DESIGN.md Composition): colored success/error trends
31
+ * belong to the hero zone only. Default renders neutral (sign + value);
32
+ * hero components (UiMetricsRow, UiMetricStat, hero cards) opt in.
33
+ */
34
+ colored?: boolean
35
+ /** Show a "NEW" badge instead of trend value */
36
+ isNew?: boolean
37
+ /** Show a "LOST" badge instead of trend value */
38
+ isLost?: boolean
39
+ /**
40
+ * Show "LOST" when a percent change reaches -100%. Default: true. Set false
41
+ * for a rate metric, where falling to zero is a value, not a lost row.
42
+ */
43
+ autoLost?: boolean
44
+ /**
45
+ * Clamp a percent change past ±999 to a fun label, with the full value in the
46
+ * title. Only `format="percent"` clamps: +1,500 clicks is a count, not a
47
+ * "10x+" ratio. Set false to always show the full value. Default: true
48
+ */
49
+ clamp?: boolean
50
+ /** Max decimal places for non-integer values. Use 'auto' to scale precision based on magnitude (1dp ≥10, 2dp ≥1, 3dp ≥0.1, 4dp smaller). Default: 1 */
51
+ precision?: number | 'auto'
52
+ }>()
53
+
54
+ const sizes = {
55
+ '2xs': 'text-mini',
56
+ 'xs': 'text-xs',
57
+ 'sm': 'text-sm',
58
+ 'md': 'text-base',
59
+ 'lg': 'text-lg',
60
+ 'xl': 'text-xl',
61
+ } as const
62
+
63
+ const numericValue = computed(() => typeof value === 'string' ? Number.parseFloat(value) : value)
64
+
65
+ // Auto-detect "lost" state: percent format and value <= -100 means went to zero
66
+ const detectedLost = computed(() => autoLost && !isNew && !isLost && !inverted && format === 'percent' && numericValue.value <= -100)
67
+
68
+ // The sign of the value as DISPLAYED. A change that rounds to zero at the
69
+ // display precision (0.04% at 1dp) is no change: a green "+0.0%" claimed one.
70
+ const trend = computed<-1 | 0 | 1>(() => {
71
+ const v = numericValue.value
72
+ if (!Number.isFinite(v) || Number(formatNum(Math.abs(v))) === 0)
73
+ return 0
74
+ return v > 0 ? 1 : -1
75
+ })
76
+
77
+ const clampsValue = computed(() => shouldClamp && format === 'percent')
78
+
79
+ const trendColor = computed(() => {
80
+ const t = trend.value
81
+ if (t === 0 || !colored)
82
+ return 'text-muted'
83
+ const positive = inverted ? t === -1 : t === 1
84
+ return positive ? 'text-success' : 'text-error'
85
+ })
86
+
87
+ const isClamped = computed(() => {
88
+ if (!clampsValue.value)
89
+ return false
90
+ const raw = typeof value === 'string' ? Number.parseFloat(value) : value
91
+ return Math.abs(raw) > 999
92
+ })
93
+
94
+ function resolveDecimals(abs: number): number {
95
+ if (precision !== 'auto')
96
+ return precision
97
+ if (abs >= 10)
98
+ return 1
99
+ if (abs >= 1)
100
+ return 2
101
+ if (abs >= 0.1)
102
+ return 3
103
+ return 4
104
+ }
105
+
106
+ function formatNum(n: number): string {
107
+ if (n % 1 === 0)
108
+ return String(n)
109
+ const fixed = n.toFixed(resolveDecimals(Math.abs(n)))
110
+ return precision === 'auto' ? fixed.replace(/\.?0+$/, '') || '0' : fixed
111
+ }
112
+
113
+ const fullValue = computed(() => {
114
+ const raw = typeof value === 'string' ? Number.parseFloat(value) : value
115
+ const signed = showSign || format !== 'number'
116
+ const sign = signed && raw > 0 ? '+' : signed && raw < 0 ? '-' : ''
117
+ const abs = Math.abs(raw)
118
+ const suffix = format === 'percent' ? '%' : format === 'points' ? ' percentage points' : ''
119
+ return `${sign}${formatNum(abs)}${suffix}`
120
+ })
121
+
122
+ const displayValue = computed(() => {
123
+ const raw = typeof value === 'string' ? Number.parseFloat(value) : value
124
+ const v = clampsValue.value ? clamp(raw, -999, 999) : raw
125
+ const abs = Math.abs(v)
126
+ const signed = showSign || format !== 'number'
127
+ const sign = signed && v > 0 ? '+' : signed && v < 0 ? '-' : ''
128
+ const num = signed ? abs : v
129
+ const suffix = format === 'percent' ? '%' : format === 'points' ? ' points' : ''
130
+ return `${sign}${formatNum(num)}${suffix}`
131
+ })
132
+
133
+ const trendIcon = computed(() =>
134
+ trend.value === 1 ? 'arrow-up-right' : 'arrow-down-right',
135
+ )
136
+
137
+ const classes = computed(() => {
138
+ return [
139
+ trendColor.value,
140
+ sizes[size as keyof typeof sizes],
141
+ ]
142
+ })
143
+ </script>
144
+
145
+ <template>
146
+ <!-- NEW badge mode -->
147
+ <span v-if="isNew" data-ui="UiTrend" class="inline-flex items-center font-medium uppercase px-1.5 py-0.5 rounded bg-success/10 text-success" :class="sizes[size as keyof typeof sizes]">
148
+ New
149
+ </span>
150
+ <!-- LOST badge mode (explicit or auto-detected from -100% change) -->
151
+ <span v-else-if="isLost || detectedLost" data-ui="UiTrend" class="inline-flex items-center font-medium uppercase px-1.5 py-0.5 rounded bg-error/10 text-error" :class="sizes[size as keyof typeof sizes]">
152
+ Lost
153
+ </span>
154
+ <!-- Zero trend — show dash -->
155
+ <span v-else-if="trend === 0" data-ui="UiTrend" class="text-dimmed" :class="sizes[size as keyof typeof sizes]" role="img" aria-label="No change">—</span>
156
+ <!-- Clamped trend — fun tag with tooltip for real value -->
157
+ <span
158
+ v-else-if="isClamped"
159
+ data-ui="UiTrend"
160
+ class="inline-flex items-center whitespace-nowrap leading-none font-semibold tracking-tight px-1.5 py-0.5 ml-1 rounded cursor-default"
161
+ :class="[classes, colored ? (trend === 1 ? 'bg-success/10' : 'bg-error/10') : 'bg-muted']"
162
+ :title="fullValue"
163
+ >
164
+ {{ trend === 1 ? '10x+' : 'oof' }}
165
+ </span>
166
+ <!-- Standard trend mode -->
167
+ <span
168
+ v-else
169
+ data-ui="UiTrend"
170
+ class="inline-flex gap-px items-center whitespace-nowrap leading-none tabular-nums font-semibold tracking-tight ml-1"
171
+ :class="classes"
172
+ :title="format === 'points' ? fullValue : undefined"
173
+ :role="iconOnly || format === 'points' ? 'img' : undefined"
174
+ :aria-label="iconOnly ? `${trend === 1 ? 'Up' : 'Down'} ${fullValue}` : format === 'points' ? fullValue : undefined"
175
+ >
176
+ <UiIcon
177
+ v-if="iconOnly"
178
+ :name="trendIcon"
179
+ class="size-3 shrink-0"
180
+ aria-hidden="true"
181
+ />
182
+ <template v-else>{{ displayValue }}</template>
183
+ </span>
184
+ </template>