@usefragments/ui 1.3.2 → 1.4.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 (145) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/ui.css +1261 -849
  3. package/dist/chart.cjs +60 -24
  4. package/dist/chart.js +38 -24
  5. package/dist/codeblock.cjs +14 -6
  6. package/dist/codeblock.js +14 -6
  7. package/dist/colorpicker.cjs +56 -9
  8. package/dist/colorpicker.js +34 -9
  9. package/dist/components/Badge/Badge.module.scss.cjs +23 -16
  10. package/dist/components/Badge/Badge.module.scss.js +23 -16
  11. package/dist/components/Badge/index.cjs +8 -1
  12. package/dist/components/Badge/index.d.ts +3 -0
  13. package/dist/components/Badge/index.d.ts.map +1 -1
  14. package/dist/components/Badge/index.js +8 -1
  15. package/dist/components/Button/Button.module.scss.cjs +16 -13
  16. package/dist/components/Button/Button.module.scss.js +16 -13
  17. package/dist/components/Button/index.d.ts +2 -1
  18. package/dist/components/Button/index.d.ts.map +1 -1
  19. package/dist/components/Chart/index.d.ts +3 -0
  20. package/dist/components/Chart/index.d.ts.map +1 -1
  21. package/dist/components/CodeBlock/CodeBlock.module.scss.cjs +21 -21
  22. package/dist/components/CodeBlock/CodeBlock.module.scss.js +21 -21
  23. package/dist/components/CodeBlock/index.d.ts.map +1 -1
  24. package/dist/components/ColorPicker/index.d.ts +4 -0
  25. package/dist/components/ColorPicker/index.d.ts.map +1 -1
  26. package/dist/components/Combobox/Combobox.module.scss.cjs +23 -23
  27. package/dist/components/Combobox/Combobox.module.scss.js +23 -23
  28. package/dist/components/Command/Command.module.scss.cjs +11 -11
  29. package/dist/components/Command/Command.module.scss.js +11 -11
  30. package/dist/components/ConversationList/ConversationList.module.scss.cjs +13 -10
  31. package/dist/components/ConversationList/ConversationList.module.scss.js +14 -11
  32. package/dist/components/ConversationList/index.cjs +26 -22
  33. package/dist/components/ConversationList/index.d.ts +5 -1
  34. package/dist/components/ConversationList/index.d.ts.map +1 -1
  35. package/dist/components/ConversationList/index.js +27 -23
  36. package/dist/components/DataTable/DataTable.module.scss.cjs +35 -35
  37. package/dist/components/DataTable/DataTable.module.scss.js +35 -35
  38. package/dist/components/DataTable/index.d.ts +4 -1
  39. package/dist/components/DataTable/index.d.ts.map +1 -1
  40. package/dist/components/DatePicker/index.d.ts +3 -0
  41. package/dist/components/DatePicker/index.d.ts.map +1 -1
  42. package/dist/components/Editor/index.d.ts +4 -1
  43. package/dist/components/Editor/index.d.ts.map +1 -1
  44. package/dist/components/Listbox/Listbox.module.scss.cjs +8 -8
  45. package/dist/components/Listbox/Listbox.module.scss.js +8 -8
  46. package/dist/components/Markdown/Markdown.module.scss.cjs +1 -1
  47. package/dist/components/Markdown/Markdown.module.scss.js +1 -1
  48. package/dist/components/Markdown/index.d.ts +16 -0
  49. package/dist/components/Markdown/index.d.ts.map +1 -1
  50. package/dist/components/Menu/Menu.module.scss.cjs +14 -14
  51. package/dist/components/Menu/Menu.module.scss.js +14 -14
  52. package/dist/components/Message/Message.module.scss.cjs +23 -20
  53. package/dist/components/Message/Message.module.scss.js +24 -21
  54. package/dist/components/Message/index.cjs +5 -1
  55. package/dist/components/Message/index.d.ts.map +1 -1
  56. package/dist/components/Message/index.js +5 -1
  57. package/dist/components/NavigationMenu/NavigationMenu.module.scss.cjs +28 -28
  58. package/dist/components/NavigationMenu/NavigationMenu.module.scss.js +28 -28
  59. package/dist/components/Pagination/Pagination.module.scss.cjs +7 -7
  60. package/dist/components/Pagination/Pagination.module.scss.js +7 -7
  61. package/dist/components/Prompt/Prompt.module.scss.cjs +52 -22
  62. package/dist/components/Prompt/Prompt.module.scss.js +52 -22
  63. package/dist/components/Prompt/index.cjs +197 -2
  64. package/dist/components/Prompt/index.d.ts +98 -3
  65. package/dist/components/Prompt/index.d.ts.map +1 -1
  66. package/dist/components/Prompt/index.js +198 -3
  67. package/dist/components/Select/Select.module.scss.cjs +29 -17
  68. package/dist/components/Select/Select.module.scss.js +30 -18
  69. package/dist/components/Select/index.cjs +45 -8
  70. package/dist/components/Select/index.d.ts +17 -2
  71. package/dist/components/Select/index.d.ts.map +1 -1
  72. package/dist/components/Select/index.js +45 -8
  73. package/dist/components/Sidebar/Sidebar.module.scss.cjs +45 -42
  74. package/dist/components/Sidebar/Sidebar.module.scss.js +45 -42
  75. package/dist/components/Sidebar/index.cjs +2 -0
  76. package/dist/components/Sidebar/index.d.ts +6 -1
  77. package/dist/components/Sidebar/index.d.ts.map +1 -1
  78. package/dist/components/Sidebar/index.js +2 -0
  79. package/dist/components/Text/Text.module.scss.cjs +63 -51
  80. package/dist/components/Text/Text.module.scss.js +63 -51
  81. package/dist/components/Text/index.d.ts +4 -2
  82. package/dist/components/Text/index.d.ts.map +1 -1
  83. package/dist/data-table.cjs +210 -28
  84. package/dist/data-table.js +189 -29
  85. package/dist/datepicker.cjs +63 -14
  86. package/dist/datepicker.js +41 -14
  87. package/dist/editor.cjs +67 -16
  88. package/dist/editor.js +45 -16
  89. package/dist/index.cjs +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +2 -1
  93. package/dist/markdown.cjs +63 -19
  94. package/dist/markdown.js +41 -19
  95. package/fragments.json +1 -1
  96. package/package.json +2 -2
  97. package/src/components/Badge/Badge.contract.json +28 -5
  98. package/src/components/Badge/Badge.module.scss +21 -0
  99. package/src/components/Badge/index.tsx +10 -1
  100. package/src/components/Button/Button.contract.json +9 -3
  101. package/src/components/Button/Button.module.scss +34 -0
  102. package/src/components/Button/index.tsx +11 -1
  103. package/src/components/Chart/Chart.contract.json +7 -0
  104. package/src/components/Chart/index.tsx +44 -27
  105. package/src/components/CodeBlock/CodeBlock.contract.json +7 -0
  106. package/src/components/CodeBlock/CodeBlock.module.scss +7 -2
  107. package/src/components/CodeBlock/index.tsx +38 -6
  108. package/src/components/ColorPicker/ColorPicker.contract.json +9 -1
  109. package/src/components/ColorPicker/index.tsx +42 -12
  110. package/src/components/Combobox/Combobox.module.scss +4 -13
  111. package/src/components/ConversationList/ConversationList.contract.json +16 -1
  112. package/src/components/ConversationList/ConversationList.module.scss +12 -0
  113. package/src/components/ConversationList/index.tsx +40 -26
  114. package/src/components/DataTable/DataTable.contract.json +8 -1
  115. package/src/components/DataTable/DataTable.module.scss +5 -0
  116. package/src/components/DataTable/index.tsx +282 -51
  117. package/src/components/DatePicker/DatePicker.contract.json +8 -1
  118. package/src/components/DatePicker/index.tsx +51 -22
  119. package/src/components/Editor/Editor.contract.json +19 -1
  120. package/src/components/Editor/index.tsx +67 -23
  121. package/src/components/Listbox/Listbox.module.scss +3 -11
  122. package/src/components/Markdown/Markdown.contract.json +15 -1
  123. package/src/components/Markdown/Markdown.module.scss +18 -1
  124. package/src/components/Markdown/index.tsx +58 -24
  125. package/src/components/Menu/Menu.module.scss +12 -2
  126. package/src/components/Message/Message.contract.json +3 -0
  127. package/src/components/Message/Message.module.scss +33 -4
  128. package/src/components/Message/index.tsx +7 -1
  129. package/src/components/Pagination/Pagination.contract.json +1 -0
  130. package/src/components/Pagination/Pagination.module.scss +5 -4
  131. package/src/components/Prompt/Prompt.contract.json +40 -1
  132. package/src/components/Prompt/Prompt.module.scss +351 -4
  133. package/src/components/Prompt/index.tsx +324 -0
  134. package/src/components/Select/Select.contract.json +11 -1
  135. package/src/components/Select/Select.module.scss +80 -13
  136. package/src/components/Select/index.tsx +73 -7
  137. package/src/components/Sidebar/Sidebar.contract.json +1 -0
  138. package/src/components/Sidebar/Sidebar.module.scss +10 -0
  139. package/src/components/Sidebar/index.tsx +7 -0
  140. package/src/components/Text/Text.contract.json +7 -4
  141. package/src/components/Text/Text.module.scss +18 -0
  142. package/src/components/Text/index.tsx +4 -2
  143. package/src/index.ts +1 -0
  144. package/src/tokens/_mixins.scss +39 -4
  145. package/src/tokens/_variables.scss +30 -0
