@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,276 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends object">
|
|
2
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
3
|
+
import type { ViewState } from '../../shared/view-state'
|
|
4
|
+
import { computed, onMounted, ref } from 'vue'
|
|
5
|
+
import { NuxtLink, UiButton, UiHelpLabel, UiIcon, UiNavIcon, UiSkeleton } from '#components'
|
|
6
|
+
import { widgetFailureCopy } from '../../shared/widget-failure'
|
|
7
|
+
import { getIconColor } from '../../utils/icon-color'
|
|
8
|
+
|
|
9
|
+
type UiDataListRouteTarget = string | Record<string, unknown>
|
|
10
|
+
interface UiDataListKeyFields {
|
|
11
|
+
id?: string | number
|
|
12
|
+
key?: string | number
|
|
13
|
+
path?: string
|
|
14
|
+
url?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
title,
|
|
19
|
+
icon,
|
|
20
|
+
iconColor,
|
|
21
|
+
tooltip,
|
|
22
|
+
metricLabel,
|
|
23
|
+
state,
|
|
24
|
+
items,
|
|
25
|
+
loading,
|
|
26
|
+
error,
|
|
27
|
+
onRetry,
|
|
28
|
+
loadingCount = 5,
|
|
29
|
+
viewMoreTo,
|
|
30
|
+
viewMoreLabel = 'View all',
|
|
31
|
+
emptyIcon,
|
|
32
|
+
emptyText = 'No data available',
|
|
33
|
+
barValue,
|
|
34
|
+
barTotal,
|
|
35
|
+
barColor = 'bg-pro',
|
|
36
|
+
itemTo,
|
|
37
|
+
subtle = false,
|
|
38
|
+
} = defineProps<{
|
|
39
|
+
title?: string
|
|
40
|
+
icon?: UiIconName
|
|
41
|
+
iconColor?: string
|
|
42
|
+
/** Render the title as a quieter sub-tier (smaller + muted) for nested lists. */
|
|
43
|
+
subtle?: boolean
|
|
44
|
+
tooltip?: string
|
|
45
|
+
metricLabel?: string
|
|
46
|
+
/**
|
|
47
|
+
* The read behind the list, built with `listViewState`. When set, it decides
|
|
48
|
+
* every branch and `items`, `loading` and `error` are ignored. The list
|
|
49
|
+
* shows its empty copy only for `empty`, so a failed read or a read still in
|
|
50
|
+
* flight can never say "No data".
|
|
51
|
+
*/
|
|
52
|
+
state?: ViewState<readonly T[]>
|
|
53
|
+
items?: T[]
|
|
54
|
+
loading?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* The caught value of the read behind `items`, for classification only (see
|
|
57
|
+
* UiWidgetState). With no rows, the list shows the failure instead of the
|
|
58
|
+
* empty copy, so a failed read never says "No data". With rows, a failure
|
|
59
|
+
* notice sits above the rows still in hand.
|
|
60
|
+
*/
|
|
61
|
+
error?: unknown
|
|
62
|
+
/** Run the read again. Bind with `@retry`; shown when a retry can fix the failure. */
|
|
63
|
+
onRetry?: () => void
|
|
64
|
+
loadingCount?: number
|
|
65
|
+
viewMoreTo?: UiDataListRouteTarget
|
|
66
|
+
viewMoreLabel?: string
|
|
67
|
+
emptyIcon?: UiIconName
|
|
68
|
+
emptyText?: string
|
|
69
|
+
/** Accessor fn to get bar value from item. When set, renders a % fill bar behind each row. */
|
|
70
|
+
barValue?: (item: T) => number
|
|
71
|
+
/**
|
|
72
|
+
* Denominator for the bar fill. Omit it (the default) and the bars are
|
|
73
|
+
* max-relative: the largest row fills 100% and every other row is drawn in
|
|
74
|
+
* proportion to it, so the list reads as magnitude. Pass an explicit value to
|
|
75
|
+
* mean something else — `100` for rows that already hold percentages, or the
|
|
76
|
+
* sum of a wider set when the bar must read as share-of-total.
|
|
77
|
+
*/
|
|
78
|
+
barTotal?: number
|
|
79
|
+
/** Bar color class (default: 'bg-pro') */
|
|
80
|
+
barColor?: string
|
|
81
|
+
/** Accessor fn for row link target. When set, each row becomes a NuxtLink with a trailing chevron. */
|
|
82
|
+
itemTo?: (item: T) => UiDataListRouteTarget | undefined | null
|
|
83
|
+
}>()
|
|
84
|
+
|
|
85
|
+
defineSlots<{
|
|
86
|
+
'default'?: (props: { item: T, index: number }) => unknown
|
|
87
|
+
/**
|
|
88
|
+
* The list's identity mark, in place of `icon`. A list that reads through
|
|
89
|
+
* one Source passes its `UiSourceLogos` here, so the header names where the
|
|
90
|
+
* rows came from instead of decorating the title.
|
|
91
|
+
*/
|
|
92
|
+
'mark'?: () => unknown
|
|
93
|
+
'header-trailing'?: () => unknown
|
|
94
|
+
'empty'?: () => unknown
|
|
95
|
+
'footer'?: () => unknown
|
|
96
|
+
}>()
|
|
97
|
+
|
|
98
|
+
// Treat SSR as loading to avoid hydration mismatch when data arrives client-side
|
|
99
|
+
const hydrated = ref(false)
|
|
100
|
+
onMounted(() => { hydrated.value = true })
|
|
101
|
+
|
|
102
|
+
// One state decides every branch. A `state` prop wins. The loose props map
|
|
103
|
+
// onto the same union, so both paths share one template and the empty copy
|
|
104
|
+
// can only follow a read that answered with no rows.
|
|
105
|
+
const view = computed<ViewState<readonly T[]>>(() => {
|
|
106
|
+
if (state)
|
|
107
|
+
return state
|
|
108
|
+
if (loading || (!hydrated.value && !items?.length))
|
|
109
|
+
return { _tag: 'loading' }
|
|
110
|
+
if (!items?.length)
|
|
111
|
+
return error == null ? { _tag: 'empty' } : { _tag: 'error', error }
|
|
112
|
+
return { _tag: 'data', data: items, refreshError: error ?? null }
|
|
113
|
+
})
|
|
114
|
+
const isLoading = computed(() => view.value._tag === 'loading')
|
|
115
|
+
const rows = computed<readonly T[]>(() => view.value._tag === 'data' ? view.value.data : [])
|
|
116
|
+
const failure = computed(() => {
|
|
117
|
+
if (view.value._tag === 'error')
|
|
118
|
+
return widgetFailureCopy(view.value.error)
|
|
119
|
+
if (view.value._tag === 'data' && view.value.refreshError != null)
|
|
120
|
+
return widgetFailureCopy(view.value.refreshError)
|
|
121
|
+
return null
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Default denominator is the LARGEST row, not the sum: dividing by the sum
|
|
125
|
+
// squashes even a dominant row into a third of the track (4.8K of 14K = 34%)
|
|
126
|
+
// and renders the tail as slivers, so the bars carry no magnitude. Max-relative
|
|
127
|
+
// puts the top row at 100% and scales the rest against it. An explicit
|
|
128
|
+
// `barTotal` still wins — that's the share-of-total / already-a-percentage case.
|
|
129
|
+
const computedBarTotal = computed(() => {
|
|
130
|
+
if (barTotal != null)
|
|
131
|
+
return barTotal
|
|
132
|
+
if (!barValue || !rows.value.length)
|
|
133
|
+
return 0
|
|
134
|
+
return rows.value.reduce((max, item) => Math.max(max, barValue!(item)), 0)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
function barPct(item: T): number {
|
|
138
|
+
if (!barValue)
|
|
139
|
+
return 0
|
|
140
|
+
const total = computedBarTotal.value
|
|
141
|
+
return total > 0 ? (barValue(item) / total) * 100 : 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const hasSemanticColor = computed(() => !!iconColor)
|
|
145
|
+
const iconClasses = computed(() => hasSemanticColor.value ? getIconColor(iconColor!) : null)
|
|
146
|
+
const titleClass = computed(() => subtle ? 'text-xs font-medium text-muted' : 'text-sm font-medium')
|
|
147
|
+
|
|
148
|
+
function itemKey(item: T, index: number): string | number {
|
|
149
|
+
const row = item as UiDataListKeyFields
|
|
150
|
+
return row.id ?? row.key ?? row.path ?? row.url ?? index
|
|
151
|
+
}
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<template>
|
|
155
|
+
<div class="flex flex-col" :aria-busy="isLoading">
|
|
156
|
+
<!-- Header -->
|
|
157
|
+
<div v-if="title || icon || $slots.mark || $slots['header-trailing']" class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1 pb-2.5">
|
|
158
|
+
<div v-if="title || icon || $slots.mark" class="flex items-center gap-2">
|
|
159
|
+
<slot name="mark">
|
|
160
|
+
<div v-if="icon && hasSemanticColor" class="p-1 rounded-md" :class="iconClasses!.bg">
|
|
161
|
+
<UiIcon :name="icon" class="size-3.5" :class="iconClasses!.text" />
|
|
162
|
+
</div>
|
|
163
|
+
<UiNavIcon v-else-if="icon" :icon="icon" />
|
|
164
|
+
</slot>
|
|
165
|
+
<!-- UiHelpLabel renders its text through UiTooltip, whose root is a
|
|
166
|
+
reka TooltipProvider with no DOM node — a fallthrough `class` would
|
|
167
|
+
be dropped. Wrap it in a real span so the title size/tone lands. -->
|
|
168
|
+
<span v-if="title && tooltip" :class="titleClass">
|
|
169
|
+
<UiHelpLabel :text="title" :tooltip="tooltip" />
|
|
170
|
+
</span>
|
|
171
|
+
<span v-else-if="title" :class="titleClass">{{ title }}</span>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="flex items-center justify-between gap-3">
|
|
174
|
+
<slot name="header-trailing">
|
|
175
|
+
<span v-if="metricLabel" class="text-sm text-muted">{{ metricLabel }}</span>
|
|
176
|
+
</slot>
|
|
177
|
+
<NuxtLink
|
|
178
|
+
v-if="viewMoreTo"
|
|
179
|
+
:to="viewMoreTo"
|
|
180
|
+
class="inline-flex min-h-11 items-center gap-1 text-xs text-muted hover:text-default transition-colors group/link"
|
|
181
|
+
>
|
|
182
|
+
{{ viewMoreLabel }}
|
|
183
|
+
<UiIcon name="next" class="size-3 transition-transform group-hover/link:translate-x-0.5" aria-hidden="true" />
|
|
184
|
+
</NuxtLink>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="rounded-lg border border-default bg-elevated/50 flex flex-col flex-1 overflow-hidden">
|
|
189
|
+
<!-- Loading -->
|
|
190
|
+
<div v-if="isLoading" class="px-3 py-3 space-y-1">
|
|
191
|
+
<UiSkeleton :lines="loadingCount" :base="180" :range="80" />
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Failed with nothing in hand: never the empty copy. -->
|
|
195
|
+
<div
|
|
196
|
+
v-else-if="view._tag === 'error' && failure"
|
|
197
|
+
role="alert"
|
|
198
|
+
data-ui="UiDataList-error"
|
|
199
|
+
class="flex flex-1 items-start gap-2.5 px-3 py-3"
|
|
200
|
+
>
|
|
201
|
+
<UiIcon name="caution" class="mt-0.5 size-4 shrink-0 text-error" aria-hidden="true" />
|
|
202
|
+
<div class="min-w-0 flex-1">
|
|
203
|
+
<p class="text-sm font-medium text-default">
|
|
204
|
+
{{ failure.title }}
|
|
205
|
+
</p>
|
|
206
|
+
<p class="text-sm text-muted">
|
|
207
|
+
{{ failure.body }}
|
|
208
|
+
</p>
|
|
209
|
+
<UiButton v-if="onRetry && failure.retryable" purpose="secondary" size="xs" icon="refresh" class="mt-2" @click="onRetry()">
|
|
210
|
+
Retry
|
|
211
|
+
</UiButton>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- Empty: only a read that answered with no rows reaches this branch. -->
|
|
216
|
+
<div v-else-if="view._tag === 'empty'" class="px-4 py-6 text-center flex-1 flex flex-col items-center justify-center">
|
|
217
|
+
<UiIcon v-if="emptyIcon" :name="emptyIcon" class="size-6 text-muted/40 mb-2" aria-hidden="true" />
|
|
218
|
+
<p class="text-sm text-dimmed">
|
|
219
|
+
<slot name="empty">
|
|
220
|
+
{{ emptyText }}
|
|
221
|
+
</slot>
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<!-- Items — condensed by default. `min-h-11` keeps the 44px touch target
|
|
226
|
+
on coarse pointers; from `sm` up a row floors at 32px, so a 5-row
|
|
227
|
+
list costs ~60px less height for the same signal. Rows with two-line
|
|
228
|
+
or badge content still grow past the floor on their own. -->
|
|
229
|
+
<!-- A failed refresh keeps the rows in hand and says so above them. -->
|
|
230
|
+
<div
|
|
231
|
+
v-if="view._tag === 'data' && failure"
|
|
232
|
+
role="alert"
|
|
233
|
+
data-ui="UiDataList-error"
|
|
234
|
+
class="flex items-start gap-2.5 border-b border-default px-3 py-2"
|
|
235
|
+
>
|
|
236
|
+
<UiIcon name="caution" class="mt-0.5 size-4 shrink-0 text-error" aria-hidden="true" />
|
|
237
|
+
<p class="min-w-0 flex-1 text-sm text-muted">
|
|
238
|
+
<span class="font-medium text-default">{{ failure.title }}.</span> {{ failure.body }}
|
|
239
|
+
</p>
|
|
240
|
+
<UiButton v-if="onRetry && failure.retryable" purpose="secondary" size="xs" icon="refresh" @click="onRetry()">
|
|
241
|
+
Retry
|
|
242
|
+
</UiButton>
|
|
243
|
+
</div>
|
|
244
|
+
<div v-if="view._tag === 'data'" class="flex-1 p-1 space-y-0.5">
|
|
245
|
+
<component
|
|
246
|
+
:is="itemTo && itemTo(item) ? NuxtLink : 'div'"
|
|
247
|
+
v-for="(item, index) in rows"
|
|
248
|
+
:key="itemKey(item, index)"
|
|
249
|
+
:to="itemTo ? itemTo(item) || undefined : undefined"
|
|
250
|
+
class="relative flex min-h-11 sm:min-h-8 items-center gap-2 py-1 px-2.5 rounded-lg group hover:bg-accented transition-colors"
|
|
251
|
+
>
|
|
252
|
+
<div
|
|
253
|
+
v-if="barValue"
|
|
254
|
+
class="absolute inset-y-0 left-0 rounded-lg opacity-[0.05] dark:opacity-[0.07] pointer-events-none"
|
|
255
|
+
:class="barColor"
|
|
256
|
+
:style="{ width: `${barPct(item)}%` }"
|
|
257
|
+
/>
|
|
258
|
+
<div class="relative flex-1 min-w-0 flex items-center justify-between gap-2">
|
|
259
|
+
<slot :item="item" :index="index" />
|
|
260
|
+
</div>
|
|
261
|
+
<UiIcon
|
|
262
|
+
v-if="itemTo && itemTo(item)"
|
|
263
|
+
name="chevron-right"
|
|
264
|
+
class="size-3.5 text-dimmed shrink-0"
|
|
265
|
+
aria-hidden="true"
|
|
266
|
+
/>
|
|
267
|
+
</component>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- Footer -->
|
|
271
|
+
<div v-if="$slots.footer && view._tag === 'data'" class="px-4 pb-3 pt-2">
|
|
272
|
+
<slot name="footer" />
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</template>
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
|
2
|
+
import type { RowSelectionState, SortingState } from '@tanstack/vue-table'
|
|
3
|
+
import type { UiTableColumn, UiTableRowId } from '../../shared/table'
|
|
4
|
+
import type { UiIcon as UiIconName } from '../../shared/ui-icons'
|
|
5
|
+
import { refDebounced } from '@vueuse/core'
|
|
6
|
+
import { computed, ref, watch } from 'vue'
|
|
7
|
+
import { UiButton, UiIcon, UInput, UiTable, UiTablePagination } from '#components'
|
|
8
|
+
import { formatGroupedNumber } from '../../composables/formatting'
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
pending = false,
|
|
12
|
+
rows,
|
|
13
|
+
total,
|
|
14
|
+
columns,
|
|
15
|
+
error,
|
|
16
|
+
pageSize = 12,
|
|
17
|
+
searchable = true,
|
|
18
|
+
searchPlaceholder = 'Search…',
|
|
19
|
+
emptyIcon = 'search',
|
|
20
|
+
emptyTitle = 'No results found',
|
|
21
|
+
emptyDescription = '',
|
|
22
|
+
itemLabel = 'items',
|
|
23
|
+
manualPagination = true,
|
|
24
|
+
manualSorting = false,
|
|
25
|
+
rowHover = true,
|
|
26
|
+
rowClickable = false,
|
|
27
|
+
rowId,
|
|
28
|
+
label,
|
|
29
|
+
filtersActive = false,
|
|
30
|
+
formatTotal,
|
|
31
|
+
} = defineProps<{
|
|
32
|
+
pending?: boolean
|
|
33
|
+
rows: T[]
|
|
34
|
+
total: number
|
|
35
|
+
columns: UiTableColumn<T>[]
|
|
36
|
+
error?: unknown
|
|
37
|
+
pageSize?: number
|
|
38
|
+
searchable?: boolean
|
|
39
|
+
searchPlaceholder?: string
|
|
40
|
+
emptyIcon?: UiIconName
|
|
41
|
+
emptyTitle?: string
|
|
42
|
+
emptyDescription?: string
|
|
43
|
+
itemLabel?: string
|
|
44
|
+
manualPagination?: boolean
|
|
45
|
+
manualSorting?: boolean
|
|
46
|
+
rowHover?: boolean
|
|
47
|
+
rowClickable?: boolean
|
|
48
|
+
rowId?: UiTableRowId<T>
|
|
49
|
+
/** Accessible name for the table (passed through to <caption>). */
|
|
50
|
+
label?: string
|
|
51
|
+
/** Whether any non-search filter is currently active. Drives empty-state copy. */
|
|
52
|
+
filtersActive?: boolean
|
|
53
|
+
/** Caller-supplied number formatter for the pagination "X items total" caption. Defaults to `formatGroupedNumber`. */
|
|
54
|
+
formatTotal?: (n: number) => string
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits<{
|
|
58
|
+
retry: []
|
|
59
|
+
sortColumn: [column: string]
|
|
60
|
+
rowClick: [row: T]
|
|
61
|
+
}>()
|
|
62
|
+
|
|
63
|
+
const totalDisplay = computed(() => formatTotal ? formatTotal(total) : formatGroupedNumber(total))
|
|
64
|
+
|
|
65
|
+
const search = defineModel<string>('search', { default: '' })
|
|
66
|
+
const page = defineModel<number>('page', { default: 1 })
|
|
67
|
+
const sorting = defineModel<SortingState>('sorting', { default: () => [] })
|
|
68
|
+
const rowSelection = defineModel<RowSelectionState>('rowSelection')
|
|
69
|
+
const rowSelectionEnabled = computed(() => rowSelection.value !== undefined)
|
|
70
|
+
const searchDraft = ref(search.value)
|
|
71
|
+
const debouncedSearch = refDebounced(searchDraft, 300)
|
|
72
|
+
|
|
73
|
+
const showEmpty = computed(() => !pending && !error && rows.length === 0)
|
|
74
|
+
const showTable = computed(() => !error && (pending || rows.length > 0))
|
|
75
|
+
const showPagination = computed(() => !pending && rows.length > 0 && total > pageSize)
|
|
76
|
+
|
|
77
|
+
watch(search, (value) => {
|
|
78
|
+
if (value !== searchDraft.value)
|
|
79
|
+
searchDraft.value = value
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
watch(debouncedSearch, (value) => {
|
|
83
|
+
if (value !== search.value)
|
|
84
|
+
search.value = value
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function commitSearch(value = searchDraft.value) {
|
|
88
|
+
searchDraft.value = value
|
|
89
|
+
if (search.value !== value)
|
|
90
|
+
search.value = value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function errorText(value: unknown): string {
|
|
94
|
+
if (typeof value === 'string')
|
|
95
|
+
return value
|
|
96
|
+
if (value && typeof value === 'object' && 'message' in value && typeof value.message === 'string')
|
|
97
|
+
return value.message
|
|
98
|
+
return 'Failed to load'
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<div data-ui="UiDataTableSection" class="flex flex-col gap-4">
|
|
104
|
+
<slot name="stats" />
|
|
105
|
+
|
|
106
|
+
<!-- Filter row -->
|
|
107
|
+
<div v-if="searchable || $slots['filters-leading'] || $slots['filters-trailing']" class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
|
108
|
+
<div class="flex items-center gap-1.5 flex-wrap">
|
|
109
|
+
<slot name="filters-leading" />
|
|
110
|
+
</div>
|
|
111
|
+
<div class="flex w-full items-center gap-3 sm:w-auto">
|
|
112
|
+
<slot name="filters-trailing" />
|
|
113
|
+
<UInput
|
|
114
|
+
v-if="searchable"
|
|
115
|
+
v-model="searchDraft"
|
|
116
|
+
type="search"
|
|
117
|
+
class="w-full sm:w-56"
|
|
118
|
+
:placeholder="searchPlaceholder"
|
|
119
|
+
icon="search"
|
|
120
|
+
autocomplete="off"
|
|
121
|
+
size="sm"
|
|
122
|
+
:ui="{ base: 'min-h-11 transition-[width] duration-200 focus-within:w-72 sm:min-h-0' }"
|
|
123
|
+
aria-label="Filter rows"
|
|
124
|
+
data-inp-target="data-table-search"
|
|
125
|
+
@keydown.enter="commitSearch()"
|
|
126
|
+
>
|
|
127
|
+
<template #trailing>
|
|
128
|
+
<UiButton
|
|
129
|
+
v-if="searchDraft !== ''"
|
|
130
|
+
purpose="quiet"
|
|
131
|
+
icon="close"
|
|
132
|
+
size="xs"
|
|
133
|
+
class="min-h-11 min-w-11 rounded-lg sm:min-h-0 sm:min-w-0"
|
|
134
|
+
aria-label="Clear search"
|
|
135
|
+
@click="commitSearch('')"
|
|
136
|
+
/>
|
|
137
|
+
</template>
|
|
138
|
+
</UInput>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<slot name="notices" />
|
|
143
|
+
|
|
144
|
+
<!-- Error -->
|
|
145
|
+
<div v-if="error" role="alert" aria-live="assertive" class="rounded-xl border border-dashed border-default bg-[var(--ui-bg-elevated)]/5">
|
|
146
|
+
<slot name="error" :error="error">
|
|
147
|
+
<div class="py-10 text-center">
|
|
148
|
+
<p class="text-sm text-error mb-3">
|
|
149
|
+
{{ errorText(error) }}
|
|
150
|
+
</p>
|
|
151
|
+
<UiButton size="sm" purpose="secondary" @click="emit('retry')">
|
|
152
|
+
Retry
|
|
153
|
+
</UiButton>
|
|
154
|
+
</div>
|
|
155
|
+
</slot>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Empty -->
|
|
159
|
+
<div v-else-if="showEmpty" role="status" data-testid="table-empty-state" class="rounded-xl border border-dashed border-default bg-[var(--ui-bg-elevated)]/5 py-16">
|
|
160
|
+
<slot name="empty">
|
|
161
|
+
<div class="text-center max-w-sm mx-auto">
|
|
162
|
+
<UiIcon :name="emptyIcon" class="size-7 text-dimmed mb-3 inline-block" aria-hidden="true" />
|
|
163
|
+
<h3 class="text-sm font-semibold text-default mb-1">
|
|
164
|
+
{{ emptyTitle }}
|
|
165
|
+
</h3>
|
|
166
|
+
<p v-if="searchDraft || filtersActive || emptyDescription" class="text-sm text-muted mb-4">
|
|
167
|
+
<template v-if="searchDraft">
|
|
168
|
+
No {{ itemLabel }} match “<span class="font-medium text-default">{{ searchDraft }}</span>”
|
|
169
|
+
</template>
|
|
170
|
+
<template v-else-if="filtersActive">
|
|
171
|
+
No {{ itemLabel }} match the selected filter
|
|
172
|
+
</template>
|
|
173
|
+
<template v-else>
|
|
174
|
+
{{ emptyDescription }}
|
|
175
|
+
</template>
|
|
176
|
+
</p>
|
|
177
|
+
<slot name="empty-actions">
|
|
178
|
+
<UiButton
|
|
179
|
+
v-if="searchDraft"
|
|
180
|
+
size="sm"
|
|
181
|
+
purpose="secondary"
|
|
182
|
+
@click="commitSearch('')"
|
|
183
|
+
>
|
|
184
|
+
Clear search
|
|
185
|
+
</UiButton>
|
|
186
|
+
</slot>
|
|
187
|
+
</div>
|
|
188
|
+
</slot>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<!-- Body: defaults to the table; pass #body to render an alternate view
|
|
192
|
+
(e.g. a card grid) while keeping the search / empty / pagination chrome. -->
|
|
193
|
+
<template v-else-if="showTable">
|
|
194
|
+
<slot name="body" :rows="rows" :pending="pending">
|
|
195
|
+
<UiTable
|
|
196
|
+
v-model:sorting="sorting"
|
|
197
|
+
v-model:selected="rowSelection"
|
|
198
|
+
:data="rows"
|
|
199
|
+
:columns="columns"
|
|
200
|
+
:page-size="pageSize"
|
|
201
|
+
:row-hover="rowHover"
|
|
202
|
+
:row-clickable="rowClickable"
|
|
203
|
+
:manual-pagination="manualPagination"
|
|
204
|
+
:manual-sorting="manualSorting"
|
|
205
|
+
:enable-sorting="manualSorting"
|
|
206
|
+
:total="total"
|
|
207
|
+
:row-id="rowId"
|
|
208
|
+
:label="label ?? itemLabel"
|
|
209
|
+
:loading="pending"
|
|
210
|
+
:controlled-selection="rowSelectionEnabled"
|
|
211
|
+
bordered
|
|
212
|
+
disable-pagination
|
|
213
|
+
@sort-column="(c: string) => emit('sortColumn', c)"
|
|
214
|
+
@row-click="(r: T) => emit('rowClick', r)"
|
|
215
|
+
/>
|
|
216
|
+
</slot>
|
|
217
|
+
</template>
|
|
218
|
+
|
|
219
|
+
<!-- Pagination (sibling: applies to the table and any #body view) -->
|
|
220
|
+
<UiTablePagination
|
|
221
|
+
v-if="showPagination"
|
|
222
|
+
v-model:page="page"
|
|
223
|
+
:page-size="pageSize"
|
|
224
|
+
:total="total"
|
|
225
|
+
>
|
|
226
|
+
<slot name="pagination-leading">
|
|
227
|
+
<span class="font-medium text-default">{{ totalDisplay }}</span> {{ itemLabel }} total
|
|
228
|
+
</slot>
|
|
229
|
+
</UiTablePagination>
|
|
230
|
+
|
|
231
|
+
<slot name="footer" />
|
|
232
|
+
</div>
|
|
233
|
+
</template>
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SemanticStatus } from '../../composables/semanticColors'
|
|
3
|
+
import { motion, stagger, useReducedMotion } from 'motion-v'
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
6
|
+
|
|
7
|
+
// One column per CATEGORY, not per period — the categorical sibling of
|
|
8
|
+
// UiSparkline, meant as an ambient background for a KPI tile the same way
|
|
9
|
+
// UiCwvSparkline is. Each column occupies the full height as a faint track and
|
|
10
|
+
// fills the share its value holds of the largest category, so the reader sees a
|
|
11
|
+
// shape ("one type dominates" vs "evenly spread") without needing axis labels.
|
|
12
|
+
//
|
|
13
|
+
// The scale is ZERO-BASED against the max on purpose. UiSparkline's min–max
|
|
14
|
+
// projection is right for a trend, where the interesting part is the movement,
|
|
15
|
+
// and wrong here: it would paint the smallest category at zero height and read
|
|
16
|
+
// as "none of these", which for a distribution is a different claim entirely.
|
|
17
|
+
|
|
18
|
+
interface Slice {
|
|
19
|
+
label: string
|
|
20
|
+
value: number
|
|
21
|
+
/**
|
|
22
|
+
* Semantic tone for this column. Omit it for the non-event — the colour
|
|
23
|
+
* budget says only the exceptional value takes a hue, so a caller that tones
|
|
24
|
+
* EVERY slice has already lost the signal it was reaching for.
|
|
25
|
+
*/
|
|
26
|
+
tone?: SemanticStatus
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
/** Categories in the order they should be drawn. Caller decides the sort. */
|
|
31
|
+
data: Slice[]
|
|
32
|
+
/** Columns to draw; the remainder is dropped, never silently merged. */
|
|
33
|
+
limit?: number
|
|
34
|
+
width?: number | string
|
|
35
|
+
height?: number | string
|
|
36
|
+
/** SVG preserveAspectRatio — "none" stretches to fill a container. */
|
|
37
|
+
preserveAspectRatio?: string
|
|
38
|
+
/** Bar fill opacity. Moderate, so an enclosing card's value stays dominant. */
|
|
39
|
+
opacity?: number
|
|
40
|
+
/** Hover state, fed from a parent (e.g. the enclosing UiStat card). */
|
|
41
|
+
hovered?: boolean
|
|
42
|
+
/** What the bars count, read before the values: "Issue types by open count". */
|
|
43
|
+
label?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
data,
|
|
48
|
+
limit = 14,
|
|
49
|
+
width = '100%',
|
|
50
|
+
height = '100%',
|
|
51
|
+
preserveAspectRatio = 'none',
|
|
52
|
+
opacity = 0.55,
|
|
53
|
+
hovered = false,
|
|
54
|
+
label = 'Categories by count',
|
|
55
|
+
} = defineProps<Props>()
|
|
56
|
+
|
|
57
|
+
const reduced = useReducedMotion()
|
|
58
|
+
|
|
59
|
+
const container = {
|
|
60
|
+
rest: {},
|
|
61
|
+
hover: { transition: { delayChildren: stagger(0.04, { ease: 'easeInOut' }) } },
|
|
62
|
+
}
|
|
63
|
+
const barVariants = {
|
|
64
|
+
rest: { scaleY: 0.82, opacity: 0.85 },
|
|
65
|
+
hover: {
|
|
66
|
+
scaleY: 1,
|
|
67
|
+
opacity: 1,
|
|
68
|
+
transition: { type: 'spring' as const, stiffness: 190, damping: 16, mass: 0.8 },
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const vbW = 120
|
|
73
|
+
const vbH = 40
|
|
74
|
+
|
|
75
|
+
// `color` may be a CSS custom property (the neutral reads `--ui-viz-neutral`),
|
|
76
|
+
// so mix it instead of parsing hex. Mixing with `transparent` in srgb keeps the
|
|
77
|
+
// channels and sets the alpha, the same paint as `rgba(r, g, b, a)`.
|
|
78
|
+
function withAlpha(color: string, a: number): string {
|
|
79
|
+
return `color-mix(in srgb, ${color} ${Math.round(a * 10000) / 100}%, transparent)`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const shown = computed(() => data.filter(slice => Number.isFinite(slice.value)).slice(0, limit))
|
|
83
|
+
|
|
84
|
+
interface Column { x: number, w: number, y: number, h: number, fill: string }
|
|
85
|
+
|
|
86
|
+
const columns = computed<Column[]>(() => {
|
|
87
|
+
const slices = shown.value
|
|
88
|
+
if (!slices.length)
|
|
89
|
+
return []
|
|
90
|
+
const max = Math.max(...slices.map(slice => slice.value))
|
|
91
|
+
const gap = 0.3
|
|
92
|
+
const slot = vbW / slices.length
|
|
93
|
+
const w = Math.max(0.5, slot * (1 - gap))
|
|
94
|
+
return slices.map((slice, i) => {
|
|
95
|
+
// A zero max would make every share NaN; draw the floor instead.
|
|
96
|
+
const share = max > 0 ? Math.max(0, slice.value) / max : 0
|
|
97
|
+
const h = Math.max(1, share * vbH)
|
|
98
|
+
// Hue carries the severity; it stays under the neutral opacity so the mix
|
|
99
|
+
// reads as a tint on the card, never as a stack of status chips.
|
|
100
|
+
const tone = slice.tone ?? 'neutral'
|
|
101
|
+
return {
|
|
102
|
+
x: i * slot + (slot - w) / 2,
|
|
103
|
+
w,
|
|
104
|
+
y: vbH - h,
|
|
105
|
+
h,
|
|
106
|
+
// A saturated hue at the neutral's opacity shouts; hold the tinted columns
|
|
107
|
+
// well under it so severity reads as a wash, not as a row of alerts.
|
|
108
|
+
fill: withAlpha(semanticColors[tone].hex, tone === 'neutral' ? opacity : opacity * 0.55),
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
const trackFill = computed(() => withAlpha(semanticColors.neutral.hex, opacity * 0.18))
|
|
113
|
+
|
|
114
|
+
// The caller names the data. A generic primitive announced every chart as
|
|
115
|
+
// "Issue types by open count".
|
|
116
|
+
const accessibleName = computed(() => shown.value.length
|
|
117
|
+
? `${label}: ${shown.value.map(slice => `${slice.label} ${slice.value}`).join(', ')}`
|
|
118
|
+
: `${label}: none`)
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<svg
|
|
123
|
+
data-ui="UiDistributionBars"
|
|
124
|
+
:width="width"
|
|
125
|
+
:height="height"
|
|
126
|
+
:viewBox="`0 0 ${vbW} ${vbH}`"
|
|
127
|
+
:preserveAspectRatio="preserveAspectRatio"
|
|
128
|
+
role="img"
|
|
129
|
+
:aria-label="accessibleName"
|
|
130
|
+
>
|
|
131
|
+
<!-- Full-height tracks: they are what makes a short bar read as a small
|
|
132
|
+
share rather than as a missing category. -->
|
|
133
|
+
<rect
|
|
134
|
+
v-for="(col, i) in columns"
|
|
135
|
+
:key="`track-${i}`"
|
|
136
|
+
:x="col.x"
|
|
137
|
+
:y="0"
|
|
138
|
+
:width="col.w"
|
|
139
|
+
:height="vbH"
|
|
140
|
+
:fill="trackFill"
|
|
141
|
+
/>
|
|
142
|
+
<!-- tabindex -1: motion-v binds focus listeners, and Chromium makes any
|
|
143
|
+
SVG element with one a Tab stop. The svg label already names the data. -->
|
|
144
|
+
<motion.g
|
|
145
|
+
:variants="container"
|
|
146
|
+
initial="rest"
|
|
147
|
+
:animate="hovered && !reduced ? 'hover' : 'rest'"
|
|
148
|
+
tabindex="-1"
|
|
149
|
+
>
|
|
150
|
+
<motion.rect
|
|
151
|
+
v-for="(col, i) in columns"
|
|
152
|
+
:key="i"
|
|
153
|
+
tabindex="-1"
|
|
154
|
+
:variants="barVariants"
|
|
155
|
+
:x="col.x"
|
|
156
|
+
:y="col.y"
|
|
157
|
+
:width="col.w"
|
|
158
|
+
:height="col.h"
|
|
159
|
+
:fill="col.fill"
|
|
160
|
+
:style="{ transformBox: 'fill-box', transformOrigin: 'bottom' }"
|
|
161
|
+
/>
|
|
162
|
+
</motion.g>
|
|
163
|
+
</svg>
|
|
164
|
+
</template>
|