@scalepad/ui 0.1.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 (273) hide show
  1. package/.ai/rules/date-handling.md +39 -0
  2. package/.ai/rules/figma-design-system.md +372 -0
  3. package/.ai/rules/figma-lm-design-system-keys.md +680 -0
  4. package/.ai/rules/file-extensions.md +13 -0
  5. package/.ai/rules/modal-confirmation-mutation.md +56 -0
  6. package/.ai/rules/react-hooks.md +29 -0
  7. package/.ai/rules/styling.md +83 -0
  8. package/AGENTS.md +37 -0
  9. package/README.md +125 -0
  10. package/figma.config.json +9 -0
  11. package/package.json +127 -0
  12. package/scripts/install-ai-rules.mjs +136 -0
  13. package/src/ThemeProvider.tsx +57 -0
  14. package/src/charts.ts +32 -0
  15. package/src/components/ActionCard/ActionCard.css.ts +60 -0
  16. package/src/components/ActionCard/ActionCard.tsx +154 -0
  17. package/src/components/ActionCard/index.ts +2 -0
  18. package/src/components/Anchor/Anchor.tsx +47 -0
  19. package/src/components/Anchor/index.ts +2 -0
  20. package/src/components/AppliedFiltersManagerBar/AppliedFiltersManagerBar.tsx +105 -0
  21. package/src/components/AppliedFiltersManagerBar/FilterBadge.css.ts +23 -0
  22. package/src/components/AppliedFiltersManagerBar/FilterBadge.tsx +50 -0
  23. package/src/components/AppliedFiltersManagerBar/index.ts +5 -0
  24. package/src/components/Badge/Badge.css.ts +72 -0
  25. package/src/components/Badge/Badge.figma.tsx +43 -0
  26. package/src/components/Badge/Badge.tsx +159 -0
  27. package/src/components/Badge/index.ts +2 -0
  28. package/src/components/BreadCrumb/BreadCrumb.tsx +62 -0
  29. package/src/components/BreadCrumb/index.ts +2 -0
  30. package/src/components/BulkActionBar/BulkActionBar.css.ts +26 -0
  31. package/src/components/BulkActionBar/BulkActionBar.tsx +164 -0
  32. package/src/components/BulkActionBar/index.ts +2 -0
  33. package/src/components/Button/Button.css.ts +272 -0
  34. package/src/components/Button/Button.figma.tsx +74 -0
  35. package/src/components/Button/Button.tsx +84 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Charts/ChartTooltip.figma.tsx +33 -0
  38. package/src/components/Charts/ChartTooltip.tsx +101 -0
  39. package/src/components/Charts/MiniBarSparkline.tsx +75 -0
  40. package/src/components/Charts/StackedPatternBarChart.tsx +494 -0
  41. package/src/components/Charts/TrendAreaChart.css.ts +23 -0
  42. package/src/components/Charts/TrendAreaChart.tsx +210 -0
  43. package/src/components/Charts/index.ts +12 -0
  44. package/src/components/CodePanel/CodePanel.css.ts +113 -0
  45. package/src/components/CodePanel/CodePanel.tsx +121 -0
  46. package/src/components/CodePanel/index.ts +2 -0
  47. package/src/components/CommentComposer/CommentComposer.css.ts +60 -0
  48. package/src/components/CommentComposer/CommentComposer.tsx +181 -0
  49. package/src/components/CommentComposer/index.ts +2 -0
  50. package/src/components/ConfirmationModal/ConfirmationModal.tsx +149 -0
  51. package/src/components/ConfirmationModal/index.ts +2 -0
  52. package/src/components/ConfirmationTooltip/ConfirmationTooltip.tsx +132 -0
  53. package/src/components/ConfirmationTooltip/index.ts +2 -0
  54. package/src/components/DataDialog.figma.tsx +33 -0
  55. package/src/components/DataDialog.tsx +46 -0
  56. package/src/components/DataTable/DataTable.tsx +1042 -0
  57. package/src/components/DataTable/RowExpandToggle.tsx +105 -0
  58. package/src/components/DataTable/RowGroupHeader.tsx +190 -0
  59. package/src/components/DataTable/createActionsColumn.tsx +86 -0
  60. package/src/components/DataTable/index.ts +25 -0
  61. package/src/components/DatePicker/CustomRangePicker.tsx +59 -0
  62. package/src/components/DatePicker/DateInput.tsx +329 -0
  63. package/src/components/DatePicker/DateNavigator.tsx +486 -0
  64. package/src/components/DatePicker/DatePicker.tsx +242 -0
  65. package/src/components/DatePicker/MonthlyRangePicker.tsx +231 -0
  66. package/src/components/DatePicker/QuarterlyRangePicker.tsx +224 -0
  67. package/src/components/DatePicker/QuickPicksSidebar.tsx +242 -0
  68. package/src/components/DatePicker/YearlyRangePicker.tsx +171 -0
  69. package/src/components/DatePicker/index.ts +7 -0
  70. package/src/components/DatePicker/types.ts +12 -0
  71. package/src/components/DesignSystemPrimitives/FluidGrid.tsx +44 -0
  72. package/src/components/DesignSystemPrimitives/InteractivePrimitives.tsx +177 -0
  73. package/src/components/DesignSystemPrimitives/LayoutPrimitives.tsx +220 -0
  74. package/src/components/DesignSystemPrimitives/LayoutPrimitives.types.tsx +15 -0
  75. package/src/components/DesignSystemPrimitives/SurfacePrimitives.tsx +46 -0
  76. package/src/components/DesignSystemPrimitives/index.ts +55 -0
  77. package/src/components/Details/Details.css.ts +74 -0
  78. package/src/components/Details/Details.tsx +140 -0
  79. package/src/components/Details/index.ts +2 -0
  80. package/src/components/DownloadCard/DownloadCard.css.ts +22 -0
  81. package/src/components/DownloadCard/DownloadCard.tsx +63 -0
  82. package/src/components/DownloadCard/index.ts +2 -0
  83. package/src/components/Drawer/Drawer.css.ts +32 -0
  84. package/src/components/Drawer/Drawer.tsx +236 -0
  85. package/src/components/Drawer/hooks/useDetailDrawer.ts +61 -0
  86. package/src/components/Drawer/hooks/useDetailDrawerNavigation.ts +125 -0
  87. package/src/components/Drawer/hooks/useDetailDrawerNavigationContext.ts +66 -0
  88. package/src/components/EditableRichText/EditableRichText.css.ts +72 -0
  89. package/src/components/EditableRichText/EditableRichText.tsx +324 -0
  90. package/src/components/EditableRichText/index.ts +2 -0
  91. package/src/components/EditableSelect/EditableSelect.css.ts +62 -0
  92. package/src/components/EditableSelect/EditableSelect.tsx +224 -0
  93. package/src/components/EditableSelect/index.ts +2 -0
  94. package/src/components/EditableText/EditableText.tsx +377 -0
  95. package/src/components/EditableText/index.ts +2 -0
  96. package/src/components/EmptyState/EmptyState.figma.tsx +33 -0
  97. package/src/components/EmptyState/EmptyState.tsx +230 -0
  98. package/src/components/EmptyState/index.ts +2 -0
  99. package/src/components/ErrorBoundary.tsx +135 -0
  100. package/src/components/ErrorState/ErrorState.tsx +197 -0
  101. package/src/components/ErrorState/index.ts +2 -0
  102. package/src/components/FeatureCard.tsx +42 -0
  103. package/src/components/FilterMenu/FilterMenu.figma.tsx +30 -0
  104. package/src/components/FilterMenu/FilterMenu.tsx +198 -0
  105. package/src/components/FilterMenu/FilterSubMenuTypes/BooleanFilterSubmenu.tsx +46 -0
  106. package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +239 -0
  107. package/src/components/FilterMenu/FilterSubMenuTypes/index.ts +8 -0
  108. package/src/components/FilterMenu/defaultFilterSchemas.ts +63 -0
  109. package/src/components/FilterMenu/helpers.ts +115 -0
  110. package/src/components/FilterMenu/index.ts +35 -0
  111. package/src/components/FilterMenu/types.ts +101 -0
  112. package/src/components/IconButton/IconButton.css.ts +272 -0
  113. package/src/components/IconButton/IconButton.figma.tsx +47 -0
  114. package/src/components/IconButton/IconButton.tsx +72 -0
  115. package/src/components/IconButton/README.md +230 -0
  116. package/src/components/IconButton/index.ts +2 -0
  117. package/src/components/InfiniteScrollSentinel.tsx +86 -0
  118. package/src/components/InfiniteScrollTrigger.tsx +78 -0
  119. package/src/components/InfoCard.figma.tsx +47 -0
  120. package/src/components/InfoCard.tsx +216 -0
  121. package/src/components/KbdHint/KbdHint.tsx +23 -0
  122. package/src/components/KbdHint/index.ts +2 -0
  123. package/src/components/LabeledField/LabeledField.tsx +21 -0
  124. package/src/components/LabeledField/index.ts +2 -0
  125. package/src/components/LookupSelect/LookupSelect.css.ts +149 -0
  126. package/src/components/LookupSelect/LookupSelect.tsx +325 -0
  127. package/src/components/LookupSelect/index.ts +2 -0
  128. package/src/components/Menu/Menu.css.ts +89 -0
  129. package/src/components/Menu/Menu.tsx +105 -0
  130. package/src/components/Menu/index.ts +2 -0
  131. package/src/components/MessageBox/MessageBox.tsx +168 -0
  132. package/src/components/MessageBox/index.ts +2 -0
  133. package/src/components/MetricDisplay/MetricDisplay.tsx +55 -0
  134. package/src/components/MetricDisplay/index.ts +1 -0
  135. package/src/components/MultiSelect/MultiSelect.tsx +278 -0
  136. package/src/components/MultiSelect/index.ts +2 -0
  137. package/src/components/Notifications/Notifications.tsx +12 -0
  138. package/src/components/Notifications/README.md +93 -0
  139. package/src/components/Notifications/index.ts +4 -0
  140. package/src/components/Notifications/showToast.tsx +100 -0
  141. package/src/components/PropertyRow/PropertyRow.tsx +96 -0
  142. package/src/components/PropertyRow/index.ts +2 -0
  143. package/src/components/RadioTile/RadioTile.tsx +253 -0
  144. package/src/components/RadioTile/index.ts +2 -0
  145. package/src/components/RichText/FormattingToolbar.css.ts +69 -0
  146. package/src/components/RichText/FormattingToolbar.tsx +112 -0
  147. package/src/components/RichText/RichTextInline.css.ts +54 -0
  148. package/src/components/RichText/RichTextInline.tsx +318 -0
  149. package/src/components/RichText/formattingCommands.ts +181 -0
  150. package/src/components/RichText/formattingTypes.ts +34 -0
  151. package/src/components/RichText/index.ts +49 -0
  152. package/src/components/RichText/richTextExtensions.ts +111 -0
  153. package/src/components/RichText/richTextHelpers.ts +65 -0
  154. package/src/components/RichText/richTextImage.ts +253 -0
  155. package/src/components/RichText/richTextImageHandlers.ts +244 -0
  156. package/src/components/RichText/richTextProse.css.ts +261 -0
  157. package/src/components/RichTextEditor/RichTextEditor.css.ts +82 -0
  158. package/src/components/RichTextEditor/RichTextEditor.tsx +204 -0
  159. package/src/components/RichTextEditor/index.ts +2 -0
  160. package/src/components/RichTextView/RichTextView.css.ts +11 -0
  161. package/src/components/RichTextView/RichTextView.tsx +114 -0
  162. package/src/components/RichTextView/index.ts +2 -0
  163. package/src/components/Schedule/Schedule.tsx +35 -0
  164. package/src/components/SchedulePicker/SchedulePicker.css.ts +42 -0
  165. package/src/components/SchedulePicker/SchedulePicker.tsx +130 -0
  166. package/src/components/SchedulePicker/index.ts +2 -0
  167. package/src/components/SearchableList/types.ts +30 -0
  168. package/src/components/SearchableSubMenu/SearchableSubMenu.css.ts +25 -0
  169. package/src/components/SearchableSubMenu/SearchableSubMenu.tsx +139 -0
  170. package/src/components/SearchableSubMenu/index.ts +2 -0
  171. package/src/components/Select/README.md +114 -0
  172. package/src/components/Select/Select.css.ts +110 -0
  173. package/src/components/Select/Select.tsx +133 -0
  174. package/src/components/Select/index.ts +2 -0
  175. package/src/components/SelectCreatable/SelectCreatable.css.ts +16 -0
  176. package/src/components/SelectCreatable/SelectCreatable.tsx +203 -0
  177. package/src/components/SelectCreatable/index.ts +2 -0
  178. package/src/components/SettingsCard/SettingsCard.tsx +98 -0
  179. package/src/components/SettingsCard/index.ts +2 -0
  180. package/src/components/Sidebar/Sidebar.css.ts +91 -0
  181. package/src/components/Sidebar/Sidebar.tsx +129 -0
  182. package/src/components/Sidebar/index.ts +5 -0
  183. package/src/components/SimpleList/SimpleList.css.ts +12 -0
  184. package/src/components/SimpleList/SimpleList.tsx +44 -0
  185. package/src/components/SimpleList/index.ts +2 -0
  186. package/src/components/SimpleTable/SimpleTable.tsx +296 -0
  187. package/src/components/SimpleTable/index.ts +2 -0
  188. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.css.ts +62 -0
  189. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.tsx +85 -0
  190. package/src/components/SlashRichTextEditor/SlashCommandMenu.css.ts +124 -0
  191. package/src/components/SlashRichTextEditor/SlashCommandMenu.tsx +168 -0
  192. package/src/components/SlashRichTextEditor/SlashRichTextEditor.css.ts +81 -0
  193. package/src/components/SlashRichTextEditor/SlashRichTextEditor.tsx +538 -0
  194. package/src/components/SlashRichTextEditor/SlashSuggestionExtension.ts +48 -0
  195. package/src/components/SlashRichTextEditor/index.ts +13 -0
  196. package/src/components/SlashRichTextEditor/types.ts +48 -0
  197. package/src/components/StatCard/StatCard.css.ts +70 -0
  198. package/src/components/StatCard/StatCard.tsx +201 -0
  199. package/src/components/StatCard/index.ts +1 -0
  200. package/src/components/StatusBadge/StatusBadge.tsx +70 -0
  201. package/src/components/StatusBadge/index.ts +2 -0
  202. package/src/components/StatusIndicator/StatusIndicator.tsx +67 -0
  203. package/src/components/StatusIndicator/index.ts +6 -0
  204. package/src/components/SubNavigation/SubNavigation.css.ts +72 -0
  205. package/src/components/SubNavigation/SubNavigation.tsx +104 -0
  206. package/src/components/SubNavigation/index.ts +2 -0
  207. package/src/components/SuspenseLoader.tsx +22 -0
  208. package/src/components/Table/SortableColumnHeader.tsx +99 -0
  209. package/src/components/Table/TableSkeletonRows.figma.tsx +22 -0
  210. package/src/components/Table/TableSkeletonRows.tsx +113 -0
  211. package/src/components/Table/index.ts +9 -0
  212. package/src/components/TableActionsMenu.tsx +58 -0
  213. package/src/components/TableCard.tsx +29 -0
  214. package/src/components/TableContainer/TableContainer.tsx +86 -0
  215. package/src/components/TableContainer/index.ts +2 -0
  216. package/src/components/TableControlBar/TableControlBar.tsx +156 -0
  217. package/src/components/TableControlBar/TableSelectionButton.tsx +57 -0
  218. package/src/components/TableControlBar/index.ts +13 -0
  219. package/src/components/TableControlBar/useTableControlBar.tsx +314 -0
  220. package/src/components/TableSelection/TableSelection.tsx +43 -0
  221. package/src/components/TableSelection/index.ts +5 -0
  222. package/src/components/Tabs/README.md +76 -0
  223. package/src/components/Tabs/Tabs.css.ts +54 -0
  224. package/src/components/Tabs/Tabs.figma.tsx +47 -0
  225. package/src/components/Tabs/Tabs.tsx +96 -0
  226. package/src/components/Tabs/index.ts +8 -0
  227. package/src/components/TextInput/README.md +98 -0
  228. package/src/components/TextInput/SearchTextInput.figma.tsx +22 -0
  229. package/src/components/TextInput/SearchTextInput.tsx +150 -0
  230. package/src/components/TextInput/TextInput.figma.tsx +44 -0
  231. package/src/components/TextInput/TextInput.tsx +42 -0
  232. package/src/components/TextInput/index.ts +4 -0
  233. package/src/components/ThemeSwitcher.figma.tsx +28 -0
  234. package/src/components/ThemeSwitcher.tsx +69 -0
  235. package/src/components/TrendBadge/TrendBadge.tsx +76 -0
  236. package/src/components/TrendBadge/index.ts +2 -0
  237. package/src/components/TruncatedText.tsx +115 -0
  238. package/src/components/Typography/Text.tsx +74 -0
  239. package/src/components/Typography/Title.tsx +100 -0
  240. package/src/components/Typography/index.ts +4 -0
  241. package/src/geist-fonts.ts +48 -0
  242. package/src/hooks/index.ts +31 -0
  243. package/src/hooks/useFilters.ts +152 -0
  244. package/src/hooks/useInfiniteScroll.ts +62 -0
  245. package/src/hooks/usePlatform.ts +33 -0
  246. package/src/hooks/useServerTable.ts +495 -0
  247. package/src/hooks/useTableSelection.ts +102 -0
  248. package/src/hooks/useTableSort.ts +259 -0
  249. package/src/index.ts +483 -0
  250. package/src/mantine.ts +25 -0
  251. package/src/theme/mantineVars.ts +12 -0
  252. package/src/theme/themeContract.css.ts +131 -0
  253. package/src/theme/themeVars.ts +31 -0
  254. package/src/theme.ts +168 -0
  255. package/src/tokens/color-types.ts +107 -0
  256. package/src/tokens/colors.ts +243 -0
  257. package/src/tokens/index.ts +14 -0
  258. package/src/tokens/radius.ts +17 -0
  259. package/src/tokens/semantic-colors.ts +224 -0
  260. package/src/tokens/semantic-tokens-css.ts +53 -0
  261. package/src/tokens/shadows.ts +11 -0
  262. package/src/tokens/spacing.ts +20 -0
  263. package/src/tokens/text-styles.ts +179 -0
  264. package/src/tokens/typography.ts +40 -0
  265. package/src/tokens/zIndex.ts +27 -0
  266. package/src/types/mantine-theme.d.ts +17 -0
  267. package/src/types/tanstack-table.d.ts +22 -0
  268. package/src/utils/avatar.ts +150 -0
  269. package/src/utils/chartHelpers.ts +53 -0
  270. package/src/utils/color-props.ts +77 -0
  271. package/src/utils/createDesignComponent.tsx +104 -0
  272. package/src/utils/nestFlatRows.ts +111 -0
  273. package/src/utils/withStaticComponents.ts +6 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * EditableRichText
