@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,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>
|