@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.
Files changed (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. 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>