@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,445 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
3
|
+
import type { SlotTextOptions } from '../../utils/slot-text'
|
|
4
|
+
import { useElementHover } from '@vueuse/core'
|
|
5
|
+
import { computed, onMounted, ref, useSlots } from 'vue'
|
|
6
|
+
import { ClientOnly, NuxtLink, UiIcon, UiSkeleton, UiSlotText, UiSparkline, UiTooltip } from '#components'
|
|
7
|
+
import { formatGroupedNumber } from '../../composables/formatting'
|
|
8
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
to,
|
|
12
|
+
icon,
|
|
13
|
+
title,
|
|
14
|
+
tooltip,
|
|
15
|
+
tooltipDescription,
|
|
16
|
+
value,
|
|
17
|
+
suffix,
|
|
18
|
+
valueClass,
|
|
19
|
+
format,
|
|
20
|
+
trend,
|
|
21
|
+
trendFormat,
|
|
22
|
+
trendSuffix,
|
|
23
|
+
trendLabel,
|
|
24
|
+
invertTrend,
|
|
25
|
+
trendColored,
|
|
26
|
+
sparkline,
|
|
27
|
+
size = 'md',
|
|
28
|
+
loading,
|
|
29
|
+
status,
|
|
30
|
+
card,
|
|
31
|
+
animatedValue = true,
|
|
32
|
+
readableLabels = false,
|
|
33
|
+
} = defineProps<UiStatProps>()
|
|
34
|
+
|
|
35
|
+
// Stat card primitive. Ported from nuxtseo/core with two swaps:
|
|
36
|
+
// - UiNavIcon → UiIcon (design-system registry)
|
|
37
|
+
// - UiHelpLabel (→ UiPopover + useMarkdown) → UiTooltip (the design-system
|
|
38
|
+
// tooltip primitive — keeps tooltip usage consistent across components)
|
|
39
|
+
// Behavior + slots kept identical so consumers port cleanly.
|
|
40
|
+
|
|
41
|
+
// NuxtLink auto-registers globally; resolve by name so we don't depend on
|
|
42
|
+
// the `#components` virtual (which doesn't narrow nicely in a layer).
|
|
43
|
+
|
|
44
|
+
type Datum = Record<string, number | string>
|
|
45
|
+
|
|
46
|
+
export interface UiStatProps {
|
|
47
|
+
// link
|
|
48
|
+
to?: string
|
|
49
|
+
|
|
50
|
+
// label
|
|
51
|
+
icon?: UiIconName
|
|
52
|
+
title?: string
|
|
53
|
+
tooltip?: string
|
|
54
|
+
tooltipDescription?: string
|
|
55
|
+
|
|
56
|
+
// value
|
|
57
|
+
value?: string | number | null
|
|
58
|
+
suffix?: string
|
|
59
|
+
valueClass?: string
|
|
60
|
+
format?: (n: number) => string
|
|
61
|
+
|
|
62
|
+
// trend
|
|
63
|
+
trend?: number | null
|
|
64
|
+
/**
|
|
65
|
+
* Formats the trend magnitude. The value `format` never applies: the trend is
|
|
66
|
+
* a percent or a delta, so a currency formatter printed "+$12.5%".
|
|
67
|
+
*/
|
|
68
|
+
trendFormat?: (n: number) => string
|
|
69
|
+
trendSuffix?: string
|
|
70
|
+
trendLabel?: string
|
|
71
|
+
invertTrend?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Trend color budget (DESIGN.md Composition): colored success/error trends
|
|
74
|
+
* belong to the hero zone only. Default renders neutral; `UiStats
|
|
75
|
+
* variant="cards"` (the hero pattern) opts its stats in automatically.
|
|
76
|
+
*/
|
|
77
|
+
trendColored?: boolean
|
|
78
|
+
|
|
79
|
+
// sparkline
|
|
80
|
+
sparkline?: Datum[]
|
|
81
|
+
|
|
82
|
+
// sizing
|
|
83
|
+
size?: 'sm' | 'md' | 'lg'
|
|
84
|
+
/** Keep report-visible labels and deltas at the 14px readable tier. */
|
|
85
|
+
readableLabels?: boolean
|
|
86
|
+
|
|
87
|
+
// state
|
|
88
|
+
loading?: boolean
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rolls compact metric values when they change. Defaults on for numeric-ish
|
|
92
|
+
* values; pass false for dense/realtime surfaces or object options to tune.
|
|
93
|
+
*/
|
|
94
|
+
animatedValue?: boolean | SlotTextOptions
|
|
95
|
+
|
|
96
|
+
// threshold alerting
|
|
97
|
+
status?: 'crisis' | 'warning' | 'good'
|
|
98
|
+
|
|
99
|
+
// render the value area inside a card, keeping the header outside
|
|
100
|
+
card?: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const hydrated = ref(false)
|
|
104
|
+
onMounted(() => {
|
|
105
|
+
hydrated.value = true
|
|
106
|
+
})
|
|
107
|
+
const isLoading = computed(() => hydrated.value && loading)
|
|
108
|
+
const slots = useSlots()
|
|
109
|
+
|
|
110
|
+
// Hover state for the background viz: the card owns the gesture, the chart
|
|
111
|
+
// (default sparkline or #chart slot) reacts to it. Subtle, opt-in via the
|
|
112
|
+
// chart component itself.
|
|
113
|
+
const cardEl = ref<HTMLElement | null>(null)
|
|
114
|
+
const cardHovered = useElementHover(cardEl)
|
|
115
|
+
|
|
116
|
+
const rootTag = computed(() => to ? NuxtLink : 'div')
|
|
117
|
+
|
|
118
|
+
const sizeConfig = computed(() => {
|
|
119
|
+
const map = {
|
|
120
|
+
// `chartH` is the band the chart may draw in; `bgH` minus it is headroom
|
|
121
|
+
// under the value so no peak crosses the numerals.
|
|
122
|
+
sm: { value: 'text-xl', sparkW: 96, sparkH: 32, ribbonH: 30, bgH: 46, chartH: 26 },
|
|
123
|
+
md: { value: 'text-2xl', sparkW: 120, sparkH: 32, ribbonH: 38, bgH: 56, chartH: 30 },
|
|
124
|
+
lg: { value: 'text-3xl', sparkW: 160, sparkH: 36, ribbonH: 46, bgH: 64, chartH: 34 },
|
|
125
|
+
}
|
|
126
|
+
return map[size]
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// A raw number with no `format` still gets digit grouping (12,345) in the
|
|
130
|
+
// pinned display locale, so the server and the browser render the same string.
|
|
131
|
+
|
|
132
|
+
const displayValue = computed(() => {
|
|
133
|
+
if (value == null)
|
|
134
|
+
return null
|
|
135
|
+
if (typeof value === 'number')
|
|
136
|
+
return format ? format(value) : formatGroupedNumber(value)
|
|
137
|
+
return value
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const animatedValueOptions = computed<SlotTextOptions | null>(() => {
|
|
141
|
+
if (!displayValue.value || animatedValue === false || slots.default)
|
|
142
|
+
return null
|
|
143
|
+
|
|
144
|
+
const compactMetricValue = /^[+\-$€£¥]?\s*[\d,.]+(?:\s?[kmbt])?(?:\.\d+)?%?$/i.test(displayValue.value)
|
|
145
|
+
if (typeof animatedValue === 'object')
|
|
146
|
+
return animatedValue
|
|
147
|
+
|
|
148
|
+
return compactMetricValue && displayValue.value.length <= 14
|
|
149
|
+
? { direction: 'up', duration: 320, stagger: 24 }
|
|
150
|
+
: null
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const trendDirection = computed(() => {
|
|
154
|
+
if (trend == null || trend === 0)
|
|
155
|
+
return 'neutral'
|
|
156
|
+
return trend > 0 ? 'up' : 'down'
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const isTrendPositive = computed(() => {
|
|
160
|
+
if (trendDirection.value === 'neutral')
|
|
161
|
+
return null
|
|
162
|
+
return invertTrend
|
|
163
|
+
? trendDirection.value === 'down'
|
|
164
|
+
: trendDirection.value === 'up'
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const trendColorClass = computed(() => {
|
|
168
|
+
if (!trendColored || isTrendPositive.value === null)
|
|
169
|
+
return 'text-muted'
|
|
170
|
+
return isTrendPositive.value ? 'text-success' : 'text-error'
|
|
171
|
+
})
|
|
172
|
+
const titleTypeClass = computed(() => readableLabels ? 'text-sm font-strong' : 'text-label')
|
|
173
|
+
const trendTypeClass = computed(() => readableLabels ? 'text-sm' : 'text-xs')
|
|
174
|
+
const statusTypeClass = computed(() => readableLabels ? 'text-sm' : 'text-mini')
|
|
175
|
+
|
|
176
|
+
// Sparkline stays neutral by default — trend valence is already encoded in
|
|
177
|
+
// the colored text delta. Avoid double-encoding (Tufte: one channel per signal).
|
|
178
|
+
// Plain const (not computed): it never varies, so a reactive node per instance
|
|
179
|
+
// across a KPI grid is pure overhead.
|
|
180
|
+
const sparklineColor = semanticColors.neutral.hex
|
|
181
|
+
|
|
182
|
+
const statusChip = computed(() => {
|
|
183
|
+
if (!status)
|
|
184
|
+
return null
|
|
185
|
+
const map: Record<string, { label: string, dot: string, bg: string, text: string }> = {
|
|
186
|
+
crisis: { label: 'Critical', dot: 'bg-error', bg: 'bg-error/10', text: 'text-error' },
|
|
187
|
+
warning: { label: 'Needs work', dot: 'bg-warning', bg: 'bg-warning/10', text: 'text-warning' },
|
|
188
|
+
good: { label: 'Healthy', dot: 'bg-success', bg: 'bg-success/10', text: 'text-success' },
|
|
189
|
+
}
|
|
190
|
+
return map[status] ?? null
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const formattedTrend = computed(() => {
|
|
194
|
+
if (trend == null)
|
|
195
|
+
return ''
|
|
196
|
+
const abs = Math.abs(trend)
|
|
197
|
+
const sign = trend > 0 ? '+' : trend < 0 ? '-' : ''
|
|
198
|
+
const formatted = trendFormat ? trendFormat(abs) : abs % 1 === 0 ? String(abs) : abs.toFixed(1)
|
|
199
|
+
return `${sign}${formatted}${trendSuffix || ''}`
|
|
200
|
+
})
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<UiTooltip
|
|
205
|
+
:text="tooltipDescription || tooltip"
|
|
206
|
+
:disabled="!to || !tooltip"
|
|
207
|
+
trigger-as="child"
|
|
208
|
+
>
|
|
209
|
+
<component
|
|
210
|
+
:is="rootTag"
|
|
211
|
+
:to="to || undefined"
|
|
212
|
+
data-ui="UiStat"
|
|
213
|
+
class="relative flex min-w-0 flex-col"
|
|
214
|
+
:class="[
|
|
215
|
+
card ? 'gap-2' : 'gap-1',
|
|
216
|
+
to ? 'cursor-pointer' : '',
|
|
217
|
+
]"
|
|
218
|
+
>
|
|
219
|
+
<!-- Header: icon + label + trend + status chip.
|
|
220
|
+
`leading-none` collapses .text-label's 1.5 line-box to the cap band so
|
|
221
|
+
items-center aligns the leading icon to the text, not to empty leading. -->
|
|
222
|
+
<!-- `min-h-5` pins the header to the tooltip button's height, so a
|
|
223
|
+
title rendered as a plain span (link mode, or no tooltip) does not
|
|
224
|
+
make its card sit 8px higher than its siblings. -->
|
|
225
|
+
<div
|
|
226
|
+
class="flex min-h-5 items-center gap-1.5 leading-none"
|
|
227
|
+
:class="to && !card ? 'transition-opacity hover:opacity-80' : ''"
|
|
228
|
+
>
|
|
229
|
+
<slot name="icon">
|
|
230
|
+
<UiIcon v-if="icon" :name="icon" class="size-3 text-dimmed shrink-0" />
|
|
231
|
+
</slot>
|
|
232
|
+
<slot name="title">
|
|
233
|
+
<!-- `mx-0.5` cancels the trigger button's `-m-0.5` bleed, so this
|
|
234
|
+
title starts on the same edge as the link-mode title and the value. -->
|
|
235
|
+
<UiTooltip v-if="tooltip && title && !to" :text="tooltipDescription || tooltip" trigger-as="button">
|
|
236
|
+
<span class="mx-0.5 inline-flex items-center gap-1" :class="titleTypeClass">
|
|
237
|
+
{{ title }}
|
|
238
|
+
<UiIcon name="help" class="size-3 opacity-50 shrink-0" aria-hidden="true" />
|
|
239
|
+
</span>
|
|
240
|
+
</UiTooltip>
|
|
241
|
+
<!-- Link mode: the tooltip wraps the whole card (above), so the
|
|
242
|
+
title keeps the same help glyph and the same height as its
|
|
243
|
+
siblings; a card that loses the icon sits higher in the row. -->
|
|
244
|
+
<span v-else-if="title" class="inline-flex items-center gap-1" :class="titleTypeClass">
|
|
245
|
+
{{ title }}
|
|
246
|
+
<UiIcon v-if="tooltip && to" name="help" class="size-3 opacity-50 shrink-0" aria-hidden="true" />
|
|
247
|
+
</span>
|
|
248
|
+
</slot>
|
|
249
|
+
<slot v-if="!card" name="trend">
|
|
250
|
+
<span v-if="trend != null && trend !== 0" class="numerals-display" :class="[trendTypeClass, trendColorClass]">
|
|
251
|
+
{{ formattedTrend }}
|
|
252
|
+
</span>
|
|
253
|
+
<span v-else-if="trend === null || trend === 0" class="numerals-display text-dimmed" :class="trendTypeClass" aria-label="No comparison data">—</span>
|
|
254
|
+
<span v-if="trendLabel" class="text-dimmed" :class="trendTypeClass">{{ trendLabel }}</span>
|
|
255
|
+
</slot>
|
|
256
|
+
<span
|
|
257
|
+
v-if="statusChip"
|
|
258
|
+
class="inline-flex items-center gap-1 px-1.5 py-px rounded-md font-medium leading-tight"
|
|
259
|
+
:class="[statusTypeClass, statusChip.bg, statusChip.text]"
|
|
260
|
+
>
|
|
261
|
+
<span class="size-1 rounded-full" :class="statusChip.dot" />
|
|
262
|
+
{{ statusChip.label }}
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div
|
|
267
|
+
ref="cardEl"
|
|
268
|
+
class="group/card relative flex flex-col gap-1 overflow-hidden"
|
|
269
|
+
:class="[
|
|
270
|
+
card ? 'ui-stat-card rounded-xl border border-default bg-[var(--ui-bg-elevated)]/5 p-4 min-h-[5.5rem]' : '',
|
|
271
|
+
to && card ? 'ui-stat-card--linked' : '',
|
|
272
|
+
to && !card ? 'transition-opacity hover:opacity-80' : '',
|
|
273
|
+
]"
|
|
274
|
+
>
|
|
275
|
+
<!-- Hover affordance: chevron signals the card is clickable -->
|
|
276
|
+
<UiIcon
|
|
277
|
+
v-if="to && card"
|
|
278
|
+
name="chevron-right"
|
|
279
|
+
class="pointer-events-none absolute top-2.5 right-2.5 size-3.5 text-dimmed opacity-0 -translate-x-1 transition-[opacity,transform] duration-150 group-hover/card:opacity-100 group-hover/card:translate-x-0"
|
|
280
|
+
aria-hidden="true"
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<!-- Loading skeleton -->
|
|
284
|
+
<template v-if="isLoading">
|
|
285
|
+
<UiSkeleton :base="80" :index="0" />
|
|
286
|
+
</template>
|
|
287
|
+
|
|
288
|
+
<!-- Content -->
|
|
289
|
+
<template v-else>
|
|
290
|
+
<!-- Card mode: viz fills the lower portion of the card as a background
|
|
291
|
+
layer (sparkline by default, or a #chart slot e.g. UiCwvSparkline).
|
|
292
|
+
A left→right bg→transparent fade keeps the value/trend legible where
|
|
293
|
+
they overlap the chart, so the card keeps its compact height. -->
|
|
294
|
+
<div
|
|
295
|
+
v-if="card && (sparkline?.length || $slots.chart)"
|
|
296
|
+
class="pointer-events-none absolute inset-x-0 bottom-0 z-0"
|
|
297
|
+
:style="{ height: `${sizeConfig.bgH}px` }"
|
|
298
|
+
>
|
|
299
|
+
<!-- The chart draws in the LOWER band of the layer only. A series
|
|
300
|
+
pinned at its maximum (a fresh site whose whole backlog opened
|
|
301
|
+
this week) used to run its line straight through the value's
|
|
302
|
+
numerals; text-shadow cannot rescue a stroke crossing the glyphs.
|
|
303
|
+
Headroom above the band keeps every peak under the value's
|
|
304
|
+
baseline while the area fill still bleeds to the card edge. -->
|
|
305
|
+
<div class="absolute inset-x-0 bottom-0" :style="{ height: `${sizeConfig.chartH}px` }">
|
|
306
|
+
<!-- Client-only: the chart (sparkline or a motion-driven #chart slot)
|
|
307
|
+
is decorative and motion components hydrate awkwardly, so skip SSR. -->
|
|
308
|
+
<ClientOnly>
|
|
309
|
+
<slot name="chart" :hovered="cardHovered">
|
|
310
|
+
<UiSparkline
|
|
311
|
+
:data="sparkline ?? []"
|
|
312
|
+
:color="sparklineColor"
|
|
313
|
+
:inverted="invertTrend"
|
|
314
|
+
area
|
|
315
|
+
interactive
|
|
316
|
+
:hovered="cardHovered"
|
|
317
|
+
:max-points="48"
|
|
318
|
+
width="100%"
|
|
319
|
+
height="100%"
|
|
320
|
+
preserve-aspect-ratio="none"
|
|
321
|
+
class="block size-full opacity-90"
|
|
322
|
+
/>
|
|
323
|
+
</slot>
|
|
324
|
+
</ClientOnly>
|
|
325
|
+
</div>
|
|
326
|
+
<!-- Legibility wash: a light left→right dark gradient just takes the
|
|
327
|
+
edge off the chart under the value. The heavy lifting is the
|
|
328
|
+
value's text-shadow, so the chart itself stays bright. -->
|
|
329
|
+
<div class="absolute inset-0 bg-gradient-to-r from-[var(--ui-bg)]/70 via-[var(--ui-bg)]/10 via-[50%] to-transparent" />
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<!-- Value + trend row. In card mode it floats above the background viz
|
|
333
|
+
(text-shadow keeps it legible); outside card mode the sparkline
|
|
334
|
+
stays inline on the right. -->
|
|
335
|
+
<div class="relative z-10 flex items-end justify-between gap-3" :class="card && (sparkline?.length || $slots.chart) ? 'ui-stat-legible' : ''">
|
|
336
|
+
<slot>
|
|
337
|
+
<div v-if="value != null" class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
338
|
+
<span class="numerals-display tracking-tight" :class="[sizeConfig.value, valueClass || 'text-default']">
|
|
339
|
+
<UiSlotText
|
|
340
|
+
v-if="animatedValueOptions && displayValue"
|
|
341
|
+
:text="displayValue"
|
|
342
|
+
:options="animatedValueOptions"
|
|
343
|
+
/>
|
|
344
|
+
<template v-else>
|
|
345
|
+
{{ displayValue }}
|
|
346
|
+
</template>
|
|
347
|
+
</span>
|
|
348
|
+
<span v-if="suffix" class="text-sm text-muted">{{ suffix }}</span>
|
|
349
|
+
<slot v-if="card" name="trend">
|
|
350
|
+
<span
|
|
351
|
+
v-if="trend != null && trend !== 0"
|
|
352
|
+
class="numerals-display"
|
|
353
|
+
:class="[trendTypeClass, trendColorClass]"
|
|
354
|
+
>
|
|
355
|
+
{{ formattedTrend }}
|
|
356
|
+
</span>
|
|
357
|
+
<span
|
|
358
|
+
v-else-if="trend === null || trend === 0"
|
|
359
|
+
class="numerals-display text-dimmed"
|
|
360
|
+
:class="trendTypeClass"
|
|
361
|
+
aria-label="No comparison data"
|
|
362
|
+
>—</span>
|
|
363
|
+
<span v-if="trendLabel" class="text-dimmed" :class="trendTypeClass">{{ trendLabel }}</span>
|
|
364
|
+
</slot>
|
|
365
|
+
</div>
|
|
366
|
+
<span v-else class="font-semibold text-muted" :class="sizeConfig.value" role="img" aria-label="No data">—</span>
|
|
367
|
+
</slot>
|
|
368
|
+
<!-- Compact-strip viz: the same chart the card mode floats as a
|
|
369
|
+
background renders here as a small fixed-size cell on the right
|
|
370
|
+
(a #chart slot, e.g. the CWV composition), so status tiles keep
|
|
371
|
+
their signal in compact mode. Falls back to the inline sparkline
|
|
372
|
+
for metric tiles that supply a `sparkline` array. -->
|
|
373
|
+
<div
|
|
374
|
+
v-if="!card && ($slots.chart || sparkline?.length)"
|
|
375
|
+
class="ui-stat-inline-chart -mb-0.5 min-w-0 flex-1 overflow-hidden"
|
|
376
|
+
:style="{ maxWidth: `${sizeConfig.sparkW}px` }"
|
|
377
|
+
>
|
|
378
|
+
<ClientOnly>
|
|
379
|
+
<div
|
|
380
|
+
v-if="$slots.chart"
|
|
381
|
+
class="w-full"
|
|
382
|
+
:style="{ height: `${sizeConfig.sparkH}px` }"
|
|
383
|
+
>
|
|
384
|
+
<slot name="chart" :hovered="cardHovered" />
|
|
385
|
+
</div>
|
|
386
|
+
<UiSparkline
|
|
387
|
+
v-else
|
|
388
|
+
:data="sparkline ?? []"
|
|
389
|
+
:size="size"
|
|
390
|
+
:color="sparklineColor"
|
|
391
|
+
:inverted="invertTrend"
|
|
392
|
+
:area="false"
|
|
393
|
+
width="100%"
|
|
394
|
+
class="block max-w-full"
|
|
395
|
+
/>
|
|
396
|
+
</ClientOnly>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<!-- Info slot for extra content -->
|
|
401
|
+
<slot name="info" />
|
|
402
|
+
</template>
|
|
403
|
+
</div>
|
|
404
|
+
</component>
|
|
405
|
+
</UiTooltip>
|
|
406
|
+
</template>
|
|
407
|
+
|
|
408
|
+
<style scoped>
|
|
409
|
+
/* Value/trend floats over the background chart. A soft halo in the card-bg
|
|
410
|
+
colour (so it adapts to light/dark) buys contrast without a heavy mask,
|
|
411
|
+
letting the chart stay bright. */
|
|
412
|
+
.ui-stat-legible {
|
|
413
|
+
text-shadow:
|
|
414
|
+
0 0 2px var(--ui-bg),
|
|
415
|
+
0 1px 10px var(--ui-bg);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.ui-stat-card {
|
|
419
|
+
/* Recessed resting depth — carved edge, no lift. */
|
|
420
|
+
box-shadow: var(--elevation-inset);
|
|
421
|
+
letter-spacing: var(--tracking-data);
|
|
422
|
+
-webkit-font-smoothing: antialiased;
|
|
423
|
+
transition:
|
|
424
|
+
box-shadow 200ms ease-out,
|
|
425
|
+
border-color 200ms ease-out,
|
|
426
|
+
transform 220ms var(--ease-spring);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* Linked cards lift to the raised tier on hover + the accent-tinted hover
|
|
430
|
+
halo, with a small spring rise — signals "clickable". */
|
|
431
|
+
.ui-stat-card--linked:hover {
|
|
432
|
+
border-color: var(--ui-border-accented);
|
|
433
|
+
box-shadow: var(--elevation-raised), var(--elevation-hover);
|
|
434
|
+
transform: translateY(-2px);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
@media (prefers-reduced-motion: reduce) {
|
|
438
|
+
.ui-stat-card {
|
|
439
|
+
transition: border-color 150ms ease-out;
|
|
440
|
+
}
|
|
441
|
+
.ui-stat-card--linked:hover {
|
|
442
|
+
transform: none;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
</style>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { UiStatProps } from './UiStat.vue'
|
|
3
|
+
// Port of nuxtseo/core UiStats. Swaps tailwind-merge for a plain
|
|
4
|
+
// filter+join — the component doesn't generate conflicting Tailwind classes,
|
|
5
|
+
// and consumer `class` attrs cascade normally via the class attribute merge.
|
|
6
|
+
|
|
7
|
+
import { computed, useSlots } from 'vue'
|
|
8
|
+
import { UiStat } from '#components'
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
data,
|
|
12
|
+
variant = 'card',
|
|
13
|
+
layout = 'flex',
|
|
14
|
+
wrap = false,
|
|
15
|
+
} = defineProps<{
|
|
16
|
+
data: UiStatProps[]
|
|
17
|
+
variant?: 'card' | 'cards' | 'inline'
|
|
18
|
+
layout?: 'flex' | 'grid'
|
|
19
|
+
wrap?: boolean
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const gridCols = [
|
|
23
|
+
'lg:grid-cols-1 md:grid-cols-1',
|
|
24
|
+
'lg:grid-cols-2 md:grid-cols-2',
|
|
25
|
+
'lg:grid-cols-3 md:grid-cols-2',
|
|
26
|
+
'lg:grid-cols-4 md:grid-cols-2',
|
|
27
|
+
'lg:grid-cols-5 md:grid-cols-3',
|
|
28
|
+
'lg:grid-cols-6 md:grid-cols-3',
|
|
29
|
+
'lg:grid-cols-7 md:grid-cols-4',
|
|
30
|
+
'lg:grid-cols-8 md:grid-cols-4',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function join(...parts: (string | false | null | undefined)[]): string {
|
|
34
|
+
return parts.filter(Boolean).join(' ')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const containerClass = computed(() => {
|
|
38
|
+
const length = data.length
|
|
39
|
+
const gridIndex = length > 0 && length < gridCols.length ? length - 1 : 0
|
|
40
|
+
const isGrid = layout === 'grid' || variant === 'cards'
|
|
41
|
+
return join(
|
|
42
|
+
'justify-around',
|
|
43
|
+
isGrid ? `grid ${length === 1 ? 'grid-cols-1' : 'grid-cols-2'} ${gridCols[gridIndex]}` : 'flex flex-col md:flex-row',
|
|
44
|
+
wrap && 'flex-wrap',
|
|
45
|
+
variant === 'card' && 'divide-y md:divide-y-0 md:divide-x divide-[var(--ui-border-muted)] rounded-lg border border-default py-4',
|
|
46
|
+
variant === 'cards' && 'gap-4',
|
|
47
|
+
// Consumer `class` is merged onto the root automatically by Vue's
|
|
48
|
+
// attribute fallthrough (reactive), so it isn't joined in here.
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const statClass = computed(() =>
|
|
53
|
+
join(
|
|
54
|
+
variant === 'card' && 'shrink-0 grow',
|
|
55
|
+
variant === 'card' && 'px-5 py-2 md:py-0',
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const slots = useSlots()
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<div data-ui="UiStats" :class="containerClass">
|
|
64
|
+
<template v-for="(item, index) in data" :key="item.title">
|
|
65
|
+
<slot v-if="slots.default" v-bind="{ item, index }" />
|
|
66
|
+
<template v-else>
|
|
67
|
+
<UiStat
|
|
68
|
+
v-bind="item"
|
|
69
|
+
:card="variant === 'cards'"
|
|
70
|
+
:trend-colored="item.trendColored ?? (variant === 'cards')"
|
|
71
|
+
:class="statClass"
|
|
72
|
+
/>
|
|
73
|
+
</template>
|
|
74
|
+
</template>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|