3
+ *
4
+ * Inline-editable rich text field. Renders formatted Tiptap content that
5
+ * the user can click to focus and edit directly. Changes save live, with
6
+ * keystrokes debounced by `saveDebounceMs` (default 600ms). Focus leaving
7
+ * the component flushes any pending save immediately.
8
+ *
9
+ * Cmd/Ctrl+Z inside the editor triggers Tiptap's history, so users can
10
+ * revert their own edits within an active editing session. There is no
11
+ * explicit Save or Cancel button.
12
+ *
13
+ * External value prop changes (e.g. from optimistic-update reconciliation
14
+ * or a refetch) only apply when the editor does not currently have focus,
15
+ * so in-progress typing is never clobbered.
16
+ *
17
+ * When `required` is true and the user deletes all text, an inline error
18
+ * appears and no save is fired until they type again.
19
+ *
20
+ * Built on the canonical `RichText` foundation (shared extensions, shared
21
+ * `FormattingToolbar`) so a doc authored here renders identically in
22
+ * `RichTextView` and round-trips through `RichTextEditor` /
23
+ * `SlashRichTextEditor` without losing marks.
24
+ *
25
+ * @example
26
+ * <EditableRichText
27
+ * value={summary}
28
+ * ariaLabel="Executive summary"
29
+ * label="Executive summary"
30
+ * placeholder="Add an executive summary…"
31
+ * onSave={next => mutation.mutateAsync({ executiveSummaryJson: JSON.stringify(next) })}
32
+ * />
33
+ */
34
+
35
+ import {
36
+ useCallback,
37
+ useEffect,
38
+ useMemo,
39
+ useRef,
40
+ useState,
41
+ type ChangeEvent,
42
+ type FocusEvent,
43
+ } from 'react';
44
+
45
+ import { EditorContent, useEditor, type JSONContent } from '@tiptap/react';
46
+
47
+ import { Button } from '../Button';
48
+ import { STANDARD_FORMATTING_COMMANDS } from '../RichText/formattingCommands';
49
+ import { FormattingToolbar } from '../RichText/FormattingToolbar';
50
+ import { getRichTextExtensions } from '../RichText/richTextExtensions';
51
+ import {
52
+ docsEqual,
53
+ isDocEmpty,
54
+ normalizeValue,
55
+ plainTextFromDoc,
56
+ richTextDocFromPlainText,
57
+ } from '../RichText/richTextHelpers';
58
+ import {
59
+ installRichTextImageHandlers,
60
+ useRichTextImageLoader,
61
+ type RichTextImageEditorConfig,
62
+ } from '../RichText/richTextImageHandlers';
63
+ import * as proseStyles from '../RichText/richTextProse.css';
64
+ import { Text } from '../Typography';
65
+ import * as styles from './EditableRichText.css';
66
+
67
+ export interface EditableRichTextProps {
68
+ /** Document to render and edit. `null` is treated as empty. */
69
+ value: JSONContent | null;
70
+ /**
71
+ * Called (debounced) when the user edits. Called again immediately on
72
+ * blur to flush any pending edit. May return a Promise — a rejection
73
+ * is surfaced as an inline error with a Retry button.
74
+ */
75
+ onSave: (next: JSONContent) => void | Promise<void>;
76
+ /** Accessible label for the hidden plain-text textarea. Required. */
77
+ ariaLabel: string;
78
+ /** Visible-to-screen-reader label for the hidden plain-text textarea. */
79
+ label: string;
80
+ /** Shown (muted) over the editor when the document is empty and blurred. */
81
+ placeholder?: string;
82
+ /**
83
+ * When true, blocks saves (with an inline error) while the document is
84
+ * empty. As soon as the user types, the error clears and saves resume.
85
+ */
86
+ required?: boolean;
87
+ /** Error message shown when `required` fails. */
88
+ requiredMessage?: string;
89
+ /** When true, the field cannot be focused or edited. */
90
+ disabled?: boolean;
91
+ /** Debounce window for live save, in milliseconds. Defaults to 600. */
92
+ saveDebounceMs?: number;
93
+ /**
94
+ * When provided, enables inline image upload via paste / drop. See
95
+ * {@link RichTextImageEditorConfig} for the full callback surface and
96
+ * the rate-limit warning around `autoLoad`.
97
+ */
98
+ image?: RichTextImageEditorConfig;
99
+ }
100
+
101
+ interface PendingSave {
102
+ value: JSONContent;
103
+ timeoutId: ReturnType<typeof setTimeout>;
104
+ }
105
+
106
+ export function EditableRichText({
107
+ value,
108
+ onSave,
109
+ ariaLabel,
110
+ label,
111
+ placeholder,
112
+ required = false,
113
+ requiredMessage,
114
+ disabled = false,
115
+ saveDebounceMs = 600,
116
+ image,
117
+ }: EditableRichTextProps) {
118
+ const normalizedValue = useMemo(() => normalizeValue(value), [value]);
119
+ const enableImages = Boolean(image);
120
+
121
+ const [isFocused, setIsFocused] = useState(false);
122
+ const [requiredError, setRequiredError] = useState<string | null>(null);
123
+ const [saveError, setSaveError] = useState<string | null>(null);
124
+
125
+ // Keep current `onSave`, `required`, and `requiredMessage` in refs so
126
+ // scheduled save callbacks always see the latest values without forcing
127
+ // the debounce machinery to reschedule.
128
+ const onSaveRef = useRef(onSave);
129
+ const requiredRef = useRef(required);
130
+ const requiredMessageRef = useRef(requiredMessage);
131
+ useEffect(() => {
132
+ onSaveRef.current = onSave;
133
+ requiredRef.current = required;
134
+ requiredMessageRef.current = requiredMessage;
135
+ });
136
+
137
+ const pendingRef = useRef<PendingSave | null>(null);
138
+ const lastSavedRef = useRef<JSONContent>(normalizedValue);
139
+
140
+ const commitSave = useCallback((next: JSONContent) => {
141
+ if (docsEqual(next, lastSavedRef.current)) {
142
+ return;
143
+ }
144
+
145
+ if (requiredRef.current && isDocEmpty(next)) {
146
+ setRequiredError(requiredMessageRef.current ?? 'This field is required');
147
+ return;
148
+ }
149
+
150
+ lastSavedRef.current = next;
151
+ setSaveError(null);
152
+
153
+ Promise.resolve(onSaveRef.current(next)).catch(err => {
154
+ setSaveError(err instanceof Error ? err.message : 'Unable to save');
155
+ });
156
+ }, []);
157
+
158
+ const flushPendingSave = useCallback(() => {
159
+ const pending = pendingRef.current;
160
+ if (!pending) {
161
+ return;
162
+ }
163
+ clearTimeout(pending.timeoutId);
164
+ pendingRef.current = null;
165
+ commitSave(pending.value);
166
+ }, [commitSave]);
167
+
168
+ const scheduleSave = useCallback(
169
+ (next: JSONContent) => {
170
+ if (pendingRef.current) {
171
+ clearTimeout(pendingRef.current.timeoutId);
172
+ }
173
+ const timeoutId = setTimeout(() => {
174
+ const pending = pendingRef.current;
175
+ pendingRef.current = null;
176
+ if (pending) {
177
+ commitSave(pending.value);
178
+ }
179
+ }, saveDebounceMs);
180
+ pendingRef.current = { value: next, timeoutId };
181
+ },
182
+ [commitSave, saveDebounceMs],
183
+ );
184
+
185
+ const editor = useEditor(
186
+ {
187
+ immediatelyRender: false,
188
+ editable: !disabled,
189
+ extensions: getRichTextExtensions({ enableImages }),
190
+ content: normalizedValue,
191
+ editorProps: installRichTextImageHandlers(image),
192
+ onUpdate: ({ editor: cur }) => {
193
+ const next = cur.getJSON();
194
+ if (requiredError) {
195
+ setRequiredError(null);
196
+ }
197
+ scheduleSave(next);
198
+ },
199
+ },
200
+ // Remount when `disabled` changes (Tiptap doesn't have a runtime
201
+ // setter for `editable`) or when image-config presence toggles (the
202
+ // schema gains/loses the Image extension). The image callbacks
203
+ // themselves are captured at install time, so swapping them without
204
+ // toggling `enableImages` doesn't rebuild the editor.
205
+ [disabled, enableImages],
206
+ );
207
+
208
+ useRichTextImageLoader(editor, image);
209
+
210
+ // Flush pending save on unmount.
211
+ useEffect(() => {
212
+ return () => {
213
+ flushPendingSave();
214
+ };
215
+ }, [flushPendingSave]);
216
+
217
+ // Sync external value changes into the editor, but only when the editor
218
+ // doesn't have focus — so a mid-typing cache refetch never clobbers the
219
+ // user's in-progress edits.
220
+ useEffect(() => {
221
+ if (!editor) {
222
+ return;
223
+ }
224
+ if (editor.isFocused) {
225
+ return;
226
+ }
227
+ const current = editor.getJSON();
228
+ if (!docsEqual(current, normalizedValue)) {
229
+ editor.commands.setContent(normalizedValue, { emitUpdate: false });
230
+ }
231
+ lastSavedRef.current = normalizedValue;
232
+ }, [editor, normalizedValue]);
233
+
234
+ const handleContainerBlur = (event: FocusEvent<HTMLDivElement>) => {
235
+ const nextTarget = event.relatedTarget as Node | null;
236
+ if (event.currentTarget.contains(nextTarget)) {
237
+ return;
238
+ }
239
+ setIsFocused(false);
240
+ flushPendingSave();
241
+ };
242
+
243
+ const retry = () => {
244
+ if (!editor) return;
245
+ const current = editor.getJSON();
246
+ // Force a resend even if the document hasn't changed since last save.
247
+ lastSavedRef.current = { type: 'doc', content: [] };
248
+ commitSave(current);
249
+ };
250
+
251
+ if (!editor) {
252
+ return null;
253
+ }
254
+
255
+ const showPlaceholder =
256
+ placeholder != null && !isFocused && isDocEmpty(value);
257
+
258
+ const rootClasses = [
259
+ styles.root,
260
+ isFocused && !disabled ? styles.rootFocused : null,
261
+ disabled ? styles.rootDisabled : null,
262
+ ]
263
+ .filter(Boolean)
264
+ .join(' ');
265
+
266
+ return (
267
+ <div
268
+ className={rootClasses}
269
+ onFocusCapture={() => setIsFocused(true)}
270
+ onBlur={handleContainerBlur}
271
+ aria-disabled={disabled || undefined}
272
+ >
273
+ <textarea
274
+ aria-label={ariaLabel}
275
+ className={styles.srOnlyTextarea}
276
+ value={plainTextFromDoc(normalizedValue)}
277
+ onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
278
+ editor.commands.setContent(
279
+ richTextDocFromPlainText(event.target.value),
280
+ { emitUpdate: true },
281
+ );
282
+ }}
283
+ disabled={disabled}
284
+ />
285
+
286
+ {isFocused && !disabled && (
287
+ <FormattingToolbar
288
+ editor={editor}
289
+ commands={STANDARD_FORMATTING_COMMANDS}
290
+ className={styles.toolbar}
291
+ ariaLabel={`${label} formatting`}
292
+ />
293
+ )}
294
+
295
+ <div className={styles.contentWrapper}>
296
+ <div className={proseStyles.prose}>
297
+ <EditorContent editor={editor} aria-label={label} />
298
+ </div>
299
+ {showPlaceholder && (
300
+ <Text variant="body1" className={styles.placeholder} aria-hidden>
301
+ {placeholder}
302
+ </Text>
303
+ )}
304
+ </div>
305
+
306
+ {requiredError && (
307
+ <Text variant="caption1" c="text.danger.default">
308
+ {requiredError}
309
+ </Text>
310
+ )}
311
+
312
+ {saveError && (
313
+ <div className={styles.errorRow}>
314
+ <Text variant="caption1" c="text.danger.default">
315
+ {saveError}
316
+ </Text>
317
+ <Button type="button" size="xs" variant="outline" onClick={retry}>
318
+ Retry
319
+ </Button>
320
+ </div>
321
+ )}
322
+ </div>
323
+ );
324
+ }
@@ -0,0 +1,2 @@
1
+ export { EditableRichText } from './EditableRichText';
2
+ export type { EditableRichTextProps } from './EditableRichText';
@@ -0,0 +1,62 @@
1
+ import { style } from '@vanilla-extract/css';
2
+
3
+ import { tokens } from '../../theme/themeContract.css';
4
+
5
+ export const root = style({
6
+ display: 'inline-flex',
7
+ alignItems: 'center',
8
+ maxWidth: '100%',
9
+ });
10
+
11
+ export const displayButton = style({
12
+ all: 'unset',
13
+ boxSizing: 'border-box',
14
+ position: 'relative',
15
+ display: 'inline-flex',
16
+ alignItems: 'center',
17
+ maxWidth: '100%',
18
+ padding: '2px 22px 2px 6px',
19
+ margin: '-2px -6px',
20
+ background: 'transparent',
21
+ border: '1px solid transparent',
22
+ borderRadius: 'var(--radius-lg)',
23
+ cursor: 'text',
24
+ textAlign: 'left',
25
+ color: 'inherit',
26
+ font: 'inherit',
27
+ selectors: {
28
+ '&:hover:not(:disabled)': {
29
+ borderColor: tokens.color.stroke.subduedDefault,
30
+ },
31
+ '&:focus-visible:not(:disabled)': {
32
+ borderColor: tokens.color.stroke.focusDefault,
33
+ },
34
+ '&:disabled': {
35
+ cursor: 'default',
36
+ },
37
+ },
38
+ });
39
+
40
+ export const defaultLabel = style({
41
+ minWidth: 0,
42
+ overflow: 'hidden',
43
+ textOverflow: 'ellipsis',
44
+ whiteSpace: 'nowrap',
45
+ });
46
+
47
+ export const pencilIcon = style({
48
+ position: 'absolute',
49
+ right: 6,
50
+ top: '50%',
51
+ transform: 'translateY(-50%)',
52
+ opacity: 0,
53
+ transition: 'opacity 120ms ease',
54
+ selectors: {
55
+ [`${displayButton}:hover:not(:disabled) &`]: {
56
+ opacity: 0.6,
57
+ },
58
+ [`${displayButton}:focus-visible:not(:disabled) &`]: {
59
+ opacity: 0.6,
60
+ },
61
+ },
62
+ });
@@ -0,0 +1,224 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type MutableRefObject,
9
+ type ReactNode,
10
+ type Ref,
11
+ } from 'react';
12
+
13
+ import { Pencil } from 'lucide-react';
14
+
15
+ import { Select } from '../Select';
16
+ import { Text } from '../Typography';
17
+ import * as styles from './EditableSelect.css';
18
+
19
+ export interface EditableSelectProps {
20
+ value: string;
21
+ data: Array<{ value: string; label: string }>;
22
+ onSave: (value: string) => Promise<void>;
23
+ placeholder?: string;
24
+ disabled?: boolean;
25
+ renderDisplay?: (value: string, label: string) => ReactNode;
26
+ }
27
+
28
+ function assignRef<T>(ref: Ref<T> | undefined, value: T) {
29
+ if (typeof ref === 'function') {
30
+ ref(value);
31
+ return;
32
+ }
33
+
34
+ if (ref) {
35
+ (ref as MutableRefObject<T>).current = value;
36
+ }
37
+ }
38
+
39
+ export const EditableSelect = forwardRef<
40
+ HTMLButtonElement,
41
+ EditableSelectProps
42
+ >(
43
+ (
44
+ {
45
+ value,
46
+ data,
47
+ onSave,
48
+ placeholder = 'Select',
49
+ disabled = false,
50
+ renderDisplay,
51
+ },
52
+ ref,
53
+ ) => {
54
+ const [isEditing, setIsEditing] = useState(false);
55
+ const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
56
+
57
+ const rootRef = useRef<HTMLDivElement | null>(null);
58
+ const displayButtonRef = useRef<HTMLButtonElement | null>(null);
59
+ const selectButtonRef = useRef<HTMLButtonElement | null>(null);
60
+
61
+ const currentValue = optimisticValue ?? value;
62
+ const selectedOption = useMemo(
63
+ () => data.find(option => option.value === currentValue) ?? null,
64
+ [currentValue, data],
65
+ );
66
+ const selectedLabel = selectedOption?.label ?? currentValue;
67
+ const displayLabel = selectedLabel || placeholder;
68
+
69
+ const setDisplayRef = useCallback(
70
+ (node: HTMLButtonElement | null) => {
71
+ displayButtonRef.current = node;
72
+ if (node) {
73
+ assignRef(ref, node);
74
+ }
75
+ },
76
+ [ref],
77
+ );
78
+
79
+ const setSelectRef = useCallback(
80
+ (node: HTMLButtonElement | null) => {
81
+ selectButtonRef.current = node;
82
+ if (node) {
83
+ assignRef(ref, node);
84
+ }
85
+ },
86
+ [ref],
87
+ );
88
+
89
+ const stopEditing = useCallback(() => {
90
+ setIsEditing(false);
91
+ }, []);
92
+
93
+ const startEditing = useCallback(() => {
94
+ if (disabled || isEditing) {
95
+ return;
96
+ }
97
+
98
+ setIsEditing(true);
99
+ }, [disabled, isEditing]);
100
+
101
+ const cancel = useCallback(() => {
102
+ if (!isEditing) {
103
+ return;
104
+ }
105
+
106
+ stopEditing();
107
+ }, [isEditing, stopEditing]);
108
+
109
+ const commit = useCallback(
110
+ (nextValue: string) => {
111
+ if (nextValue === currentValue) {
112
+ stopEditing();
113
+ return;
114
+ }
115
+
116
+ setOptimisticValue(nextValue);
117
+ stopEditing();
118
+ Promise.resolve(onSave(nextValue)).catch(() => {
119
+ setOptimisticValue(null);
120
+ });
121
+ },
122
+ [currentValue, onSave, stopEditing],
123
+ );
124
+
125
+ useEffect(() => {
126
+ if (optimisticValue === value) {
127
+ setOptimisticValue(null);
128
+ }
129
+ }, [optimisticValue, value]);
130
+
131
+ useEffect(() => {
132
+ if (!isEditing) {
133
+ return;
134
+ }
135
+
136
+ const timer = window.setTimeout(() => {
137
+ selectButtonRef.current?.focus();
138
+ selectButtonRef.current?.click();
139
+ }, 0);
140
+
141
+ return () => window.clearTimeout(timer);
142
+ }, [isEditing]);
143
+
144
+ useEffect(() => {
145
+ if (!isEditing) {
146
+ return;
147
+ }
148
+
149
+ const handleMouseDown = (event: MouseEvent) => {
150
+ const target = event.target as HTMLElement | null;
151
+ const isWithinComboboxDropdown =
152
+ target?.closest('[role="listbox"]') != null ||
153
+ target?.closest('[role="option"]') != null;
154
+
155
+ if (isWithinComboboxDropdown) {
156
+ return;
157
+ }
158
+
159
+ if (!rootRef.current?.contains(target as Node)) {
160
+ cancel();
161
+ }
162
+ };
163
+
164
+ const handleKeyDown = (event: KeyboardEvent) => {
165
+ if (event.key === 'Escape') {
166
+ event.preventDefault();
167
+ cancel();
168
+ }
169
+ };
170
+
171
+ document.addEventListener('mousedown', handleMouseDown, true);
172
+ document.addEventListener('keydown', handleKeyDown, true);
173
+
174
+ return () => {
175
+ document.removeEventListener('mousedown', handleMouseDown, true);
176
+ document.removeEventListener('keydown', handleKeyDown, true);
177
+ };
178
+ }, [cancel, isEditing]);
179
+
180
+ return (
181
+ <div ref={rootRef} className={styles.root}>
182
+ {isEditing ? (
183
+ <Select
184
+ ref={setSelectRef}
185
+ data={data}
186
+ value={currentValue}
187
+ onChange={nextValue => {
188
+ if (nextValue == null) {
189
+ cancel();
190
+ return;
191
+ }
192
+
193
+ commit(nextValue);
194
+ }}
195
+ placeholder={placeholder}
196
+ />
197
+ ) : (
198
+ <button
199
+ ref={setDisplayRef}
200
+ type="button"
201
+ onClick={startEditing}
202
+ disabled={disabled}
203
+ className={styles.displayButton}
204
+ >
205
+ {renderDisplay ? (
206
+ renderDisplay(currentValue, displayLabel)
207
+ ) : (
208
+ <Text
209
+ variant="body1"
210
+ c={selectedOption ? undefined : 'text.subdued.default'}
211
+ className={styles.defaultLabel}
212
+ >
213
+ {displayLabel}
214
+ </Text>
215
+ )}
216
+ <Pencil size={14} aria-hidden className={styles.pencilIcon} />
217
+ </button>
218
+ )}
219
+ </div>
220
+ );
221
+ },
222
+ );
223
+
224
+ EditableSelect.displayName = 'EditableSelect';
@@ -0,0 +1,2 @@
1
+ export { EditableSelect } from './EditableSelect';
2
+ export type { EditableSelectProps } from './EditableSelect';