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