@structuralists/scaffolding 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/.storybook/main.ts +9 -0
  2. package/.storybook/manager.ts +13 -0
  3. package/.storybook/preview.tsx +18 -0
  4. package/CLAUDE.md +30 -0
  5. package/LICENSE +21 -0
  6. package/README.md +7 -0
  7. package/bun.lock +947 -0
  8. package/bunfig.toml +2 -0
  9. package/eslint.config.mjs +106 -0
  10. package/index.ts +1 -0
  11. package/package.json +50 -0
  12. package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +68 -0
  13. package/src/components/Chat/ChatComposer/index.tsx +74 -0
  14. package/src/components/Chat/ChatComposer/styles.module.css +88 -0
  15. package/src/components/Chat/ChatComposer/types.ts +11 -0
  16. package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +111 -0
  17. package/src/components/Chat/ChatMessage/index.tsx +42 -0
  18. package/src/components/Chat/ChatMessage/styles.module.css +58 -0
  19. package/src/components/Chat/ChatMessage/types.ts +14 -0
  20. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +145 -0
  21. package/src/components/Chat/ChatRecipientsHeader/index.tsx +29 -0
  22. package/src/components/Chat/ChatRecipientsHeader/styles.module.css +48 -0
  23. package/src/components/Chat/ChatRecipientsHeader/types.ts +26 -0
  24. package/src/components/Chat/ChatShell/ChatShell.stories.tsx +203 -0
  25. package/src/components/Chat/ChatShell/index.tsx +16 -0
  26. package/src/components/Chat/ChatShell/styles.module.css +27 -0
  27. package/src/components/Chat/ChatShell/types.ts +7 -0
  28. package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +59 -0
  29. package/src/components/Chat/PillCombobox/index.tsx +17 -0
  30. package/src/components/Chat/PillCombobox/styles.module.css +29 -0
  31. package/src/components/Chat/PillCombobox/types.ts +28 -0
  32. package/src/components/Chat/PillComboboxCore/Core.tsx +235 -0
  33. package/src/components/Chat/PillComboboxCore/styles.module.css +79 -0
  34. package/src/components/Chat/index.ts +12 -0
  35. package/src/components/Content/Badge/Badge.stories.tsx +31 -0
  36. package/src/components/Content/Badge/index.tsx +22 -0
  37. package/src/components/Content/Badge/styles.module.css +25 -0
  38. package/src/components/Content/Badge/types.ts +7 -0
  39. package/src/components/Content/Card/Card.stories.tsx +24 -0
  40. package/src/components/Content/Card/index.tsx +21 -0
  41. package/src/components/Content/Card/styles.module.css +13 -0
  42. package/src/components/Content/Card/types.ts +8 -0
  43. package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +58 -0
  44. package/src/components/Content/EditableMarkdown/index.tsx +140 -0
  45. package/src/components/Content/EditableMarkdown/styles.module.css +221 -0
  46. package/src/components/Content/EditableMarkdown/types.ts +11 -0
  47. package/src/components/Content/Heading/Heading.stories.tsx +26 -0
  48. package/src/components/Content/Heading/index.tsx +20 -0
  49. package/src/components/Content/Heading/styles.module.css +19 -0
  50. package/src/components/Content/Heading/types.ts +8 -0
  51. package/src/components/Content/Link/Link.stories.tsx +21 -0
  52. package/src/components/Content/Link/index.tsx +19 -0
  53. package/src/components/Content/Link/styles.module.css +11 -0
  54. package/src/components/Content/Link/types.ts +8 -0
  55. package/src/components/Content/List/List.stories.tsx +62 -0
  56. package/src/components/Content/List/index.tsx +26 -0
  57. package/src/components/Content/List/styles.module.css +41 -0
  58. package/src/components/Content/List/types.ts +33 -0
  59. package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +105 -0
  60. package/src/components/Content/LoadingContainer/index.tsx +36 -0
  61. package/src/components/Content/LoadingContainer/styles.module.css +54 -0
  62. package/src/components/Content/LoadingContainer/types.ts +8 -0
  63. package/src/components/Content/Markdown/Markdown.stories.tsx +39 -0
  64. package/src/components/Content/Markdown/index.tsx +28 -0
  65. package/src/components/Content/Markdown/styles.module.css +79 -0
  66. package/src/components/Content/Markdown/types.ts +8 -0
  67. package/src/components/Content/Menu/Menu.stories.tsx +186 -0
  68. package/src/components/Content/Menu/index.tsx +259 -0
  69. package/src/components/Content/Menu/styles.module.css +103 -0
  70. package/src/components/Content/Menu/types.ts +25 -0
  71. package/src/components/Content/Text/Text.stories.tsx +36 -0
  72. package/src/components/Content/Text/index.tsx +35 -0
  73. package/src/components/Content/Text/styles.module.css +30 -0
  74. package/src/components/Content/Text/types.ts +11 -0
  75. package/src/components/Forms/Button/Button.stories.tsx +40 -0
  76. package/src/components/Forms/Button/index.tsx +43 -0
  77. package/src/components/Forms/Button/styles.module.css +67 -0
  78. package/src/components/Forms/Button/types.ts +16 -0
  79. package/src/components/Forms/ColorInput/index.tsx +22 -0
  80. package/src/components/Forms/ColorInput/styles.module.css +19 -0
  81. package/src/components/Forms/ColorInput/types.ts +12 -0
  82. package/src/components/Forms/Field/Field.stories.tsx +35 -0
  83. package/src/components/Forms/Field/index.tsx +17 -0
  84. package/src/components/Forms/Field/styles.module.css +21 -0
  85. package/src/components/Forms/Field/types.ts +9 -0
  86. package/src/components/Forms/IconButton/IconButton.stories.tsx +91 -0
  87. package/src/components/Forms/IconButton/index.tsx +55 -0
  88. package/src/components/Forms/IconButton/styles.module.css +61 -0
  89. package/src/components/Forms/IconButton/types.ts +23 -0
  90. package/src/components/Forms/Input/Input.stories.tsx +22 -0
  91. package/src/components/Forms/Input/index.tsx +42 -0
  92. package/src/components/Forms/Input/styles.module.css +30 -0
  93. package/src/components/Forms/Input/types.ts +18 -0
  94. package/src/components/Forms/SearchInput/index.tsx +41 -0
  95. package/src/components/Forms/SearchInput/styles.module.css +30 -0
  96. package/src/components/Forms/SearchInput/types.ts +17 -0
  97. package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +116 -0
  98. package/src/components/Forms/Select/MultiSelect/index.tsx +74 -0
  99. package/src/components/Forms/Select/MultiSelect/types.ts +15 -0
  100. package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +174 -0
  101. package/src/components/Forms/Select/SingleSelect/index.tsx +62 -0
  102. package/src/components/Forms/Select/SingleSelect/types.ts +12 -0
  103. package/src/components/Forms/Select/index.ts +4 -0
  104. package/src/components/Forms/Select/internal/OptionList.tsx +124 -0
  105. package/src/components/Forms/Select/internal/SelectTrigger.tsx +60 -0
  106. package/src/components/Forms/Select/internal/styles.module.css +122 -0
  107. package/src/components/Forms/Textarea/Textarea.stories.tsx +25 -0
  108. package/src/components/Forms/Textarea/index.tsx +48 -0
  109. package/src/components/Forms/Textarea/styles.module.css +34 -0
  110. package/src/components/Forms/Textarea/types.ts +24 -0
  111. package/src/components/Json/Json/Json.stories.tsx +33 -0
  112. package/src/components/Json/Json/index.tsx +38 -0
  113. package/src/components/Json/Json/types.ts +21 -0
  114. package/src/components/Json/JsonTable/JsonLeafNode.tsx +31 -0
  115. package/src/components/Json/JsonTable/JsonTable.stories.tsx +52 -0
  116. package/src/components/Json/JsonTable/index.tsx +33 -0
  117. package/src/components/Json/JsonTable/types.ts +13 -0
  118. package/src/components/Json/JsonTable/utils.ts +6 -0
  119. package/src/components/Layout/Bar/Bar.stories.tsx +100 -0
  120. package/src/components/Layout/Bar/index.tsx +17 -0
  121. package/src/components/Layout/Bar/styles.module.css +34 -0
  122. package/src/components/Layout/Bar/types.ts +10 -0
  123. package/src/components/Layout/Debug/Debug.stories.tsx +86 -0
  124. package/src/components/Layout/Debug/index.tsx +41 -0
  125. package/src/components/Layout/Debug/styles.module.css +13 -0
  126. package/src/components/Layout/Debug/types.ts +12 -0
  127. package/src/components/Layout/Divider/Divider.stories.tsx +22 -0
  128. package/src/components/Layout/Divider/index.tsx +3 -0
  129. package/src/components/Layout/Divider/styles.module.css +6 -0
  130. package/src/components/Layout/Grid/Grid.stories.tsx +28 -0
  131. package/src/components/Layout/Grid/index.tsx +29 -0
  132. package/src/components/Layout/Grid/styles.module.css +12 -0
  133. package/src/components/Layout/Grid/types.ts +9 -0
  134. package/src/components/Layout/Panels/Panels.stories.tsx +287 -0
  135. package/src/components/Layout/Panels/index.tsx +119 -0
  136. package/src/components/Layout/Panels/styles.module.css +103 -0
  137. package/src/components/Layout/Panels/types.ts +36 -0
  138. package/src/components/Layout/Stack/Stack.stories.tsx +45 -0
  139. package/src/components/Layout/Stack/index.tsx +73 -0
  140. package/src/components/Layout/Stack/styles.module.css +41 -0
  141. package/src/components/Layout/Stack/types.ts +17 -0
  142. package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +73 -0
  143. package/src/components/Modals/ConfirmModal/index.tsx +72 -0
  144. package/src/components/Modals/ConfirmModal/styles.module.css +62 -0
  145. package/src/components/Modals/ConfirmModal/types.ts +14 -0
  146. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +75 -0
  147. package/src/components/Modals/LargeModal/index.tsx +9 -0
  148. package/src/components/Modals/LargeModal/styles.module.css +6 -0
  149. package/src/components/Modals/LargeModal/types.ts +18 -0
  150. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +121 -0
  151. package/src/components/Modals/MediumModal/MediumModal.test.tsx +48 -0
  152. package/src/components/Modals/MediumModal/index.tsx +9 -0
  153. package/src/components/Modals/MediumModal/styles.module.css +5 -0
  154. package/src/components/Modals/MediumModal/types.ts +18 -0
  155. package/src/components/Modals/index.ts +3 -0
  156. package/src/components/Modals/internal/ModalBody.tsx +21 -0
  157. package/src/components/Modals/internal/ModalFooter.tsx +12 -0
  158. package/src/components/Modals/internal/ModalHeader.tsx +27 -0
  159. package/src/components/Modals/internal/ModalShell.tsx +112 -0
  160. package/src/components/Modals/internal/styles.module.css +141 -0
  161. package/src/components/Navigation/TabBar/TabBar.stories.tsx +59 -0
  162. package/src/components/Navigation/TabBar/index.tsx +25 -0
  163. package/src/components/Navigation/TabBar/styles.module.css +32 -0
  164. package/src/components/Navigation/TabBar/types.ts +22 -0
  165. package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +41 -0
  166. package/src/components/Navigation/VerticalNav/index.tsx +25 -0
  167. package/src/components/Navigation/VerticalNav/styles.module.css +28 -0
  168. package/src/components/Navigation/VerticalNav/types.ts +19 -0
  169. package/src/components/Overlays/Popover/Popover.stories.tsx +154 -0
  170. package/src/components/Overlays/Popover/index.tsx +175 -0
  171. package/src/components/Overlays/Popover/styles.module.css +59 -0
  172. package/src/components/Overlays/Popover/types.ts +34 -0
  173. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +41 -0
  174. package/src/components/Overlays/Tooltip/index.tsx +115 -0
  175. package/src/components/Overlays/Tooltip/styles.module.css +25 -0
  176. package/src/components/Overlays/Tooltip/types.ts +15 -0
  177. package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +18 -0
  178. package/src/components/Primitives/EmptyValue/index.tsx +3 -0
  179. package/src/components/Primitives/EmptyValue/styles.module.css +3 -0
  180. package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +101 -0
  181. package/src/components/Primitives/LinedStack/index.tsx +41 -0
  182. package/src/components/Primitives/LinedStack/styles.module.css +27 -0
  183. package/src/components/Primitives/LinedStack/types.ts +49 -0
  184. package/src/components/Primitives/LongText/LongText.stories.tsx +72 -0
  185. package/src/components/Primitives/LongText/index.tsx +67 -0
  186. package/src/components/Primitives/LongText/styles.module.css +30 -0
  187. package/src/components/Primitives/LongText/types.ts +4 -0
  188. package/src/components/Primitives/Num/Num.stories.tsx +51 -0
  189. package/src/components/Primitives/Num/index.tsx +37 -0
  190. package/src/components/Primitives/Num/types.ts +19 -0
  191. package/src/components/Primitives/Percent/Percent.stories.tsx +48 -0
  192. package/src/components/Primitives/Percent/index.tsx +15 -0
  193. package/src/components/Primitives/Percent/types.ts +10 -0
  194. package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +57 -0
  195. package/src/components/Primitives/RelativeTime/index.tsx +31 -0
  196. package/src/components/Primitives/RelativeTime/types.ts +3 -0
  197. package/src/components/Tables/BigTable/BigTable.stories.tsx +367 -0
  198. package/src/components/Tables/BigTable/CLAUDE.md +118 -0
  199. package/src/components/Tables/BigTable/columnDefs.tsx +208 -0
  200. package/src/components/Tables/BigTable/index.tsx +104 -0
  201. package/src/components/Tables/BigTable/styles.module.css +83 -0
  202. package/src/components/Tables/BigTable/types.ts +20 -0
  203. package/src/components/Tables/QuickTable/CLAUDE.md +118 -0
  204. package/src/components/Tables/QuickTable/QuickTable.stories.tsx +121 -0
  205. package/src/components/Tables/QuickTable/index.tsx +86 -0
  206. package/src/components/Tables/QuickTable/internal.tsx +48 -0
  207. package/src/components/Tables/QuickTable/styles.module.css +65 -0
  208. package/src/components/Tables/QuickTable/types.ts +40 -0
  209. package/src/env.d.ts +4 -0
  210. package/src/index.ts +87 -0
  211. package/src/storybook/CLAUDE.md +35 -0
  212. package/src/storybook/Composition.stories.tsx +269 -0
  213. package/src/storybook/Lorem/index.tsx +54 -0
  214. package/src/storybook/Placeholder/index.tsx +27 -0
  215. package/src/storybook/Placeholder/styles.module.css +20 -0
  216. package/src/storybook/Repeat/index.tsx +23 -0
  217. package/src/storybook/Toggle/index.tsx +29 -0
  218. package/src/storybook/_StoryUtils.stories.tsx +58 -0
  219. package/src/storybook/index.ts +4 -0
  220. package/src/tokens.ts +31 -0
  221. package/src/utils.test.ts +24 -0
  222. package/src/utils.ts +2 -0
  223. package/test-setup.ts +3 -0
  224. package/tokens.css +323 -0
  225. package/tsconfig.json +16 -0