@@ -16,6 +16,7 @@
16
16
  "exportName": "ConversationList",
17
17
  "propsSummary": [
18
18
  "children: node (required)",
19
+ "showAvatars: boolean (default: true)",
19
20
  "autoScroll: union (default: smart)",
20
21
  "onScrollTop: function",
21
22
  "loadingHistory: boolean (default: false)",
@@ -29,6 +30,12 @@
29
30
  "description": "Message components",
30
31
  "required": true
31
32
  },
33
+ "showAvatars": {
34
+ "type": "boolean",
35
+ "description": "Show avatars for messages and typing indicators in this conversation",
36
+ "default": "true",
37
+ "required": false
38
+ },
32
39
  "autoScroll": {
33
40
  "type": "union",
34
41
  "description": "Auto-scroll behavior: true (always), false (never), or \"smart\" (only when near bottom)",
@@ -78,6 +85,8 @@
78
85
  ],
79
86
  "guidelines": [
80
87
  "Use autoScroll=\"smart\" for best UX (only auto-scrolls when near bottom)",
88
+ "Set showAvatars={false} for avatarless transcripts; messages remove their outer inline inset automatically",
89
+ "Conversation chrome uses neutral surfaces and the body foreground; reserve brand accent for actions or semantic state",
81
90
  "Implement onScrollTop for loading older messages",
82
91
  "ConversationList composes your onScroll handler with internal auto-scroll/history logic",
83
92
  "Provide an emptyState for new conversations",
@@ -85,7 +94,7 @@
85
94
  ],
