@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/ui",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Customizable UI components built on Base UI headless primitives",
6
6
  "author": "Conan McNicholl",
@@ -241,7 +241,7 @@
241
241
  "vite": "^6.0.0",
242
242
  "vitest": "^2.1.8",
243
243
  "vitest-axe": "^0.1.0",
244
- "@usefragments/core": "1.4.0"
244
+ "@usefragments/core": "1.7.1"
245
245
  },
246
246
  "files": [
247
247
  "src",
@@ -9,10 +9,13 @@
9
9
  "exportName": "Badge",
10
10
  "propsSummary": [
11
11
  "children: node (required)",
12
- "variant: default|success|warning|error|info|outline (default: default)",
12
+ "variant: default|success|warning|error|info|outline|label|dim (default: default)",
13
13
  "size: sm|md|lg (default: md)",
14
14
  "dot: boolean (default: false)",
15
+ "dotPulse: boolean (default: false)",
16
+ "dotColor: string",
15
17
  "icon: node",
18
+ "active: boolean (default: false)",
16
19
  "onRemove: function",
17
20
  "announce: boolean (default: false)",
18
21
  "role: union"
@@ -25,10 +28,10 @@
25
28
  },
26
29
  "variant": {
27
30
  "type": "enum",
28
- "description": "Visual style indicating severity or category",
31
+ "description": "Visual style indicating severity or category. `label` is a hairline-bordered metadata pill (pair with dotColor); `dim` is transparent filter-pill chrome (pair with active).",
29
32
  "default": "default",
30
33
  "required": false,
31
- "values": ["default", "success", "warning", "error", "info", "outline"]
34
+ "values": ["default", "success", "warning", "error", "info", "outline", "label", "dim"]
32
35
  },
33
36
  "size": {
34
37
  "type": "enum",
@@ -43,11 +46,28 @@
43
46
  "default": "false",
44
47
  "required": false
45
48
  },
49
+ "dotPulse": {
50
+ "type": "boolean",
51
+ "description": "Breathe the status dot, for a state that is still happening rather than one that has settled. Opacity only, and honours prefers-reduced-motion",
52
+ "default": "false",
53
+ "required": false
54
+ },
55
+ "dotColor": {
56
+ "type": "string",
57
+ "description": "Color for the leading status dot. Applies to variant=\"label\" (and the generic dot prop). Accepts any CSS color including CSS custom properties.",
58
+ "required": false
59
+ },
46
60
  "icon": {
47
61
  "type": "node",
48
62
  "description": "Optional icon element before the text",
49
63
  "required": false
50
64
  },
65
+ "active": {
66
+ "type": "boolean",
67
+ "description": "Marks variant=\"dim\" as the currently selected filter. Has no effect on other variants.",
68
+ "default": "false",
69
+ "required": false
70
+ },
51
71
  "onRemove": {
52
72
  "type": "function",
53
73
  "description": "Makes the badge removable. Called when X is clicked.",
@@ -149,9 +169,12 @@
149
169
  "contract": {
150
170
  "propsSummary": [
151
171
  "children: ReactNode - badge label (required)",
152
- "variant: default|success|warning|error|info|outline - visual style",
153
- "size: sm|md - badge size",
172
+ "variant: default|success|warning|error|info|outline|label|dim - visual style",
173
+ "size: sm|md|lg - badge size",
154
174
  "dot: boolean - show status dot indicator",
175
+ "dotPulse: boolean - breathe the dot for an in-progress state (respects prefers-reduced-motion)",
176
+ "dotColor: string - color for the leading status dot",
177
+ "active: boolean - marks variant=\"dim\" as the selected filter",
155
178
  "onRemove: () => void - makes badge removable",
156
179
  "announce: boolean - opt into role=\"status\" + auto status aria-label"
157
180
  ],
@@ -116,6 +116,27 @@ $fui-badge-dot-md: 6px !default;
116
116
  }
117
117
  }
118
118
 
119
+ // "Still happening", as distinct from "is in this state". A live badge and a
120
+ // settled one are otherwise the same object, and the difference between them is
121
+ // the one thing a status board is scanned for — so it is carried by the only
122
+ // motion the component has. Opacity only: nothing reflows, and a reader who has
123
+ // asked for less motion loses the breathing and keeps the badge.
124
+ .dotPulse {
125
+ animation: fui-badge-dot-pulse 1.6s ease-in-out infinite;
126
+ }
127
+
128
+ @keyframes fui-badge-dot-pulse {
129
+ 50% {
130
+ opacity: 0.35;
131
+ }
132
+ }
133
+
134
+ @media (prefers-reduced-motion: reduce) {
135
+ .dotPulse {
136
+ animation: none;
137
+ }
138
+ }
139
+
119
140
  .icon {
120
141
  display: flex;
121
142
  align-items: center;
@@ -26,6 +26,9 @@ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
26
26
  size?: "sm" | "md" | "lg";
27
27
  /** Show a status dot before the label */
28
28
  dot?: boolean;
29
+ /** Breathe the status dot, for a state that is still happening rather than
30
+ * one that has settled. Respects `prefers-reduced-motion`. */
31
+ dotPulse?: boolean;
29
32
  /** Color for the leading status dot. Applies to `variant="label"` (and the
30
33
  * generic `dot` prop). Accepts any CSS color including CSS custom properties. */
31
34
  dotColor?: string;
@@ -47,6 +50,7 @@ const BadgeRoot = React.forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
47
50
  variant = "default",
48
51
  size = "md",
49
52
  dot = false,
53
+ dotPulse = false,
50
54
  dotColor,
51
55
  icon,
52
56
  active = false,
@@ -90,7 +94,12 @@ const BadgeRoot = React.forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
90
94
  role={role ?? (announce ? "status" : undefined)}
91
95
  aria-label={effectiveAriaLabel}
92
96
  >
93
- {dot && <span className={styles.dot} aria-hidden="true" />}
97
+ {dot && (
98
+ <span
99
+ className={[styles.dot, dotPulse && styles.dotPulse].filter(Boolean).join(" ")}
100
+ aria-hidden="true"
101
+ />
102
+ )}
94
103
  {icon && (
95
104
  <span className={styles.icon} aria-hidden="true">
96
105
  {icon}
@@ -15,7 +15,7 @@
15
15
  "propsSummary": [
16
16
  "as: button|a",
17
17
  "children: node (required)",
18
- "variant: primary|secondary|ghost|link|danger|outlined|outline|icon (default: \"primary\")",
18
+ "variant: primary|secondary|ghost|link|quiet|danger|outlined|outline|icon (default: \"primary\")",
19
19
  "size: xs|sm|md|lg (default: \"md\")",
20
20
  "icon: boolean",
21
21
  "fullWidth: boolean",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "variant": {
41
41
  "type": "enum",
42
- "description": "Visual style variant (\"icon\" is a convenience alias for outlined + icon-only layout, \"link\" is accent-coloured + transparent for tertiary CTAs)",
42
+ "description": "Visual style variant (\"icon\" is a convenience alias for outlined + icon-only layout, \"link\" is accent-coloured + transparent for tertiary CTAs, \"quiet\" is a neutral text button with no box and no height floor for meta lines and footnotes)",
43
43
  "default": "\"primary\"",
44
44
  "required": false,
45
45
  "values": [
@@ -47,6 +47,7 @@
47
47
  "secondary",
48
48
  "ghost",
49
49
  "link",
50
+ "quiet",
50
51
  "danger",
51
52
  "outlined",
52
53
  "outline",
@@ -134,6 +135,11 @@
134
135
  "description": "Accent-coloured transparent button for tertiary CTAs",
135
136
  "code": "<Button variant=\"link\">View all →</Button>"
136
137
  },
138
+ {
139
+ "name": "Quiet",
140
+ "description": "Neutral text button with no box and no height floor, for meta lines and footnotes",
141
+ "code": "<Button variant=\"quiet\" size=\"xs\">main</Button>"
142
+ },
137
143
  {
138
144
  "name": "Danger",
139
145
  "description": "Destructive action requiring attention",
@@ -184,7 +190,7 @@
184
190
  ],
185
191
  "contract": {
186
192
  "propsSummary": [
187
- "variant: primary|secondary|ghost|link|danger|outlined|outline|icon (default: primary, icon = outlined + icon-only, link = accent transparent)",
193
+ "variant: primary|secondary|ghost|link|quiet|danger|outlined|outline|icon (default: primary, icon = outlined + icon-only, link = accent transparent, quiet = neutral text button, no box)",
188
194
  "size: xs|sm|md|lg (default: md, xs for inline row-action controls)",
189
195
  "disabled: boolean - disables interaction",
190
196
  "type: button|submit|reset (default: button)",
@@ -180,6 +180,40 @@
180
180
  --_button-shadow-active: none;
181
181
  }
182
182
 
183
+ // Quiet: a button that reads as text. The neutral sibling of `link` — nothing
184
+ // but the foreground changes on hover, because in a meta line, a breadcrumb or
185
+ // a footnote a box would be the loudest thing on the row for the least
186
+ // important one. It is also the one variant that drops the interactive height
187
+ // floor: there is no box to hold open, so it sits on the line it is in and
188
+ // takes its hit target from the text and the row's own padding. That makes it
189
+ // the wrong choice for a standalone action — reach for `ghost` there.
190
+ .quiet {
191
+ --_button-bg: transparent;
192
+ --_button-bg-hover: transparent;
193
+ --_button-bg-active: transparent;
194
+ --_button-border: transparent;
195
+ --_button-border-hover: transparent;
196
+ --_button-color: var(--fui-text-tertiary, #{$fui-text-tertiary});
197
+ --_button-shadow: none;
198
+ --_button-shadow-hover: none;
199
+ --_button-shadow-active: none;
200
+
201
+ min-height: 0;
202
+ padding: 0;
203
+ font-weight: var(--fui-font-weight-normal, $fui-font-weight-normal);
204
+
205
+ // Only the custom property is restated: `color` reads it on .button, so the
206
+ // foreground follows without a second `color` declaration to keep in step.
207
+ &:hover:not(:disabled):not([data-disabled]) {
208
+ --_button-color: var(--fui-text-secondary, #{$fui-text-secondary});
209
+ }
210
+
211
+ &:active:not(:disabled):not([data-disabled]),
212
+ &[data-pressed] {
213
+ --_button-color: var(--fui-text-primary, #{$fui-text-primary});
214
+ }
215
+ }
216
+
183
217
  .danger {
184
218
  // Flat danger fill — no gradient. Hover/active darken the solid colour.
185
219
  --_button-bg: var(--fui-color-danger, #{$fui-color-danger});
@@ -29,9 +29,19 @@ type ButtonBaseProps = {
29
29
  * @see https://usefragments.com/components/button#variants
30
30
  * @remarks
31
31
  * - `"link"` — accent-coloured, transparent; for tertiary CTAs like "View all →"
32
+ * - `"quiet"` — neutral text button with no box and no height floor; for meta lines and footnotes
32
33
  * - `"icon"` — convenience alias for outlined + icon-only layout
33
34
  * - `"outline"` — alias for `"outlined"` */
34
- variant?: "primary" | "secondary" | "ghost" | "link" | "danger" | "outlined" | "outline" | "icon";
35
+ variant?:
36
+ | "primary"
37
+ | "secondary"
38
+ | "ghost"
39
+ | "link"
40
+ | "quiet"
41
+ | "danger"
42
+ | "outlined"
43
+ | "outline"
44
+ | "icon";
35
45
  /** Button size. `"xs"` is for inline row-action controls (accept/dismiss, table-row icons) where `"sm"` is too tall.
36
46
  * @default "md"
37
47
  * @see https://usefragments.com/components/button#sizes */
@@ -10,6 +10,13 @@
10
10
  "recharts"
11
11
  ],
12
12
  "status": "stable",
13
+ "dependencies": [
14
+ {
15
+ "name": "recharts",
16
+ "version": ">=2.0.0 || >=3.0.0",
17
+ "reason": "Optional — powers Chart.Tooltip and Chart.Legend; both render nothing until it is installed."
18
+ }
19
+ ],
13
20
  "sourcePath": "src/components/Chart/index.tsx",
14
21
  "exportName": "ChartContainer",
15
22
  "propsSummary": [
@@ -60,20 +60,46 @@ type RechartsLegendProps = Record<string, unknown>;
60
60
 
61
61
  let _RechartsTooltip: React.ComponentType<Record<string, unknown>> | null = null;
62
62
  let _RechartsLegend: React.ComponentType<Record<string, unknown>> | null = null;
63
- let _chartLoaded = false;
63
+ let _chartLoadPromise: Promise<void> | null = null;
64
64
  let _chartFailed = false;
65
65
 
66
- function loadChartDeps() {
67
- if (_chartLoaded) return;
68
- _chartLoaded = true;
69
- try {
70
- // eslint-disable-next-line @typescript-eslint/no-require-imports
71
- const rc = require('recharts');
72
- _RechartsTooltip = rc.Tooltip as React.ComponentType<Record<string, unknown>>;
73
- _RechartsLegend = rc.Legend as React.ComponentType<Record<string, unknown>>;
74
- } catch {
75
- _chartFailed = true;
66
+ // Resolved with import() rather than require(): browser ESM bundles have no
67
+ // `require`, so the synchronous shape disabled recharts even when installed.
68
+ function loadChartDeps(): Promise<void> {
69
+ if (!_chartLoadPromise) {
70
+ _chartLoadPromise = (async () => {
71
+ try {
72
+ const rc = await import('recharts');
73
+ _RechartsTooltip = rc.Tooltip as unknown as React.ComponentType<Record<string, unknown>>;
74
+ _RechartsLegend = rc.Legend as unknown as React.ComponentType<Record<string, unknown>>;
75
+ } catch {
76
+ _chartFailed = true;
77
+ if (process.env.NODE_ENV === 'development') {
78
+ console.warn(
79
+ '[@usefragments/ui] Chart: recharts is not installed. ' +
80
+ 'Install it with: npm install recharts'
81
+ );
82
+ }
83
+ }
84
+ })();
76
85
  }
86
+ return _chartLoadPromise;
87
+ }
88
+
89
+ /** Kick off the lazy recharts load on mount and re-render once it settles. */
90
+ function useChartDeps(): boolean {
91
+ const [, rerender] = React.useReducer((n: number) => n + 1, 0);
92
+ React.useEffect(() => {
93
+ if (_RechartsTooltip || _chartFailed) return;
94
+ let active = true;
95
+ void loadChartDeps().then(() => {
96
+ if (active) rerender();
97
+ });
98
+ return () => {
99
+ active = false;
100
+ };
101
+ }, []);
102
+ return _RechartsTooltip !== null;
77
103
  }
78
104
 
79
105
 
@@ -252,7 +278,7 @@ export function ChartTooltip({
252
278
  content,
253
279
  ...props
254
280
  }: ChartTooltipProps) {
255
- loadChartDeps();
281
+ const chartReady = useChartDeps();
256
282
 
257
283
  const defaultContent = React.useCallback(
258
284
 
@@ -269,13 +295,8 @@ export function ChartTooltip({
269
295
  [indicator, hideLabel, hideIndicator, labelFormatter, valueFormatter],
270
296
  );
271
297
 
272
- if (_chartFailed || !_RechartsTooltip) {
273
- if (_chartFailed && process.env.NODE_ENV === 'development') {
274
- console.warn(
275
- '[@usefragments/ui] Chart: recharts is not installed. ' +
276
- 'Install it with: npm install recharts'
277
- );
278
- }
298
+ // Nothing until recharts resolves; nothing at all if it is not installed.
299
+ if (!chartReady || !_RechartsTooltip) {
279
300
  return null;
280
301
  }
281
302
 
@@ -330,19 +351,13 @@ type ChartLegendProps = RechartsLegendProps & {
330
351
  };
331
352
 
332
353
  export function ChartLegend({ content, ...props }: ChartLegendProps) {
333
- loadChartDeps();
354
+ const chartReady = useChartDeps();
334
355
 
335
356
  const defaultContent = (legendProps: Record<string, unknown>) => (
336
357
  <ChartLegendContent {...(legendProps as ChartLegendContentProps)} />
337
358
  );
338
359
 
339
- if (_chartFailed || !_RechartsLegend) {
340
- if (_chartFailed && process.env.NODE_ENV === 'development') {
341
- console.warn(
342
- '[@usefragments/ui] Chart: recharts is not installed. ' +
343
- 'Install it with: npm install recharts'
344
- );
345
- }
360
+ if (!chartReady || !_RechartsLegend) {
346
361
  return null;
347
362
  }
348
363
 
@@ -362,4 +377,6 @@ export const Chart = Object.assign(ChartContainer, {
362
377
  TooltipContent: ChartTooltipContent,
363
378
  Legend: ChartLegend,
364
379
  LegendContent: ChartLegendContent,
380
+ /** Start resolving recharts before first render (optional). */
381
+ preload: loadChartDeps,
365
382
  });
@@ -12,6 +12,13 @@
12
12
  "diff"
13
13
  ],
14
14
  "status": "stable",
15
+ "dependencies": [
16
+ {
17
+ "name": "shiki",
18
+ "version": ">=1.0.0",
19
+ "reason": "Optional — provides syntax highlighting; without it code renders unhighlighted."
20
+ }
21
+ ],
15
22
  "sourcePath": "src/components/CodeBlock/index.tsx",
16
23
  "exportName": "CodeBlock",
17
24
  "propsSummary": [
@@ -318,9 +318,14 @@ $fui-code-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2) !default;
318
318
  word-break: break-word;
319
319
  }
320
320
 
321
+ // `display: block` here would double-space every wrapped block: shiki emits a
322
+ // newline between each `.line`, and under `pre-wrap` that newline becomes a
323
+ // line box of its own the moment the lines stop being inline. The base rule's
324
+ // `inline-block` keeps it in the inline flow, and a full-width inline-block
325
+ // still wraps and still grows taller, which is all this variant needs.
321
326
  :global(.line) {
322
- display: block;
323
- width: auto;
327
+ display: inline-block;
328
+ width: 100%;
324
329
  }
325
330
  }
326
331
 
@@ -249,24 +249,56 @@ function dedent(str: string): string {
249
249
  return lines.map((line) => line.slice(minIndent)).join("\n");
250
250
  }
251
251
 
252
+ /**
253
+ * Languages where leading whitespace carries meaning, so the first line has to
254
+ * take part in the common-indent calculation like any other.
255
+ */
256
+ const INDENTATION_SIGNIFICANT_LANGUAGES = new Set<CodeBlockLanguage>([
257
+ "yaml",
258
+ "yml",
259
+ "json",
260
+ "python",
261
+ "py",
262
+ ]);
263
+
252
264
  /**
253
265
  * Normalize indentation while handling JSX where first line is already at column 0.
266
+ *
267
+ * The JSX case this exists for is a snippet authored inline, where the body
268
+ * carries the source file's indentation but the opening tag does not:
269
+ *
270
+ * code={`<Card>
271
+ * <Card.Header>Title</Card.Header>
272
+ * </Card>`}
273
+ *
274
+ * Excluding line 0 from the minimum is what renders that correctly. But
275
+ * `normalizeCode` trims first, so line 0 has always had its indentation removed
276
+ * by the time we get here — which means the same rule strips one real level off
277
+ * any snippet with a single root and an indented body:
278
+ *
279
+ * job: job:
280
+ * image: node → image: node (now a sibling key, not a child)
281
+ *
282
+ * For YAML, JSON and Python that is not cosmetic: the rendered snippet is
283
+ * invalid, and copying it gives you a broken file. Those languages count line 0,
284
+ * which is plain dedent. Everything else keeps the JSX-friendly heuristic.
254
285
  */
255
- function normalizeIndentation(str: string): string {
286
+ function normalizeIndentation(str: string, language: CodeBlockLanguage): string {
256
287
  const lines = str.split("\n");
257
288
  if (lines.length <= 1) return str;
258
289
 
290
+ const firstLineCounts = INDENTATION_SIGNIFICANT_LANGUAGES.has(language);
259
291
  let minIndent = Infinity;
260
292
  const firstLineIndent = lines[0].match(/^(\s*)/)?.[1].length ?? 0;
261
293
 
262
- for (let i = 1; i < lines.length; i += 1) {
294
+ for (let i = firstLineCounts ? 0 : 1; i < lines.length; i += 1) {
263
295
  const line = lines[i];
264
296
  if (line.trim().length === 0) continue;
265
297
  const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
266
298
  minIndent = Math.min(minIndent, indent);
267
299
  }
268
300
 
269
- if (firstLineIndent > 0) {
301
+ if (!firstLineCounts && firstLineIndent > 0) {
270
302
  minIndent = Math.min(minIndent, firstLineIndent);
271
303
  }
272
304
 
@@ -431,11 +463,11 @@ function formatLongJsxTags(code: string): string {
431
463
  .join("\n");
432
464
  }
433
465
 
434
- function normalizeCode(code: string): string {
466
+ function normalizeCode(code: string, language: CodeBlockLanguage): string {
435
467
  const trimmed = code.trim();
436
468
  if (trimmed.length === 0) return "";
437
469
 
438
- const normalized = normalizeIndentation(trimmed);
470
+ const normalized = normalizeIndentation(trimmed, language);
439
471
  const dedented = dedent(normalized);
440
472
  const withoutTrailingWhitespace = trimTrailingWhitespace(dedented);
441
473
  return formatLongJsxTags(withoutTrailingWhitespace);
@@ -562,7 +594,7 @@ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(function
562
594
  const [highlight, setHighlight] = useState<{ html: string; loading: boolean }>({ html: '', loading: true });
563
595
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
564
596
 
565
- const trimmedCode = useMemo(() => normalizeCode(code), [code]);
597
+ const trimmedCode = useMemo(() => normalizeCode(code, language), [code, language]);
566
598
  const codeLines = trimmedCode.split("\n");
567
599
  const totalLines = codeLines.length;
568
600
  const shouldShowCollapse = collapsible && totalLines > collapsedLines;
@@ -12,6 +12,13 @@
12
12
  "theme"
13
13
  ],
14
14
  "status": "stable",
15
+ "dependencies": [
16
+ {
17
+ "name": "react-colorful",
18
+ "version": ">=5.0.0",
19
+ "reason": "Optional — renders the visual picker surface; the swatch and hex input work without it."
20
+ }
21
+ ],
15
22
  "sourcePath": "src/components/ColorPicker/index.tsx",
16
23
  "exportName": "ColorPicker",
17
24
  "propsSummary": [
@@ -200,7 +207,8 @@
200
207
  "ai": {
201
208
  "compositionPattern": "compound",
202
209
  "subComponents": [
203
- "Root"
210
+ "Root",
211
+ "preload"
204
212
  ]
205
213
  },
206
214
  "provenance": {
@@ -7,20 +7,48 @@ import { Popover as BasePopover } from "@base-ui/react/popover";
7
7
  // Lazy-loaded dependency (react-colorful)
8
8
  // ============================================
9
9
 
10
- let _HexColorPicker: any = null;
11
- let _colorfulLoaded = false;
10
+ let _HexColorPicker: React.ComponentType<{
11
+ color: string;
12
+ onChange: (color: string) => void;
13
+ }> | null = null;
14
+ let _colorfulLoadPromise: Promise<void> | null = null;
12
15
  let _colorfulFailed = false;
13
16
 
14
- function loadColorfulDeps() {
15
- if (_colorfulLoaded) return;
16
- _colorfulLoaded = true;
17
- try {
18
- // eslint-disable-next-line @typescript-eslint/no-require-imports
19
- const rc = require("react-colorful");
20
- _HexColorPicker = rc.HexColorPicker;
21
- } catch {
22
- _colorfulFailed = true;
17
+ // Resolved with import() rather than require(): browser ESM bundles have no
18
+ // `require`, so the synchronous shape hid the picker even when installed.
19
+ function loadColorfulDeps(): Promise<void> {
20
+ if (!_colorfulLoadPromise) {
21
+ _colorfulLoadPromise = (async () => {
22
+ try {
23
+ const rc = await import("react-colorful");
24
+ _HexColorPicker = rc.HexColorPicker;
25
+ } catch {
26
+ _colorfulFailed = true;
27
+ if (process.env.NODE_ENV === "development") {
28
+ console.warn(
29
+ "[@usefragments/ui] ColorPicker: react-colorful is not installed. " +
30
+ "Install it with: npm install react-colorful"
31
+ );
32
+ }
33
+ }
34
+ })();
23
35
  }
36
+ return _colorfulLoadPromise;
37
+ }
38
+
39
+ /** Kick off the lazy react-colorful load on mount and re-render once it settles. */
40
+ function useColorfulDeps(): void {
41
+ const [, rerender] = React.useReducer((n: number) => n + 1, 0);
42
+ React.useEffect(() => {
43
+ if (_HexColorPicker || _colorfulFailed) return;
44
+ let active = true;
45
+ void loadColorfulDeps().then(() => {
46
+ if (active) rerender();
47
+ });
48
+ return () => {
49
+ active = false;
50
+ };
51
+ }, []);
24
52
  }
25
53
  import { Field } from "@base-ui/react/field";
26
54
  import { useResolvedControlSize } from "../ComponentDefaults";
@@ -75,7 +103,7 @@ const ColorPickerRoot = React.forwardRef<HTMLDivElement, ColorPickerProps>(funct
75
103
  ) {
76
104
  const size = useResolvedControlSize(sizeProp);
77
105
  const resolvedHelperText = helperText ?? description;
78
- loadColorfulDeps();
106
+ useColorfulDeps();
79
107
  const [internalValue, setInternalValue] = React.useState(defaultValue);
80
108
  const [inputValue, setInputValue] = React.useState(value ?? defaultValue);
81
109
  const displayValue = value !== undefined ? value : internalValue;
@@ -179,4 +207,6 @@ const ColorPickerRoot = React.forwardRef<HTMLDivElement, ColorPickerProps>(funct
179
207
 
180
208
  export const ColorPicker = Object.assign(ColorPickerRoot, {
181
209
  Root: ColorPickerRoot,
210
+ /** Start resolving react-colorful before first render (optional). */
211
+ preload: loadColorfulDeps,
182
212
  });
@@ -194,22 +194,13 @@
194
194
  @include surface-elevated;
195
195
  @include popup-container;
196
196
 
197
- border-color: var(--fui-form-group-border, $fui-border);
198
-
199
- // Item height derived from text-base font + vertical padding
200
- --_combobox-item-h: calc(
201
- var(--fui-font-size-sm, #{$fui-font-size-sm}) *
202
- var(--fui-line-height-normal, #{$fui-line-height-normal}) +
203
- var(--fui-space-2, #{$fui-space-2}) * 2
204
- );
197
+ @include popup-scroll-viewport;
205
198
 
199
+ border-color: var(--fui-form-group-border, $fui-border);
206
200
  min-width: var(--anchor-width);
207
- // Show N items + half-peek scroll hint (default 4.5 items)
201
+ // Show N items + half-peek scroll hint (default 4.5 items).
208
202
  // !important needed to override Base UI's inline max-height: 100%
209
- max-height: calc(
210
- var(--_combobox-item-h) * var(--fui-select-max-items, 4.5) +
211
- var(--fui-space-1, #{$fui-space-1}) * 2
212
- ) !important;
203
+ max-height: var(--_popup-scroll-max-h) !important;
213
204
  overflow-y: auto !important;
214
205
 
215
206
  // Animation