@proteos/ui 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
|
2
|
+
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
|
|
3
|
+
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge'
|
|
4
|
+
import {
|
|
5
|
+
type ColumnDef,
|
|
6
|
+
flexRender,
|
|
7
|
+
getCoreRowModel,
|
|
8
|
+
type OnChangeFn,
|
|
9
|
+
type Row,
|
|
10
|
+
type SortingState,
|
|
11
|
+
useReactTable,
|
|
12
|
+
} from '@tanstack/react-table'
|
|
13
|
+
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
14
|
+
import {
|
|
15
|
+
type CSSProperties,
|
|
16
|
+
type KeyboardEvent,
|
|
17
|
+
type MouseEvent,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
useEffect,
|
|
20
|
+
useLayoutEffect,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
} from 'react'
|
|
25
|
+
|
|
26
|
+
import { cn } from '../../lib/cn'
|
|
27
|
+
import {
|
|
28
|
+
isColumnDragData,
|
|
29
|
+
type SortDirection,
|
|
30
|
+
Table,
|
|
31
|
+
TableCell,
|
|
32
|
+
TableHeader,
|
|
33
|
+
TableHeaderCell,
|
|
34
|
+
TableHeaderSpacer,
|
|
35
|
+
TableRow,
|
|
36
|
+
TableScroller,
|
|
37
|
+
type TableVariants,
|
|
38
|
+
} from '../table'
|
|
39
|
+
import { DataTableEmpty } from './states/data-table-empty'
|
|
40
|
+
import { DataTableError } from './states/data-table-error'
|
|
41
|
+
import { DataTableSkeleton } from './states/data-table-skeleton'
|
|
42
|
+
import { useInfiniteScrollSentinel } from './use-infinite-scroll-sentinel'
|
|
43
|
+
|
|
44
|
+
/* =========================================================================
|
|
45
|
+
DataTable — generic virtualized infinite-scroll table.
|
|
46
|
+
- TanStack Table v8 owns the column model and sorting state.
|
|
47
|
+
- TanStack Virtual virtualizes the body against an internal scroller ref.
|
|
48
|
+
- An IntersectionObserver sentinel triggers `query.fetchNextPage()` ~200 px
|
|
49
|
+
before the user reaches the bottom. Visuals (loader, end marker, empty,
|
|
50
|
+
error) match the Anthropic design's `pt-loader`/`pt-end`/`pt-empty`.
|
|
51
|
+
- The component is data-fetching-shape-agnostic: it accepts anything
|
|
52
|
+
conforming to InfiniteQueryLike (matches @tanstack/react-query's
|
|
53
|
+
useInfiniteQuery return shape).
|
|
54
|
+
========================================================================= */
|
|
55
|
+
|
|
56
|
+
type InfinitePage<TRow> = { data: TRow[]; meta?: { items_total?: number } }
|
|
57
|
+
|
|
58
|
+
export type InfiniteQueryLike<TRow> = {
|
|
59
|
+
data?: { pages: InfinitePage<TRow>[] } | undefined
|
|
60
|
+
isLoading: boolean
|
|
61
|
+
isFetchingNextPage: boolean
|
|
62
|
+
hasNextPage: boolean | undefined
|
|
63
|
+
error: Error | null
|
|
64
|
+
fetchNextPage: () => void
|
|
65
|
+
refetch?: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DataTableProps<TRow> = {
|
|
69
|
+
query: InfiniteQueryLike<TRow>
|
|
70
|
+
columns: ColumnDef<TRow>[]
|
|
71
|
+
sorting?: SortingState
|
|
72
|
+
onSortingChange?: OnChangeFn<SortingState>
|
|
73
|
+
rowHeight?: number
|
|
74
|
+
overscan?: number
|
|
75
|
+
/**
|
|
76
|
+
* Called when a row is clicked. Provides both the original data row and
|
|
77
|
+
* the underlying event so consumers can inspect modifier keys.
|
|
78
|
+
*/
|
|
79
|
+
onRowClick?: (
|
|
80
|
+
row: TRow,
|
|
81
|
+
event: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>,
|
|
82
|
+
) => void
|
|
83
|
+
zebra?: boolean
|
|
84
|
+
emptyState?: ReactNode
|
|
85
|
+
errorState?: ReactNode
|
|
86
|
+
loadingState?: ReactNode
|
|
87
|
+
/**
|
|
88
|
+
* Optional accessor used as the React key for each row. Defaults to a
|
|
89
|
+
* row's `id` field (if present), then `slug`, then index.
|
|
90
|
+
*/
|
|
91
|
+
getRowId?: (row: TRow, index: number) => string
|
|
92
|
+
className?: string
|
|
93
|
+
ariaLabel?: string
|
|
94
|
+
/**
|
|
95
|
+
* Enable horizontal drag-and-drop reordering of columns. When on, a grip
|
|
96
|
+
* handle and `monitorForElements` are wired up; on a successful reorder
|
|
97
|
+
* `onReorderColumn` fires with the moved column's id and its new index.
|
|
98
|
+
* Each `ColumnDef` must carry a stable `id` for events to be routable.
|
|
99
|
+
*/
|
|
100
|
+
enableColumnReordering?: boolean
|
|
101
|
+
/**
|
|
102
|
+
* Enable an `×` remove affordance on each column header. The handler is
|
|
103
|
+
* called with the column's id; the parent decides what removal means
|
|
104
|
+
* (e.g. filter the displayed `columns` array).
|
|
105
|
+
*/
|
|
106
|
+
enableColumnRemoval?: boolean
|
|
107
|
+
onReorderColumn?: (params: { id: string; toIndex: number }) => void
|
|
108
|
+
onRemoveColumn?: (id: string) => void
|
|
109
|
+
/**
|
|
110
|
+
* Visual chrome around the table. `panel` (default) wraps the table in a
|
|
111
|
+
* bordered, rounded, soft-shadowed card. `bare` strips that chrome so the
|
|
112
|
+
* table can sit flush with its container — used when the surrounding
|
|
113
|
+
* surface already supplies borders (e.g. the Query Builder's results pane).
|
|
114
|
+
*/
|
|
115
|
+
variant?: TableVariants['variant']
|
|
116
|
+
/**
|
|
117
|
+
* Optional per-row wrapper. When provided, the row's cell content is
|
|
118
|
+
* passed through this function, which may render context providers (e.g.
|
|
119
|
+
* a row-scoped TanStack Form for inline editing) around the cells. The
|
|
120
|
+
* default is identity — cells render directly inside the `<TableRow>`.
|
|
121
|
+
*/
|
|
122
|
+
rowWrapper?: (row: Row<TRow>, cells: ReactNode) => ReactNode
|
|
123
|
+
/**
|
|
124
|
+
* Extra rows rendered alongside the server data. Used for inline-editable
|
|
125
|
+
* draft rows that are not yet persisted. `start` rows render above the
|
|
126
|
+
* server data, `end` rows below. They participate in the same column
|
|
127
|
+
* model, virtualization, and `rowWrapper` as server rows.
|
|
128
|
+
*/
|
|
129
|
+
extraRows?: { start?: TRow[]; end?: TRow[] }
|
|
130
|
+
/**
|
|
131
|
+
* Slot rendered ABOVE the body (above the virtualized region). Used for
|
|
132
|
+
* controls like an Add row button at slot='start'. Stays put when the
|
|
133
|
+
* body scrolls (sits inside the header sticky region or just below it).
|
|
134
|
+
*/
|
|
135
|
+
bodyBefore?: ReactNode
|
|
136
|
+
/**
|
|
137
|
+
* Slot rendered BELOW the body. Used for controls like an Add row button
|
|
138
|
+
* at slot='end'. Sits inside the scrollable area below the rows.
|
|
139
|
+
*/
|
|
140
|
+
bodyAfter?: ReactNode
|
|
141
|
+
/**
|
|
142
|
+
* Draw vertical gridlines between body cells. The header always has them;
|
|
143
|
+
* the body opts in (e.g. inline-edit mode wants the full Excel-style grid).
|
|
144
|
+
* Default false — read-only tables stay borderless in the body.
|
|
145
|
+
*/
|
|
146
|
+
columnBorders?: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const DEFAULT_ROW_HEIGHT = 32
|
|
150
|
+
|
|
151
|
+
export function DataTable<TRow>({
|
|
152
|
+
query,
|
|
153
|
+
columns,
|
|
154
|
+
sorting,
|
|
155
|
+
onSortingChange,
|
|
156
|
+
rowHeight = DEFAULT_ROW_HEIGHT,
|
|
157
|
+
overscan = 8,
|
|
158
|
+
onRowClick,
|
|
159
|
+
zebra = true,
|
|
160
|
+
emptyState,
|
|
161
|
+
errorState,
|
|
162
|
+
loadingState,
|
|
163
|
+
getRowId,
|
|
164
|
+
className,
|
|
165
|
+
ariaLabel,
|
|
166
|
+
enableColumnReordering = false,
|
|
167
|
+
enableColumnRemoval = false,
|
|
168
|
+
onReorderColumn,
|
|
169
|
+
onRemoveColumn,
|
|
170
|
+
variant = 'panel',
|
|
171
|
+
rowWrapper,
|
|
172
|
+
extraRows,
|
|
173
|
+
bodyBefore,
|
|
174
|
+
bodyAfter,
|
|
175
|
+
columnBorders = false,
|
|
176
|
+
}: DataTableProps<TRow>) {
|
|
177
|
+
const rows = useMemo<TRow[]>(() => {
|
|
178
|
+
const pages = query.data?.pages
|
|
179
|
+
const dataRows = pages && pages.length > 0 ? pages.flatMap((p) => p.data) : []
|
|
180
|
+
const startExtras = extraRows?.start ?? []
|
|
181
|
+
const endExtras = extraRows?.end ?? []
|
|
182
|
+
if (startExtras.length === 0 && endExtras.length === 0) return dataRows
|
|
183
|
+
return [...startExtras, ...dataRows, ...endExtras]
|
|
184
|
+
}, [query.data, extraRows])
|
|
185
|
+
|
|
186
|
+
const table = useReactTable({
|
|
187
|
+
data: rows,
|
|
188
|
+
columns,
|
|
189
|
+
state: { sorting: sorting ?? [] },
|
|
190
|
+
...(onSortingChange ? { onSortingChange } : {}),
|
|
191
|
+
manualSorting: true,
|
|
192
|
+
getCoreRowModel: getCoreRowModel(),
|
|
193
|
+
getRowId: getRowId
|
|
194
|
+
? (row, index) => getRowId(row, index)
|
|
195
|
+
: (row, index) => {
|
|
196
|
+
const r = row as Record<string, unknown>
|
|
197
|
+
if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
|
|
198
|
+
if (typeof r.slug === 'string') return r.slug
|
|
199
|
+
return String(index)
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const tableRows = table.getRowModel().rows
|
|
204
|
+
const tableRowCount = tableRows.length
|
|
205
|
+
|
|
206
|
+
// Reset scroll to top when sorting changes — without this, the user is
|
|
207
|
+
// staring at a different slice of a freshly-sorted dataset.
|
|
208
|
+
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
|
209
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — scroll resets on sort change only
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
scrollerRef.current?.scrollTo({ top: 0 })
|
|
212
|
+
}, [sorting])
|
|
213
|
+
|
|
214
|
+
// The sticky header lives inside the scroller, ABOVE the row list, so the
|
|
215
|
+
// list starts at an offset (the header's height, plus any `bodyBefore`).
|
|
216
|
+
// Feed that offset to the virtualizer as `scrollMargin` so its visible
|
|
217
|
+
// window math accounts for it; render rows at `start - scrollMargin`.
|
|
218
|
+
const listRef = useRef<HTMLDivElement | null>(null)
|
|
219
|
+
const [listOffset, setListOffset] = useState(0)
|
|
220
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-measure every render; cheap offsetTop read, guarded setState
|
|
221
|
+
useLayoutEffect(() => {
|
|
222
|
+
const top = listRef.current?.offsetTop ?? 0
|
|
223
|
+
setListOffset((prev) => (prev === top ? prev : top))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const virtualizer = useVirtualizer({
|
|
227
|
+
count: tableRowCount,
|
|
228
|
+
getScrollElement: () => scrollerRef.current,
|
|
229
|
+
estimateSize: () => rowHeight,
|
|
230
|
+
overscan,
|
|
231
|
+
scrollMargin: listOffset,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const sentinelRef = useInfiniteScrollSentinel<HTMLDivElement>({
|
|
235
|
+
onIntersect: () => {
|
|
236
|
+
if (query.hasNextPage && !query.isFetchingNextPage && !query.isLoading) {
|
|
237
|
+
query.fetchNextPage()
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
rootMargin: '200px',
|
|
241
|
+
root: scrollerRef.current,
|
|
242
|
+
enabled: !!query.hasNextPage,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const totalColumnWidth = useMemo(
|
|
246
|
+
() => columns.reduce((sum, c) => sum + (c.size ?? 140), 0),
|
|
247
|
+
[columns],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// Single global pragmatic-dnd monitor for column reorder. We mirror the
|
|
251
|
+
// entity-designer pattern: read the latest columns + handler via refs so
|
|
252
|
+
// the monitor doesn't re-register on every render. Gated on
|
|
253
|
+
// `enableColumnReordering` so it's a no-op when the feature is off.
|
|
254
|
+
const columnsRef = useRef(columns)
|
|
255
|
+
columnsRef.current = columns
|
|
256
|
+
const onReorderRef = useRef(onReorderColumn)
|
|
257
|
+
onReorderRef.current = onReorderColumn
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (!enableColumnReordering) return
|
|
261
|
+
return monitorForElements({
|
|
262
|
+
canMonitor: ({ source }) => isColumnDragData(source.data),
|
|
263
|
+
onDrop: ({ source, location }) => {
|
|
264
|
+
const target = location.current.dropTargets[0]
|
|
265
|
+
if (!target) return
|
|
266
|
+
if (!isColumnDragData(source.data) || !isColumnDragData(target.data)) return
|
|
267
|
+
if (source.data.columnId === target.data.columnId) return
|
|
268
|
+
|
|
269
|
+
const ids = columnsRef.current.map((c) => c.id).filter((id): id is string => !!id)
|
|
270
|
+
const startIndex = ids.indexOf(source.data.columnId)
|
|
271
|
+
const indexOfTarget = ids.indexOf(target.data.columnId)
|
|
272
|
+
if (startIndex === -1 || indexOfTarget === -1) return
|
|
273
|
+
|
|
274
|
+
const closestEdge = extractClosestEdge(target.data)
|
|
275
|
+
const reordered = reorderWithEdge({
|
|
276
|
+
list: ids,
|
|
277
|
+
startIndex,
|
|
278
|
+
indexOfTarget,
|
|
279
|
+
closestEdgeOfTarget: closestEdge,
|
|
280
|
+
axis: 'horizontal',
|
|
281
|
+
})
|
|
282
|
+
if (reordered === ids) return
|
|
283
|
+
|
|
284
|
+
const sourceId = source.data.columnId
|
|
285
|
+
onReorderRef.current?.({ id: sourceId, toIndex: reordered.indexOf(sourceId) })
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
}, [enableColumnReordering])
|
|
289
|
+
|
|
290
|
+
const showEmpty =
|
|
291
|
+
!query.isLoading && !query.error && tableRowCount === 0 && !query.isFetchingNextPage
|
|
292
|
+
const showInitialLoading = query.isLoading && tableRowCount === 0
|
|
293
|
+
const showError = !!query.error && tableRowCount === 0
|
|
294
|
+
const showEndMarker =
|
|
295
|
+
!query.isLoading && !query.isFetchingNextPage && !query.hasNextPage && tableRowCount > 0
|
|
296
|
+
|
|
297
|
+
const totalSize = virtualizer.getTotalSize()
|
|
298
|
+
const virtualItems = virtualizer.getVirtualItems()
|
|
299
|
+
|
|
300
|
+
const headerGroups = table.getHeaderGroups()
|
|
301
|
+
const minPanelWidth = totalColumnWidth + 24
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<Table
|
|
305
|
+
{...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : {})}
|
|
306
|
+
variant={variant}
|
|
307
|
+
className={cn('flex-1 min-h-0', className)}
|
|
308
|
+
>
|
|
309
|
+
{/* Single both-axes scroll container. Horizontal wheel/trackpad scrolls
|
|
310
|
+
the columns; vertical scrolls the rows. The sticky header pins to the
|
|
311
|
+
top, and any `sticky left-0` gutter column freezes to the left. */}
|
|
312
|
+
<TableScroller ref={scrollerRef}>
|
|
313
|
+
{/* Content track — owns the horizontal min-width so the header + rows
|
|
314
|
+
scroll horizontally together as one unit. */}
|
|
315
|
+
<div className="flex flex-col" style={{ minWidth: minPanelWidth }}>
|
|
316
|
+
{/* Header row(s) — sticky-top so they pin while the rows scroll. */}
|
|
317
|
+
{headerGroups.map((group) => (
|
|
318
|
+
<TableHeader key={group.id} sticky>
|
|
319
|
+
{group.headers.map((header) => {
|
|
320
|
+
const meta = header.column.columnDef.meta as
|
|
321
|
+
| {
|
|
322
|
+
align?: 'left' | 'right' | 'center'
|
|
323
|
+
sticky?: 'left' | 'right' | false
|
|
324
|
+
cellClassName?: string
|
|
325
|
+
}
|
|
326
|
+
| undefined
|
|
327
|
+
const align = meta?.align ?? 'left'
|
|
328
|
+
const sticky = meta?.sticky ?? false
|
|
329
|
+
const headerSize = header.getSize() || undefined
|
|
330
|
+
const headerNode = header.isPlaceholder
|
|
331
|
+
? null
|
|
332
|
+
: flexRender(header.column.columnDef.header, header.getContext())
|
|
333
|
+
|
|
334
|
+
const sorted = header.column.getIsSorted()
|
|
335
|
+
const direction: SortDirection = sorted === false ? null : sorted
|
|
336
|
+
const canSort = header.column.getCanSort()
|
|
337
|
+
const handleSort = header.column.getToggleSortingHandler()
|
|
338
|
+
const ariaLabel =
|
|
339
|
+
typeof header.column.columnDef.header === 'string'
|
|
340
|
+
? header.column.columnDef.header
|
|
341
|
+
: header.column.id
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<TableHeaderCell
|
|
345
|
+
key={header.id}
|
|
346
|
+
align={align}
|
|
347
|
+
ariaLabel={ariaLabel}
|
|
348
|
+
draggable={enableColumnReordering && !!header.column.id}
|
|
349
|
+
{...(header.column.id ? { columnId: header.column.id } : {})}
|
|
350
|
+
{...(headerSize !== undefined ? { width: headerSize } : {})}
|
|
351
|
+
{...(sticky !== false ? { sticky } : {})}
|
|
352
|
+
{...(meta?.cellClassName ? { className: meta.cellClassName } : {})}
|
|
353
|
+
{...(canSort
|
|
354
|
+
? {
|
|
355
|
+
sortDirection: direction,
|
|
356
|
+
onSortToggle: () => handleSort?.({} as never),
|
|
357
|
+
}
|
|
358
|
+
: {})}
|
|
359
|
+
{...(enableColumnRemoval && onRemoveColumn && header.column.id
|
|
360
|
+
? { onRemove: () => onRemoveColumn(header.column.id) }
|
|
361
|
+
: {})}
|
|
362
|
+
>
|
|
363
|
+
{headerNode}
|
|
364
|
+
</TableHeaderCell>
|
|
365
|
+
)
|
|
366
|
+
})}
|
|
367
|
+
<TableHeaderSpacer />
|
|
368
|
+
</TableHeader>
|
|
369
|
+
))}
|
|
370
|
+
|
|
371
|
+
{bodyBefore}
|
|
372
|
+
|
|
373
|
+
{showInitialLoading ? (
|
|
374
|
+
(loadingState ?? <DataTableSkeleton rowHeight={rowHeight} />)
|
|
375
|
+
) : showError ? (
|
|
376
|
+
<div role="rowgroup">
|
|
377
|
+
{errorState ?? (query.error ? <DataTableError error={query.error} /> : null)}
|
|
378
|
+
</div>
|
|
379
|
+
) : (
|
|
380
|
+
<div role="rowgroup">
|
|
381
|
+
{showEmpty ? (
|
|
382
|
+
(emptyState ?? <DataTableEmpty />)
|
|
383
|
+
) : (
|
|
384
|
+
<div
|
|
385
|
+
ref={listRef}
|
|
386
|
+
role="presentation"
|
|
387
|
+
style={{ height: totalSize, position: 'relative', width: '100%' }}
|
|
388
|
+
>
|
|
389
|
+
{virtualItems.map((vRow) => {
|
|
390
|
+
const row = tableRows[vRow.index]
|
|
391
|
+
if (!row) return null
|
|
392
|
+
const rowStyle: CSSProperties = {
|
|
393
|
+
// Items' `start` includes the virtualizer's scrollMargin
|
|
394
|
+
// (the list's offset below the sticky header); subtract
|
|
395
|
+
// it to position relative to the list container.
|
|
396
|
+
transform: `translateY(${vRow.start - listOffset}px)`,
|
|
397
|
+
height: vRow.size,
|
|
398
|
+
}
|
|
399
|
+
return (
|
|
400
|
+
<DataRow
|
|
401
|
+
key={row.id}
|
|
402
|
+
row={row}
|
|
403
|
+
index={vRow.index}
|
|
404
|
+
zebra={zebra}
|
|
405
|
+
onRowClick={onRowClick}
|
|
406
|
+
style={rowStyle}
|
|
407
|
+
columnBorders={columnBorders}
|
|
408
|
+
{...(rowWrapper ? { rowWrapper } : {})}
|
|
409
|
+
/>
|
|
410
|
+
)
|
|
411
|
+
})}
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
414
|
+
|
|
415
|
+
{/* Sentinel — visually empty, positioned just above the loader/end
|
|
416
|
+
marker so it intersects ~200 px before the bottom. */}
|
|
417
|
+
{!showEmpty && (
|
|
418
|
+
<div ref={sentinelRef} aria-hidden="true" style={{ height: 1, width: '100%' }} />
|
|
419
|
+
)}
|
|
420
|
+
|
|
421
|
+
{query.isFetchingNextPage ? <LoaderRow /> : null}
|
|
422
|
+
{showEndMarker ? <EndMarker count={tableRowCount} /> : null}
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
|
|
426
|
+
{bodyAfter}
|
|
427
|
+
</div>
|
|
428
|
+
</TableScroller>
|
|
429
|
+
</Table>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* ---------- Row ---------------------------------------------------------- */
|
|
434
|
+
|
|
435
|
+
type DataRowProps<TRow> = {
|
|
436
|
+
row: Row<TRow>
|
|
437
|
+
index: number
|
|
438
|
+
zebra: boolean
|
|
439
|
+
onRowClick?: DataTableProps<TRow>['onRowClick']
|
|
440
|
+
style: CSSProperties
|
|
441
|
+
rowWrapper?: (row: Row<TRow>, cells: ReactNode) => ReactNode
|
|
442
|
+
columnBorders?: boolean
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function DataRow<TRow>({
|
|
446
|
+
row,
|
|
447
|
+
index,
|
|
448
|
+
zebra,
|
|
449
|
+
onRowClick,
|
|
450
|
+
style,
|
|
451
|
+
rowWrapper,
|
|
452
|
+
columnBorders = false,
|
|
453
|
+
}: DataRowProps<TRow>) {
|
|
454
|
+
const isAlt = zebra && index % 2 === 1
|
|
455
|
+
const interactive = !!onRowClick
|
|
456
|
+
|
|
457
|
+
const cells = row.getVisibleCells().map((cell) => {
|
|
458
|
+
const meta = cell.column.columnDef.meta as
|
|
459
|
+
| {
|
|
460
|
+
align?: 'left' | 'right' | 'center'
|
|
461
|
+
mono?: boolean
|
|
462
|
+
ink?: boolean
|
|
463
|
+
sticky?: 'left' | 'right' | false
|
|
464
|
+
/** Extra classes merged onto the cell — e.g. to drop the default
|
|
465
|
+
* horizontal padding for a narrow fixed-width gutter column. */
|
|
466
|
+
cellClassName?: string
|
|
467
|
+
}
|
|
468
|
+
| undefined
|
|
469
|
+
const cellSize = cell.column.getSize() || undefined
|
|
470
|
+
const cellClassName = cn(
|
|
471
|
+
columnBorders ? 'border-r border-rule-2' : undefined,
|
|
472
|
+
meta?.cellClassName,
|
|
473
|
+
)
|
|
474
|
+
return (
|
|
475
|
+
<TableCell
|
|
476
|
+
key={cell.id}
|
|
477
|
+
{...(cellSize !== undefined ? { width: cellSize } : {})}
|
|
478
|
+
align={meta?.align ?? 'left'}
|
|
479
|
+
{...(meta?.mono !== undefined ? { mono: meta.mono } : {})}
|
|
480
|
+
{...(meta?.ink !== undefined ? { ink: meta.ink } : {})}
|
|
481
|
+
{...(meta?.sticky ? { sticky: meta.sticky } : {})}
|
|
482
|
+
{...(cellClassName ? { className: cellClassName } : {})}
|
|
483
|
+
>
|
|
484
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
485
|
+
</TableCell>
|
|
486
|
+
)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const body = rowWrapper ? rowWrapper(row, cells) : cells
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<TableRow
|
|
493
|
+
absolute
|
|
494
|
+
alt={isAlt}
|
|
495
|
+
interactive={interactive}
|
|
496
|
+
style={style}
|
|
497
|
+
data-row-id={row.id}
|
|
498
|
+
tabIndex={interactive ? 0 : undefined}
|
|
499
|
+
onClick={interactive ? (e) => onRowClick?.(row.original, e) : undefined}
|
|
500
|
+
onKeyDown={
|
|
501
|
+
interactive
|
|
502
|
+
? (e) => {
|
|
503
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
504
|
+
e.preventDefault()
|
|
505
|
+
onRowClick?.(row.original, e)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
: undefined
|
|
509
|
+
}
|
|
510
|
+
>
|
|
511
|
+
{body}
|
|
512
|
+
</TableRow>
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* ---------- Loader & end marker ----------------------------------------- */
|
|
517
|
+
|
|
518
|
+
function LoaderRow() {
|
|
519
|
+
return (
|
|
520
|
+
<div
|
|
521
|
+
className={cn(
|
|
522
|
+
'flex items-center justify-center gap-3',
|
|
523
|
+
'h-8 font-mono text-[11px] tracking-[0.04em]',
|
|
524
|
+
'text-ink-4',
|
|
525
|
+
)}
|
|
526
|
+
>
|
|
527
|
+
<span
|
|
528
|
+
aria-hidden="true"
|
|
529
|
+
className="relative inline-block h-[2px] w-24 overflow-hidden rounded-pill bg-rule"
|
|
530
|
+
>
|
|
531
|
+
<span className="absolute inset-0 w-[30%] rounded-pill bg-accent animate-[pt-slide_1.1s_ease-in-out_infinite]" />
|
|
532
|
+
</span>
|
|
533
|
+
<span>loading more</span>
|
|
534
|
+
<style>{`
|
|
535
|
+
@keyframes pt-slide {
|
|
536
|
+
0% { transform: translateX(-110%); }
|
|
537
|
+
100% { transform: translateX(360%); }
|
|
538
|
+
}
|
|
539
|
+
`}</style>
|
|
540
|
+
</div>
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function EndMarker({ count }: { count: number }) {
|
|
545
|
+
return (
|
|
546
|
+
<div
|
|
547
|
+
className={cn(
|
|
548
|
+
'flex items-center justify-center',
|
|
549
|
+
'h-8 font-mono text-[10.5px] uppercase tracking-[0.08em]',
|
|
550
|
+
'text-ink-4',
|
|
551
|
+
)}
|
|
552
|
+
>
|
|
553
|
+
end · {count.toLocaleString()} rows
|
|
554
|
+
</div>
|
|
555
|
+
)
|
|
556
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
BooleanCellProps,
|
|
3
|
+
DateCellProps,
|
|
4
|
+
DateTimeCellProps,
|
|
5
|
+
LinkCellProps,
|
|
6
|
+
TruncateCellProps,
|
|
7
|
+
} from './cells'
|
|
8
|
+
export { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
|
|
9
|
+
export type { DataTableProps, InfiniteQueryLike } from './data-table'
|
|
10
|
+
export { DataTable } from './data-table'
|
|
11
|
+
|
|
12
|
+
export type { ActionsColumnOptions } from './make-actions-column'
|
|
13
|
+
export { makeActionsColumn } from './make-actions-column'
|
|
14
|
+
export type { DataTableEmptyProps } from './states/data-table-empty'
|
|
15
|
+
export { DataTableEmpty } from './states/data-table-empty'
|
|
16
|
+
export type { DataTableErrorProps } from './states/data-table-error'
|
|
17
|
+
export { DataTableError } from './states/data-table-error'
|
|
18
|
+
export type { DataTableSkeletonProps } from './states/data-table-skeleton'
|
|
19
|
+
export { DataTableSkeleton } from './states/data-table-skeleton'
|
|
20
|
+
|
|
21
|
+
export type { UseInfiniteScrollSentinelOptions } from './use-infinite-scroll-sentinel'
|
|
22
|
+
export { useInfiniteScrollSentinel } from './use-infinite-scroll-sentinel'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
export type ActionsColumnOptions<TData> = {
|
|
5
|
+
id?: string
|
|
6
|
+
header?: string
|
|
7
|
+
width?: number
|
|
8
|
+
cell: (row: TData) => ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convenience builder for a non-sortable, right-aligned actions column.
|
|
13
|
+
* Carries `meta.align: 'right'` so DataTable renders the header/cell
|
|
14
|
+
* end-aligned even though the column has no value to sort by.
|
|
15
|
+
*/
|
|
16
|
+
export function makeActionsColumn<TData>({
|
|
17
|
+
id = 'actions',
|
|
18
|
+
header = '',
|
|
19
|
+
width = 80,
|
|
20
|
+
cell,
|
|
21
|
+
}: ActionsColumnOptions<TData>): ColumnDef<TData> {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
header,
|
|
25
|
+
size: width,
|
|
26
|
+
enableSorting: false,
|
|
27
|
+
meta: { align: 'right' as const },
|
|
28
|
+
cell: ({ row }) => cell(row.original),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../lib/cn'
|
|
4
|
+
|
|
5
|
+
export type DataTableEmptyProps = {
|
|
6
|
+
title?: string
|
|
7
|
+
message?: string
|
|
8
|
+
icon?: ReactNode
|
|
9
|
+
action?: ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Empty state shown when the query returns zero rows.
|
|
15
|
+
* Mirrors the design's `pt-empty` block.
|
|
16
|
+
*/
|
|
17
|
+
export function DataTableEmpty({
|
|
18
|
+
title = 'No results',
|
|
19
|
+
message,
|
|
20
|
+
icon,
|
|
21
|
+
action,
|
|
22
|
+
className,
|
|
23
|
+
}: DataTableEmptyProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
role="status"
|
|
27
|
+
className={cn(
|
|
28
|
+
'flex flex-col items-center justify-center gap-2',
|
|
29
|
+
'px-5 py-16 text-center',
|
|
30
|
+
'font-sans text-[13px] text-ink-3',
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{icon ? <div className="text-ink-4">{icon}</div> : null}
|
|
35
|
+
<div className="text-ink text-[14px]">{title}</div>
|
|
36
|
+
{message ? <div className="text-ink-3 max-w-[36ch]">{message}</div> : null}
|
|
37
|
+
{action ? <div className="mt-3">{action}</div> : null}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cn } from '../../../lib/cn'
|
|
2
|
+
import { Button } from '../../button'
|
|
3
|
+
|
|
4
|
+
export type DataTableErrorProps = {
|
|
5
|
+
error: Error
|
|
6
|
+
onRetry?: () => void
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DataTableError({ error, onRetry, className }: DataTableErrorProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
role="alert"
|
|
14
|
+
className={cn(
|
|
15
|
+
'flex flex-col items-center justify-center gap-3',
|
|
16
|
+
'px-5 py-16 text-center',
|
|
17
|
+
'font-sans text-[13px] text-ink-3',
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
>
|
|
21
|
+
<div className="text-danger text-[14px]">Something went wrong</div>
|
|
22
|
+
<div className="text-ink-3 max-w-[48ch] font-mono text-[12px]">{error.message}</div>
|
|
23
|
+
{onRetry ? (
|
|
24
|
+
<Button variant="ghost" size="sm" onPress={onRetry}>
|
|
25
|
+
Retry
|
|
26
|
+
</Button>
|
|
27
|
+
) : null}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|