@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,538 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ import {
12
+ EditorContent,
13
+ type Editor,
14
+ type Range,
15
+ useEditor,
16
+ type JSONContent,
17
+ } from '@tiptap/react';
18
+
19
+ import {
20
+ resolveFormattingCommands,
21
+ type FormattingToolbarVariant,
22
+ } from '../RichText/formattingCommands';
23
+ import { getRichTextExtensions } from '../RichText/richTextExtensions';
24
+ import {
25
+ docsEqual,
26
+ isDocEmpty,
27
+ normalizeValue,
28
+ } from '../RichText/richTextHelpers';
29
+ import {
30
+ installRichTextImageHandlers,
31
+ useRichTextImageLoader,
32
+ type RichTextImageEditorConfig,
33
+ } from '../RichText/richTextImageHandlers';
34
+ import * as proseStyles from '../RichText/richTextProse.css';
35
+ import { Text } from '../Typography';
36
+ import { SelectionBubbleMenu } from './SelectionBubbleMenu';
37
+ import { SlashCommandMenu } from './SlashCommandMenu';
38
+ import * as classes from './SlashRichTextEditor.css';
39
+ import { SlashSuggestionExtension } from './SlashSuggestionExtension';
40
+
41
+ import type { SlashCommand, SlashSuggestionState } from './types';
42
+
43
+ /**
44
+ * Imperative handle for callers that need to focus / blur the editor or
45
+ * inspect the current plain text without re-rendering on every keystroke.
46
+ */
47
+ export interface SlashRichTextEditorHandle {
48
+ focus: () => void;
49
+ blur: () => void;
50
+ getPlainText: () => string;
51
+ }
52
+
53
+ export interface SlashRichTextEditorProps {
54
+ value: JSONContent | null;
55
+ onChange: (next: JSONContent) => void;
56
+ /**
57
+ * Plain-text mirror — fired alongside `onChange` so callers using the doc
58
+ * for search / validation don't need to re-derive it.
59
+ */
60
+ onPlainTextChange?: (text: string) => void;
61
+ placeholder?: string;
62
+ autoFocus?: boolean;
63
+ /** Custom field commands appended below the formatting toolbar. */
64
+ extraCommands?: SlashCommand[];
65
+ /** Toggles the built-in formatting toolbar layout. Defaults to 'standard'. */
66
+ formattingToolbar?: FormattingToolbarVariant;
67
+ onSubmit?: () => void;
68
+ onCancel?: () => void;
69
+ /**
70
+ * When true, plain `Enter` (no shift, no menu open) submits the form,
71
+ * matching the legacy `<textarea>` behavior. `Shift+Enter` always inserts
72
+ * a newline and `Cmd/Ctrl+Enter` always submits regardless of this flag.
73
+ * Defaults to false so the editor behaves like a normal multi-line input.
74
+ */
75
+ submitOnEnter?: boolean;
76
+ /**
77
+ * Mirrors the legacy `<textarea>` `maxLength` so consumers can keep their
78
+ * existing length validation. Compared against `editor.getText()`.
79
+ */
80
+ maxLengthPlainText?: number;
81
+ errorMessage?: string;
82
+ className?: string;
83
+ ariaLabel?: string;
84
+ disabled?: boolean;
85
+ /**
86
+ * Visual chrome around the editor surface.
87
+ *
88
+ * - `seamless` (default): no border / background / padding so the editor
89
+ * inherits whatever container the consumer wraps it in (e.g. the
90
+ * green-bordered Active draft card in `ActionItemQuickCreate`).
91
+ * - `bordered`: opt-in input-style box with focus ring, useful when the
92
+ * editor stands on its own.
93
+ */
94
+ variant?: 'seamless' | 'bordered';
95
+ /**
96
+ * When provided, enables inline image upload via paste / drop. See
97
+ * {@link RichTextImageEditorConfig} for the full callback surface.
98
+ */
99
+ image?: RichTextImageEditorConfig;
100
+ }
101
+
102
+ /**
103
+ * Filter the consumer's `extraCommands` by the current slash query.
104
+ * Matches the legacy `filterSlashFields` contract (case-insensitive
105
+ * substring on the label).
106
+ */
107
+ function filterCommandsByQuery(
108
+ commands: SlashCommand[],
109
+ query: string,
110
+ ): SlashCommand[] {
111
+ const normalized = query.trim().toLowerCase();
112
+ if (!normalized) return commands;
113
+ return commands.filter(c => c.label.toLowerCase().includes(normalized));
114
+ }
115
+
116
+ /**
117
+ * Tiptap-based replacement for the action item / initiative `<textarea>`s.
118
+ * Renders a single contenteditable surface plus a popup that opens on `/` and
119
+ * exposes both formatting commands (slim icon toolbar) and consumer-provided
120
+ * field commands (filterable list). Markdown shortcuts work via StarterKit's
121
+ * built-in input rules so most users never need to open the menu.
122
+ */
123
+ export const SlashRichTextEditor = forwardRef<
124
+ SlashRichTextEditorHandle,
125
+ SlashRichTextEditorProps
126
+ >(
127
+ (
128
+ {
129
+ value,
130
+ onChange,
131
+ onPlainTextChange,
132
+ placeholder,
133
+ autoFocus = false,
134
+ extraCommands = [],
135
+ formattingToolbar = 'standard',
136
+ onSubmit,
137
+ onCancel,
138
+ submitOnEnter = false,
139
+ maxLengthPlainText,
140
+ errorMessage,
141
+ className,
142
+ ariaLabel,
143
+ disabled = false,
144
+ variant = 'seamless',
145
+ image,
146
+ },
147
+ ref,
148
+ ) => {
149
+ const enableImages = Boolean(image);
150
+ /**
151
+ * The slash plugin's render lifecycle is non-React, so we mirror the
152
+ * relevant pieces into React state and re-render the popup whenever the
153
+ * suggestion plugin tells us the query / range changed.
154
+ */
155
+ const [suggestion, setSuggestion] = useState<SlashSuggestionState | null>(
156
+ null,
157
+ );
158
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
159
+ const [focused, setFocused] = useState(false);
160
+
161
+ // Refs let the suggestion plugin (which is created once) reach the latest
162
+ // commands + highlight state without re-instantiating Tiptap on every render.
163
+ const suggestionRef = useRef<SlashSuggestionState | null>(suggestion);
164
+ suggestionRef.current = suggestion;
165
+
166
+ const extraCommandsRef = useRef<SlashCommand[]>(extraCommands);
167
+ extraCommandsRef.current = extraCommands;
168
+
169
+ const highlightedRef = useRef(highlightedIndex);
170
+ highlightedRef.current = highlightedIndex;
171
+
172
+ const visibleCommandsRef = useRef<SlashCommand[]>([]);
173
+
174
+ const onSubmitRef = useRef(onSubmit);
175
+ onSubmitRef.current = onSubmit;
176
+
177
+ const onCancelRef = useRef(onCancel);
178
+ onCancelRef.current = onCancel;
179
+
180
+ const submitOnEnterRef = useRef(submitOnEnter);
181
+ submitOnEnterRef.current = submitOnEnter;
182
+
183
+ const closeSuggestion = useCallback(() => {
184
+ setSuggestion(null);
185
+ setHighlightedIndex(0);
186
+ }, []);
187
+
188
+ const runCommand = useCallback(
189
+ (cmd: SlashCommand, currentRange: Range, editor: Editor) => {
190
+ if (cmd.disabled) return;
191
+ // Drop the `/<query>` token first so the consumer doesn't have to.
192
+ editor.chain().focus().deleteRange(currentRange).run();
193
+ cmd.run({ editor, range: currentRange });
194
+ // Hand control back to suggestion's exit() so its plugin state stays
195
+ // consistent. Falling through to setSuggestion(null) is a no-op if the
196
+ // exit handler already cleared us.
197
+ suggestionRef.current?.exit?.();
198
+ closeSuggestion();
199
+ },
200
+ [closeSuggestion],
201
+ );
202
+
203
+ const toolbarCommands = useMemo(
204
+ () => resolveFormattingCommands(formattingToolbar),
205
+ [formattingToolbar],
206
+ );
207
+
208
+ // Stable ref the suggestion plugin can ask for "what's currently visible?"
209
+ const filterAndCacheVisibleCommands = useCallback((query: string) => {
210
+ const filtered = filterCommandsByQuery(extraCommandsRef.current, query);
211
+ visibleCommandsRef.current = filtered;
212
+ return filtered;
213
+ }, []);
214
+
215
+ // Build the suggestion config once. We avoid passing closures over React
216
+ // state — the plugin would otherwise capture stale values. Refs +
217
+ // imperative setters keep the wiring stable across renders.
218
+ const suggestionOptions = useMemo(
219
+ () => ({
220
+ char: '/',
221
+ allowSpaces: false,
222
+ allowedPrefixes: [' ', '\n', '\t'],
223
+ startOfLine: false,
224
+ items: ({ query }: { query: string }) => {
225
+ return filterAndCacheVisibleCommands(query).map(c => c.id);
226
+ },
227
+ render: () => {
228
+ let cachedRange: Range | null = null;
229
+ let cachedExit: () => void = () => {};
230
+ return {
231
+ onStart: (props: {
232
+ query: string;
233
+ range: Range;
234
+ command: (item: unknown) => void;
235
+ clientRect?: (() => DOMRect | null) | null;
236
+ }) => {
237
+ cachedRange = props.range;
238
+ cachedExit = () => {
239
+ setSuggestion(null);
240
+ setHighlightedIndex(0);
241
+ };
242
+ setHighlightedIndex(0);
243
+ setSuggestion({
244
+ query: props.query,
245
+ range: props.range,
246
+ exit: cachedExit,
247
+ });
248
+ },
249
+ onUpdate: (props: { query: string; range: Range }) => {
250
+ cachedRange = props.range;
251
+ setSuggestion(prev =>
252
+ prev
253
+ ? { ...prev, query: props.query, range: props.range }
254
+ : {
255
+ query: props.query,
256
+ range: props.range,
257
+ exit: cachedExit,
258
+ },
259
+ );
260
+ setHighlightedIndex(0);
261
+ },
262
+ onKeyDown: (props: { event: KeyboardEvent }) => {
263
+ const { event } = props;
264
+ const visible = visibleCommandsRef.current;
265
+ if (event.key === 'ArrowDown') {
266
+ event.preventDefault();
267
+ setHighlightedIndex(idx =>
268
+ visible.length === 0 ? 0 : (idx + 1) % visible.length,
269
+ );
270
+ return true;
271
+ }
272
+ if (event.key === 'ArrowUp') {
273
+ event.preventDefault();
274
+ setHighlightedIndex(idx =>
275
+ visible.length === 0
276
+ ? 0
277
+ : (idx - 1 + visible.length) % visible.length,
278
+ );
279
+ return true;
280
+ }
281
+ if (event.key === 'Enter') {
282
+ event.preventDefault();
283
+ const visibleNow = visibleCommandsRef.current;
284
+ const target = visibleNow[highlightedRef.current];
285
+ if (target && cachedRange) {
286
+ runCommandFromKeyboardRef.current(target, cachedRange);
287
+ }
288
+ return true;
289
+ }
290
+ if (event.key === 'Escape') {
291
+ event.preventDefault();
292
+ cachedExit();
293
+ return true;
294
+ }
295
+ return false;
296
+ },
297
+ onExit: () => {
298
+ cachedRange = null;
299
+ setSuggestion(null);
300
+ setHighlightedIndex(0);
301
+ },
302
+ };
303
+ },
304
+ command: () => {
305
+ // No-op: command application happens through `runCommand` when the
306
+ // user clicks / hits Enter. The suggestion plugin still calls this
307
+ // when a programmatic command callback is requested.
308
+ },
309
+ }),
310
+ [filterAndCacheVisibleCommands],
311
+ );
312
+
313
+ // Imperative bridge from the suggestion's keyboard handler back to React,
314
+ // so we don't need to thread the editor through the closure. The ref
315
+ // wrapper keeps `useMemo` deps stable while still letting us pull the
316
+ // latest `runCommand` closure on every keystroke.
317
+ const editorRef = useRef<Editor | null>(null);
318
+ const runCommandFromKeyboardRef = useRef<
319
+ (cmd: SlashCommand, currentRange: Range) => void
320
+ >(() => {});
321
+ runCommandFromKeyboardRef.current = (
322
+ cmd: SlashCommand,
323
+ currentRange: Range,
324
+ ) => {
325
+ const editor = editorRef.current;
326
+ if (!editor) return;
327
+ runCommand(cmd, currentRange, editor);
328
+ };
329
+
330
+ const imageHandlers = useMemo(
331
+ () => installRichTextImageHandlers(image),
332
+ [image],
333
+ );
334
+
335
+ const editor = useEditor(
336
+ {
337
+ immediatelyRender: false,
338
+ editable: !disabled,
339
+ extensions: [
340
+ ...getRichTextExtensions({ placeholder, enableImages }),
341
+ SlashSuggestionExtension.configure({ suggestion: suggestionOptions }),
342
+ ],
343
+ content: normalizeValue(value),
344
+ autofocus: autoFocus ? 'end' : false,
345
+ editorProps: {
346
+ attributes: ariaLabel ? { 'aria-label': ariaLabel } : {},
347
+ ...imageHandlers,
348
+ handleKeyDown: (_view, event) => {
349
+ // Suggestion plugin gets first crack — only handle global shortcuts
350
+ // when the menu is closed.
351
+ if (suggestionRef.current) return false;
352
+
353
+ if (event.key === 'Escape') {
354
+ if (onCancelRef.current) {
355
+ event.preventDefault();
356
+ onCancelRef.current();
357
+ return true;
358
+ }
359
+ return false;
360
+ }
361
+
362
+ if (
363
+ event.key === 'Enter' &&
364
+ !event.shiftKey &&
365
+ (event.metaKey || event.ctrlKey)
366
+ ) {
367
+ event.preventDefault();
368
+ onSubmitRef.current?.();
369
+ return true;
370
+ }
371
+
372
+ if (
373
+ submitOnEnterRef.current &&
374
+ event.key === 'Enter' &&
375
+ !event.shiftKey &&
376
+ !event.metaKey &&
377
+ !event.ctrlKey &&
378
+ !event.altKey
379
+ ) {
380
+ event.preventDefault();
381
+ onSubmitRef.current?.();
382
+ return true;
383
+ }
384
+
385
+ return false;
386
+ },
387
+ },
388
+ onUpdate: ({ editor: current }) => {
389
+ const next = current.getJSON();
390
+ const plainText = current.getText();
391
+ if (
392
+ maxLengthPlainText != null &&
393
+ plainText.length > maxLengthPlainText
394
+ ) {
395
+ // Trim the overflow by deleting the trailing characters in a
396
+ // single ProseMirror transaction. Doing it this way preserves
397
+ // every mark / node / link the user had typed up to the limit —
398
+ // a blanket `setContent` rebuild would flatten the doc back to a
399
+ // single plain paragraph.
400
+ //
401
+ // We compute how many plain-text characters need to drop and
402
+ // walk that many positions backward from doc end. ProseMirror
403
+ // positions are 1-based and include node boundaries, but
404
+ // `delete(from, end)` collapses any non-text positions it
405
+ // crosses automatically, so the conservative "delete the last
406
+ // N positions" approach lands the doc at exactly the limit.
407
+ //
408
+ // Consumers that care about strict enforcement should also apply
409
+ // a server-side check.
410
+ const excess = plainText.length - maxLengthPlainText;
411
+ const { state } = current;
412
+ const end = state.doc.content.size;
413
+ const deleteFrom = Math.max(0, end - excess);
414
+ current.view.dispatch(state.tr.delete(deleteFrom, end));
415
+ onChange(current.getJSON());
416
+ onPlainTextChange?.(current.getText());
417
+ return;
418
+ }
419
+ onChange(next);
420
+ onPlainTextChange?.(plainText);
421
+ },
422
+ onFocus: () => setFocused(true),
423
+ onBlur: () => setFocused(false),
424
+ },
425
+ // Remount only when `disabled` changes (Tiptap doesn't have a runtime
426
+ // setter for it) or when image support is toggled on/off (since that
427
+ // changes the schema). The image callbacks themselves are captured
428
+ // through `imageHandlers` so swapping `image.uploadImage` doesn't
429
+ // rebuild the editor.
430
+ [disabled, enableImages],
431
+ );
432
+
433
+ useRichTextImageLoader(editor, image);
434
+
435
+ editorRef.current = editor;
436
+
437
+ // Keep the editor in sync when the parent replaces the value (e.g. after a
438
+ // successful submit clears the form).
439
+ useEffect(() => {
440
+ if (!editor) return;
441
+ const next = normalizeValue(value);
442
+ if (!docsEqual(editor.getJSON(), next)) {
443
+ editor.commands.setContent(next, { emitUpdate: false });
444
+ }
445
+ }, [editor, value]);
446
+
447
+ // Update the Placeholder extension at runtime so a changing `placeholder`
448
+ // prop doesn't have to remount the editor (which would reset selection
449
+ // and scroll). The extension is added unconditionally by
450
+ // `getRichTextExtensions`, so the lookup will always find it.
451
+ useEffect(() => {
452
+ if (!editor) return;
453
+ const placeholderExt = editor.extensionManager.extensions.find(
454
+ ext => ext.name === 'placeholder',
455
+ );
456
+ if (!placeholderExt) return;
457
+ if (placeholderExt.options.placeholder === (placeholder ?? '')) return;
458
+ placeholderExt.options.placeholder = placeholder ?? '';
459
+ // Force a redraw so the empty-editor placeholder pseudo updates.
460
+ editor.view.dispatch(editor.state.tr);
461
+ }, [editor, placeholder]);
462
+
463
+ useImperativeHandle(
464
+ ref,
465
+ () => ({
466
+ focus: () => editor?.commands.focus(),
467
+ blur: () => editor?.commands.blur(),
468
+ getPlainText: () => editor?.getText() ?? '',
469
+ }),
470
+ [editor],
471
+ );
472
+
473
+ const visibleCommands = useMemo(
474
+ () => filterCommandsByQuery(extraCommands, suggestion?.query ?? ''),
475
+ [extraCommands, suggestion?.query],
476
+ );
477
+
478
+ // Mirror the visible list into the ref so the suggestion plugin's keyboard
479
+ // handler sees the same array React just rendered.
480
+ useEffect(() => {
481
+ visibleCommandsRef.current = visibleCommands;
482
+ }, [visibleCommands]);
483
+
484
+ if (!editor) return null;
485
+
486
+ const showError = Boolean(errorMessage);
487
+ const isEmpty = isDocEmpty(editor.getJSON());
488
+
489
+ const surfaceClass = [
490
+ classes.editorSurface,
491
+ proseStyles.prose,
492
+ variant === 'bordered' ? classes.editorSurfaceBordered : undefined,
493
+ ]
494
+ .filter(Boolean)
495
+ .join(' ');
496
+
497
+ return (
498
+ <div className={[classes.root, className].filter(Boolean).join(' ')}>
499
+ <div
500
+ className={surfaceClass}
501
+ data-focused={focused ? 'true' : undefined}
502
+ data-error={showError ? 'true' : undefined}
503
+ data-disabled={disabled ? 'true' : undefined}
504
+ data-empty={isEmpty ? 'true' : undefined}
505
+ >
506
+ <EditorContent editor={editor} />
507
+ <SelectionBubbleMenu
508
+ editor={editor}
509
+ hidden={Boolean(suggestion) || disabled}
510
+ />
511
+ {suggestion ? (
512
+ <div className={`${classes.menuLayer} ${classes.menuLayerActive}`}>
513
+ <SlashCommandMenu
514
+ editor={editor}
515
+ range={suggestion.range}
516
+ query={suggestion.query}
517
+ commands={visibleCommands}
518
+ toolbarCommands={toolbarCommands}
519
+ highlightedIndex={highlightedIndex}
520
+ onHighlightChange={setHighlightedIndex}
521
+ onSelect={cmd => runCommand(cmd, suggestion.range, editor)}
522
+ onVisibleCommandsChange={() => {
523
+ // No-op: highlight reset is driven by suggestion onUpdate.
524
+ }}
525
+ ariaLabel={ariaLabel ? `${ariaLabel} commands` : undefined}
526
+ />
527
+ </div>
528
+ ) : null}
529
+ </div>
530
+ {showError ? (
531
+ <Text variant="caption2" className={classes.errorMessage}>
532
+ {errorMessage}
533
+ </Text>
534
+ ) : null}
535
+ </div>
536
+ );
537
+ },
538
+ );
@@ -0,0 +1,48 @@
1
+ import { Extension } from '@tiptap/react';
2
+ import Suggestion, {
3
+ SuggestionPluginKey,
4
+ type SuggestionOptions,
5
+ } from '@tiptap/suggestion';
6
+
7
+ /**
8
+ * Tiptap extension that wires `@tiptap/suggestion` to the `/` trigger so
9
+ * `SlashRichTextEditor` can render its own popup. The extension itself stays
10
+ * thin — the actual `items`, `render`, and `command` callbacks are supplied by
11
+ * the React component via `configure({ suggestion })`. Splitting the wiring
12
+ * here keeps the editor file focused on state management and lets unit tests
13
+ * import the extension in isolation.
14
+ */
15
+ export interface SlashSuggestionOptions {
16
+ suggestion: Omit<SuggestionOptions, 'editor'>;
17
+ }
18
+
19
+ export const SlashSuggestionExtension =
20
+ Extension.create<SlashSuggestionOptions>({
21
+ name: 'slashSuggestion',
22
+ addOptions() {
23
+ return {
24
+ suggestion: {
25
+ char: '/',
26
+ allowSpaces: false,
27
+ // Trigger only at start-of-line or after whitespace so `foo/bar`
28
+ // never opens the menu — matches the legacy `findActiveSlashAnchor`
29
+ // contract that consumers were already relying on.
30
+ allowedPrefixes: [' ', '\n', '\t'],
31
+ startOfLine: false,
32
+ items: () => [],
33
+ render: () => ({}),
34
+ command: () => {},
35
+ },
36
+ };
37
+ },
38
+ addProseMirrorPlugins() {
39
+ return [
40
+ Suggestion({
41
+ editor: this.editor,
42
+ ...this.options.suggestion,
43
+ }),
44
+ ];
45
+ },
46
+ });
47
+
48
+ export { SuggestionPluginKey };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Slash-specific public surface. The shared toolbar / formatting commands /
3
+ * extension set live in `../RichText` and are re-exported from the package
4
+ * root via `RichText`'s barrel — this module only exposes the slash
5
+ * editor itself plus its slash-specific extension and types.
6
+ */
7
+ export {
8
+ SlashRichTextEditor,
9
+ type SlashRichTextEditorHandle,
10
+ type SlashRichTextEditorProps,
11
+ } from './SlashRichTextEditor';
12
+ export { SlashSuggestionExtension } from './SlashSuggestionExtension';
13
+ export type { SlashCommand, SlashSuggestionState } from './types';
@@ -0,0 +1,48 @@
1
+ import type { Editor, Range } from '@tiptap/react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+
4
+ /**
5
+ * A single entry in the slash menu's command list (the lower section of
6
+ * the Option B layout). Formatting commands rendered in the toolbar are
7
+ * described separately via `FormattingToolbarCommand` (in
8
+ * `RichText/formattingTypes.ts`) because they don't participate in the
9
+ * filterable list.
10
+ */
11
+ export interface SlashCommand {
12
+ /** Stable id for keyboard nav and React keys. */
13
+ id: string;
14
+ /** Display label rendered in the row. */
15
+ label: string;
16
+ /** Lucide icon shown to the left of the label. */
17
+ icon: LucideIcon;
18
+ /** Short keyboard hint shown on the right (e.g. `C`, `D`). */
19
+ shortcut?: string;
20
+ /** Section header label used to bucket the row in the list. */
21
+ group: string;
22
+ /** Optional helper text — currently unused but reserved for future UX. */
23
+ description?: string;
24
+ /** Greyed out and unselectable if true. */
25
+ disabled?: boolean;
26
+ /** Replaces the shortcut chip on disabled rows. */
27
+ disabledHint?: string;
28
+ /**
29
+ * Called when the user selects the row. The component drops the `/<query>`
30
+ * token before invoking this so consumers don't have to think about the
31
+ * slash anchor — they just open their flyout / mutate the doc.
32
+ */
33
+ run: (ctx: { editor: Editor; range: Range }) => void;
34
+ }
35
+
36
+ /**
37
+ * Shape stored in the React state while the slash suggestion is active.
38
+ * Drives the menu UI (open/close, query, range, command callback).
39
+ */
40
+ export interface SlashSuggestionState {
41
+ query: string;
42
+ range: Range;
43
+ /**
44
+ * The exit callback registered by `@tiptap/suggestion`. We invoke this when
45
+ * the user picks something so the plugin can clean up its internal state.
46
+ */
47
+ exit: () => void;
48
+ }