@@ -0,0 +1,367 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import {
4
+ BigTable,
5
+ badgeColumn,
6
+ booleanColumn,
7
+ currencyColumn,
8
+ dateColumn,
9
+ idColumn,
10
+ numberColumn,
11
+ relativeDateColumn,
12
+ textColumn,
13
+ } from './index';
14
+ import type { ColumnDef } from './types';
15
+ import { Text } from '../../Content/Text';
16
+ import { LinedStack } from '../../Primitives/LinedStack';
17
+ import { LongText } from '../../Primitives/LongText';
18
+ import { Panels } from '../../Layout/Panels';
19
+ import { Bar } from '../../Layout/Bar';
20
+ import { Button } from '../../Forms/Button';
21
+ import { Heading } from '../../Content/Heading';
22
+ import { Stack } from '../../Layout/Stack';
23
+
24
+ const meta: Meta<typeof BigTable> = {
25
+ title: 'Tables/BigTable',
26
+ component: BigTable,
27
+ parameters: { layout: 'fullscreen' },
28
+ };
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof BigTable>;
32
+
33
+ type User = {
34
+ id: string;
35
+ name: string;
36
+ email: string;
37
+ role: 'engineer' | 'designer' | 'pm' | 'sales';
38
+ team: string;
39
+ amount: number;
40
+ createdAt: string;
41
+ lastSeenAt: string;
42
+ isActive: boolean;
43
+ };
44
+
45
+ const ROLES: User['role'][] = ['engineer', 'designer', 'pm', 'sales'];
46
+ const TEAMS = ['Platform', 'Growth', 'Infra', 'Brand', 'Revenue', 'Search'];
47
+ const FIRST = ['Alice', 'Bob', 'Charlie', 'Dana', 'Evan', 'Faye', 'Gus', 'Hana'];
48
+ const LAST = ['Smith', 'Jones', 'Park', 'Khan', 'Reyes', 'Fox', 'Wells'];
49
+
50
+ const makeUsers = (n: number): User[] => {
51
+ const users: User[] = [];
52
+ for (let i = 0; i < n; i++) {
53
+ const first = FIRST[i % FIRST.length];
54
+ const last = LAST[(i * 3) % LAST.length];
55
+ const created = Date.now() - (i + 1) * 86_400_000 * 7;
56
+ const seen = Date.now() - (i % 9) * 3_600_000;
57
+ users.push({
58
+ id: `usr_${1000 + i}`,
59
+ name: `${first} ${last}`,
60
+ email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
61
+ role: ROLES[i % ROLES.length],
62
+ team: TEAMS[i % TEAMS.length],
63
+ amount: (i + 1) * 1234,
64
+ createdAt: new Date(created).toISOString(),
65
+ lastSeenAt: new Date(seen).toISOString(),
66
+ isActive: i % 5 !== 0,
67
+ });
68
+ }
69
+ return users;
70
+ };
71
+
72
+ const fillStyle = { height: '100vh' };
73
+
74
+ export const PrefabColumns: Story = {
75
+ name: 'Prefab columns — typical case',
76
+ render: () => {
77
+ const data = makeUsers(40);
78
+ const columnDefs: ColumnDef<User>[] = [
79
+ idColumn({ id: 'id', header: 'ID', value: (u) => u.id }),
80
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name }),
81
+ textColumn({ id: 'email', header: 'Email', value: (u) => u.email, minWidth: 220 }),
82
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
83
+ textColumn({ id: 'team', header: 'Team', value: (u) => u.team }),
84
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
85
+ booleanColumn({ id: 'active', header: 'Active', value: (u) => u.isActive }),
86
+ relativeDateColumn({ id: 'seen', header: 'Last Seen', value: (u) => u.lastSeenAt }),
87
+ dateColumn({ id: 'created', header: 'Created', value: (u) => u.createdAt }),
88
+ ];
89
+
90
+ return (
91
+ <div style={fillStyle}>
92
+ <BigTable data={data} columnDefs={columnDefs} getRowKey={(u) => u.id} />
93
+ </div>
94
+ );
95
+ },
96
+ };
97
+
98
+ export const StickyFirstColumn: Story = {
99
+ name: 'Sticky first column + horizontal scroll',
100
+ render: () => {
101
+ const data = makeUsers(40);
102
+ const columnDefs: ColumnDef<User>[] = [
103
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name, width: 180 }),
104
+ idColumn({ id: 'id', header: 'ID', value: (u) => u.id }),
105
+ textColumn({ id: 'email', header: 'Email', value: (u) => u.email, minWidth: 240 }),
106
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
107
+ textColumn({ id: 'team', header: 'Team', value: (u) => u.team, minWidth: 140 }),
108
+ currencyColumn({ id: 'q1', header: 'Q1', value: (u) => u.amount }),
109
+ currencyColumn({ id: 'q2', header: 'Q2', value: (u) => u.amount * 1.1 }),
110
+ currencyColumn({ id: 'q3', header: 'Q3', value: (u) => u.amount * 0.95 }),
111
+ currencyColumn({ id: 'q4', header: 'Q4', value: (u) => u.amount * 1.2 }),
112
+ numberColumn({ id: 'visits', header: 'Visits', value: (u) => u.amount % 97 }),
113
+ booleanColumn({ id: 'active', header: 'Active', value: (u) => u.isActive }),
114
+ relativeDateColumn({ id: 'seen', header: 'Last Seen', value: (u) => u.lastSeenAt }),
115
+ dateColumn({ id: 'created', header: 'Created', value: (u) => u.createdAt }),
116
+ ];
117
+
118
+ return (
119
+ <div style={fillStyle}>
120
+ <BigTable
121
+ data={data}
122
+ columnDefs={columnDefs}
123
+ getRowKey={(u) => u.id}
124
+ hasStickyFirstColumn
125
+ />
126
+ </div>
127
+ );
128
+ },
129
+ };
130
+
131
+ export const HandRolledColumn: Story = {
132
+ name: 'Hand-rolled ColumnDef (no prefab)',
133
+ render: () => {
134
+ const data = makeUsers(20);
135
+ const columnDefs: ColumnDef<User>[] = [
136
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name }),
137
+ {
138
+ id: 'profile',
139
+ header: 'Profile',
140
+ cell: (u) => (
141
+ <span>
142
+ <strong>{u.name}</strong>
143
+ {' — '}
144
+ <em style={{ color: 'var(--ui-muted)' }}>{u.email}</em>
145
+ </span>
146
+ ),
147
+ minWidth: 280,
148
+ },
149
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
150
+ ];
151
+
152
+ return (
153
+ <div style={fillStyle}>
154
+ <BigTable data={data} columnDefs={columnDefs} getRowKey={(u) => u.id} />
155
+ </div>
156
+ );
157
+ },
158
+ };
159
+
160
+ export const CompositeCell: Story = {
161
+ name: 'Composite cell — LinedStack with link',
162
+ render: () => {
163
+ const data = makeUsers(40);
164
+ const columnDefs: ColumnDef<User>[] = [
165
+ {
166
+ id: 'user',
167
+ header: 'User',
168
+ cell: (u) => (
169
+ <LinedStack to={`/users/${u.id}`}>
170
+ <strong>{u.name}</strong>
171
+ <Text isMuted size="xsmall">
172
+ {u.email}
173
+ </Text>
174
+ </LinedStack>
175
+ ),
176
+ minWidth: 240,
177
+ },
178
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
179
+ textColumn({ id: 'team', header: 'Team', value: (u) => u.team }),
180
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
181
+ relativeDateColumn({ id: 'seen', header: 'Last Seen', value: (u) => u.lastSeenAt }),
182
+ ];
183
+
184
+ return (
185
+ <div style={fillStyle}>
186
+ <BigTable data={data} columnDefs={columnDefs} getRowKey={(u) => u.id} />
187
+ </div>
188
+ );
189
+ },
190
+ };
191
+
192
+ export const LongTextCells: Story = {
193
+ name: 'LongText cells — truncate + click to reveal',
194
+ render: () => {
195
+ const notes = [
196
+ 'Short note.',
197
+ 'Wants quarterly review pushed back, mentioned travel through end of month.',
198
+ 'Renewal pending — legal review on the master agreement, contract expected to land week of the 15th. Needs procurement sign-off after. Has flagged the data-residency clause as potentially blocking; CISO is aware.',
199
+ null,
200
+ 'Followed up via Slack, no response yet.',
201
+ 'Account paused last week pending billing reconciliation. Disputed line items: 3. Resolution target this Friday.',
202
+ ];
203
+ const data = makeUsers(40).map((u, i) => ({ ...u, note: notes[i % notes.length] }));
204
+
205
+ const columnDefs: ColumnDef<typeof data[number]>[] = [
206
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name, width: 160 }),
207
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
208
+ {
209
+ id: 'note',
210
+ header: 'Note',
211
+ cell: (u) => <LongText>{u.note}</LongText>,
212
+ width: 280,
213
+ },
214
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
215
+ relativeDateColumn({ id: 'seen', header: 'Last Seen', value: (u) => u.lastSeenAt }),
216
+ ];
217
+
218
+ return (
219
+ <div style={fillStyle}>
220
+ <BigTable data={data} columnDefs={columnDefs} getRowKey={(u) => u.id} />
221
+ </div>
222
+ );
223
+ },
224
+ };
225
+
226
+ export const ClickableRows: Story = {
227
+ name: 'Clickable rows — onRowClick',
228
+ render: () => {
229
+ const data = makeUsers(40);
230
+ const columnDefs: ColumnDef<User>[] = [
231
+ idColumn({ id: 'id', header: 'ID', value: (u) => u.id }),
232
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name }),
233
+ textColumn({ id: 'email', header: 'Email', value: (u) => u.email, minWidth: 220 }),
234
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
235
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
236
+ {
237
+ id: 'profile',
238
+ header: 'Profile',
239
+ cell: (u) => (
240
+ <LinedStack to={`/users/${u.id}`}>
241
+ <strong>view</strong>
242
+ </LinedStack>
243
+ ),
244
+ minWidth: 80,
245
+ },
246
+ ];
247
+
248
+ return (
249
+ <div style={fillStyle}>
250
+ <BigTable
251
+ data={data}
252
+ columnDefs={columnDefs}
253
+ getRowKey={(u) => u.id}
254
+ onRowClick={(u) => {
255
+ alert(`Row clicked: ${u.name}`);
256
+ }}
257
+ />
258
+ </div>
259
+ );
260
+ },
261
+ };
262
+
263
+ export const MasterDetailInPanels: Story = {
264
+ name: 'Master-detail — BigTable inside Panels with row → overlay',
265
+ render: () => {
266
+ const Demo = () => {
267
+ const [selected, setSelected] = useState<User | null>(null);
268
+ const data = makeUsers(40);
269
+
270
+ const columnDefs: ColumnDef<User>[] = [
271
+ idColumn({ id: 'id', header: 'ID', value: (u) => u.id }),
272
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name }),
273
+ textColumn({ id: 'email', header: 'Email', value: (u) => u.email, minWidth: 220 }),
274
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
275
+ textColumn({ id: 'team', header: 'Team', value: (u) => u.team }),
276
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
277
+ relativeDateColumn({ id: 'seen', header: 'Last Seen', value: (u) => u.lastSeenAt }),
278
+ ];
279
+
280
+ const detailRows: { label: string; value: string }[] = selected
281
+ ? [
282
+ { label: 'ID', value: selected.id },
283
+ { label: 'Email', value: selected.email },
284
+ { label: 'Role', value: selected.role },
285
+ { label: 'Team', value: selected.team },
286
+ { label: 'Amount', value: `$${selected.amount.toLocaleString()}` },
287
+ { label: 'Active', value: selected.isActive ? 'Yes' : 'No' },
288
+ ]
289
+ : [];
290
+
291
+ return (
292
+ <div style={fillStyle}>
293
+ <Panels
294
+ header={<Bar title="Users" />}
295
+ rightOverlayWidth={400}
296
+ rightOverlay={
297
+ selected ? (
298
+ <Panels
299
+ header={
300
+ <Bar
301
+ title={selected.name}
302
+ right={
303
+ <Button size="small" onClick={() => setSelected(null)}>
304
+ Close
305
+ </Button>
306
+ }
307
+ />
308
+ }
309
+ >
310
+ <div style={{ padding: 16 }}>
311
+ <Stack gap={3}>
312
+ <Heading level={3}>Profile</Heading>
313
+ <Stack gap={2}>
314
+ {detailRows.map((row) => {
315
+ const { label, value } = row;
316
+
317
+ return (
318
+ <Stack key={label} gap={0}>
319
+ <Text isMuted size="xsmall">
320
+ {label}
321
+ </Text>
322
+ <Text>{value}</Text>
323
+ </Stack>
324
+ );
325
+ })}
326
+ </Stack>
327
+ </Stack>
328
+ </div>
329
+ </Panels>
330
+ ) : null
331
+ }
332
+ >
333
+ <BigTable
334
+ data={data}
335
+ columnDefs={columnDefs}
336
+ getRowKey={(u) => u.id}
337
+ onRowClick={(u) => setSelected((prev) => (prev?.id === u.id ? null : u))}
338
+ />
339
+ </Panels>
340
+ </div>
341
+ );
342
+ };
343
+
344
+ return <Demo />;
345
+ },
346
+ };
347
+
348
+ export const ManyRows: Story = {
349
+ name: 'Many rows — vertical scroll, sticky header',
350
+ render: () => {
351
+ const data = makeUsers(500);
352
+ const columnDefs: ColumnDef<User>[] = [
353
+ idColumn({ id: 'id', header: 'ID', value: (u) => u.id }),
354
+ textColumn({ id: 'name', header: 'Name', value: (u) => u.name }),
355
+ badgeColumn({ id: 'role', header: 'Role', value: (u) => u.role }),
356
+ textColumn({ id: 'team', header: 'Team', value: (u) => u.team }),
357
+ currencyColumn({ id: 'amount', header: 'Amount', value: (u) => u.amount }),
358
+ relativeDateColumn({ id: 'seen', header: 'Last Seen', value: (u) => u.lastSeenAt }),
359
+ ];
360
+
361
+ return (
362
+ <div style={fillStyle}>
363
+ <BigTable data={data} columnDefs={columnDefs} getRowKey={(u) => u.id} />
364
+ </div>
365
+ );
366
+ },
367
+ };
@@ -0,0 +1,118 @@
1
+ # BigTable — read this before editing
2
+
3
+ `BigTable` is the data-driven sibling of `QuickTable`. It accepts a typed
4
+ `data: T[]` plus `columnDefs: ColumnDef<T>[]` and renders a sticky-header
5
+ table that fills its container.
6
+
7
+ ## What it is
8
+
9
+ - **Data-driven.** Caller passes a row array and a column-def array. Each
10
+ column has a `cell: (row: T) => ReactNode` renderer. No JSX-children API.
11
+ - **Container-filling.** The shell is `height: 100%; overflow: auto`. The
12
+ parent must give it a bounded height (the way `Panels` works), or it
13
+ collapses.
14
+ - **Sticky header.** `<thead>` is `position: sticky; top: 0`.
15
+ - **Optional sticky first column.** `hasStickyFirstColumn` pins the leftmost
16
+ column horizontally. Only one column, only the leftmost — by design.
17
+ - **Horizontal scroll.** The table has `min-width: max-content`, so any column
18
+ that needs more space pushes total table width past the shell, triggering
19
+ horizontal scroll without truncating cells.
20
+ - **Optional row click.** `onRowClick(row)` is a pure passthrough — no
21
+ selection state, no highlight, no keyboard handling beyond what an
22
+ interactive cell child already provides. When set, rows get
23
+ `cursor: pointer` and a hover background. Clicks whose target is inside
24
+ an interactive element (`a, button, input, select, textarea, label,
25
+ [role="button"]`) are skipped, so a `linkColumn` or button-cell inside a
26
+ clickable row keeps its own behavior without firing the row handler too.
27
+
28
+ ## What it is NOT
29
+
30
+ - **Not a feature-grown data grid.** No internal sorting, filtering, selection,
31
+ multi-column pinning, column resizing, virtualization, expand/collapse rows,
32
+ inline editing, footers, grouping, or pagination. The controller is responsible
33
+ for sort/filter/page on its side; BigTable just renders what it's handed.
34
+ - **Not a replacement for QuickTable.** QuickTable is for ad-hoc admin/debug
35
+ surfaces; BigTable is for application UI where a controller has a typed
36
+ array and wants efficient, consistent rendering.
37
+ - **Not virtualized in v0.** "Many rows" works at 500–2k rows; beyond that,
38
+ add row virtualization (TanStack Virtual is the lightest path) without
39
+ changing the public API.
40
+
41
+ If you feel the pull to add `sortable`, `onSort`, `selectable`, `onRowSelect`,
42
+ `columnReorderable`, `expandable`, `pageSize`, `footer`, `groupBy` —
43
+ **stop**. That's the signal that we either need a new primitive
44
+ (e.g. `DataGrid`) or that the controller should own the behavior. Adding
45
+ those here defeats the "limited scope" pact and slowly rebuilds Excel.
46
+
47
+ `onRowClick` is the one row-interaction prop that lives here. The line is
48
+ **state vs. event**: `onRowClick` is a pure event passthrough that owns no
49
+ state — no selected-row tracking, no highlight, no multi-select. Anything
50
+ that asks BigTable to *remember* which row(s) the user touched
51
+ (`selectedRowIds`, `onRowSelect`, `defaultSelected`, `selectionMode`)
52
+ crosses the line and belongs in a `DataGrid` or in the controller.
53
+
54
+ ## ColumnDef anatomy
55
+
56
+ ```ts
57
+ type ColumnDef<T> = {
58
+ id: string; // stable identity (React key, future pinning)
59
+ header: ReactNode; // header content — usually a string
60
+ cell: (row: T) => ReactNode; // generic renderer — the base case
61
+ align?: 'left' | 'right' | 'center';
62
+ width?: number | string; // px number or CSS unit; omitted = flexible
63
+ minWidth?: number; // floor when flexible
64
+ };
65
+ ```
66
+
67
+ You can write a `ColumnDef<T>` by hand for any one-off rendering. For the
68
+ common cases, prefer a prefab — they set sensible `align` / `minWidth`
69
+ defaults and keep call sites consistent across the app.
70
+
71
+ ## Prefabs
72
+
73
+ All prefabs take `{ id, header, value, ... }` and return a full
74
+ `ColumnDef<T>`. Override layout via the `width` / `minWidth` args; for any
75
+ deeper override, spread the prefab result and patch:
76
+
77
+ ```ts
78
+ { ...numberColumn({ id, header, value: (r) => r.x }), align: 'left' }
79
+ ```
80
+
81
+ | Prefab | Value | Defaults |
82
+ |---|---|---|
83
+ | `textColumn` | `string` | left |
84
+ | `numberColumn` | `number` | right, minWidth 80 |
85
+ | `currencyColumn` | `number` | right, minWidth 100, USD |
86
+ | `dateColumn` | `Date \| string \| number` | left, minWidth 110 |
87
+ | `relativeDateColumn` | `Date \| string \| number` | left, minWidth 120 |
88
+ | `badgeColumn` | `string` (rendered in `<Badge>`) | left |
89
+ | `booleanColumn` | `boolean` (Yes/No, configurable) | center, minWidth 60 |
90
+ | `linkColumn` | `string` (rendered in `<Link>`) | left |
91
+ | `idColumn` | `string` (mono, muted) | left, minWidth 100 |
92
+
93
+ ## Container-fill contract
94
+
95
+ `<BigTable />` fills `height: 100%; width: 100%`. The parent has to give it
96
+ a bounded box — same contract as `Panels`. If the parent is `height: auto`,
97
+ the table grows with content and the sticky header has nothing to stick to.
98
+
99
+ ## Empty / loading state
100
+
101
+ Not handled in v0. Caller checks `data.length === 0` (or `isLoading`) and
102
+ renders something else (a `LoadingContainer`, an empty-state card) before
103
+ mounting `BigTable`. Adding a built-in slot is cheap if it earns its keep,
104
+ but most callers already have an empty-state convention.
105
+
106
+ ## Row identity
107
+
108
+ `getRowKey` is required, not optional. Forcing the caller to declare
109
+ identity prevents the index-key trap (rows get reordered, React reuses the
110
+ wrong DOM, focus/selection state leaks). One extra line per call site.
111
+
112
+ ## File layout
113
+
114
+ - `index.tsx` — `BigTable` component + barrel re-exports.
115
+ - `types.ts` — `ColumnDef`, `BigTableProps`, `ColumnAlign`.
116
+ - `columnDefs.tsx` — prefab factories.
117
+ - `styles.module.css` — shell, sticky header, sticky column, alignment, mono.
118
+ - `BigTable.stories.tsx` — stories.
@@ -0,0 +1,208 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Badge } from '../../Content/Badge';
3
+ import { Link } from '../../Content/Link';
4
+ import { Num } from '../../Primitives/Num';
5
+ import type { NumVariant } from '../../Primitives/Num';
6
+ import { RelativeTime } from '../../Primitives/RelativeTime';
7
+ import { EmptyValue } from '../../Primitives/EmptyValue';
8
+ import type { ColumnDef } from './types';
9
+ import styles from './styles.module.css';
10
+
11
+ type Layout = {
12
+ width?: number | string;
13
+ minWidth?: number;
14
+ };
15
+
16
+ type Base<T, V> = Layout & {
17
+ id: string;
18
+ header: ReactNode;
19
+ value: (row: T) => V;
20
+ };
21
+
22
+ export const textColumn = <T,>({
23
+ id,
24
+ header,
25
+ value,
26
+ width,
27
+ minWidth,
28
+ }: Base<T, string | null | undefined>): ColumnDef<T> => ({
29
+ id,
30
+ header,
31
+ cell: (row) => {
32
+ const v = value(row);
33
+ return v == null || v === '' ? <EmptyValue /> : v;
34
+ },
35
+ align: 'left',
36
+ width,
37
+ minWidth,
38
+ });
39
+
40
+ export const numberColumn = <T,>({
41
+ id,
42
+ header,
43
+ value,
44
+ as,
45
+ precision,
46
+ width,
47
+ minWidth,
48
+ }: Base<T, number | null | undefined> & {
49
+ as?: NumVariant;
50
+ precision?: number;
51
+ }): ColumnDef<T> => ({
52
+ id,
53
+ header,
54
+ cell: (row) => <Num value={value(row)} as={as} precision={precision} />,
55
+ align: 'right',
56
+ width,
57
+ minWidth: minWidth ?? 80,
58
+ });
59
+
60
+ export const currencyColumn = <T,>({
61
+ id,
62
+ header,
63
+ value,
64
+ currency,
65
+ precision,
66
+ width,
67
+ minWidth,
68
+ }: Base<T, number | null | undefined> & {
69
+ currency?: string;
70
+ precision?: number;
71
+ }): ColumnDef<T> => ({
72
+ id,
73
+ header,
74
+ cell: (row) => (
75
+ <Num value={value(row)} as="currency" currency={currency} precision={precision} />
76
+ ),
77
+ align: 'right',
78
+ width,
79
+ minWidth: minWidth ?? 100,
80
+ });
81
+
82
+ const toDate = (v: Date | string | number): Date => (v instanceof Date ? v : new Date(v));
83
+
84
+ export const dateColumn = <T,>({
85
+ id,
86
+ header,
87
+ value,
88
+ width,
89
+ minWidth,
90
+ }: Base<T, Date | string | number | null | undefined>): ColumnDef<T> => ({
91
+ id,
92
+ header,
93
+ cell: (row) => {
94
+ const v = value(row);
95
+ if (v == null) return <EmptyValue />;
96
+ const d = toDate(v);
97
+ return Number.isNaN(d.getTime()) ? <EmptyValue /> : d.toLocaleDateString();
98
+ },
99
+ align: 'left',
100
+ width,
101
+ minWidth: minWidth ?? 110,
102
+ });
103
+
104
+ export const relativeDateColumn = <T,>({
105
+ id,
106
+ header,
107
+ value,
108
+ width,
109
+ minWidth,
110
+ }: Base<T, Date | string | number | null | undefined>): ColumnDef<T> => ({
111
+ id,
112
+ header,
113
+ cell: (row) => <RelativeTime value={value(row)} />,
114
+ align: 'left',
115
+ width,
116
+ minWidth: minWidth ?? 120,
117
+ });
118
+
119
+ export const badgeColumn = <T,>({
120
+ id,
121
+ header,
122
+ value,
123
+ isMono,
124
+ width,
125
+ minWidth,
126
+ }: Base<T, string | null | undefined> & { isMono?: boolean }): ColumnDef<T> => ({
127
+ id,
128
+ header,
129
+ cell: (row) => {
130
+ const v = value(row);
131
+ return v == null || v === '' ? <EmptyValue /> : <Badge isMono={isMono}>{v}</Badge>;
132
+ },
133
+ align: 'left',
134
+ width,
135
+ minWidth,
136
+ });
137
+
138
+ export const booleanColumn = <T,>({
139
+ id,
140
+ header,
141
+ value,
142
+ labels = { true: 'Yes', false: 'No' },
143
+ width,
144
+ minWidth,
145
+ }: Base<T, boolean | null | undefined> & {
146
+ labels?: { true: string; false: string };
147
+ }): ColumnDef<T> => ({
148
+ id,
149
+ header,
150
+ cell: (row) => {
151
+ const v = value(row);
152
+ if (v == null) return <EmptyValue />;
153
+ return v ? labels.true : labels.false;
154
+ },
155
+ align: 'center',
156
+ width,
157
+ minWidth: minWidth ?? 60,
158
+ });
159
+
160
+ export const linkColumn = <T,>({
161
+ id,
162
+ header,
163
+ value,
164
+ href,
165
+ isExternal,
166
+ width,
167
+ minWidth,
168
+ }: Base<T, string | null | undefined> & {
169
+ href: (row: T) => string;
170
+ isExternal?: boolean;
171
+ }): ColumnDef<T> => ({
172
+ id,
173
+ header,
174
+ cell: (row) => {
175
+ const v = value(row);
176
+ if (v == null || v === '') return <EmptyValue />;
177
+ return (
178
+ <Link href={href(row)} isExternal={isExternal}>
179
+ {v}
180
+ </Link>
181
+ );
182
+ },
183
+ align: 'left',
184
+ width,
185
+ minWidth,
186
+ });
187
+
188
+ export const idColumn = <T,>({
189
+ id,
190
+ header,
191
+ value,
192
+ width,
193
+ minWidth,
194
+ }: Base<T, string | null | undefined>): ColumnDef<T> => ({
195
+ id,
196
+ header,
197
+ cell: (row) => {
198
+ const v = value(row);
199
+ return v == null || v === '' ? (
200
+ <EmptyValue />
201
+ ) : (
202
+ <span className={styles.cellMono}>{v}</span>
203
+ );
204
+ },
205
+ align: 'left',
206
+ width,
207
+ minWidth: minWidth ?? 100,
208
+ });