@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.
Files changed (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -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 +232 -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 +125 -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 +55 -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 +68 -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 +29 -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/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,517 @@
1
+ <script setup lang="ts" generic="T extends object">
2
+ import type {
3
+ Column,
4
+ ColumnFiltersState,
5
+ ColumnVisibilityState,
6
+ ExpandedState,
7
+ Row,
8
+ RowSelectionState,
9
+ SortingState,
10
+ } from '@tanstack/vue-table'
11
+ import type { UiTableColumn, UiTableFeatures, UiTableRowId, UiTableSize } from '../../shared/table'
12
+ import { FlexRender, functionalUpdate, useTable } from '@tanstack/vue-table'
13
+ import { useIntersectionObserver } from '@vueuse/core'
14
+ import { computed, ref, toRef, useId, useSlots, useTemplateRef, watch } from 'vue'
15
+ import { UiButton, UiSkeleton, UiTableExport, UiTableFrame, UiTableHeaderCell, UPagination } from '#components'
16
+ import { resolveUiTableRowId, uiTableCellSizeClass, uiTableFeatures, uiTableSkeletonSizeClass, uiTableVisibleFromClass } from '../../shared/table'
17
+ import { uiTableExport } from '../../shared/table-export'
18
+
19
+ const {
20
+ data,
21
+ columns,
22
+ selected,
23
+ controlledSelection = false,
24
+ rowHover = false,
25
+ rowClickable = false,
26
+ enableSorting = false,
27
+ manualSorting = false,
28
+ pageSize = 10,
29
+ ignoreHeader,
30
+ size = 'md',
31
+ loading = false,
32
+ loadingRows = 5,
33
+ rowId,
34
+ manualPagination = false,
35
+ disablePagination = false,
36
+ total,
37
+ rowClass,
38
+ activeRowId,
39
+ initialExpandedRowId,
40
+ label,
41
+ bordered = false,
42
+ paginationPreset = 'default',
43
+ fill = false,
44
+ exportFilename,
45
+ } = defineProps<UiTableProps<T>>()
46
+
47
+ const emit = defineEmits<{
48
+ 'rowSelectionChange': [value: RowSelectionState]
49
+ 'rowClick': [row: T]
50
+ 'update:page': [page: number]
51
+ 'sortColumn': [column: string]
52
+ }>()
53
+
54
+ const selectedModel = defineModel<RowSelectionState>('selected')
55
+ const pageModel = defineModel<number>('page', { default: 1 })
56
+ const sortingModel = defineModel<SortingState>('sorting', { default: () => [] })
57
+ const tableInstanceId = useId()
58
+
59
+ const columnFilters = ref<ColumnFiltersState>([])
60
+ const columnVisibility = ref<ColumnVisibilityState>({})
61
+ const rowSelection = ref(selected || {})
62
+ const expanded = ref<ExpandedState>({})
63
+ // Client mode reads the page from `v-model:page`, so a page restored from the
64
+ // URL lands on that page rather than on page 1.
65
+ const pagination = ref({ pageIndex: manualPagination ? 0 : Math.max(0, pageModel.value - 1), pageSize })
66
+
67
+ // Props are read live, never copied once. A copy kept the first `pageSize` in
68
+ // the row slice while the pager counted pages of the new size, and kept the
69
+ // first `selected` after the parent cleared it.
70
+ watch(() => pageSize, (size) => {
71
+ pagination.value = { pageIndex: 0, pageSize: size }
72
+ if (!manualPagination)
73
+ pageModel.value = 1
74
+ })
75
+ watch(() => selected, (value) => {
76
+ if (!controlledSelection)
77
+ rowSelection.value = value ?? {}
78
+ })
79
+ watch(pageModel, (page) => {
80
+ if (!manualPagination && page - 1 !== pagination.value.pageIndex)
81
+ pagination.value = { ...pagination.value, pageIndex: Math.max(0, page - 1) }
82
+ })
83
+
84
+ watch(() => initialExpandedRowId, (value) => {
85
+ expanded.value = value ? { [value]: true } : {}
86
+ }, { immediate: true })
87
+
88
+ function getSortDirection(columnId: string): 'asc' | 'desc' | false {
89
+ const entry = sortingModel.value.find(s => s.id === columnId)
90
+ if (!entry)
91
+ return false
92
+ return entry.desc ? 'desc' : 'asc'
93
+ }
94
+
95
+ function getAriaSort(columnId: string): 'ascending' | 'descending' | 'none' {
96
+ const dir = getSortDirection(columnId)
97
+ if (dir === 'asc')
98
+ return 'ascending'
99
+ if (dir === 'desc')
100
+ return 'descending'
101
+ return 'none'
102
+ }
103
+
104
+ function toggleSort(columnId: string) {
105
+ if (manualSorting) {
106
+ emit('sortColumn', columnId)
107
+ return
108
+ }
109
+ const current = sortingModel.value.find(s => s.id === columnId)
110
+ if (!current)
111
+ sortingModel.value = [{ id: columnId, desc: false }]
112
+ else if (!current.desc)
113
+ sortingModel.value = [{ id: columnId, desc: true }]
114
+ else
115
+ sortingModel.value = []
116
+ }
117
+
118
+ function setClientPage(page: number) {
119
+ table.setPageIndex(page - 1)
120
+ pageModel.value = page
121
+ emit('update:page', page)
122
+ }
123
+
124
+ function getTextAlignClass(align?: 'left' | 'center' | 'right', numeric = false): string {
125
+ if (numeric)
126
+ return 'text-right tabular-nums'
127
+ if (align === 'center')
128
+ return 'text-center'
129
+ if (align === 'right')
130
+ return 'text-right'
131
+ return 'text-left'
132
+ }
133
+
134
+ function rowExpansionStateId(row: Row<UiTableFeatures, T>): string {
135
+ return `${tableInstanceId}-row-${row.index}-expansion-state`
136
+ }
137
+
138
+ const nestedControlSelector = 'a, button, input, select, textarea, [role="button"], [role="link"], [contenteditable="true"]'
139
+
140
+ function eventTargetsNestedControl(event: Event): boolean {
141
+ return event.target instanceof Element
142
+ && event.target !== event.currentTarget
143
+ && event.target.closest(nestedControlSelector) !== null
144
+ }
145
+
146
+ function activateRow(row: T, tanstackRow: Row<UiTableFeatures, T>) {
147
+ if (!rowClickable)
148
+ return
149
+ emit('rowClick', row)
150
+ if (slots['expanded-component']) {
151
+ if (tanstackRow.getIsExpanded())
152
+ expanded.value = {}
153
+ else
154
+ expanded.value = { [tanstackRow.id]: true }
155
+ }
156
+ }
157
+
158
+ function onRowClick(e: MouseEvent, row: T, tanstackRow: Row<UiTableFeatures, T>) {
159
+ if (eventTargetsNestedControl(e))
160
+ return
161
+ activateRow(row, tanstackRow)
162
+ }
163
+
164
+ function onRowKeydown(e: KeyboardEvent, row: T, tanstackRow: Row<UiTableFeatures, T>) {
165
+ if (eventTargetsNestedControl(e))
166
+ return
167
+ if (e.key === 'Enter' || e.key === ' ') {
168
+ e.preventDefault()
169
+ activateRow(row, tanstackRow)
170
+ }
171
+ }
172
+
173
+ const slots = useSlots()
174
+
175
+ // Optional trailing actions column (row buttons/menus). Rendered outside the
176
+ // TanStack column model so callers express rich actions as a slot, not a cell
177
+ // render fn. `colSpan` accounts for it in the empty/skeleton rows.
178
+ const hasActions = computed(() => !!slots.actions)
179
+ const colSpan = computed(() => columns.length + (hasActions.value ? 1 : 0))
180
+
181
+ function uiColumnDef(column: Column<UiTableFeatures, T, unknown>): UiTableColumn<T> {
182
+ return column.columnDef as UiTableColumn<T>
183
+ }
184
+
185
+ const table = useTable<UiTableFeatures, T>({
186
+ features: uiTableFeatures,
187
+ data: toRef(() => data),
188
+ columns: toRef(() => columns),
189
+ enableSorting,
190
+ manualSorting,
191
+ // `disablePagination` mounts every row. Left to the client row model it
192
+ // still sliced rows to `pageSize` and hid the rest with no pager to reach them.
193
+ manualPagination: manualPagination || disablePagination,
194
+ // The table owns expansion. TanStack's default reset cleared it on the first
195
+ // data change, so `initialExpandedRowId` died when rows loaded after mount
196
+ // and every refetch collapsed the open row.
197
+ autoResetExpanded: false,
198
+ ...(manualPagination && total != null && { rowCount: total }),
199
+ onPaginationChange(u) {
200
+ pagination.value = functionalUpdate(u, pagination.value)
201
+ // TanStack resets the page when the data changes; the parent model follows.
202
+ const page = pagination.value.pageIndex + 1
203
+ if (!manualPagination && page !== pageModel.value)
204
+ pageModel.value = page
205
+ },
206
+ onSortingChange: u => sortingModel.value = functionalUpdate(u, sortingModel.value),
207
+ onColumnFiltersChange: u => columnFilters.value = functionalUpdate(u, columnFilters.value),
208
+ onColumnVisibilityChange: u => columnVisibility.value = functionalUpdate(u, columnVisibility.value),
209
+ getRowId: (row, index) => resolveUiTableRowId(row, index, rowId),
210
+ onRowSelectionChange(u) {
211
+ const target = controlledSelection ? selectedModel : rowSelection
212
+ target.value = functionalUpdate(u, target.value ?? {})
213
+ // Emitted from the user's change in both modes. A watch on the internal
214
+ // copy never fired in controlled mode, and echoed prop syncs back up.
215
+ emit('rowSelectionChange', target.value)
216
+ },
217
+ onExpandedChange: u => expanded.value = functionalUpdate(u, expanded.value),
218
+ state: {
219
+ get pagination() { return pagination.value },
220
+ get sorting() { return sortingModel.value },
221
+ get columnFilters() { return columnFilters.value },
222
+ get columnVisibility() { return columnVisibility.value },
223
+ get rowSelection() { return controlledSelection ? selectedModel.value ?? {} : rowSelection.value },
224
+ get expanded() { return expanded.value },
225
+ },
226
+ })
227
+
228
+ const currentPage = computed(() => table.atoms.pagination.get().pageIndex + 1)
229
+ const paginationTotal = computed(() => manualPagination ? total ?? 0 : data.length)
230
+ const paginationPage = computed(() => manualPagination ? pageModel.value : currentPage.value)
231
+ const paginationPageCount = computed(() => Math.max(1, Math.ceil(paginationTotal.value / pageSize)))
232
+ const showPagination = computed(() => !disablePagination && paginationTotal.value > pageSize)
233
+
234
+ function setPaginationPage(page: number): void {
235
+ if (manualPagination) {
236
+ pageModel.value = page
237
+ emit('update:page', page)
238
+ return
239
+ }
240
+ setClientPage(page)
241
+ }
242
+
243
+ // `sm:` is a viewport width and says nothing about the pointer; a touch tablet
244
+ // is wide AND needs the 44px target.
245
+ const PAGINATION_TARGET = 'min-h-11 min-w-11 [@media(pointer:fine)]:min-h-8 [@media(pointer:fine)]:min-w-8'
246
+ const paginationUi = {
247
+ first: PAGINATION_TARGET,
248
+ prev: PAGINATION_TARGET,
249
+ item: PAGINATION_TARGET,
250
+ next: PAGINATION_TARGET,
251
+ last: PAGINATION_TARGET,
252
+ } as const
253
+
254
+ // Exposed so callers can reach the TanStack instance (e.g. a column-visibility
255
+ // menu via `getAllLeafColumns()`).
256
+ defineExpose({ table })
257
+
258
+ // Sticky-header shadow: a zero-height sentinel sits at the top of the wrapper;
259
+ // once it scrolls out through the top the sticky thead has reached the edge, so
260
+ // we lift it. IntersectionObserver fires only on crossing — no per-scroll
261
+ // `getBoundingClientRect()` reflow (the old window-scroll listener ran a
262
+ // querySelector + layout read on every tick, for every mounted table).
263
+ const sentinelEl = useTemplateRef<HTMLElement>('sentinelEl')
264
+ const isScrolled = ref(false)
265
+
266
+ useIntersectionObserver(sentinelEl, ([entry]) => {
267
+ if (entry)
268
+ isScrolled.value = !entry.isIntersecting
269
+ })
270
+
271
+ // Dev guard: pagination (default + manual) bounds the rendered rows, but
272
+ // `disablePagination` mounts every row. Warn before that becomes a perf problem
273
+ // so the caller paginates or caps the list rather than virtualizing blindly.
274
+ if (import.meta.dev) {
275
+ const ROW_WARN_THRESHOLD = 150
276
+ let warned = false
277
+ watch(() => disablePagination && data.length, () => {
278
+ if (disablePagination && data.length > ROW_WARN_THRESHOLD && !warned) {
279
+ warned = true
280
+ console.warn(`[UiTable] rendering ${data.length} rows with disablePagination — every row mounts to the DOM. Paginate, cap the list, or split it to keep rendering performant.`)
281
+ }
282
+ }, { immediate: true })
283
+ }
284
+ </script>
285
+
286
+ <script lang="ts">
287
+ export interface UiTableProps<T extends object> {
288
+ data: T[]
289
+ columns: UiTableColumn<T>[]
290
+ selected?: RowSelectionState
291
+ controlledSelection?: boolean
292
+ rowHover?: boolean
293
+ rowClickable?: boolean
294
+ enableSorting?: boolean
295
+ /** Caller owns sort state; UiTable emits @sortColumn and bypasses client-side sorting. */
296
+ manualSorting?: boolean
297
+ pageSize?: number
298
+ ignoreHeader?: boolean
299
+ size?: UiTableSize
300
+ loading?: boolean
301
+ loadingRows?: number
302
+ rowId?: UiTableRowId<T>
303
+ manualPagination?: boolean
304
+ disablePagination?: boolean
305
+ total?: number
306
+ /** Accessible name for the table. Rendered as a visually-hidden <caption>. */
307
+ label: string
308
+ /** Draw the canonical rounded table surface. */
309
+ bordered?: boolean
310
+ /** Pagination density. Compact renders previous, page position, and next only. */
311
+ paginationPreset?: 'default' | 'compact'
312
+ /** Fill the caller's available height and keep pagination pinned to the bottom. */
313
+ fill?: boolean
314
+ /** Extra classes applied per data row — e.g. a selection highlight. */
315
+ rowClass?: (row: T) => string
316
+ /** Row id presented as the active item in a click-through table. */
317
+ activeRowId?: string
318
+ /** Row id to open when the table first receives deep-linked evidence. */
319
+ initialExpandedRowId?: string
320
+ /**
321
+ * Show Export CSV and Export JSON, saving to this file name without the
322
+ * extension. The export holds every filtered and sorted row, not one page.
323
+ * Ignored with `manualPagination`: mount `UiTableExport` with every row.
324
+ */
325
+ exportFilename?: string
326
+ }
327
+ </script>
328
+
329
+ <template>
330
+ <div class="w-full" :class="fill && 'flex h-full min-h-0 flex-col'">
331
+ <UiTableExport
332
+ v-if="exportFilename && !manualPagination"
333
+ class="mb-2"
334
+ :filename="exportFilename"
335
+ :rows="() => uiTableExport(table).rows"
336
+ :columns="uiTableExport(table).columns"
337
+ />
338
+ <UiTableFrame
339
+ name="UiTable"
340
+ :class="fill && 'min-h-0 flex-1'"
341
+ :bordered="bordered"
342
+ :row-hover="rowHover || rowClickable"
343
+ :scrolled="isScrolled"
344
+ :scroll-label="label ? `${label} table scroll area` : undefined"
345
+ >
346
+ <div ref="sentinelEl" aria-hidden="true" class="h-px w-full" />
347
+ <table class="w-full" :data-size="size" :aria-busy="loading || undefined">
348
+ <caption v-if="label" class="sr-only">
349
+ {{ label }}
350
+ </caption>
351
+ <thead v-if="!ignoreHeader">
352
+ <tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id" class="h-10">
353
+ <th
354
+ v-for="header in headerGroup.headers"
355
+ :key="header.id"
356
+ class="text-label text-left whitespace-nowrap border-b border-default bg-default"
357
+ :class="[
358
+ uiColumnDef(header.column).noPadding ? '' : header.column.getCanSort() ? 'px-2' : 'px-3',
359
+ getTextAlignClass(uiColumnDef(header.column).align, uiColumnDef(header.column).numeric),
360
+ uiColumnDef(header.column).visibleFrom ? uiTableVisibleFromClass[uiColumnDef(header.column).visibleFrom!] : '',
361
+ uiColumnDef(header.column).headClass,
362
+ ]"
363
+ :aria-sort="header.column.getCanSort() ? getAriaSort(header.column.id) : undefined"
364
+ scope="col"
365
+ >
366
+ <UiTableHeaderCell
367
+ :header="header"
368
+ :sort-direction="getSortDirection(header.column.id)"
369
+ @sort="toggleSort"
370
+ />
371
+ </th>
372
+ <th v-if="hasActions" class="w-px border-b border-default bg-default" scope="col">
373
+ <span class="sr-only">Actions</span>
374
+ </th>
375
+ </tr>
376
+ </thead>
377
+ <tbody>
378
+ <template v-if="table.getRowModel().rows?.length">
379
+ <tr class="spacer" />
380
+ <template v-for="row in table.getRowModel().rows" :key="row.id">
381
+ <tr
382
+ :data-state="(row.getIsSelected() || row.id === activeRowId) && 'selected'"
383
+ :data-expanded="row.getIsExpanded()"
384
+ :data-row-id="row.id"
385
+ :tabindex="rowClickable ? 0 : undefined"
386
+ :aria-describedby="rowClickable && slots['expanded-component'] ? rowExpansionStateId(row) : undefined"
387
+ :aria-selected="row.getIsSelected() || row.id === activeRowId ? true : undefined"
388
+ :class="[
389
+ rowClickable && 'cursor-pointer focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary',
390
+ rowClass?.(row.original),
391
+ ]"
392
+ @click="onRowClick($event, row.original, row)"
393
+ @keydown="rowClickable && onRowKeydown($event, row.original, row)"
394
+ >
395
+ <component
396
+ :is="uiColumnDef(cell.column).rowHeader ? 'th' : 'td'"
397
+ v-for="cell in row.getVisibleCells()"
398
+ :key="cell.id"
399
+ :scope="uiColumnDef(cell.column).rowHeader ? 'row' : undefined"
400
+ class="text-xs font-normal text-default relative"
401
+ :class="[
402
+ uiTableCellSizeClass[size],
403
+ uiColumnDef(cell.column).noPadding ? '' : cell.column.getCanSort() ? 'px-2' : 'px-3',
404
+ getTextAlignClass(uiColumnDef(cell.column).align, uiColumnDef(cell.column).numeric),
405
+ uiColumnDef(cell.column).visibleFrom ? uiTableVisibleFromClass[uiColumnDef(cell.column).visibleFrom!] : '',
406
+ uiColumnDef(cell.column).cellClass,
407
+ uiColumnDef(cell.column).ui?.td?.base || '',
408
+ ]"
409
+ >
410
+ <span
411
+ v-if="rowClickable && slots['expanded-component'] && cell.id === row.getVisibleCells()[0]?.id"
412
+ :id="rowExpansionStateId(row)"
413
+ class="sr-only"
414
+ >
415
+ {{ row.getIsExpanded() ? 'Expanded.' : 'Collapsed.' }}
416
+ Press Enter or Space to {{ row.getIsExpanded() ? 'collapse' : 'expand' }} row details.
417
+ </span>
418
+ <UiSkeleton
419
+ v-if="loading && !uiColumnDef(cell.column).stableData"
420
+ :class="[uiTableSkeletonSizeClass[size]]"
421
+ :index="row.index * columns.length + cell.column.getIndex()"
422
+ :base="60"
423
+ :range="50"
424
+ />
425
+ <FlexRender v-else :cell="cell" />
426
+ </component>
427
+ <td v-if="hasActions" class="text-right whitespace-nowrap px-3" :class="uiTableCellSizeClass[size]" @click.stop>
428
+ <slot name="actions" :row="row.original" />
429
+ </td>
430
+ </tr>
431
+ <tr v-if="row.getIsExpanded()" class="expanded-row">
432
+ <td :colspan="colSpan" class="px-2 pb-2">
433
+ <div class="rounded-lg bg-accented">
434
+ <slot name="expanded-component" :row="row.original" />
435
+ </div>
436
+ </td>
437
+ </tr>
438
+ </template>
439
+ </template>
440
+
441
+ <template v-else-if="loading">
442
+ <tr class="spacer" />
443
+ <tr v-for="i in loadingRows" :key="`skeleton-${i}`">
444
+ <td
445
+ v-for="(col, j) in columns"
446
+ :key="j"
447
+ class="text-xs px-3"
448
+ :class="[
449
+ uiTableCellSizeClass[size],
450
+ getTextAlignClass(col.align, col.numeric),
451
+ col.visibleFrom ? uiTableVisibleFromClass[col.visibleFrom] : '',
452
+ ]"
453
+ >
454
+ <UiSkeleton class="h-4" :index="i * columns.length + j" :base="60" :range="50" />
455
+ </td>
456
+ </tr>
457
+ </template>
458
+
459
+ <tr v-else>
460
+ <td :colspan="colSpan" class="h-24 text-center" role="status">
461
+ <slot name="empty-component">
462
+ <span class="text-xs font-mono text-dimmed">0 rows · adjust filters</span>
463
+ </slot>
464
+ </td>
465
+ </tr>
466
+ </tbody>
467
+ <slot name="tfoot" />
468
+ </table>
469
+ </UiTableFrame>
470
+
471
+ <nav
472
+ v-if="showPagination && paginationPreset === 'compact'"
473
+ class="mt-3 flex items-center justify-center gap-1 sm:justify-end"
474
+ :aria-label="`${label} pagination`"
475
+ >
476
+ <UiButton
477
+ purpose="quiet"
478
+ icon="chevron-left"
479
+ size="xs"
480
+ class="min-h-11 min-w-11 [@media(pointer:fine)]:min-h-8 [@media(pointer:fine)]:min-w-8"
481
+ :disabled="paginationPage <= 1"
482
+ aria-label="Previous page"
483
+ @click="setPaginationPage(paginationPage - 1)"
484
+ />
485
+ <span class="min-w-14 text-center text-sm tabular-nums text-muted" aria-live="polite">
486
+ {{ paginationPage }} / {{ paginationPageCount }}
487
+ </span>
488
+ <UiButton
489
+ purpose="quiet"
490
+ icon="chevron-right"
491
+ size="xs"
492
+ class="min-h-11 min-w-11 [@media(pointer:fine)]:min-h-8 [@media(pointer:fine)]:min-w-8"
493
+ :disabled="paginationPage >= paginationPageCount"
494
+ aria-label="Next page"
495
+ @click="setPaginationPage(paginationPage + 1)"
496
+ />
497
+ </nav>
498
+ <UPagination
499
+ v-else-if="paginationPreset === 'default' && !disablePagination && manualPagination && total != null && total > pageSize"
500
+ class="mt-5"
501
+ :page="pageModel"
502
+ :items-per-page="pageSize"
503
+ :total="total"
504
+ :ui="paginationUi"
505
+ @update:page="(e: number) => { pageModel = e; emit('update:page', e) }"
506
+ />
507
+ <UPagination
508
+ v-else-if="paginationPreset === 'default' && !disablePagination && !manualPagination && data.length > pageSize"
509
+ class="mt-5"
510
+ :page="currentPage"
511
+ :items-per-page="pageSize"
512
+ :total="data.length"
513
+ :ui="paginationUi"
514
+ @update:page="setClientPage"
515
+ />
516
+ </div>
517
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts" generic="R">
2
+ import type { ExportColumn } from '../../utils/export-download'
3
+ import { UiButton } from '#components'
4
+ import { downloadCsvText, downloadJson, tableToCsv, tableToRecords } from '../../utils/export-download'
5
+
6
+ /**
7
+ * UiTableExport: the Export CSV and Export JSON actions for a data list.
8
+ * VISION principle 7: every data list ships a way out.
9
+ *
10
+ * `UiTable` renders this when it gets `export-filename`. A table that pages on
11
+ * the server holds one page only, so it mounts this directly with every row
12
+ * the current filters match.
13
+ */
14
+ const { filename, rows, columns } = defineProps<{
15
+ /** File name without the extension, e.g. `queries-2026-11-11`. */
16
+ filename: string
17
+ /** Every row to export, in display order. Not one page. */
18
+ rows: () => readonly R[]
19
+ columns: readonly ExportColumn<R>[]
20
+ }>()
21
+
22
+ function exportCsv(): void {
23
+ downloadCsvText(`${filename}.csv`, tableToCsv(rows(), columns))
24
+ }
25
+
26
+ function exportJson(): void {
27
+ downloadJson(`${filename}.json`, tableToRecords(rows(), columns))
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div data-ui="UiTableExport" class="flex items-center justify-end gap-1">
33
+ <UiButton purpose="quiet" size="xs" icon="download" @click="exportCsv">
34
+ Export CSV
35
+ </UiButton>
36
+ <UiButton purpose="quiet" size="xs" icon="download" @click="exportJson">
37
+ Export JSON
38
+ </UiButton>
39
+ </div>
40
+ </template>