@nuxtseo/design-system 0.0.0-stage → 0.1.1
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.
- package/README.md +181 -2
- package/app/app.config.ts +592 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +233 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +127 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +68 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +93 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +15 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/app-favicon.ts +64 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +69 -0
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1896 -0
- package/nuxt.config.ts +143 -0
- package/package.json +93 -3
- package/public/fonts/README.md +33 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- 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>
|