86
95
  "accessibility": [
87
96
  "Uses proper ARIA roles for separators",
88
- "Typing indicator has aria-label",
97
+ "Typing indicator uses a status role with an aria-label",
89
98
  "Smooth scroll respects reduced motion preferences",
90
99
  "Keyboard navigation works within scrollable container"
91
100
  ]
@@ -101,6 +110,11 @@
101
110
  "description": "Messages grouped by date",
102
111
  "code": "<ConversationList>\n <ConversationList.DateSeparator date={new Date(Date.now() - 86400000)} />\n <Message role=\"user\">\n <Message.Content>A message from yesterday</Message.Content>\n </Message>\n <ConversationList.DateSeparator date={new Date()} />\n <Message role=\"assistant\">\n <Message.Content>And a message from today!</Message.Content>\n </Message>\n</ConversationList>"
103
112
  },
113
+ {
114
+ "name": "Without Avatars",
115
+ "description": "Compact transcript without message or typing-indicator avatars",
116
+ "code": "<ConversationList showAvatars={false}>\n <Message role=\"user\">\n <Message.Content>Hello!</Message.Content>\n </Message>\n <Message role=\"assistant\">\n <Message.Content>How can I help?</Message.Content>\n </Message>\n</ConversationList>"
117
+ },
104
118
  {
105
119
  "name": "With Typing Indicator",
106
120
  "description": "Shows assistant is typing",
@@ -132,6 +146,7 @@
132
146
  "contract": {
133
147
  "propsSummary": [
134
148
  "children: ReactNode - Message components",
149
+ "showAvatars: boolean - show message and typing-indicator avatars (default: true)",
135
150
  "autoScroll: boolean | \"smart\" - scroll behavior (default: \"smart\")",
136
151
  "onScrollTop: (event?) => void - callback for loading history (receives scroll event)",
137
152
  "loadingHistory: boolean - show history loading spinner",
@@ -9,6 +9,7 @@
9
9
  display: flex;
10
10
  flex-direction: column;
11
11
  flex: 1;
12
+ color: var(--fui-text-primary, $fui-text-primary);
12
13
  overflow-y: auto;
13
14
  overflow-x: hidden;
14
15
  scroll-behavior: smooth;
@@ -25,6 +26,13 @@
25
26
  gap: var(--fui-space-2, $fui-space-2);
26
27
  padding: var(--fui-space-3, $fui-space-3);
27
28
  min-height: 100%;
29
+ // Own the full height of the conversation. As a shrinkable flex item it was
30
+ // squashed to the height of the scroll port while its messages overflowed
31
+ // past it — the list still scrolled, because the overflow lands on the
32
+ // scroller, but this element reported a constant height no matter how much
33
+ // was in it. Anything measuring the conversation, auto-scroll included, was
34
+ // measuring the window rather than the content.
35
+ flex-shrink: 0;
28
36
  }
29
37
 
30
38
  // ============================================
@@ -95,6 +103,10 @@
95
103
  padding: var(--fui-space-2, $fui-space-2);
96
104
  }
97
105
 
106
+ .withoutAvatar {
107
+ padding-inline: 0;
108
+ }
109
+
98
110
  .typingAvatar {
99
111
  flex-shrink: 0;
100
112
  display: flex;
@@ -13,6 +13,8 @@ export type AutoScrollBehavior = boolean | 'smart';
13
13
  export interface ConversationListProps extends React.HTMLAttributes<HTMLDivElement> {
14
14
  /** Message components */
15
15
  children: React.ReactNode;
16
+ /** Show avatars for messages and typing indicators in this conversation */
17
+ showAvatars?: boolean;
16
18
  /** Auto-scroll behavior: true (always), false (never), or 'smart' (only when near bottom) */
17
19
  autoScroll?: AutoScrollBehavior;
18
20
  /** Callback when user scrolls to top (for loading history) */
@@ -47,12 +49,17 @@ export interface TypingIndicatorProps extends React.HTMLAttributes<HTMLDivElemen
47
49
 
48
50
  interface ConversationListContextValue {
49
51
  scrollToBottom: () => void;
52
+ showAvatars: boolean;
50
53
  }
51
54
 
52
55
  const ConversationListContext = React.createContext<ConversationListContextValue | null>(null);
53
56
 
57
+ export function useOptionalConversationList() {
58
+ return React.useContext(ConversationListContext);
59
+ }
60
+
54
61
  export function useConversationList() {
55
- const context = React.useContext(ConversationListContext);
62
+ const context = useOptionalConversationList();
56
63
  if (!context) {
57
64
  throw new Error('useConversationList must be used within a ConversationList');
58
65
  }
@@ -113,11 +120,16 @@ function TypingIndicator({
113
120
  className,
114
121
  ...htmlProps
115
122
  }: TypingIndicatorProps) {
116
- const classes = [styles.typingIndicator, className].filter(Boolean).join(' ');
123
+ const showAvatar = (useOptionalConversationList()?.showAvatars ?? true) && Boolean(avatar);
124
+ const classes = [
125
+ styles.typingIndicator,
126
+ !showAvatar && styles.withoutAvatar,
127
+ className,
128
+ ].filter(Boolean).join(' ');
117
129
 
118
130
  return (
119
- <div {...htmlProps} className={classes} aria-label={`${name} is typing`}>
120
- {avatar && <div className={styles.typingAvatar}>{avatar}</div>}
131
+ <div {...htmlProps} className={classes} role="status" aria-label={`${name} is typing`}>
132
+ {showAvatar && <div className={styles.typingAvatar}>{avatar}</div>}
121
133
  <div className={styles.typingContent}>
122
134
  <span className={styles.typingDot} />
123
135
  <span className={styles.typingDot} />
@@ -133,6 +145,7 @@ function TypingIndicator({
133
145
 
134
146
  function ConversationListRoot({
135
147
  children,
148
+ showAvatars = true,
136
149
  autoScroll = 'smart',
137
150
  onScrollTop,
138
151
  loadingHistory = false,
@@ -145,7 +158,6 @@ function ConversationListRoot({
145
158
  const containerRef = React.useRef<HTMLDivElement>(null);
146
159
  const contentRef = React.useRef<HTMLDivElement>(null);
147
160
  const isNearBottomRef = React.useRef(true);
148
- const prevChildrenCountRef = React.useRef(0);
149
161
  const userOnScroll = htmlProps.onScroll;
150
162
 
151
163
  // Check if user is near the bottom
@@ -185,31 +197,33 @@ function ConversationListRoot({
185
197
  }
186
198
  }, [checkIsNearBottom, onScrollTop, scrollTopThreshold, userOnScroll]);
187
199
 
188
- // Auto-scroll on new messages
189
- React.useEffect(() => {
190
- const childrenCount = React.Children.count(children);
191
- const hasNewMessages = childrenCount > prevChildrenCountRef.current;
192
- prevChildrenCountRef.current = childrenCount;
193
-
194
- if (!hasNewMessages) return;
195
-
196
- if (autoScroll === true) {
197
- scrollToBottom();
198
- } else if (autoScroll === 'smart' && isNearBottomRef.current) {
199
- scrollToBottom();
200
- }
201
- }, [children, autoScroll, scrollToBottom]);
202
-
203
- // Initial scroll to bottom
200
+ // Keep the reader at the newest content as the conversation grows.
201
+ //
202
+ // Child count is the wrong signal for this. A list whose messages are wrapped
203
+ // in a single element — a measured column, a virtualiser, a fragment the
204
+ // caller maps into — never changes count, so the list would pin once on mount
205
+ // and never again. And even when the count does change, the height keeps
206
+ // moving afterwards: markdown reflows, images decode, syntax highlighting
207
+ // lands, a streaming reply grows a character at a time. Rendered height is
208
+ // what actually moves the bottom of the list, so that is what this watches.
204
209
  React.useEffect(() => {
205
- if (autoScroll) {
206
- scrollToBottom('instant');
207
- }
210
+ const content = contentRef.current;
211
+ if (!content || !autoScroll) return;
212
+
213
+ // Instant, not smooth: while a reply streams this fires on every frame of
214
+ // growth, and overlapping smooth scrolls fight each other into a stutter.
215
+ // The context's scrollToBottom() stays smooth for deliberate jumps.
216
+ const observer = new ResizeObserver(() => {
217
+ if (autoScroll === true || isNearBottomRef.current) scrollToBottom('instant');
218
+ });
219
+ observer.observe(content);
220
+ return () => observer.disconnect();
208
221
  }, [autoScroll, scrollToBottom]);
209
222
 
210
- const contextValue: ConversationListContextValue = {
223
+ const contextValue = React.useMemo<ConversationListContextValue>(() => ({
211
224
  scrollToBottom,
212
- };
225
+ showAvatars,
226
+ }), [scrollToBottom, showAvatars]);
213
227
 
214
228
  const hasChildren = React.Children.count(children) > 0;
215
229
 
@@ -5,6 +5,13 @@
5
5
  "category": "display",
6
6
  "tags": ["table", "data", "grid", "list", "sorting", "tanstack"],
7
7
  "status": "stable",
8
+ "dependencies": [
9
+ {
10
+ "name": "@tanstack/react-table",
11
+ "version": ">=8.0.0",
12
+ "reason": "Optional — powers sorting, selection, and expansion; without it DataTable renders a static, non-interactive table."
13
+ }
14
+ ],
8
15
  "sourcePath": "src/components/DataTable/index.tsx",
9
16
  "exportName": "DataTable",
10
17
  "propsSummary": [
@@ -281,7 +288,7 @@
281
288
  },
282
289
  "ai": {
283
290
  "compositionPattern": "simple",
284
- "subComponents": ["Root", "Columns"],
291
+ "subComponents": ["Root", "Columns", "preload"],
285
292
  "commonPatterns": [
286
293
  "<DataTable columns={createColumns([{key:\"name\",header:\"Name\"},{key:\"status\",header:\"Status\"}])} data={[{name:\"Item 1\",status:\"Active\"}]} />"
287
294
  ]
@@ -2,6 +2,11 @@
2
2
  @use "../../tokens/mixins" as *;
3
3
 
4
4
  .wrapper {
5
+ // Tables may have a larger intrinsic width than a narrow flex or grid
6
+ // column. Keep that width inside this scroll container instead of allowing
7
+ // the container itself to widen the page.
8
+ min-width: 0;
9
+ max-width: 100%;
5
10
  --fui-data-table-bg: var(--fui-table-bg, var(--fui-bg-elevated, #{$fui-bg-elevated}));
6
11
  --fui-data-table-header-bg: var(
7
12
  --fui-table-header-bg,
@@ -7,15 +7,14 @@ import { ExpandIcon, SortAscIcon, SortDescIcon, SortIcon } from "./DataTable.ico
7
7
  import { DataTableSkeletonRows, useArrowKeyRowNav } from "./DataTable.support";
8
8
 
9
9
  // ============================================
10
- // Dependency (@tanstack/react-table) — lazy-required
10
+ // Dependency (@tanstack/react-table) — lazy import()
11
11
  // ============================================
12
- // `@tanstack/react-table` is an optional peer dependency. It is pulled in via a
13
- // synchronous `require()` (mirroring Chart/Editor) rather than a static ESM
14
- // import so a bundler's dep-optimizer (e.g. Vite dev) does not create a static
15
- // import edge for consumers who only use, say, `{ Button }`. `require` resolves
16
- // synchronously, so `useReactTable` (a hook) is available before the first hook
17
- // call and hook order stays stable across renders. When the peer is absent the
18
- // component throws a friendly install message on first render.
12
+ // `@tanstack/react-table` is an optional peer dependency, resolved with a
13
+ // dynamic `import()` rather than `require()`: browser ESM bundles have no
14
+ // `require`, so the synchronous shape failed even with the peer installed
15
+ // (same lazy-ESM shape as CodeBlock's shiki loader). While the module resolves
16
+ // the table renders skeleton rows; if it is genuinely missing, a static
17
+ // fallback table renders instead of throwing.
19
18
 
20
19
  type ReactTableModule = {
21
20
  flexRender: (...args: any[]) => React.ReactNode;
@@ -26,18 +25,43 @@ type ReactTableModule = {
26
25
  };
27
26
 
28
27
  let _reactTable: ReactTableModule | null = null;
29
- let _reactTableLoaded = false;
28
+ let _reactTableLoadPromise: Promise<void> | null = null;
30
29
  let _reactTableFailed = false;
31
30
 
32
- function loadReactTable() {
33
- if (_reactTableLoaded) return;
34
- _reactTableLoaded = true;
35
- try {
36
- // eslint-disable-next-line @typescript-eslint/no-require-imports
37
- _reactTable = require("@tanstack/react-table") as ReactTableModule;
38
- } catch {
39
- _reactTableFailed = true;
31
+ function loadReactTable(): Promise<void> {
32
+ if (!_reactTableLoadPromise) {
33
+ _reactTableLoadPromise = (async () => {
34
+ try {
35
+ _reactTable = (await import("@tanstack/react-table")) as unknown as ReactTableModule;
36
+ } catch {
37
+ _reactTableFailed = true;
38
+ if (process.env.NODE_ENV === "development") {
39
+ console.warn(
40
+ "[@usefragments/ui] DataTable: @tanstack/react-table is not installed. " +
41
+ "Rendering a static table without sorting, selection, or expansion. " +
42
+ "Install it with: npm install @tanstack/react-table"
43
+ );
44
+ }
45
+ }
46
+ })();
40
47
  }
48
+ return _reactTableLoadPromise;
49
+ }
50
+
51
+ /** Kick off the lazy load on mount and re-render once it settles. */
52
+ function useReactTableDeps(): "ready" | "pending" | "failed" {
53
+ const [, rerender] = React.useReducer((n: number) => n + 1, 0);
54
+ React.useEffect(() => {
55
+ if (_reactTable || _reactTableFailed) return;
56
+ let active = true;
57
+ void loadReactTable().then(() => {
58
+ if (active) rerender();
59
+ });
60
+ return () => {
61
+ active = false;
62
+ };
63
+ }, []);
64
+ return _reactTable ? "ready" : _reactTableFailed ? "failed" : "pending";
41
65
  }
42
66
 
43
67
  /** Horizontal alignment for a column's header + cells. */
@@ -156,6 +180,48 @@ function getColumnSizeStyle(column: {
156
180
  };
157
181
  }
158
182
 
183
+ function computeTableClasses(opts: {
184
+ size: "sm" | "md";
185
+ density?: DataTableDensity;
186
+ striped: boolean;
187
+ bordered: boolean;
188
+ hasExplicitColumnSizing: boolean;
189
+ className?: string;
190
+ wrapperClassName?: string;
191
+ wrapperPropsClassName?: string;
192
+ }): { rootClasses: string; wrapperClasses: string } {
193
+ const densityClass =
194
+ opts.density === "condensed"
195
+ ? styles.densityCondensed
196
+ : opts.density === "regular"
197
+ ? styles.densityRegular
198
+ : opts.density === "relaxed"
199
+ ? styles.densityRelaxed
200
+ : undefined;
201
+
202
+ const rootClasses = [
203
+ styles.table,
204
+ opts.hasExplicitColumnSizing && styles.fixedLayout,
205
+ styles[opts.size],
206
+ densityClass,
207
+ opts.striped && styles.striped,
208
+ opts.className,
209
+ ]
210
+ .filter(Boolean)
211
+ .join(" ");
212
+
213
+ const wrapperClasses = [
214
+ styles.wrapper,
215
+ opts.bordered && styles.bordered,
216
+ opts.wrapperClassName,
217
+ opts.wrapperPropsClassName,
218
+ ]
219
+ .filter(Boolean)
220
+ .join(" ");
221
+
222
+ return { rootClasses, wrapperClasses };
223
+ }
224
+
159
225
  function isInteractiveTarget(target: EventTarget | null, currentTarget: HTMLTableRowElement) {
160
226
  if (!(target instanceof Element)) return false;
161
227
 
@@ -170,7 +236,7 @@ function isInteractiveTarget(target: EventTarget | null, currentTarget: HTMLTabl
170
236
  );
171
237
  }
172
238
 
173
- function DataTableRoot<T>({
239
+ function DataTableLive<T>({
174
240
  columns: userColumns,
175
241
  data,
176
242
  getRowId,
@@ -204,15 +270,10 @@ function DataTableRoot<T>({
204
270
  "aria-describedby": ariaDescribedBy,
205
271
  ...htmlProps
206
272
  }: DataTableProps<T>) {
207
- loadReactTable();
208
- if (_reactTableFailed || !_reactTable) {
209
- throw new Error(
210
- "[@usefragments/ui] DataTable: @tanstack/react-table is not installed. " +
211
- "Install it with: npm install @tanstack/react-table"
212
- );
213
- }
273
+ // Only rendered once useReactTableDeps() reports "ready", so the module is
274
+ // guaranteed here and hook order stays stable for this component's lifetime.
214
275
  const { flexRender, getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } =
215
- _reactTable;
276
+ _reactTable as ReactTableModule;
216
277
 
217
278
  const tableRef = React.useRef<HTMLTableElement>(null);
218
279
  useArrowKeyRowNav(tableRef, !!onRowClick);
@@ -298,34 +359,17 @@ function DataTableRoot<T>({
298
359
  });
299
360
 
300
361
  const isEmpty = !loading && data.length === 0;
301
- const densityClass =
302
- density === "condensed"
303
- ? styles.densityCondensed
304
- : density === "regular"
305
- ? styles.densityRegular
306
- : density === "relaxed"
307
- ? styles.densityRelaxed
308
- : undefined;
309
-
310
- const rootClasses = [
311
- styles.table,
312
- hasExplicitColumnSizing && styles.fixedLayout,
313
- styles[size],
314
- densityClass,
315
- striped && styles.striped,
316
- className,
317
- ]
318
- .filter(Boolean)
319
- .join(" ");
320
362
  const { className: wrapperPropsClassName, ...restWrapperProps } = wrapperProps ?? {};
321
- const wrapperClasses = [
322
- styles.wrapper,
323
- bordered && styles.bordered,
363
+ const { rootClasses, wrapperClasses } = computeTableClasses({
364
+ size,
365
+ density,
366
+ striped,
367
+ bordered,
368
+ hasExplicitColumnSizing,
369
+ className,
324
370
  wrapperClassName,
325
371
  wrapperPropsClassName,
326
- ]
327
- .filter(Boolean)
328
- .join(" ");
372
+ });
329
373
 
330
374
  if (isEmpty) {
331
375
  return (
@@ -542,6 +586,191 @@ function DataTableRoot<T>({
542
586
  );
543
587
  }
544
588
 
589
+ // ============================================
590
+ // Static fallback (peer resolving or missing)
591
+ // ============================================
592
+
593
+ function staticColumnSizeStyle<T>(col: DataTableColumn<T>): React.CSSProperties | undefined {
594
+ const { size, minSize, maxSize } = col;
595
+ if (size === undefined && minSize === undefined && maxSize === undefined) return undefined;
596
+ const resolved = size ?? minSize ?? maxSize;
597
+ return { width: resolved, minWidth: minSize ?? resolved, maxWidth: maxSize ?? resolved };
598
+ }
599
+
600
+ function staticCellContent<T>(col: DataTableColumn<T>, row: T, index: number): React.ReactNode {
601
+ const raw = col.accessorFn
602
+ ? col.accessorFn(row)
603
+ : col.accessorKey
604
+ ? (row as Record<string, unknown>)[col.accessorKey]
605
+ : undefined;
606
+ if (typeof col.cell === "function") {
607
+ try {
608
+ return col.cell({
609
+ row: { original: row, id: String(index), index, depth: 0 },
610
+ getValue: () => raw,
611
+ column: { id: col.id ?? col.accessorKey ?? "" },
612
+ });
613
+ } catch {
614
+ // Cell renderers that depend on TanStack row APIs fall back to the raw value.
615
+ }
616
+ }
617
+ return typeof raw === "string" || typeof raw === "number" ? raw : null;
618
+ }
619
+
620
+ /**
621
+ * Rendered while @tanstack/react-table resolves (skeleton rows) and when it is
622
+ * not installed (a plain, non-interactive table of the data). Sorting,
623
+ * selection, expansion, and row interactivity require the peer.
624
+ */
625
+ function DataTableStatic<T>({
626
+ pending,
627
+ columns,
628
+ data,
629
+ getRowId,
630
+ emptyMessage = "No data available",
631
+ emptyState,
632
+ loading = false,
633
+ skeletonRows = 6,
634
+ density,
635
+ hideHeader = false,
636
+ size = "md",
637
+ className,
638
+ caption,
639
+ captionHidden = false,
640
+ striped = false,
641
+ bordered = false,
642
+ wrapperClassName,
643
+ wrapperProps,
644
+ "aria-label": ariaLabel,
645
+ "aria-describedby": ariaDescribedBy,
646
+ // Interactive props are inert without the peer — accepted but unused.
647
+ sortable: _sortable,
648
+ sorting: _sorting,
649
+ onSortingChange: _onSortingChange,
650
+ selectable: _selectable,
651
+ showCheckbox: _showCheckbox,
652
+ rowSelection: _rowSelection,
653
+ onRowSelectionChange: _onRowSelectionChange,
654
+ onRowClick: _onRowClick,
655
+ getRowProps: _getRowProps,
656
+ getSubRows: _getSubRows,
657
+ expanded: _expanded,
658
+ onExpandedChange: _onExpandedChange,
659
+ ...htmlProps
660
+ }: DataTableProps<T> & { pending: boolean }) {
661
+ const hasExplicitColumnSizing = columns.some(
662
+ (column) =>
663
+ column.size !== undefined || column.minSize !== undefined || column.maxSize !== undefined
664
+ );
665
+ const { className: wrapperPropsClassName, ...restWrapperProps } = wrapperProps ?? {};
666
+ const { rootClasses, wrapperClasses } = computeTableClasses({
667
+ size,
668
+ density,
669
+ striped,
670
+ bordered,
671
+ hasExplicitColumnSizing,
672
+ className,
673
+ wrapperClassName,
674
+ wrapperPropsClassName,
675
+ });
676
+
677
+ const showSkeleton = pending || loading;
678
+ const isEmpty = !showSkeleton && data.length === 0;
679
+ const columnCount = Math.max(columns.length, 1);
680
+
681
+ return (
682
+ <div {...restWrapperProps} className={wrapperClasses}>
683
+ <table
684
+ {...htmlProps}
685
+ className={rootClasses}
686
+ aria-label={ariaLabel}
687
+ aria-describedby={ariaDescribedBy}
688
+ aria-busy={showSkeleton || undefined}
689
+ >
690
+ {caption && (
691
+ <caption className={captionHidden ? styles.captionHidden : styles.caption}>
692
+ {caption}
693
+ </caption>
694
+ )}
695
+ {!hideHeader && !isEmpty && (
696
+ <thead className={styles.thead}>
697
+ <tr className={styles.headerRow}>
698
+ {columns.map((col, colIndex) => (
699
+ <th
700
+ key={col.id ?? col.accessorKey ?? colIndex}
701
+ className={styles.th}
702
+ style={staticColumnSizeStyle(col)}
703
+ scope="col"
704
+ data-align={col.align}
705
+ >
706
+ <div className={styles.headerContent}>
707
+ {typeof col.header === "string" ? col.header : (col.id ?? col.accessorKey ?? "")}
708
+ </div>
709
+ </th>
710
+ ))}
711
+ </tr>
712
+ </thead>
713
+ )}
714
+ <tbody className={styles.tbody}>
715
+ {showSkeleton ? (
716
+ <DataTableSkeletonRows
717
+ rowCount={skeletonRows}
718
+ columnCount={columnCount}
719
+ rowClassName={styles.row}
720
+ cellClassName={styles.td}
721
+ barClassName={styles.skeletonBar}
722
+ />
723
+ ) : isEmpty ? (
724
+ <tr className={styles.row}>
725
+ <td className={styles.td} colSpan={columnCount}>
726
+ <div className={styles.emptyState}>
727
+ {emptyState ?? <span className={styles.emptyMessage}>{emptyMessage}</span>}
728
+ </div>
729
+ </td>
730
+ </tr>
731
+ ) : (
732
+ data.map((row, rowIndex) => {
733
+ const rowKey = getRowId?.(row) ?? rowIndex;
734
+ return (
735
+ <tr key={rowKey} className={styles.row}>
736
+ {columns.map((col, colIndex) => {
737
+ const content = staticCellContent(col, row, rowIndex);
738
+ return (
739
+ <td
740
+ key={col.id ?? col.accessorKey ?? colIndex}
741
+ className={[styles.td, col.truncate && styles.truncate]
742
+ .filter(Boolean)
743
+ .join(" ")}
744
+ data-align={col.align}
745
+ title={typeof content === "string" ? content : undefined}
746
+ style={staticColumnSizeStyle(col)}
747
+ >
748
+ {content}
749
+ </td>
750
+ );
751
+ })}
752
+ </tr>
753
+ );
754
+ })
755
+ )}
756
+ </tbody>
757
+ </table>
758
+ </div>
759
+ );
760
+ }
761
+
762
+ // ============================================
763
+ // Root — picks live vs static by dependency state
764
+ // ============================================
765
+
766
+ function DataTableRoot<T>(props: DataTableProps<T>) {
767
+ const deps = useReactTableDeps();
768
+ // Distinct component types per state: the live table calls useReactTable, so
769
+ // it must only ever mount once the module is resolved.
770
+ if (deps === "ready") return <DataTableLive {...props} />;
771
+ return <DataTableStatic {...props} pending={deps === "pending"} />;
772
+ }
773
+
545
774
  // Helper to create simple columns without TanStack's createColumnHelper
546
775
  export function createColumns<T>(
547
776
  columns: Array<{
@@ -565,4 +794,6 @@ export function createColumns<T>(
565
794
  export const DataTable = Object.assign(DataTableRoot, {
566
795
  Root: DataTableRoot,
567
796
  Columns: createColumns,
797
+ /** Start resolving @tanstack/react-table before first render (optional). */
798
+ preload: loadReactTable,
568
799
  });
@@ -5,6 +5,13 @@
5
5
  "category": "forms",
6
6
  "tags": ["date", "picker", "calendar", "range", "form", "input"],
7
7
  "status": "stable",
8
+ "dependencies": [
9
+ {
10
+ "name": "react-day-picker",
11
+ "version": ">=9.0.0",
12
+ "reason": "Optional — renders the calendar popup; the trigger and form field work without it."
13
+ }
14
+ ],
8
15
  "sourcePath": "src/components/DatePicker/index.tsx",
9
16
  "exportName": "DatePicker",
10
17
  "propsSummary": [
@@ -267,7 +274,7 @@
267
274
  },
268
275
  "ai": {
269
276
  "compositionPattern": "compound",
270
- "subComponents": ["Trigger", "Content", "Calendar", "Preset"],
277
+ "subComponents": ["Trigger", "Content", "Calendar", "Preset", "preload"],
271
278
  "requiredChildren": ["Trigger", "Content"],
272
279
  "commonPatterns": [
273
280
  "<DatePicker size=\"sm\" selected={date} onSelect={setDate}><DatePicker.Trigger placeholder=\"Pick a date\" /><DatePicker.Content><DatePicker.Calendar /></DatePicker.Content></DatePicker>",