@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.
- package/.ai/rules/date-handling.md +39 -0
- package/.ai/rules/figma-design-system.md +372 -0
- package/.ai/rules/figma-lm-design-system-keys.md +680 -0
- package/.ai/rules/file-extensions.md +13 -0
- package/.ai/rules/modal-confirmation-mutation.md +56 -0
- package/.ai/rules/react-hooks.md +29 -0
- package/.ai/rules/styling.md +83 -0
- package/AGENTS.md +37 -0
- package/README.md +125 -0
- package/figma.config.json +9 -0
- package/package.json +127 -0
- package/scripts/install-ai-rules.mjs +136 -0
- package/src/ThemeProvider.tsx +57 -0
- package/src/charts.ts +32 -0
- package/src/components/ActionCard/ActionCard.css.ts +60 -0
- package/src/components/ActionCard/ActionCard.tsx +154 -0
- package/src/components/ActionCard/index.ts +2 -0
- package/src/components/Anchor/Anchor.tsx +47 -0
- package/src/components/Anchor/index.ts +2 -0
- package/src/components/AppliedFiltersManagerBar/AppliedFiltersManagerBar.tsx +105 -0
- package/src/components/AppliedFiltersManagerBar/FilterBadge.css.ts +23 -0
- package/src/components/AppliedFiltersManagerBar/FilterBadge.tsx +50 -0
- package/src/components/AppliedFiltersManagerBar/index.ts +5 -0
- package/src/components/Badge/Badge.css.ts +72 -0
- package/src/components/Badge/Badge.figma.tsx +43 -0
- package/src/components/Badge/Badge.tsx +159 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BreadCrumb/BreadCrumb.tsx +62 -0
- package/src/components/BreadCrumb/index.ts +2 -0
- package/src/components/BulkActionBar/BulkActionBar.css.ts +26 -0
- package/src/components/BulkActionBar/BulkActionBar.tsx +164 -0
- package/src/components/BulkActionBar/index.ts +2 -0
- package/src/components/Button/Button.css.ts +272 -0
- package/src/components/Button/Button.figma.tsx +74 -0
- package/src/components/Button/Button.tsx +84 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Charts/ChartTooltip.figma.tsx +33 -0
- package/src/components/Charts/ChartTooltip.tsx +101 -0
- package/src/components/Charts/MiniBarSparkline.tsx +75 -0
- package/src/components/Charts/StackedPatternBarChart.tsx +494 -0
- package/src/components/Charts/TrendAreaChart.css.ts +23 -0
- package/src/components/Charts/TrendAreaChart.tsx +210 -0
- package/src/components/Charts/index.ts +12 -0
- package/src/components/CodePanel/CodePanel.css.ts +113 -0
- package/src/components/CodePanel/CodePanel.tsx +121 -0
- package/src/components/CodePanel/index.ts +2 -0
- package/src/components/CommentComposer/CommentComposer.css.ts +60 -0
- package/src/components/CommentComposer/CommentComposer.tsx +181 -0
- package/src/components/CommentComposer/index.ts +2 -0
- package/src/components/ConfirmationModal/ConfirmationModal.tsx +149 -0
- package/src/components/ConfirmationModal/index.ts +2 -0
- package/src/components/ConfirmationTooltip/ConfirmationTooltip.tsx +132 -0
- package/src/components/ConfirmationTooltip/index.ts +2 -0
- package/src/components/DataDialog.figma.tsx +33 -0
- package/src/components/DataDialog.tsx +46 -0
- package/src/components/DataTable/DataTable.tsx +1042 -0
- package/src/components/DataTable/RowExpandToggle.tsx +105 -0
- package/src/components/DataTable/RowGroupHeader.tsx +190 -0
- package/src/components/DataTable/createActionsColumn.tsx +86 -0
- package/src/components/DataTable/index.ts +25 -0
- package/src/components/DatePicker/CustomRangePicker.tsx +59 -0
- package/src/components/DatePicker/DateInput.tsx +329 -0
- package/src/components/DatePicker/DateNavigator.tsx +486 -0
- package/src/components/DatePicker/DatePicker.tsx +242 -0
- package/src/components/DatePicker/MonthlyRangePicker.tsx +231 -0
- package/src/components/DatePicker/QuarterlyRangePicker.tsx +224 -0
- package/src/components/DatePicker/QuickPicksSidebar.tsx +242 -0
- package/src/components/DatePicker/YearlyRangePicker.tsx +171 -0
- package/src/components/DatePicker/index.ts +7 -0
- package/src/components/DatePicker/types.ts +12 -0
- package/src/components/DesignSystemPrimitives/FluidGrid.tsx +44 -0
- package/src/components/DesignSystemPrimitives/InteractivePrimitives.tsx +177 -0
- package/src/components/DesignSystemPrimitives/LayoutPrimitives.tsx +220 -0
- package/src/components/DesignSystemPrimitives/LayoutPrimitives.types.tsx +15 -0
- package/src/components/DesignSystemPrimitives/SurfacePrimitives.tsx +46 -0
- package/src/components/DesignSystemPrimitives/index.ts +55 -0
- package/src/components/Details/Details.css.ts +74 -0
- package/src/components/Details/Details.tsx +140 -0
- package/src/components/Details/index.ts +2 -0
- package/src/components/DownloadCard/DownloadCard.css.ts +22 -0
- package/src/components/DownloadCard/DownloadCard.tsx +63 -0
- package/src/components/DownloadCard/index.ts +2 -0
- package/src/components/Drawer/Drawer.css.ts +32 -0
- package/src/components/Drawer/Drawer.tsx +236 -0
- package/src/components/Drawer/hooks/useDetailDrawer.ts +61 -0
- package/src/components/Drawer/hooks/useDetailDrawerNavigation.ts +125 -0
- package/src/components/Drawer/hooks/useDetailDrawerNavigationContext.ts +66 -0
- package/src/components/EditableRichText/EditableRichText.css.ts +72 -0
- package/src/components/EditableRichText/EditableRichText.tsx +324 -0
- package/src/components/EditableRichText/index.ts +2 -0
- package/src/components/EditableSelect/EditableSelect.css.ts +62 -0
- package/src/components/EditableSelect/EditableSelect.tsx +224 -0
- package/src/components/EditableSelect/index.ts +2 -0
- package/src/components/EditableText/EditableText.tsx +377 -0
- package/src/components/EditableText/index.ts +2 -0
- package/src/components/EmptyState/EmptyState.figma.tsx +33 -0
- package/src/components/EmptyState/EmptyState.tsx +230 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/ErrorBoundary.tsx +135 -0
- package/src/components/ErrorState/ErrorState.tsx +197 -0
- package/src/components/ErrorState/index.ts +2 -0
- package/src/components/FeatureCard.tsx +42 -0
- package/src/components/FilterMenu/FilterMenu.figma.tsx +30 -0
- package/src/components/FilterMenu/FilterMenu.tsx +198 -0
- package/src/components/FilterMenu/FilterSubMenuTypes/BooleanFilterSubmenu.tsx +46 -0
- package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +239 -0
- package/src/components/FilterMenu/FilterSubMenuTypes/index.ts +8 -0
- package/src/components/FilterMenu/defaultFilterSchemas.ts +63 -0
- package/src/components/FilterMenu/helpers.ts +115 -0
- package/src/components/FilterMenu/index.ts +35 -0
- package/src/components/FilterMenu/types.ts +101 -0
- package/src/components/IconButton/IconButton.css.ts +272 -0
- package/src/components/IconButton/IconButton.figma.tsx +47 -0
- package/src/components/IconButton/IconButton.tsx +72 -0
- package/src/components/IconButton/README.md +230 -0
- package/src/components/IconButton/index.ts +2 -0
- package/src/components/InfiniteScrollSentinel.tsx +86 -0
- package/src/components/InfiniteScrollTrigger.tsx +78 -0
- package/src/components/InfoCard.figma.tsx +47 -0
- package/src/components/InfoCard.tsx +216 -0
- package/src/components/KbdHint/KbdHint.tsx +23 -0
- package/src/components/KbdHint/index.ts +2 -0
- package/src/components/LabeledField/LabeledField.tsx +21 -0
- package/src/components/LabeledField/index.ts +2 -0
- package/src/components/LookupSelect/LookupSelect.css.ts +149 -0
- package/src/components/LookupSelect/LookupSelect.tsx +325 -0
- package/src/components/LookupSelect/index.ts +2 -0
- package/src/components/Menu/Menu.css.ts +89 -0
- package/src/components/Menu/Menu.tsx +105 -0
- package/src/components/Menu/index.ts +2 -0
- package/src/components/MessageBox/MessageBox.tsx +168 -0
- package/src/components/MessageBox/index.ts +2 -0
- package/src/components/MetricDisplay/MetricDisplay.tsx +55 -0
- package/src/components/MetricDisplay/index.ts +1 -0
- package/src/components/MultiSelect/MultiSelect.tsx +278 -0
- package/src/components/MultiSelect/index.ts +2 -0
- package/src/components/Notifications/Notifications.tsx +12 -0
- package/src/components/Notifications/README.md +93 -0
- package/src/components/Notifications/index.ts +4 -0
- package/src/components/Notifications/showToast.tsx +100 -0
- package/src/components/PropertyRow/PropertyRow.tsx +96 -0
- package/src/components/PropertyRow/index.ts +2 -0
- package/src/components/RadioTile/RadioTile.tsx +253 -0
- package/src/components/RadioTile/index.ts +2 -0
- package/src/components/RichText/FormattingToolbar.css.ts +69 -0
- package/src/components/RichText/FormattingToolbar.tsx +112 -0
- package/src/components/RichText/RichTextInline.css.ts +54 -0
- package/src/components/RichText/RichTextInline.tsx +318 -0
- package/src/components/RichText/formattingCommands.ts +181 -0
- package/src/components/RichText/formattingTypes.ts +34 -0
- package/src/components/RichText/index.ts +49 -0
- package/src/components/RichText/richTextExtensions.ts +111 -0
- package/src/components/RichText/richTextHelpers.ts +65 -0
- package/src/components/RichText/richTextImage.ts +253 -0
- package/src/components/RichText/richTextImageHandlers.ts +244 -0
- package/src/components/RichText/richTextProse.css.ts +261 -0
- package/src/components/RichTextEditor/RichTextEditor.css.ts +82 -0
- package/src/components/RichTextEditor/RichTextEditor.tsx +204 -0
- package/src/components/RichTextEditor/index.ts +2 -0
- package/src/components/RichTextView/RichTextView.css.ts +11 -0
- package/src/components/RichTextView/RichTextView.tsx +114 -0
- package/src/components/RichTextView/index.ts +2 -0
- package/src/components/Schedule/Schedule.tsx +35 -0
- package/src/components/SchedulePicker/SchedulePicker.css.ts +42 -0
- package/src/components/SchedulePicker/SchedulePicker.tsx +130 -0
- package/src/components/SchedulePicker/index.ts +2 -0
- package/src/components/SearchableList/types.ts +30 -0
- package/src/components/SearchableSubMenu/SearchableSubMenu.css.ts +25 -0
- package/src/components/SearchableSubMenu/SearchableSubMenu.tsx +139 -0
- package/src/components/SearchableSubMenu/index.ts +2 -0
- package/src/components/Select/README.md +114 -0
- package/src/components/Select/Select.css.ts +110 -0
- package/src/components/Select/Select.tsx +133 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/SelectCreatable/SelectCreatable.css.ts +16 -0
- package/src/components/SelectCreatable/SelectCreatable.tsx +203 -0
- package/src/components/SelectCreatable/index.ts +2 -0
- package/src/components/SettingsCard/SettingsCard.tsx +98 -0
- package/src/components/SettingsCard/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.css.ts +91 -0
- package/src/components/Sidebar/Sidebar.tsx +129 -0
- package/src/components/Sidebar/index.ts +5 -0
- package/src/components/SimpleList/SimpleList.css.ts +12 -0
- package/src/components/SimpleList/SimpleList.tsx +44 -0
- package/src/components/SimpleList/index.ts +2 -0
- package/src/components/SimpleTable/SimpleTable.tsx +296 -0
- package/src/components/SimpleTable/index.ts +2 -0
- package/src/components/SlashRichTextEditor/SelectionBubbleMenu.css.ts +62 -0
- package/src/components/SlashRichTextEditor/SelectionBubbleMenu.tsx +85 -0
- package/src/components/SlashRichTextEditor/SlashCommandMenu.css.ts +124 -0
- package/src/components/SlashRichTextEditor/SlashCommandMenu.tsx +168 -0
- package/src/components/SlashRichTextEditor/SlashRichTextEditor.css.ts +81 -0
- package/src/components/SlashRichTextEditor/SlashRichTextEditor.tsx +538 -0
- package/src/components/SlashRichTextEditor/SlashSuggestionExtension.ts +48 -0
- package/src/components/SlashRichTextEditor/index.ts +13 -0
- package/src/components/SlashRichTextEditor/types.ts +48 -0
- package/src/components/StatCard/StatCard.css.ts +70 -0
- package/src/components/StatCard/StatCard.tsx +201 -0
- package/src/components/StatCard/index.ts +1 -0
- package/src/components/StatusBadge/StatusBadge.tsx +70 -0
- package/src/components/StatusBadge/index.ts +2 -0
- package/src/components/StatusIndicator/StatusIndicator.tsx +67 -0
- package/src/components/StatusIndicator/index.ts +6 -0
- package/src/components/SubNavigation/SubNavigation.css.ts +72 -0
- package/src/components/SubNavigation/SubNavigation.tsx +104 -0
- package/src/components/SubNavigation/index.ts +2 -0
- package/src/components/SuspenseLoader.tsx +22 -0
- package/src/components/Table/SortableColumnHeader.tsx +99 -0
- package/src/components/Table/TableSkeletonRows.figma.tsx +22 -0
- package/src/components/Table/TableSkeletonRows.tsx +113 -0
- package/src/components/Table/index.ts +9 -0
- package/src/components/TableActionsMenu.tsx +58 -0
- package/src/components/TableCard.tsx +29 -0
- package/src/components/TableContainer/TableContainer.tsx +86 -0
- package/src/components/TableContainer/index.ts +2 -0
- package/src/components/TableControlBar/TableControlBar.tsx +156 -0
- package/src/components/TableControlBar/TableSelectionButton.tsx +57 -0
- package/src/components/TableControlBar/index.ts +13 -0
- package/src/components/TableControlBar/useTableControlBar.tsx +314 -0
- package/src/components/TableSelection/TableSelection.tsx +43 -0
- package/src/components/TableSelection/index.ts +5 -0
- package/src/components/Tabs/README.md +76 -0
- package/src/components/Tabs/Tabs.css.ts +54 -0
- package/src/components/Tabs/Tabs.figma.tsx +47 -0
- package/src/components/Tabs/Tabs.tsx +96 -0
- package/src/components/Tabs/index.ts +8 -0
- package/src/components/TextInput/README.md +98 -0
- package/src/components/TextInput/SearchTextInput.figma.tsx +22 -0
- package/src/components/TextInput/SearchTextInput.tsx +150 -0
- package/src/components/TextInput/TextInput.figma.tsx +44 -0
- package/src/components/TextInput/TextInput.tsx +42 -0
- package/src/components/TextInput/index.ts +4 -0
- package/src/components/ThemeSwitcher.figma.tsx +28 -0
- package/src/components/ThemeSwitcher.tsx +69 -0
- package/src/components/TrendBadge/TrendBadge.tsx +76 -0
- package/src/components/TrendBadge/index.ts +2 -0
- package/src/components/TruncatedText.tsx +115 -0
- package/src/components/Typography/Text.tsx +74 -0
- package/src/components/Typography/Title.tsx +100 -0
- package/src/components/Typography/index.ts +4 -0
- package/src/geist-fonts.ts +48 -0
- package/src/hooks/index.ts +31 -0
- package/src/hooks/useFilters.ts +152 -0
- package/src/hooks/useInfiniteScroll.ts +62 -0
- package/src/hooks/usePlatform.ts +33 -0
- package/src/hooks/useServerTable.ts +495 -0
- package/src/hooks/useTableSelection.ts +102 -0
- package/src/hooks/useTableSort.ts +259 -0
- package/src/index.ts +483 -0
- package/src/mantine.ts +25 -0
- package/src/theme/mantineVars.ts +12 -0
- package/src/theme/themeContract.css.ts +131 -0
- package/src/theme/themeVars.ts +31 -0
- package/src/theme.ts +168 -0
- package/src/tokens/color-types.ts +107 -0
- package/src/tokens/colors.ts +243 -0
- package/src/tokens/index.ts +14 -0
- package/src/tokens/radius.ts +17 -0
- package/src/tokens/semantic-colors.ts +224 -0
- package/src/tokens/semantic-tokens-css.ts +53 -0
- package/src/tokens/shadows.ts +11 -0
- package/src/tokens/spacing.ts +20 -0
- package/src/tokens/text-styles.ts +179 -0
- package/src/tokens/typography.ts +40 -0
- package/src/tokens/zIndex.ts +27 -0
- package/src/types/mantine-theme.d.ts +17 -0
- package/src/types/tanstack-table.d.ts +22 -0
- package/src/utils/avatar.ts +150 -0
- package/src/utils/chartHelpers.ts +53 -0
- package/src/utils/color-props.ts +77 -0
- package/src/utils/createDesignComponent.tsx +104 -0
- package/src/utils/nestFlatRows.ts +111 -0
- 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,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';
|