@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.
Files changed (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,165 @@
1
+ import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from 'react'
2
+
3
+ import { cn } from '../../lib/cn'
4
+
5
+ /* =========================================================================
6
+ Cell helpers — attribute-driven only.
7
+ These exist because RecordTable auto-generates columns from
8
+ Entity.attributes and dispatches the renderer at runtime by AttributeType.
9
+ Bespoke per-domain cells (status pills, lane pairs, deltas, avatars+name)
10
+ are NOT here — the consumer composes those inline via `column.cell`,
11
+ matching the shadcn/TanStack idiom. The lib already exports Badge / Avatar
12
+ / etc. for those compositions.
13
+ ========================================================================= */
14
+
15
+ /* ---------- DateCell ----------------------------------------------------- */
16
+
17
+ export type DateCellProps = {
18
+ value: string | number | Date | null | undefined
19
+ locale?: string
20
+ options?: Intl.DateTimeFormatOptions
21
+ fallback?: ReactNode
22
+ className?: string
23
+ }
24
+
25
+ const DEFAULT_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
26
+ year: 'numeric',
27
+ month: 'short',
28
+ day: '2-digit',
29
+ }
30
+
31
+ export function DateCell({
32
+ value,
33
+ locale,
34
+ options = DEFAULT_DATE_OPTIONS,
35
+ fallback = '—',
36
+ className,
37
+ }: DateCellProps) {
38
+ if (value == null || value === '') return <span className={className}>{fallback}</span>
39
+ const date = value instanceof Date ? value : new Date(value)
40
+ if (Number.isNaN(date.getTime())) return <span className={className}>{fallback}</span>
41
+ return <span className={className}>{date.toLocaleDateString(locale, options)}</span>
42
+ }
43
+
44
+ /* ---------- DateTimeCell ------------------------------------------------- */
45
+
46
+ export type DateTimeCellProps = DateCellProps
47
+
48
+ const DEFAULT_DATETIME_OPTIONS: Intl.DateTimeFormatOptions = {
49
+ year: 'numeric',
50
+ month: 'short',
51
+ day: '2-digit',
52
+ hour: '2-digit',
53
+ minute: '2-digit',
54
+ hour12: false,
55
+ }
56
+
57
+ export function DateTimeCell({
58
+ value,
59
+ locale,
60
+ options = DEFAULT_DATETIME_OPTIONS,
61
+ fallback = '—',
62
+ className,
63
+ }: DateTimeCellProps) {
64
+ if (value == null || value === '') return <span className={className}>{fallback}</span>
65
+ const date = value instanceof Date ? value : new Date(value)
66
+ if (Number.isNaN(date.getTime())) return <span className={className}>{fallback}</span>
67
+ return <span className={className}>{date.toLocaleString(locale, options)}</span>
68
+ }
69
+
70
+ /* ---------- BooleanCell -------------------------------------------------- */
71
+
72
+ export type BooleanCellProps = {
73
+ value: boolean | null | undefined
74
+ className?: string
75
+ }
76
+
77
+ export function BooleanCell({ value, className }: BooleanCellProps) {
78
+ if (value === true) {
79
+ return (
80
+ <span aria-label="true" className={cn('text-success', className)}>
81
+ <CheckIcon />
82
+ </span>
83
+ )
84
+ }
85
+ if (value === false) {
86
+ return (
87
+ <span aria-label="false" className={cn('text-ink-4', className)}>
88
+ <MinusIcon />
89
+ </span>
90
+ )
91
+ }
92
+ return <span className={cn('text-ink-4', className)}>—</span>
93
+ }
94
+
95
+ function CheckIcon() {
96
+ return (
97
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
98
+ <path
99
+ d="M2.5 7.5 L5.5 10.5 L11.5 4"
100
+ stroke="currentColor"
101
+ strokeWidth="1.6"
102
+ strokeLinecap="round"
103
+ strokeLinejoin="round"
104
+ />
105
+ </svg>
106
+ )
107
+ }
108
+
109
+ function MinusIcon() {
110
+ return (
111
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
112
+ <path d="M3 7 L11 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
113
+ </svg>
114
+ )
115
+ }
116
+
117
+ /* ---------- TruncateCell ------------------------------------------------- */
118
+
119
+ export type TruncateCellProps = {
120
+ value: ReactNode
121
+ title?: string
122
+ className?: string
123
+ }
124
+
125
+ /**
126
+ * The parent TableCell already clips with `overflow-hidden text-ellipsis`,
127
+ * so this just adds a `title` attribute for the native tooltip when the
128
+ * value is a string.
129
+ */
130
+ export function TruncateCell({ value, title, className }: TruncateCellProps) {
131
+ const tooltip = title ?? (typeof value === 'string' ? value : undefined)
132
+ return (
133
+ <span className={cn('truncate min-w-0', className)} title={tooltip}>
134
+ {value ?? '—'}
135
+ </span>
136
+ )
137
+ }
138
+
139
+ /* ---------- LinkCell ----------------------------------------------------- */
140
+
141
+ export type LinkCellProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
142
+ children: ReactNode
143
+ }
144
+
145
+ /**
146
+ * An anchor that stops click propagation so a row-click handler on the
147
+ * parent <TableRow> doesn't fire alongside the link navigation.
148
+ */
149
+ export function LinkCell({ children, onClick, className, ...props }: LinkCellProps) {
150
+ return (
151
+ <a
152
+ {...props}
153
+ onClick={(e: MouseEvent<HTMLAnchorElement>) => {
154
+ e.stopPropagation()
155
+ onClick?.(e)
156
+ }}
157
+ className={cn(
158
+ 'text-ink underline decoration-rule underline-offset-2 hover:decoration-ink',
159
+ className,
160
+ )}
161
+ >
162
+ {children}
163
+ </a>
164
+ )
165
+ }
@@ -0,0 +1,371 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import type { ColumnDef, SortingState } from '@tanstack/react-table'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import { Badge } from '../badge'
6
+ import { DataTable, type InfiniteQueryLike } from './data-table'
7
+
8
+ /* =========================================================================
9
+ DataTable stories — exercise the virtualized + infinite-scroll surface
10
+ in isolation. The fake infinite query is local to this file so stories
11
+ don't depend on the demo route.
12
+ ========================================================================= */
13
+
14
+ const meta: Meta<typeof DataTable> = {
15
+ title: 'Components/DataTable',
16
+ component: DataTable,
17
+ parameters: { layout: 'fullscreen' },
18
+ tags: ['autodocs'],
19
+ decorators: [
20
+ (Story) => (
21
+ <div className="bg-surface p-6 h-[640px] flex">
22
+ <Story />
23
+ </div>
24
+ ),
25
+ ],
26
+ }
27
+ export default meta
28
+
29
+ type Story = StoryObj<typeof DataTable>
30
+
31
+ /* ---------- Synthetic dataset ------------------------------------------- */
32
+
33
+ type Row = {
34
+ id: string
35
+ lane: string
36
+ carrier: string
37
+ status: 'in transit' | 'dispatching' | 'picked up' | 'delivered' | 'past due' | 'quoted'
38
+ statusVariant: 'active' | 'warning' | 'success' | 'danger' | 'neutral'
39
+ rate: number
40
+ weight: number
41
+ }
42
+
43
+ const ORIGINS = ['Long Beach', 'Newark', 'Houston', 'Savannah', 'Oakland', 'Seattle', 'Charleston']
44
+ const DESTS = ['Columbus', 'Reno', 'Allentown', 'Indianapolis', 'Salt Lake City', 'Portland']
45
+ const CARRIERS = [
46
+ 'North Star',
47
+ 'Pacific Drayage',
48
+ 'Atlas Cartage',
49
+ 'Cardinal Freight',
50
+ 'Granite Hauling',
51
+ ]
52
+ const STATUSES: ReadonlyArray<{ key: Row['status']; variant: Row['statusVariant'] }> = [
53
+ { key: 'in transit', variant: 'active' },
54
+ { key: 'dispatching', variant: 'warning' },
55
+ { key: 'picked up', variant: 'active' },
56
+ { key: 'delivered', variant: 'success' },
57
+ { key: 'past due', variant: 'danger' },
58
+ { key: 'quoted', variant: 'neutral' },
59
+ ]
60
+
61
+ function mulberry32(seed: number): () => number {
62
+ let a = seed
63
+ return () => {
64
+ a |= 0
65
+ a = (a + 0x6d2b79f5) | 0
66
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
67
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
68
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
69
+ }
70
+ }
71
+
72
+ function buildDataset(count: number): Row[] {
73
+ const rng = mulberry32(7)
74
+ const out = new Array<Row>(count)
75
+ for (let i = 0; i < count; i++) {
76
+ const status = STATUSES[Math.floor(rng() * STATUSES.length)] as (typeof STATUSES)[number]
77
+ out[i] = {
78
+ id: `SH-${(10000 + i).toString().padStart(5, '0')}`,
79
+ lane: `${ORIGINS[Math.floor(rng() * ORIGINS.length)]} → ${DESTS[Math.floor(rng() * DESTS.length)]}`,
80
+ carrier: CARRIERS[Math.floor(rng() * CARRIERS.length)] as string,
81
+ status: status.key,
82
+ statusVariant: status.variant,
83
+ rate: 800 + Math.floor(rng() * 7200),
84
+ weight: 1200 + Math.floor(rng() * 42000),
85
+ }
86
+ }
87
+ return out
88
+ }
89
+
90
+ const fmtMoney = (n: number) => `$${n.toLocaleString('en-US')}`
91
+ const fmtWeight = (n: number) => `${n.toLocaleString('en-US')} lb`
92
+
93
+ const columns: ColumnDef<Row>[] = [
94
+ {
95
+ id: 'id',
96
+ accessorKey: 'id',
97
+ header: 'Shipment',
98
+ size: 110,
99
+ meta: { align: 'left', mono: true, ink: true },
100
+ },
101
+ {
102
+ id: 'lane',
103
+ accessorKey: 'lane',
104
+ header: 'Lane',
105
+ size: 280,
106
+ meta: { align: 'left' },
107
+ },
108
+ {
109
+ id: 'carrier',
110
+ accessorKey: 'carrier',
111
+ header: 'Carrier',
112
+ size: 180,
113
+ meta: { align: 'left' },
114
+ },
115
+ {
116
+ id: 'status',
117
+ accessorKey: 'status',
118
+ header: 'Status',
119
+ size: 130,
120
+ meta: { align: 'left' },
121
+ cell: ({ row }) => <Badge variant={row.original.statusVariant}>{row.original.status}</Badge>,
122
+ },
123
+ {
124
+ id: 'weight',
125
+ accessorKey: 'weight',
126
+ header: 'Weight',
127
+ size: 110,
128
+ meta: { align: 'right', mono: true },
129
+ cell: ({ row }) => fmtWeight(row.original.weight),
130
+ },
131
+ {
132
+ id: 'rate',
133
+ accessorKey: 'rate',
134
+ header: 'Rate',
135
+ size: 110,
136
+ meta: { align: 'right', mono: true },
137
+ cell: ({ row }) => fmtMoney(row.original.rate),
138
+ },
139
+ ]
140
+
141
+ /* ---------- Fake infinite query ----------------------------------------- */
142
+
143
+ const PAGE_SIZE = 60
144
+
145
+ type FakeOptions = {
146
+ total: number
147
+ initialDelayMs?: number
148
+ pageDelayMs?: number
149
+ sorting?: SortingState
150
+ errorAfterPages?: number
151
+ }
152
+
153
+ function useFakeInfiniteQuery({
154
+ total,
155
+ initialDelayMs = 220,
156
+ pageDelayMs = 320,
157
+ sorting = [],
158
+ errorAfterPages,
159
+ }: FakeOptions): InfiniteQueryLike<Row> {
160
+ const dataset = useMemo(() => buildDataset(total), [total])
161
+
162
+ const sorted = useMemo<Row[]>(() => {
163
+ if (sorting.length === 0) return dataset
164
+ const [first] = sorting
165
+ if (!first) return dataset
166
+ const { id: key, desc } = first
167
+ const mult = desc ? -1 : 1
168
+ return dataset.slice().sort((a, b) => {
169
+ const av = a[key as keyof Row]
170
+ const bv = b[key as keyof Row]
171
+ if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * mult
172
+ return String(av).localeCompare(String(bv)) * mult
173
+ })
174
+ }, [dataset, sorting])
175
+
176
+ const [pagesLoaded, setPagesLoaded] = useState(1)
177
+ const [isFetchingNextPage, setIsFetchingNextPage] = useState(false)
178
+ const [isInitialLoading, setIsInitialLoading] = useState(true)
179
+ const [error, setError] = useState<Error | null>(null)
180
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
181
+
182
+ useEffect(() => {
183
+ setPagesLoaded(1)
184
+ setIsInitialLoading(true)
185
+ setError(null)
186
+ const t = setTimeout(() => setIsInitialLoading(false), initialDelayMs)
187
+ return () => clearTimeout(t)
188
+ }, [sorting, initialDelayMs])
189
+
190
+ const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE))
191
+ const hasNextPage = pagesLoaded < totalPages
192
+
193
+ const pages = useMemo(
194
+ () =>
195
+ Array.from({ length: pagesLoaded }, (_, i) => ({
196
+ data: sorted.slice(i * PAGE_SIZE, (i + 1) * PAGE_SIZE),
197
+ meta: { items_total: sorted.length },
198
+ })),
199
+ [sorted, pagesLoaded],
200
+ )
201
+
202
+ return {
203
+ data: { pages },
204
+ isLoading: isInitialLoading,
205
+ isFetchingNextPage,
206
+ hasNextPage,
207
+ error,
208
+ fetchNextPage: () => {
209
+ if (!hasNextPage || isFetchingNextPage) return
210
+ setIsFetchingNextPage(true)
211
+ if (timer.current) clearTimeout(timer.current)
212
+ timer.current = setTimeout(() => {
213
+ if (errorAfterPages !== undefined && pagesLoaded + 1 > errorAfterPages) {
214
+ setError(new Error('Network unreachable. Could not fetch the next page.'))
215
+ setIsFetchingNextPage(false)
216
+ return
217
+ }
218
+ setPagesLoaded((n) => n + 1)
219
+ setIsFetchingNextPage(false)
220
+ }, pageDelayMs)
221
+ },
222
+ }
223
+ }
224
+
225
+ /* ---------- Stories ------------------------------------------------------ */
226
+
227
+ export const Default: Story = {
228
+ parameters: { controls: { hideNoControlsWarning: true } },
229
+ render: () => {
230
+ const [sorting, setSorting] = useState<SortingState>([{ id: 'id', desc: false }])
231
+ const query = useFakeInfiniteQuery({ total: 50_000, sorting })
232
+ return (
233
+ <DataTable<Row>
234
+ ariaLabel="Shipments"
235
+ query={query}
236
+ columns={columns}
237
+ sorting={sorting}
238
+ onSortingChange={setSorting}
239
+ />
240
+ )
241
+ },
242
+ }
243
+
244
+ export const InitialLoading: Story = {
245
+ parameters: { controls: { hideNoControlsWarning: true } },
246
+ render: () => {
247
+ // Long initialDelay keeps the skeleton on screen.
248
+ const query = useFakeInfiniteQuery({ total: 200, initialDelayMs: 100_000 })
249
+ return <DataTable<Row> ariaLabel="Loading shipments" query={query} columns={columns} />
250
+ },
251
+ }
252
+
253
+ export const LoadingMore: Story = {
254
+ parameters: { controls: { hideNoControlsWarning: true } },
255
+ render: () => {
256
+ // Stretchy page delay so the loader bar stays on screen mid-scroll.
257
+ const [sorting, setSorting] = useState<SortingState>([])
258
+ const query = useFakeInfiniteQuery({ total: 5_000, sorting, pageDelayMs: 100_000 })
259
+ return (
260
+ <DataTable<Row>
261
+ ariaLabel="Shipments — loading next page"
262
+ query={query}
263
+ columns={columns}
264
+ sorting={sorting}
265
+ onSortingChange={setSorting}
266
+ />
267
+ )
268
+ },
269
+ }
270
+
271
+ export const EndOfList: Story = {
272
+ parameters: { controls: { hideNoControlsWarning: true } },
273
+ render: () => {
274
+ // Small dataset whose first page already covers everything — end marker
275
+ // appears as soon as the initial load resolves.
276
+ const query = useFakeInfiniteQuery({ total: 12 })
277
+ return <DataTable<Row> ariaLabel="Shipments — end" query={query} columns={columns} />
278
+ },
279
+ }
280
+
281
+ export const Empty: Story = {
282
+ parameters: { controls: { hideNoControlsWarning: true } },
283
+ render: () => {
284
+ const query = useFakeInfiniteQuery({ total: 0 })
285
+ return <DataTable<Row> ariaLabel="Shipments — empty" query={query} columns={columns} />
286
+ },
287
+ }
288
+
289
+ export const ErrorState: Story = {
290
+ parameters: { controls: { hideNoControlsWarning: true } },
291
+ render: () => {
292
+ // Returns no rows + an error so the error state replaces the body.
293
+ const query: InfiniteQueryLike<Row> = {
294
+ data: undefined,
295
+ isLoading: false,
296
+ isFetchingNextPage: false,
297
+ hasNextPage: false,
298
+ error: new Error('Network unreachable. Could not fetch shipments.'),
299
+ fetchNextPage: () => {
300
+ /* noop */
301
+ },
302
+ }
303
+ return <DataTable<Row> ariaLabel="Shipments — error" query={query} columns={columns} />
304
+ },
305
+ }
306
+
307
+ export const ReorderableColumns: Story = {
308
+ parameters: { controls: { hideNoControlsWarning: true } },
309
+ render: () => {
310
+ const [orderedColumns, setOrderedColumns] = useState<ColumnDef<Row>[]>(columns)
311
+ const [sorting, setSorting] = useState<SortingState>([])
312
+ const [lastEvent, setLastEvent] = useState<string | null>(null)
313
+ const query = useFakeInfiniteQuery({ total: 800, sorting })
314
+ return (
315
+ <div className="flex flex-col gap-3 flex-1">
316
+ <div className="font-mono text-[11px] text-ink-3">
317
+ {lastEvent ?? 'drag a column header to reorder · click × to remove'}
318
+ </div>
319
+ <DataTable<Row>
320
+ ariaLabel="Shipments — reorderable + removable columns"
321
+ query={query}
322
+ columns={orderedColumns}
323
+ sorting={sorting}
324
+ onSortingChange={setSorting}
325
+ enableColumnReordering
326
+ enableColumnRemoval
327
+ onReorderColumn={({ id, toIndex }) => {
328
+ setLastEvent(`reorder: ${id} → index ${toIndex}`)
329
+ setOrderedColumns((prev) => {
330
+ const idx = prev.findIndex((c) => c.id === id)
331
+ if (idx === -1) return prev
332
+ const next = prev.slice()
333
+ const [moved] = next.splice(idx, 1)
334
+ if (!moved) return prev
335
+ next.splice(toIndex, 0, moved)
336
+ return next
337
+ })
338
+ }}
339
+ onRemoveColumn={(id) => {
340
+ setLastEvent(`remove: ${id}`)
341
+ setOrderedColumns((prev) => prev.filter((c) => c.id !== id))
342
+ }}
343
+ />
344
+ </div>
345
+ )
346
+ },
347
+ }
348
+
349
+ export const RowClickable: Story = {
350
+ parameters: { controls: { hideNoControlsWarning: true } },
351
+ render: () => {
352
+ const [sorting, setSorting] = useState<SortingState>([])
353
+ const [lastClicked, setLastClicked] = useState<string | null>(null)
354
+ const query = useFakeInfiniteQuery({ total: 800, sorting })
355
+ return (
356
+ <div className="flex flex-col gap-3 flex-1">
357
+ <div className="font-mono text-[11px] text-ink-3">
358
+ {lastClicked ? `last clicked: ${lastClicked}` : 'click a row'}
359
+ </div>
360
+ <DataTable<Row>
361
+ ariaLabel="Shipments — clickable rows"
362
+ query={query}
363
+ columns={columns}
364
+ sorting={sorting}
365
+ onSortingChange={setSorting}
366
+ onRowClick={(row) => setLastClicked(row.id)}
367
+ />
368
+ </div>
369
+ )
370
+ },
371
+ }