@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.
- package/README.md +101 -2
- package/app/app.config.ts +596 -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 +232 -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 +125 -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 +55 -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 +68 -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 +29 -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/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 +47 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1892 -0
- package/nuxt.config.ts +132 -0
- package/package.json +86 -3
- package/public/brand-icon-dark.png +0 -0
- package/public/brand-icon-light.png +0 -0
- package/public/favicon-dark.png +0 -0
- package/public/favicon-light.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/fonts/README.md +31 -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/public/icon-dark.png +0 -0
- package/public/icon.png +0 -0
- package/public/touch-icon-dark.png +0 -0
- package/public/touch-icon.png +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,393 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { ChartAnnotation, ChartAnnotationOptions } from '../../utils/chartAnnotations'
|
|
3
|
+
import type { TopEntityStackBucket, TopEntityStackRow, TopEntityStackSeries } from '../../utils/topEntityStack'
|
|
4
|
+
import { StackedBar, TextAlign } from '@unovis/ts'
|
|
5
|
+
import { VisAxis, VisCrosshair, VisLine, VisStackedBar, VisTooltip, VisXYContainer } from '@unovis/vue'
|
|
6
|
+
import { useElementSize } from '@vueuse/core'
|
|
7
|
+
import { computed, useTemplateRef } from 'vue'
|
|
8
|
+
import { ClientOnly, UiChartAnnotations, UiChartDataReveal, UiEmptyState, UiSkeleton } from '#components'
|
|
9
|
+
import { gscTopEntityColors } from '../../composables/dataVizColors'
|
|
10
|
+
import { DISPLAY_LOCALE, formatGroupedNumber } from '../../composables/formatting'
|
|
11
|
+
import { useChartTickPlan } from '../../composables/useChartTickPlan'
|
|
12
|
+
import { anchorAnnotationsToBuckets, topEntityStackRows, topEntityStackTimeline } from '../../utils/topEntityStack'
|
|
13
|
+
|
|
14
|
+
// Shared "top N + Other" stacked-bar trend chart (manual-review-2026-08:
|
|
15
|
+
// search-console Queries / Pages / Countries). Dumb + presentational — the
|
|
16
|
+
// top-N-and-Other bucketing is `bucketTopEntities` (pure, unit-tested); this
|
|
17
|
+
// component only draws whatever `buckets`/`series` it's handed. One component,
|
|
18
|
+
// three call sites (queries, pages-on-queries-table-view, countries).
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
buckets,
|
|
22
|
+
series,
|
|
23
|
+
loading = false,
|
|
24
|
+
height = 220,
|
|
25
|
+
scale = 'share',
|
|
26
|
+
legendSlots = 0,
|
|
27
|
+
format = formatGroupedNumber,
|
|
28
|
+
xFormat,
|
|
29
|
+
emptyTitle = 'Not enough data to chart',
|
|
30
|
+
emptyDescription = 'This trend fills in once there is more than one day of data.',
|
|
31
|
+
annotations,
|
|
32
|
+
annotationOptions,
|
|
33
|
+
} = defineProps<{
|
|
34
|
+
buckets: TopEntityStackBucket[]
|
|
35
|
+
series: TopEntityStackSeries[]
|
|
36
|
+
loading?: boolean
|
|
37
|
+
height?: number | string
|
|
38
|
+
/** `share` fills each bar to 100%. `absolute` draws real levels, so growth stays visible. */
|
|
39
|
+
scale?: 'share' | 'absolute'
|
|
40
|
+
/** Reserve at least this many single-line legend cells, including while loading. */
|
|
41
|
+
legendSlots?: number
|
|
42
|
+
/** Format a metric value for the legend + tooltip. */
|
|
43
|
+
format?: (value: number) => string
|
|
44
|
+
/** Format bucket labels for details. The axis uses the shared calendar tick planner. */
|
|
45
|
+
xFormat?: (bucket: TopEntityStackBucket) => string
|
|
46
|
+
emptyTitle?: string
|
|
47
|
+
emptyDescription?: string
|
|
48
|
+
annotations?: ChartAnnotation[]
|
|
49
|
+
annotationOptions?: ChartAnnotationOptions
|
|
50
|
+
}>()
|
|
51
|
+
|
|
52
|
+
const emit = defineEmits<{
|
|
53
|
+
annotationEdit: [annotation: ChartAnnotation]
|
|
54
|
+
}>()
|
|
55
|
+
|
|
56
|
+
const chartHeight = computed(() => Number(height) || 220)
|
|
57
|
+
// `absolute` draws a value axis, because bar height alone hides the magnitude.
|
|
58
|
+
const margin = computed(() => ({ left: scale === 'absolute' ? 44 : 0, right: 0, top: 4, bottom: 28 }))
|
|
59
|
+
|
|
60
|
+
// gscTopEntityColors is a fixed 11-slot palette (10 identity hues + 1 neutral),
|
|
61
|
+
// wider than any current caller's topN (5) and already Tailwind-safelisted
|
|
62
|
+
// (dataVizColors.ts), so no new runtime class needs adding here. Other always takes the neutral slot regardless of its position in
|
|
63
|
+
// `series`, ranked entities keep their identity hue by rank order. `.hex` feeds
|
|
64
|
+
// the SVG marks + the HTML-string crosshair tooltip (neither can resolve a
|
|
65
|
+
// Tailwind/CSS-var class); `.dot` (the SOLID identity colour, vs. `.bg`'s
|
|
66
|
+
// translucent fill) feeds the real DOM legend swatches.
|
|
67
|
+
function colorFor(index: number) {
|
|
68
|
+
return series[index]?.isOther ? gscTopEntityColors.at(-1)! : (gscTopEntityColors[index] ?? gscTopEntityColors.at(-1)!)
|
|
69
|
+
}
|
|
70
|
+
const resolvedColors = computed(() => series.map((s, i) => s.isOther
|
|
71
|
+
? 'color-mix(in oklab, var(--ui-primary) 22%, transparent)'
|
|
72
|
+
: colorFor(i).hex))
|
|
73
|
+
const resolvedDotClasses = computed(() => series.map((_, i) => colorFor(i).dot))
|
|
74
|
+
const legend = computed(() => Array.from({ length: Math.max(legendSlots, series.length) }, (_, index) => series[index]))
|
|
75
|
+
|
|
76
|
+
const rows = computed(() => topEntityStackRows({ buckets, series }))
|
|
77
|
+
const y = computed(() => series.map((_, si) => (d: TopEntityStackRow) => (scale === 'absolute' ? d.values[si] : d.shares[si]) ?? 0))
|
|
78
|
+
const yDomain = computed<[number, number]>(() => [0, scale === 'absolute' ? Math.max(1, ...rows.value.map(row => row.total)) : 100])
|
|
79
|
+
const percentFormat = new Intl.NumberFormat(DISPLAY_LOCALE, { style: 'percent', maximumFractionDigits: 1 })
|
|
80
|
+
function shareLabel(share: number): string {
|
|
81
|
+
return percentFormat.format(share / 100)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const x = (_d: TopEntityStackRow, i: number) => i
|
|
85
|
+
const totalY = (d: TopEntityStackRow) => scale === 'absolute' ? d.total : d.total > 0 ? 100 : 0
|
|
86
|
+
|
|
87
|
+
// The plot box is `overflow-hidden` at a fixed height, and the crosshair
|
|
88
|
+
// tooltip lists one row per series plus a header. Rendered inside the plot it
|
|
89
|
+
// was clipped to the bars, so the caller saw a fragment of the figures. Mount it
|
|
90
|
+
// on `document.body` instead (Unovis switches to fixed positioning and
|
|
91
|
+
// constrains to the viewport). Client-only: the chart renders inside ClientOnly.
|
|
92
|
+
const tooltipContainer = import.meta.client ? document.body : undefined
|
|
93
|
+
// On `document.body` the tooltip no longer inherits the chart's dark vars, and
|
|
94
|
+
// Unovis gives it only generated class names. The dark `.unovis-tooltip` rule
|
|
95
|
+
// in global.css matches this class, so dark mode keeps a dark tooltip.
|
|
96
|
+
const TOOLTIP_CLASS = 'unovis-tooltip'
|
|
97
|
+
const chartKey = computed(() => `${buckets.length}-${series.length}`)
|
|
98
|
+
|
|
99
|
+
// Both layers share bucket centres. Adjacent normalized buckets form one
|
|
100
|
+
// continuous field, with no gaps between dates or segments.
|
|
101
|
+
const plotEl = useTemplateRef<HTMLElement>('plotEl')
|
|
102
|
+
const { width: plotWidth } = useElementSize(plotEl)
|
|
103
|
+
const barWidth = computed(() => {
|
|
104
|
+
const count = buckets.length
|
|
105
|
+
if (!count || plotWidth.value <= 0)
|
|
106
|
+
return undefined
|
|
107
|
+
return (plotWidth.value - margin.value.left) / count
|
|
108
|
+
})
|
|
109
|
+
const interactiveXDomain = computed<[number, number]>(() => [-0.5, Math.max(buckets.length - 1, 0) + 0.5])
|
|
110
|
+
|
|
111
|
+
const defaultDateFmt = new Intl.DateTimeFormat(DISPLAY_LOCALE, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
112
|
+
function bucketLabel(bucket: TopEntityStackBucket): string {
|
|
113
|
+
if (xFormat)
|
|
114
|
+
return xFormat(bucket)
|
|
115
|
+
const d = new Date(`${bucket.start}T00:00:00Z`)
|
|
116
|
+
return Number.isNaN(d.getTime()) ? bucket.start : defaultDateFmt.format(d)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const timeline = computed(() => topEntityStackTimeline(buckets))
|
|
120
|
+
const { tickPlan, tickFormat: calendarTickFormat } = useChartTickPlan({ dates: () => timeline.value.dates })
|
|
121
|
+
const tickValues = computed(() => {
|
|
122
|
+
const values = tickPlan.value.indices.map(i => timeline.value.positions[i] ?? 0)
|
|
123
|
+
const pixelsPerBucket = (plotWidth.value - margin.value.left) / Math.max(buckets.length, 1)
|
|
124
|
+
const last = values.at(-1) ?? 0
|
|
125
|
+
return values.reduce<number[]>((kept, value, i) => {
|
|
126
|
+
if (i === 0 || i === values.length - 1
|
|
127
|
+
|| ((value - kept.at(-1)!) * pixelsPerBucket >= 56 && (last - value) * pixelsPerBucket >= 56)) {
|
|
128
|
+
kept.push(value)
|
|
129
|
+
}
|
|
130
|
+
return kept
|
|
131
|
+
}, [])
|
|
132
|
+
})
|
|
133
|
+
function tickFormat(position: number): string {
|
|
134
|
+
const index = timeline.value.positions.indexOf(position)
|
|
135
|
+
return index < 0 ? '' : calendarTickFormat(index)
|
|
136
|
+
}
|
|
137
|
+
function tickTextAlign(position: number): TextAlign {
|
|
138
|
+
if (position === -0.5)
|
|
139
|
+
return TextAlign.Left
|
|
140
|
+
if (position === buckets.length - 0.5)
|
|
141
|
+
return TextAlign.Right
|
|
142
|
+
return TextAlign.Center
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Unovis inserts this template as HTML. Keep every dynamic text value literal,
|
|
146
|
+
// including caller-supplied formatters and stored entity names.
|
|
147
|
+
function escapeTooltipText(value: string): string {
|
|
148
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function crosshairTemplate(d: TopEntityStackRow): string {
|
|
152
|
+
const bucket = buckets[d.i]
|
|
153
|
+
if (!bucket)
|
|
154
|
+
return ''
|
|
155
|
+
const total = d.total
|
|
156
|
+
const rowsHtml = series
|
|
157
|
+
.map((s, i) => ({ label: s.label, value: d.values[i] ?? 0, share: d.shares[i] ?? 0, color: resolvedColors.value[i] }))
|
|
158
|
+
.filter(r => r.value > 0)
|
|
159
|
+
.map(r => `<div style="display:flex;align-items:center;gap:6px">
|
|
160
|
+
<span style="width:8px;height:8px;border-radius:9999px;background:${r.color};flex-shrink:0"></span>
|
|
161
|
+
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px;color:var(--ui-text-muted)">${escapeTooltipText(r.label)}</span>
|
|
162
|
+
<span style="font-variant-numeric:tabular-nums;color:var(--ui-text-highlighted);margin-left:auto">${escapeTooltipText(format(r.value))} <span style="color:var(--ui-text-muted)">(${escapeTooltipText(shareLabel(r.share))})</span></span>
|
|
163
|
+
</div>`)
|
|
164
|
+
.join('')
|
|
165
|
+
// Rows pin to the header's 12px. On the body they inherit the page's base
|
|
166
|
+
// size, and a large one made the tooltip swamp the chart under it.
|
|
167
|
+
return `<div style="display:flex;flex-direction:column;gap:4px;min-width:180px;padding:2px;font-size:12px">
|
|
168
|
+
<div style="display:flex;justify-content:space-between;align-items:center;color:var(--ui-text-dimmed);margin-bottom:2px;font-size:12px">
|
|
169
|
+
<span>${escapeTooltipText(bucketLabel(bucket))}</span>
|
|
170
|
+
<span style="font-weight:600;color:var(--ui-text-highlighted)">${escapeTooltipText(format(total))}</span>
|
|
171
|
+
</div>
|
|
172
|
+
${rowsHtml}
|
|
173
|
+
</div>`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Day-anchored annotation markers re-anchor onto the bucket index axis: each
|
|
177
|
+
// lands on the bucket whose day range holds it. They share the bars' domain,
|
|
178
|
+
// so a marker sits on its bar's centre, and the overlay box below starts after
|
|
179
|
+
// the value-axis margin, as the plot does.
|
|
180
|
+
const anchoredAnnotations = computed(() => anchorAnnotationsToBuckets(annotations, buckets))
|
|
181
|
+
|
|
182
|
+
const ariaLabel = computed(() => `${scale === 'absolute' ? 'Stacked' : '100% stacked'} bar chart of ${series.map(s => s.label).join(', ')} over time`)
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<template>
|
|
186
|
+
<div data-ui="UiTopEntityStackChart" class="ui-top-entity-stack-chart relative min-w-0">
|
|
187
|
+
<!-- Plot area: fixed height, layers grid-stacked. Never grows past `height`
|
|
188
|
+
regardless of series/bucket count — the axis + legend live OUTSIDE this
|
|
189
|
+
box in normal flow so nothing can push the plot taller than its box or
|
|
190
|
+
bleed past the container edge. -->
|
|
191
|
+
<div
|
|
192
|
+
ref="plotEl"
|
|
193
|
+
class="ui-top-entity-stack-chart__plot min-w-0 overflow-hidden"
|
|
194
|
+
role="img"
|
|
195
|
+
:aria-label="ariaLabel"
|
|
196
|
+
:style="{ height: `${chartHeight}px` }"
|
|
197
|
+
>
|
|
198
|
+
<div v-if="loading" class="loading-skeleton">
|
|
199
|
+
<div class="flex-1 flex items-end gap-1">
|
|
200
|
+
<UiSkeleton v-for="i in 16" :key="i" type="bar" :index="i" />
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Empty payload is not zero traffic: no buckets/series means we never
|
|
205
|
+
got data, so this must read as "not enough data", never a chart of
|
|
206
|
+
zeros. -->
|
|
207
|
+
<UiEmptyState
|
|
208
|
+
v-else-if="!buckets.length || !series.length"
|
|
209
|
+
compact
|
|
210
|
+
icon="chart-bar"
|
|
211
|
+
:title="emptyTitle"
|
|
212
|
+
:description="emptyDescription"
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
<ClientOnly v-else>
|
|
216
|
+
<VisXYContainer
|
|
217
|
+
:key="`bars-${chartKey}`"
|
|
218
|
+
:height="chartHeight"
|
|
219
|
+
:data="rows"
|
|
220
|
+
:margin="margin"
|
|
221
|
+
:auto-margin="false"
|
|
222
|
+
:y-domain="yDomain"
|
|
223
|
+
:x-domain="interactiveXDomain"
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
class="chart-layer chart-layer--bars"
|
|
226
|
+
>
|
|
227
|
+
<VisStackedBar :attributes="{ [StackedBar.selectors.bar]: { 'shape-rendering': 'crispEdges' } }" :x="x" :y="y" :color="resolvedColors" :bar-padding="0" :bar-width="barWidth" :rounded-corners="0" />
|
|
228
|
+
</VisXYContainer>
|
|
229
|
+
|
|
230
|
+
<VisXYContainer
|
|
231
|
+
:key="`interactive-${chartKey}`"
|
|
232
|
+
:height="chartHeight"
|
|
233
|
+
:data="rows"
|
|
234
|
+
:margin="margin"
|
|
235
|
+
:auto-margin="false"
|
|
236
|
+
:y-domain="yDomain"
|
|
237
|
+
:x-domain="interactiveXDomain"
|
|
238
|
+
class="chart-layer chart-layer--interactive"
|
|
239
|
+
>
|
|
240
|
+
<VisLine :x="x" :y="totalY" color="transparent" :line-width="0" />
|
|
241
|
+
<VisAxis
|
|
242
|
+
type="x"
|
|
243
|
+
:tick-line="false"
|
|
244
|
+
:grid-line="false"
|
|
245
|
+
:domain-line="false"
|
|
246
|
+
:tick-values="tickValues"
|
|
247
|
+
:tick-format="tickFormat"
|
|
248
|
+
:tick-text-align="tickTextAlign"
|
|
249
|
+
tick-text-font-size="11px"
|
|
250
|
+
tick-text-color="var(--ui-text-dimmed)"
|
|
251
|
+
/>
|
|
252
|
+
<!-- No grid lines: this layer sits above the bars, so they would cut
|
|
253
|
+
through every bar. The labels alone carry the scale. -->
|
|
254
|
+
<VisAxis
|
|
255
|
+
v-if="scale === 'absolute'"
|
|
256
|
+
type="y"
|
|
257
|
+
:tick-line="false"
|
|
258
|
+
:grid-line="false"
|
|
259
|
+
:domain-line="false"
|
|
260
|
+
:num-ticks="3"
|
|
261
|
+
:tick-format="format"
|
|
262
|
+
tick-text-font-size="11px"
|
|
263
|
+
tick-text-color="var(--ui-text-dimmed)"
|
|
264
|
+
/>
|
|
265
|
+
<VisTooltip :container="tooltipContainer" :class-name="TOOLTIP_CLASS" :follow-cursor="false" horizontal-placement="right" />
|
|
266
|
+
<VisCrosshair :x="x" :y="totalY" color="none" :template="crosshairTemplate" />
|
|
267
|
+
</VisXYContainer>
|
|
268
|
+
|
|
269
|
+
<!-- Day-anchored annotation markers (shared overlay). The box spans the
|
|
270
|
+
plot's x range only, so it starts after the value-axis margin. -->
|
|
271
|
+
<div class="pointer-events-none absolute inset-y-0 right-0" :style="{ left: `${margin.left}px` }">
|
|
272
|
+
<UiChartAnnotations
|
|
273
|
+
:annotations="anchoredAnnotations"
|
|
274
|
+
:x-domain="interactiveXDomain"
|
|
275
|
+
:options="annotationOptions"
|
|
276
|
+
@edit="emit('annotationEdit', $event)"
|
|
277
|
+
@interaction-change="() => {}"
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<template #fallback>
|
|
282
|
+
<div class="loading-skeleton">
|
|
283
|
+
<div class="flex-1 flex items-end gap-1">
|
|
284
|
+
<UiSkeleton v-for="i in 16" :key="i" type="bar" :index="i" />
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</template>
|
|
288
|
+
</ClientOnly>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<!-- Legend — normal flow BELOW the plot box, never overlaid on it, so it
|
|
292
|
+
can never crowd or overflow the bars. Always present for 2+ series so
|
|
293
|
+
identity is never color-alone. Opt-in slots also reserve loading and
|
|
294
|
+
unused cells so the surrounding panel keeps its height. -->
|
|
295
|
+
<ul
|
|
296
|
+
v-if="legendSlots > 0 || (series.length > 1 && !loading && buckets.length)"
|
|
297
|
+
class="mt-2 gap-x-3 gap-y-1"
|
|
298
|
+
:class="legendSlots ? 'ui-top-entity-stack-chart__legend--slots text-mini' : 'flex flex-wrap'"
|
|
299
|
+
:aria-label="loading ? undefined : `Legend: ${series.map(s => s.label).join(', ')}`"
|
|
300
|
+
:aria-busy="loading || undefined"
|
|
301
|
+
>
|
|
302
|
+
<li
|
|
303
|
+
v-for="(s, i) in legend"
|
|
304
|
+
:key="s?.key ?? i"
|
|
305
|
+
class="flex items-center gap-1.5 min-w-0 max-w-full"
|
|
306
|
+
:class="{ invisible: !loading && !s }"
|
|
307
|
+
:aria-hidden="loading || !s || undefined"
|
|
308
|
+
>
|
|
309
|
+
<UiSkeleton v-if="loading" type="block" class="h-3 w-24 max-w-full" />
|
|
310
|
+
<template v-else-if="s">
|
|
311
|
+
<span class="size-2 rounded-full shrink-0" :class="s.isOther ? 'bg-primary/25' : resolvedDotClasses[i]" aria-hidden="true" />
|
|
312
|
+
<span class="text-mini text-muted truncate" :title="legendSlots ? s.label : undefined">{{ s.label }}</span>
|
|
313
|
+
</template>
|
|
314
|
+
</li>
|
|
315
|
+
</ul>
|
|
316
|
+
|
|
317
|
+
<!-- Screen-reader equivalent of the position-encoded stack, and the
|
|
318
|
+
keyboard path to the hover readout (mirrors UiLineChart). -->
|
|
319
|
+
<UiChartDataReveal v-if="buckets.length">
|
|
320
|
+
<table>
|
|
321
|
+
<caption>{{ ariaLabel }}</caption>
|
|
322
|
+
<thead>
|
|
323
|
+
<tr>
|
|
324
|
+
<th scope="col">
|
|
325
|
+
Period
|
|
326
|
+
</th>
|
|
327
|
+
<th v-for="s in series" :key="s.key" scope="col">
|
|
328
|
+
{{ s.label }}
|
|
329
|
+
</th>
|
|
330
|
+
</tr>
|
|
331
|
+
</thead>
|
|
332
|
+
<tbody>
|
|
333
|
+
<tr v-for="(b, i) in buckets" :key="`${b.start}-${b.end}`">
|
|
334
|
+
<th scope="row">
|
|
335
|
+
{{ bucketLabel(b) }}
|
|
336
|
+
</th>
|
|
337
|
+
<td v-for="(s, si) in series" :key="s.key">
|
|
338
|
+
{{ format(s.values[i] ?? 0) }} ({{ shareLabel(rows[i]?.shares[si] ?? 0) }})
|
|
339
|
+
</td>
|
|
340
|
+
</tr>
|
|
341
|
+
</tbody>
|
|
342
|
+
</table>
|
|
343
|
+
</UiChartDataReveal>
|
|
344
|
+
</div>
|
|
345
|
+
</template>
|
|
346
|
+
|
|
347
|
+
<style scoped>
|
|
348
|
+
.ui-top-entity-stack-chart__legend--slots {
|
|
349
|
+
display: grid;
|
|
350
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
|
|
351
|
+
grid-auto-rows: 1.5em;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.ui-top-entity-stack-chart__plot {
|
|
355
|
+
display: grid;
|
|
356
|
+
/* minmax(0, …): a bare 1fr track floors at the stacked SVGs' width, so the
|
|
357
|
+
chart grew with its container but never shrank back. */
|
|
358
|
+
grid-template-columns: minmax(0, 1fr);
|
|
359
|
+
position: relative;
|
|
360
|
+
/* Shared edges preserve one continuous 100% field across dates. */
|
|
361
|
+
--vis-stacked-bar-stroke-width: 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.ui-top-entity-stack-chart__plot .chart-layer,
|
|
365
|
+
.ui-top-entity-stack-chart__plot .loading-skeleton {
|
|
366
|
+
grid-column-start: 1;
|
|
367
|
+
grid-row-start: 1;
|
|
368
|
+
min-width: 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.ui-top-entity-stack-chart__plot .chart-layer {
|
|
372
|
+
pointer-events: none;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.ui-top-entity-stack-chart__plot .chart-layer--bars {
|
|
376
|
+
clip-path: inset(4px 0 28px round calc(var(--ui-radius) * 2));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.ui-top-entity-stack-chart__plot .chart-layer--interactive {
|
|
380
|
+
pointer-events: auto;
|
|
381
|
+
--vis-crosshair-line-stroke-color: var(--ui-border-accented);
|
|
382
|
+
--vis-crosshair-line-stroke-opacity: 0.6;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.loading-skeleton {
|
|
386
|
+
display: flex;
|
|
387
|
+
flex-direction: column;
|
|
388
|
+
justify-content: end;
|
|
389
|
+
padding-bottom: 1.5rem;
|
|
390
|
+
padding-left: 0.25rem;
|
|
391
|
+
padding-right: 0.25rem;
|
|
392
|
+
}
|
|
393
|
+
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends object">
|
|
2
|
+
import type { Header } from '@tanstack/vue-table'
|
|
3
|
+
import type { UiTableColumn, UiTableFeatures } from '../../../shared/table'
|
|
4
|
+
import { FlexRender } from '@tanstack/vue-table'
|
|
5
|
+
import { computed } from 'vue'
|
|
6
|
+
import { UiIcon, UiTooltip } from '#components'
|
|
7
|
+
|
|
8
|
+
const { header, sortDirection } = defineProps<{
|
|
9
|
+
header: Header<UiTableFeatures, T, unknown>
|
|
10
|
+
sortDirection: 'asc' | 'desc' | false
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{ sort: [columnId: string] }>()
|
|
14
|
+
|
|
15
|
+
const def = computed(() => header.column.columnDef as UiTableColumn<T>)
|
|
16
|
+
const sortable = computed(() => header.column.getCanSort())
|
|
17
|
+
|
|
18
|
+
const justifyClass = computed(() => {
|
|
19
|
+
if (def.value.align === 'center')
|
|
20
|
+
return 'justify-center'
|
|
21
|
+
if (def.value.align === 'right' || def.value.numeric)
|
|
22
|
+
return 'justify-end'
|
|
23
|
+
return 'justify-start'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const isHeaderEmpty = computed(() => {
|
|
27
|
+
const v = def.value.header
|
|
28
|
+
return v === '' || v == null
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const sortIcon = computed(() => {
|
|
32
|
+
if (sortDirection)
|
|
33
|
+
return 'collapse'
|
|
34
|
+
return 'sort'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Chevron rotates 180° between asc/desc — a single icon animates rather than swapping.
|
|
38
|
+
const sortIconRotate = computed(() => sortDirection === 'desc' ? 'rotate-180' : '')
|
|
39
|
+
|
|
40
|
+
const buttonClass = 'flex min-h-11 items-center gap-1 w-full text-label cursor-pointer select-none transition-colors rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0'
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<UiTooltip v-if="def.tooltip" :text="def.tooltip">
|
|
45
|
+
<button
|
|
46
|
+
v-if="sortable"
|
|
47
|
+
type="button"
|
|
48
|
+
:class="[buttonClass, justifyClass, sortDirection ? 'text-highlighted' : 'text-muted hover:text-default']"
|
|
49
|
+
:aria-label="isHeaderEmpty ? `Sort by ${header.column.id}` : undefined"
|
|
50
|
+
@click.stop="emit('sort', header.column.id)"
|
|
51
|
+
>
|
|
52
|
+
<FlexRender v-if="!header.isPlaceholder" :header="header" />
|
|
53
|
+
<UiIcon :name="sortIcon" class="size-3 text-dimmed transition-transform duration-150" :class="sortIconRotate" aria-hidden="true" />
|
|
54
|
+
</button>
|
|
55
|
+
<div v-else class="flex items-center gap-1" :class="justifyClass">
|
|
56
|
+
<FlexRender v-if="!header.isPlaceholder" :header="header" />
|
|
57
|
+
</div>
|
|
58
|
+
</UiTooltip>
|
|
59
|
+
<button
|
|
60
|
+
v-else-if="sortable"
|
|
61
|
+
type="button"
|
|
62
|
+
:class="[buttonClass, justifyClass, sortDirection ? 'text-highlighted' : 'text-muted hover:text-default']"
|
|
63
|
+
:aria-label="isHeaderEmpty ? `Sort by ${header.column.id}` : undefined"
|
|
64
|
+
@click.stop="emit('sort', header.column.id)"
|
|
65
|
+
>
|
|
66
|
+
<FlexRender v-if="!header.isPlaceholder" :header="header" />
|
|
67
|
+
<UiIcon :name="sortIcon" class="size-3 text-dimmed transition-transform duration-150" :class="sortIconRotate" aria-hidden="true" />
|
|
68
|
+
</button>
|
|
69
|
+
<div v-else class="flex items-center gap-1" :class="justifyClass">
|
|
70
|
+
<FlexRender v-if="!header.isPlaceholder" :render="def.header" :props="header.getContext()" />
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { UiTableDash } from '#components'
|
|
4
|
+
|
|
5
|
+
type Status = 'good' | 'ni' | 'poor' | 'neutral'
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
value,
|
|
9
|
+
display,
|
|
10
|
+
status = 'neutral',
|
|
11
|
+
muted = false,
|
|
12
|
+
align = 'right',
|
|
13
|
+
} = defineProps<{
|
|
14
|
+
value: number | null | undefined
|
|
15
|
+
/** Pre-formatted display string. If omitted, value is rendered as-is via String(). */
|
|
16
|
+
display?: string | null
|
|
17
|
+
/** Optional status colour token (e.g. CWV good/needs-improvement/poor). */
|
|
18
|
+
status?: Status
|
|
19
|
+
/** Render in muted secondary tone (e.g. impressions vs primary clicks). */
|
|
20
|
+
muted?: boolean
|
|
21
|
+
align?: 'left' | 'center' | 'right'
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const isNullish = computed(() => value == null || display === null)
|
|
25
|
+
|
|
26
|
+
// Color rides the failure, never the pass. A table of green "good" values spends
|
|
27
|
+
// the page's color budget on non-events, so the one failing row can't stand out
|
|
28
|
+
// (DESIGN — Color budget). `good` reads as a plain numeral; the aria-label still
|
|
29
|
+
// carries the word for anyone who can't see the hue at all.
|
|
30
|
+
const statusClass: Record<Status, string> = {
|
|
31
|
+
good: '',
|
|
32
|
+
ni: 'text-warning',
|
|
33
|
+
poor: 'text-error',
|
|
34
|
+
neutral: '',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const statusLabel: Record<Status, string | undefined> = {
|
|
38
|
+
good: 'Good',
|
|
39
|
+
ni: 'Needs improvement',
|
|
40
|
+
poor: 'Poor',
|
|
41
|
+
neutral: undefined,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const alignClass = computed(() => align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start')
|
|
45
|
+
|
|
46
|
+
const ariaLabel = computed(() => {
|
|
47
|
+
const label = statusLabel[status]
|
|
48
|
+
if (!label || !display)
|
|
49
|
+
return undefined
|
|
50
|
+
return `${display}, ${label}`
|
|
51
|
+
})
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div
|
|
56
|
+
class="flex"
|
|
57
|
+
:class="[$slots.trend ? 'flex-col items-stretch gap-1' : 'items-center gap-2', alignClass]"
|
|
58
|
+
>
|
|
59
|
+
<UiTableDash v-if="isNullish" />
|
|
60
|
+
<span
|
|
61
|
+
v-else
|
|
62
|
+
class="numerals-display text-sm"
|
|
63
|
+
:class="[
|
|
64
|
+
statusClass[status],
|
|
65
|
+
muted && status === 'neutral' ? 'text-muted' : 'font-medium',
|
|
66
|
+
]"
|
|
67
|
+
:aria-label="ariaLabel"
|
|
68
|
+
>{{ display ?? String(value) }}</span>
|
|
69
|
+
<slot v-if="!isNullish" name="trend" />
|
|
70
|
+
<slot v-if="!isNullish" name="after" />
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { NuxtLink, UiIcon, UiTooltip } from '#components'
|
|
4
|
+
import { safeHttpHref } from '../../../../shared/utils/urls'
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
url,
|
|
8
|
+
label,
|
|
9
|
+
to,
|
|
10
|
+
maxWidth = '280px',
|
|
11
|
+
external = true,
|
|
12
|
+
} = defineProps<{
|
|
13
|
+
url: string
|
|
14
|
+
/** Display text. Defaults to the pathname portion of url. */
|
|
15
|
+
label?: string
|
|
16
|
+
/** Internal route. When set, renders NuxtLink to this route instead of an external anchor. */
|
|
17
|
+
to?: string
|
|
18
|
+
maxWidth?: string
|
|
19
|
+
/** Show external open icon on hover/focus. Defaults to true when no internal `to` is provided. */
|
|
20
|
+
external?: boolean
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const display = computed(() => {
|
|
24
|
+
if (label)
|
|
25
|
+
return label
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(url)
|
|
28
|
+
return u.pathname + u.search
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return url
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// `url` is stored data (Search Console pages, sitemap entries). An unfiltered
|
|
36
|
+
// `href` would run a `javascript:` value on click, so only http(s) reaches the
|
|
37
|
+
// anchor and anything else renders as inert text.
|
|
38
|
+
const safeHref = computed(() => safeHttpHref(url))
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<UiTooltip :text="url">
|
|
43
|
+
<div class="group/path inline-flex items-center gap-1.5 min-w-0">
|
|
44
|
+
<NuxtLink
|
|
45
|
+
v-if="to"
|
|
46
|
+
:to="to"
|
|
47
|
+
:title="url"
|
|
48
|
+
class="font-sans text-xs text-default hover:text-primary focus-visible:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded transition-colors truncate"
|
|
49
|
+
:style="{ maxWidth }"
|
|
50
|
+
>
|
|
51
|
+
{{ display }}
|
|
52
|
+
</NuxtLink>
|
|
53
|
+
<a
|
|
54
|
+
v-else-if="safeHref"
|
|
55
|
+
:href="safeHref"
|
|
56
|
+
:title="url"
|
|
57
|
+
target="_blank"
|
|
58
|
+
rel="noopener noreferrer"
|
|
59
|
+
class="font-sans text-xs text-default hover:text-primary focus-visible:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded transition-colors truncate"
|
|
60
|
+
:style="{ maxWidth }"
|
|
61
|
+
>{{ display }}</a>
|
|
62
|
+
<span
|
|
63
|
+
v-else
|
|
64
|
+
:title="url"
|
|
65
|
+
class="font-sans text-xs text-default truncate"
|
|
66
|
+
:style="{ maxWidth }"
|
|
67
|
+
>{{ display }}</span>
|
|
68
|
+
<UiIcon
|
|
69
|
+
v-if="external && !to && safeHref"
|
|
70
|
+
name="arrow-up-right"
|
|
71
|
+
class="size-3 text-dimmed opacity-0 group-hover/path:opacity-100 group-focus-within/path:opacity-100 transition-opacity shrink-0"
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
</UiTooltip>
|
|
76
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
score,
|
|
6
|
+
label,
|
|
7
|
+
bgClass = '',
|
|
8
|
+
} = defineProps<{
|
|
9
|
+
score: number | null | undefined
|
|
10
|
+
/** Metric name (e.g. "Performance", "LCP"). Used for the aria-label. */
|
|
11
|
+
label?: string
|
|
12
|
+
/** Background class derived from caller's threshold logic (e.g. scoreBgClass()). */
|
|
13
|
+
bgClass?: string
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const status = computed<'good' | 'ni' | 'poor' | 'neutral'>(() => {
|
|
17
|
+
if (score == null)
|
|
18
|
+
return 'neutral'
|
|
19
|
+
if (score >= 90)
|
|
20
|
+
return 'good'
|
|
21
|
+
if (score >= 50)
|
|
22
|
+
return 'ni'
|
|
23
|
+
return 'poor'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const statusWord: Record<typeof status['value'], string> = {
|
|
27
|
+
good: 'Good',
|
|
28
|
+
ni: 'Needs improvement',
|
|
29
|
+
poor: 'Poor',
|
|
30
|
+
neutral: 'No data',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ariaLabel = computed(() => {
|
|
34
|
+
const value = score == null ? 'No data' : String(score)
|
|
35
|
+
const word = statusWord[status.value]
|
|
36
|
+
return label ? `${label} ${value}, ${word}` : `${value}, ${word}`
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div
|
|
42
|
+
class="inline-flex items-center justify-center size-8 rounded-md text-mini font-bold numerals-display"
|
|
43
|
+
:class="bgClass"
|
|
44
|
+
role="img"
|
|
45
|
+
:aria-label="ariaLabel"
|
|
46
|
+
>
|
|
47
|
+
<span aria-hidden="true">{{ score ?? '—' }}</span>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|