@varialkit/inlineedit 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/docs.md ADDED
@@ -0,0 +1,61 @@
1
+ # InlineEdit
2
+
3
+ InlineEdit provides a compact, in-place editing experience for short text values. It supports click-to-edit or
4
+ button-triggered editing, optional multiline preservation, and async saves.
5
+
6
+ ## Usage
7
+
8
+ ```tsx
9
+ import { InlineEdit } from "@solara/inlineedit";
10
+
11
+ export function Example() {
12
+ const [title, setTitle] = React.useState("Quarterly review");
13
+
14
+ return (
15
+ <InlineEdit
16
+ value={title}
17
+ onSave={async (next) => setTitle(next)}
18
+ editTrigger="click"
19
+ inputVariant="borderless"
20
+ />
21
+ );
22
+ }
23
+ ```
24
+
25
+ ## Editor Options
26
+
27
+ InlineEdit renders the existing form components for editing:
28
+
29
+ - `TextField` for single-line input
30
+ - `TextArea` for multiline input
31
+ - `Dropdown` when `editor="dropdown"`
32
+
33
+ Provide `dropdownOptions` when using the dropdown editor.
34
+
35
+ ## Icons
36
+
37
+ You can add an icon to the edit trigger button.
38
+
39
+ ```tsx
40
+ <InlineEdit
41
+ value="Status"
42
+ onSave={handleSave}
43
+ editIconLeft="arrow_swap_16"
44
+ editIconRight="arrow_chevron_right_16"
45
+ />
46
+ ```
47
+
48
+ ## Props
49
+
50
+ | Prop | Type | Default | Description |
51
+ | --- | --- | --- | --- |
52
+ | `value` | `string | number | ReactNode` | — | Displayed value. |
53
+ | `onSave` | `(value: string) => Promise<any> | void` | — | Called on save. |
54
+ | `editTrigger` | `"button" | "click"` | `"button"` | How editing is triggered. |
55
+ | `inputVariant` | `"borderless" | "default"` | `"borderless"` | Visual style for TextField/TextArea. |
56
+ | `editIconLeft` | `ButtonIcon` | | Optional icon for the edit button. |
57
+ | `editIconRight` | `ButtonIcon` | | Optional trailing icon for the edit button. |
58
+ | `allowMultilineWhenWrapped` | `boolean` | `false` | Switch to TextArea when the display wraps. |
59
+ | `fullWidth` | `boolean` | `true` | Whether the control stretches to its container. |
60
+ | `editor` | `"text" | "textarea" | "dropdown"` | `"text"` | Explicit editor choice. |
61
+ | `dropdownOptions` | `{ label: string; value: string }[]` | — | Dropdown options. |
@@ -0,0 +1 @@
1
+ export { stories } from "../examples";
package/examples.tsx ADDED
@@ -0,0 +1,269 @@
1
+ import React from "react";
2
+ import { iconNames } from "@solara/icons";
3
+ import type { SolaraIconName } from "@solara/icons";
4
+ import { InlineEdit } from "./src/InlineEdit";
5
+ import type { InlineEditProps } from "./src/InlineEdit.types";
6
+
7
+ const StatusOptions = [
8
+ { label: "Planned", value: "planned" },
9
+ { label: "In Progress", value: "in-progress" },
10
+ { label: "Complete", value: "complete" },
11
+ ];
12
+
13
+ export const stories = {
14
+ playground: {
15
+ title: "Playground",
16
+ description: "Tweak the props to explore the InlineEdit API.",
17
+ render: (props: InlineEditProps) => {
18
+ const [value, setValue] = React.useState(
19
+ typeof props.value === "string" || typeof props.value === "number"
20
+ ? String(props.value)
21
+ : "Edit me"
22
+ );
23
+
24
+ React.useEffect(() => {
25
+ if (typeof props.value === "string" || typeof props.value === "number") {
26
+ setValue(String(props.value));
27
+ }
28
+ }, [props.value]);
29
+
30
+ const editIconLeft = (props.editIconLeft as SolaraIconName) || undefined;
31
+ const editIconRight = (props.editIconRight as SolaraIconName) || undefined;
32
+
33
+ return (
34
+ <InlineEdit
35
+ {...props}
36
+ editIconLeft={editIconLeft}
37
+ editIconRight={editIconRight}
38
+ value={value}
39
+ onSave={async (next) => {
40
+ setValue(next);
41
+ }}
42
+ dropdownOptions={StatusOptions}
43
+ />
44
+ );
45
+ },
46
+ controls: [
47
+ { name: "value", type: "text" },
48
+ {
49
+ name: "editor",
50
+ type: "select",
51
+ options: ["text", "textarea", "dropdown"],
52
+ },
53
+ {
54
+ name: "editTrigger",
55
+ type: "select",
56
+ options: ["button", "click"],
57
+ },
58
+ {
59
+ name: "inputVariant",
60
+ type: "select",
61
+ options: ["borderless", "default"],
62
+ },
63
+ { name: "allowMultilineWhenWrapped", type: "boolean" },
64
+ { name: "fullWidth", type: "boolean" },
65
+ { name: "disabled", type: "boolean" },
66
+ {
67
+ name: "editIconLeft",
68
+ label: "Edit Icon",
69
+ type: "select",
70
+ options: ["", ...iconNames],
71
+ },
72
+ {
73
+ name: "editIconRight",
74
+ label: "Edit Icon Right",
75
+ type: "select",
76
+ options: ["", ...iconNames],
77
+ },
78
+ ],
79
+ initialProps: {
80
+ value: "Edit me",
81
+ editor: "text",
82
+ editTrigger: "button",
83
+ inputVariant: "borderless",
84
+ allowMultilineWhenWrapped: false,
85
+ fullWidth: true,
86
+ disabled: false,
87
+ editIconLeft: "",
88
+ editIconRight: "",
89
+ },
90
+ },
91
+ clickToEdit: {
92
+ title: "Click to Edit",
93
+ description: "Click the value to edit it inline.",
94
+ showProps: false,
95
+ render: () => {
96
+ const [title, setTitle] = React.useState("Quarterly review");
97
+ return (
98
+ <InlineEdit
99
+ value={title}
100
+ onSave={async (next) => setTitle(next)}
101
+ editTrigger="click"
102
+ inputVariant="borderless"
103
+ />
104
+ );
105
+ },
106
+ code: `import React from "react";
107
+ import { InlineEdit } from "@solara/inlineedit";
108
+
109
+ export function Example() {
110
+ const [title, setTitle] = React.useState("Quarterly review");
111
+
112
+ return (
113
+ <InlineEdit
114
+ value={title}
115
+ onSave={async (next) => setTitle(next)}
116
+ editTrigger="click"
117
+ inputVariant="borderless"
118
+ />
119
+ );
120
+ }
121
+ `,
122
+ },
123
+ dropdown: {
124
+ title: "Dropdown",
125
+ description: "Swap to a dropdown editor for known options.",
126
+ showProps: false,
127
+ render: () => {
128
+ const [status, setStatus] = React.useState("in-progress");
129
+ return (
130
+ <InlineEdit
131
+ value={status}
132
+ onSave={async (next) => setStatus(next)}
133
+ editTrigger="button"
134
+ editor="dropdown"
135
+ dropdownOptions={StatusOptions}
136
+ />
137
+ );
138
+ },
139
+ code: `import React from "react";
140
+ import { InlineEdit } from "@solara/inlineedit";
141
+
142
+ const StatusOptions = [
143
+ { label: "Planned", value: "planned" },
144
+ { label: "In Progress", value: "in-progress" },
145
+ { label: "Complete", value: "complete" },
146
+ ];
147
+
148
+ export function Example() {
149
+ const [status, setStatus] = React.useState("in-progress");
150
+
151
+ return (
152
+ <InlineEdit
153
+ value={status}
154
+ onSave={async (next) => setStatus(next)}
155
+ editTrigger="button"
156
+ editor="dropdown"
157
+ dropdownOptions={StatusOptions}
158
+ />
159
+ );
160
+ }
161
+ `,
162
+ },
163
+ multiline: {
164
+ title: "Multiline",
165
+ description: "Preserve line breaks when the text wraps.",
166
+ showProps: false,
167
+ render: () => {
168
+ const [summary, setSummary] = React.useState(
169
+ "A longer sentence that wraps across two lines when space is constrained."
170
+ );
171
+ return (
172
+ <div style={{ maxWidth: "320px" }}>
173
+ <InlineEdit
174
+ value={summary}
175
+ onSave={async (next) => setSummary(next)}
176
+ editTrigger="click"
177
+ allowMultilineWhenWrapped
178
+ />
179
+ </div>
180
+ );
181
+ },
182
+ code: `import React from "react";
183
+ import { InlineEdit } from "@solara/inlineedit";
184
+
185
+ export function Example() {
186
+ const [summary, setSummary] = React.useState(
187
+ "A longer sentence that wraps across two lines when space is constrained."
188
+ );
189
+
190
+ return (
191
+ <div style={{ maxWidth: "320px" }}>
192
+ <InlineEdit
193
+ value={summary}
194
+ onSave={async (next) => setSummary(next)}
195
+ editTrigger="click"
196
+ allowMultilineWhenWrapped
197
+ />
198
+ </div>
199
+ );
200
+ }
201
+ `,
202
+ },
203
+ fitContent: {
204
+ title: "Fit Content",
205
+ description: "Hug the text width for dense layouts.",
206
+ showProps: false,
207
+ render: () => {
208
+ const [title, setTitle] = React.useState("Short label");
209
+ return (
210
+ <InlineEdit
211
+ value={title}
212
+ onSave={async (next) => setTitle(next)}
213
+ fullWidth={false}
214
+ editTrigger="click"
215
+ />
216
+ );
217
+ },
218
+ code: `import React from "react";
219
+ import { InlineEdit } from "@solara/inlineedit";
220
+
221
+ export function Example() {
222
+ const [title, setTitle] = React.useState("Short label");
223
+
224
+ return (
225
+ <InlineEdit
226
+ value={title}
227
+ onSave={async (next) => setTitle(next)}
228
+ fullWidth={false}
229
+ editTrigger="click"
230
+ />
231
+ );
232
+ }
233
+ `,
234
+ },
235
+ editIcon: {
236
+ title: "Edit Button Icon",
237
+ description: "Add an icon to the edit trigger button.",
238
+ showProps: false,
239
+ render: () => {
240
+ const [title, setTitle] = React.useState("Project status");
241
+ return (
242
+ <InlineEdit
243
+ value={title}
244
+ onSave={async (next) => setTitle(next)}
245
+ editIconLeft="arrow_swap_16"
246
+ editIconRight="arrow_chevron_right_16"
247
+ editTrigger="button"
248
+ />
249
+ );
250
+ },
251
+ code: `import React from "react";
252
+ import { InlineEdit } from "@solara/inlineedit";
253
+
254
+ export function Example() {
255
+ const [title, setTitle] = React.useState("Project status");
256
+
257
+ return (
258
+ <InlineEdit
259
+ value={title}
260
+ onSave={async (next) => setTitle(next)}
261
+ editIconLeft="arrow_swap_16"
262
+ editIconRight="arrow_chevron_right_16"
263
+ editTrigger="button"
264
+ />
265
+ );
266
+ }
267
+ `,
268
+ },
269
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@varialkit/inlineedit",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples/index.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/button": "0.1.0",
13
+ "@varialkit/textfield": "0.1.0",
14
+ "@varialkit/icons": "0.1.0",
15
+ "@varialkit/dropdown": "0.1.0",
16
+ "@varialkit/textarea": "0.1.0"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "docs.md",
21
+ "examples",
22
+ "examples.tsx"
23
+ ],
24
+ "peerDependencies": {
25
+ "react": "^19.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "19.0.10",
29
+ "react": "19.0.0"
30
+ }
31
+ }
@@ -0,0 +1,99 @@
1
+ .solara-inline-edit {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: calc(var(--space-1) * var(--spacing-multiplier));
5
+ position: relative;
6
+ width: 100%;
7
+ min-height: 1.5rem;
8
+ padding: calc(var(--space-1) * var(--spacing-multiplier));
9
+ border-radius: var(--radius-1);
10
+ transition: background-color 0.2s ease, box-shadow 0.2s ease;
11
+ cursor: pointer;
12
+ }
13
+
14
+ .solara-inline-edit:hover:not(.solara-inline-edit--disabled) {
15
+ background-color: var(--color-surface-200);
16
+ }
17
+
18
+ .solara-inline-edit:focus-visible:not(.solara-inline-edit--disabled) {
19
+ outline: none;
20
+ box-shadow: 0 0 0 3px var(--color-focus-halo);
21
+ }
22
+
23
+ .solara-inline-edit--disabled {
24
+ cursor: not-allowed;
25
+ opacity: 0.6;
26
+ }
27
+
28
+ .solara-inline-edit--editing {
29
+ cursor: default;
30
+ background-color: transparent;
31
+ }
32
+
33
+ .solara-inline-edit__display {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ flex-grow: 1;
37
+ min-height: 1.5rem;
38
+ white-space: normal;
39
+ word-break: break-word;
40
+ color: var(--color-text-primary);
41
+ }
42
+
43
+ .solara-inline-edit__display--empty {
44
+ color: var(--color-text-secondary);
45
+ }
46
+
47
+ .solara-inline-edit__button {
48
+ flex-shrink: 0;
49
+ }
50
+
51
+ .solara-inline-edit--fit {
52
+ width: fit-content;
53
+ max-width: 100%;
54
+ flex: 0 0 auto;
55
+ }
56
+
57
+ .solara-inline-edit--fit .solara-inline-edit__display {
58
+ flex-grow: 0;
59
+ }
60
+
61
+ .solara-inline-edit .solara-textfield-wrapper,
62
+ .solara-inline-edit .solara-textarea-wrapper,
63
+ .solara-inline-edit .solara-dropdown-wrapper,
64
+ .solara-inline-edit .solara-textfield,
65
+ .solara-inline-edit .solara-textarea,
66
+ .solara-inline-edit .solara-dropdown {
67
+ width: 100%;
68
+ min-width: 0;
69
+ }
70
+
71
+ .solara-inline-edit--fit .solara-textfield-wrapper,
72
+ .solara-inline-edit--fit .solara-textarea-wrapper,
73
+ .solara-inline-edit--fit .solara-dropdown-wrapper,
74
+ .solara-inline-edit--fit .solara-textfield,
75
+ .solara-inline-edit--fit .solara-textarea,
76
+ .solara-inline-edit--fit .solara-dropdown {
77
+ width: auto;
78
+ }
79
+
80
+ .solara-inline-edit__measure {
81
+ position: absolute;
82
+ visibility: hidden;
83
+ pointer-events: none;
84
+ white-space: pre;
85
+ font: inherit;
86
+ line-height: inherit;
87
+ letter-spacing: inherit;
88
+ padding: 0;
89
+ border: 0;
90
+ }
91
+
92
+ .solara-inline-edit__error {
93
+ position: absolute;
94
+ top: 100%;
95
+ left: 0;
96
+ margin-top: calc(var(--space-1) * var(--spacing-multiplier));
97
+ font-size: var(--font-size-footnote-scaled);
98
+ color: var(--color-text-alert);
99
+ }
@@ -0,0 +1,429 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Button } from "@solara/button";
3
+ import type { ButtonSize, ButtonVariant } from "@solara/button";
4
+ import { Dropdown } from "@solara/dropdown";
5
+ import { TextArea } from "@solara/textarea";
6
+ import { TextField } from "@solara/textfield";
7
+ import type {
8
+ InlineEditEditor,
9
+ InlineEditProps,
10
+ InlineEditSize,
11
+ } from "./InlineEdit.types";
12
+ import "./InlineEdit.scss";
13
+
14
+ const buttonSizeMap: Record<InlineEditSize, ButtonSize> = {
15
+ xs: "small",
16
+ sm: "small",
17
+ default: "medium",
18
+ lg: "large",
19
+ icon: "small",
20
+ small: "small",
21
+ medium: "medium",
22
+ large: "large",
23
+ };
24
+
25
+ const inputSizeMap: Record<InlineEditSize, "small" | "medium" | "large"> = {
26
+ xs: "small",
27
+ sm: "small",
28
+ default: "medium",
29
+ lg: "large",
30
+ icon: "small",
31
+ small: "small",
32
+ medium: "medium",
33
+ large: "large",
34
+ };
35
+
36
+ const resolveButtonVariant = (
37
+ variant: InlineEditProps["variant"]
38
+ ): { variant: ButtonVariant; destructive: boolean } => {
39
+ switch (variant) {
40
+ case "ghost":
41
+ return { variant: "ghost", destructive: false };
42
+ case "outline":
43
+ return { variant: "tertiary", destructive: false };
44
+ case "link":
45
+ return { variant: "ghost", destructive: false };
46
+ case "destructive":
47
+ return { variant: "default", destructive: true };
48
+ case "default":
49
+ default:
50
+ return { variant: "default", destructive: false };
51
+ }
52
+ };
53
+
54
+ const resolveEditor = (
55
+ editor: InlineEditEditor | undefined,
56
+ useMultilineEditor: boolean
57
+ ): InlineEditEditor => {
58
+ if (editor === "dropdown" || editor === "textarea" || editor === "text") {
59
+ return editor;
60
+ }
61
+ return useMultilineEditor ? "textarea" : "text";
62
+ };
63
+
64
+ export const InlineEdit: React.FC<InlineEditProps> = ({
65
+ value,
66
+ onSave,
67
+ disabled = false,
68
+ className = "",
69
+ placeholder = "Click to edit",
70
+ children,
71
+ size = "sm",
72
+ variant = "ghost",
73
+ editLabel = "Edit",
74
+ editIconLeft,
75
+ editIconRight,
76
+ editTrigger = "button",
77
+ inputVariant = "borderless",
78
+ editing,
79
+ onEditingChange,
80
+ allowMultilineWhenWrapped = false,
81
+ fullWidth = true,
82
+ editor,
83
+ dropdownOptions,
84
+ dropdownVariant = "default",
85
+ }: InlineEditProps) => {
86
+ const [isEditingInternal, setIsEditingInternal] = useState(false);
87
+ const isEditing = typeof editing === "boolean" ? editing : isEditingInternal;
88
+ const setEditing = (val: boolean) => {
89
+ if (typeof editing === "boolean") {
90
+ onEditingChange?.(val);
91
+ } else {
92
+ setIsEditingInternal(val);
93
+ }
94
+ };
95
+
96
+ const [isSaving, setIsSaving] = useState(false);
97
+ const [error, setError] = useState<string | null>(null);
98
+ const [inputValue, setInputValue] = useState<string>(
99
+ typeof value === "string" || typeof value === "number"
100
+ ? String(value)
101
+ : ""
102
+ );
103
+ const containerRef = useRef<HTMLDivElement>(null);
104
+ const displayRef = useRef<HTMLDivElement>(null);
105
+ const [tempDisplayValue, setTempDisplayValue] = useState<string | null>(null);
106
+ const [expectedValueAfterSave, setExpectedValueAfterSave] = useState<string | null>(
107
+ null
108
+ );
109
+ const [useMultilineEditor, setUseMultilineEditor] = useState(false);
110
+ const [initialRows, setInitialRows] = useState<number>(1);
111
+ const measureRef = useRef<HTMLSpanElement>(null);
112
+ const [measuredInputWidth, setMeasuredInputWidth] = useState<number | null>(
113
+ null
114
+ );
115
+
116
+ const resolvedButtonSize = buttonSizeMap[size];
117
+ const resolvedInputSize = inputSizeMap[size];
118
+ const buttonVariantConfig = resolveButtonVariant(variant);
119
+ const resolvedEditor = resolveEditor(editor, useMultilineEditor);
120
+ const isStringValue = typeof value === "string" || typeof value === "number";
121
+
122
+ useEffect(() => {
123
+ setInputValue(
124
+ typeof value === "string" || typeof value === "number" ? String(value) : ""
125
+ );
126
+ }, [value]);
127
+
128
+ useEffect(() => {
129
+ const currentPropValue =
130
+ typeof value === "string" || typeof value === "number"
131
+ ? String(value).trim()
132
+ : "";
133
+ if (expectedValueAfterSave && currentPropValue === expectedValueAfterSave) {
134
+ setTempDisplayValue(null);
135
+ setExpectedValueAfterSave(null);
136
+ }
137
+ }, [value, expectedValueAfterSave]);
138
+
139
+ useEffect(() => {
140
+ if (tempDisplayValue) {
141
+ const t = setTimeout(() => setTempDisplayValue(null), 2500);
142
+ return () => clearTimeout(t);
143
+ }
144
+ }, [tempDisplayValue]);
145
+
146
+ useEffect(() => {
147
+ if (!isEditing) return;
148
+ const input = containerRef.current?.querySelector(
149
+ "input, textarea, select"
150
+ );
151
+ if (!input) return;
152
+
153
+ if (input instanceof HTMLTextAreaElement) {
154
+ input.style.height = "auto";
155
+ input.style.height = `${input.scrollHeight}px`;
156
+ }
157
+
158
+ input.focus();
159
+ if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
160
+ input.select?.();
161
+ }
162
+ }, [isEditing, resolvedEditor]);
163
+
164
+ useLayoutEffect(() => {
165
+ if (isEditing && !fullWidth && resolvedEditor === "text") {
166
+ const el = measureRef.current;
167
+ if (el) {
168
+ el.textContent = inputValue || placeholder || "";
169
+ const width = el.scrollWidth;
170
+ const clamped = Math.min(Math.max(width, 8), 10000);
171
+ setMeasuredInputWidth(clamped);
172
+ }
173
+ } else {
174
+ setMeasuredInputWidth(null);
175
+ }
176
+ }, [isEditing, inputValue, fullWidth, placeholder, resolvedEditor]);
177
+
178
+ const handleSave = async (overrideValue?: string) => {
179
+ if (disabled || isSaving) return;
180
+
181
+ const nextValue = String(overrideValue ?? inputValue ?? "");
182
+ const trimmedValue = nextValue.trim();
183
+ const currentValue =
184
+ typeof value === "string" || typeof value === "number"
185
+ ? String(value).trim()
186
+ : "";
187
+
188
+ if (trimmedValue === currentValue) {
189
+ setEditing(false);
190
+ return;
191
+ }
192
+
193
+ try {
194
+ setIsSaving(true);
195
+ setError(null);
196
+ setExpectedValueAfterSave(trimmedValue);
197
+ setTempDisplayValue(trimmedValue);
198
+ await onSave(trimmedValue);
199
+ setEditing(false);
200
+ } catch (err) {
201
+ setError(err instanceof Error ? err.message : "Failed to save");
202
+ setTempDisplayValue(null);
203
+ setExpectedValueAfterSave(null);
204
+ } finally {
205
+ setIsSaving(false);
206
+ }
207
+ };
208
+
209
+ const handleKeyDown = (e: React.KeyboardEvent) => {
210
+ if (resolvedEditor === "dropdown") return;
211
+
212
+ if (e.key === "Enter") {
213
+ e.preventDefault();
214
+ handleSave();
215
+ } else if (e.key === "Escape") {
216
+ setInputValue(
217
+ typeof value === "string" || typeof value === "number"
218
+ ? String(value)
219
+ : ""
220
+ );
221
+ setEditing(false);
222
+ }
223
+ };
224
+
225
+ const measureLines = () => {
226
+ if (!allowMultilineWhenWrapped || !displayRef.current) return { lines: 1 };
227
+ const el = displayRef.current;
228
+ const rect = el.getBoundingClientRect();
229
+ const stylesComp = window.getComputedStyle(el);
230
+ const lineHeightStr = stylesComp.lineHeight;
231
+ const lineHeight = lineHeightStr.endsWith("px")
232
+ ? parseFloat(lineHeightStr)
233
+ : el.clientHeight;
234
+ const lines = Math.max(1, Math.round(rect.height / (lineHeight || 1)));
235
+ return { lines };
236
+ };
237
+
238
+ const startEditingWithMeasurement = (e?: React.MouseEvent) => {
239
+ if (disabled) return;
240
+ e?.stopPropagation();
241
+
242
+ const shouldForceTextarea = editor === "textarea";
243
+ const shouldUseDropdown = editor === "dropdown";
244
+ const { lines } = measureLines();
245
+ const shouldUseMultiline =
246
+ shouldForceTextarea ||
247
+ (!shouldUseDropdown && allowMultilineWhenWrapped && lines > 1);
248
+
249
+ setUseMultilineEditor(shouldUseMultiline);
250
+ setInitialRows(Math.min(Math.max(lines, 1), 6));
251
+
252
+ if (!fullWidth && !shouldUseMultiline && !shouldUseDropdown) {
253
+ const span = measureRef.current;
254
+ if (span) {
255
+ span.textContent = (inputValue || placeholder || "") as string;
256
+ const width = span.scrollWidth;
257
+ const clamped = Math.min(Math.max(width, 8), 10000);
258
+ setMeasuredInputWidth(clamped);
259
+ }
260
+ }
261
+
262
+ setEditing(true);
263
+ };
264
+
265
+ const handleDisplayClick = (e?: React.MouseEvent) => {
266
+ e?.stopPropagation();
267
+ startEditingWithMeasurement(e);
268
+ };
269
+
270
+ const rootClasses = [
271
+ "solara-inline-edit",
272
+ isEditing ? "solara-inline-edit--editing" : null,
273
+ !fullWidth ? "solara-inline-edit--fit" : null,
274
+ disabled ? "solara-inline-edit--disabled" : null,
275
+ className,
276
+ ]
277
+ .filter(Boolean)
278
+ .join(" ");
279
+
280
+ if (isEditing) {
281
+ const inputStyle =
282
+ !fullWidth && measuredInputWidth && resolvedEditor === "text"
283
+ ? { width: `${measuredInputWidth}px` }
284
+ : undefined;
285
+
286
+ return (
287
+ <div
288
+ className={rootClasses}
289
+ ref={containerRef}
290
+ role="group"
291
+ aria-busy={isSaving || undefined}
292
+ >
293
+ <span
294
+ ref={measureRef}
295
+ className="solara-inline-edit__measure"
296
+ aria-hidden="true"
297
+ />
298
+ {resolvedEditor === "dropdown" ? (
299
+ <Dropdown
300
+ options={dropdownOptions ?? []}
301
+ value={inputValue}
302
+ onChange={(e) => {
303
+ const next = e.target.value;
304
+ setInputValue(next);
305
+ handleSave(next);
306
+ }}
307
+ size={resolvedInputSize}
308
+ variant={dropdownVariant}
309
+ isDisabled={isSaving}
310
+ />
311
+ ) : resolvedEditor === "textarea" ? (
312
+ <TextArea
313
+ value={inputValue}
314
+ rows={initialRows}
315
+ onChange={(e) => {
316
+ setInputValue(e.target.value);
317
+ const ta = e.target;
318
+ ta.style.height = "auto";
319
+ ta.style.height = `${ta.scrollHeight}px`;
320
+ }}
321
+ onKeyDown={handleKeyDown}
322
+ onBlur={() => handleSave()}
323
+ isDisabled={isSaving}
324
+ variant={inputVariant === "borderless" ? "quiet" : "default"}
325
+ size={resolvedInputSize}
326
+ />
327
+ ) : (
328
+ <TextField
329
+ type="text"
330
+ value={inputValue}
331
+ onChange={(e) => setInputValue(e.target.value)}
332
+ onKeyDown={handleKeyDown}
333
+ onBlur={() => handleSave()}
334
+ isDisabled={isSaving}
335
+ variant={inputVariant === "borderless" ? "quiet" : "default"}
336
+ size={resolvedInputSize}
337
+ style={inputStyle}
338
+ />
339
+ )}
340
+ </div>
341
+ );
342
+ }
343
+
344
+ const effectiveDisplayValue = tempDisplayValue ?? value;
345
+ const dropdownLabel =
346
+ resolvedEditor === "dropdown" &&
347
+ dropdownOptions &&
348
+ (typeof effectiveDisplayValue === "string" ||
349
+ typeof effectiveDisplayValue === "number")
350
+ ? dropdownOptions.find(
351
+ (option) => option.value === String(effectiveDisplayValue)
352
+ )?.label
353
+ : null;
354
+ const hasDisplayValue =
355
+ typeof effectiveDisplayValue === "string" ||
356
+ typeof effectiveDisplayValue === "number"
357
+ ? String(effectiveDisplayValue).length > 0
358
+ : Boolean(effectiveDisplayValue);
359
+
360
+ const displayValue = children ? (
361
+ <div className="solara-inline-edit__display" ref={displayRef}>
362
+ {children({ isEditing: false })}
363
+ </div>
364
+ ) : (
365
+ <div
366
+ className={[
367
+ "solara-inline-edit__display",
368
+ !hasDisplayValue ? "solara-inline-edit__display--empty" : null,
369
+ ]
370
+ .filter(Boolean)
371
+ .join(" ")}
372
+ ref={displayRef}
373
+ >
374
+ {hasDisplayValue ? dropdownLabel ?? effectiveDisplayValue : placeholder}
375
+ </div>
376
+ );
377
+
378
+ return (
379
+ <div
380
+ className={rootClasses}
381
+ onClick={editTrigger === "click" ? handleDisplayClick : undefined}
382
+ role={editTrigger === "click" && !disabled ? "button" : undefined}
383
+ tabIndex={editTrigger === "click" && !disabled ? 0 : undefined}
384
+ onKeyDown={(e) => {
385
+ if (
386
+ !disabled &&
387
+ editTrigger === "click" &&
388
+ (e.key === "Enter" || e.key === " ")
389
+ ) {
390
+ e.preventDefault();
391
+ e.stopPropagation();
392
+ startEditingWithMeasurement();
393
+ }
394
+ }}
395
+ >
396
+ <span
397
+ ref={measureRef}
398
+ className="solara-inline-edit__measure"
399
+ aria-hidden="true"
400
+ />
401
+ {displayValue}
402
+
403
+ {editTrigger === "button" && isStringValue && !disabled ? (
404
+ <Button
405
+ className="solara-inline-edit__button"
406
+ size={resolvedButtonSize}
407
+ variant={buttonVariantConfig.variant}
408
+ destructive={buttonVariantConfig.destructive}
409
+ label={editLabel}
410
+ iconLeft={editIconLeft}
411
+ iconRight={editIconRight}
412
+ onClick={(e) => {
413
+ e.preventDefault();
414
+ e.stopPropagation();
415
+ startEditingWithMeasurement(e);
416
+ }}
417
+ aria-label={editLabel}
418
+ />
419
+ ) : null}
420
+ {error ? (
421
+ <span className="solara-inline-edit__error" role="alert">
422
+ {error}
423
+ </span>
424
+ ) : null}
425
+ </div>
426
+ );
427
+ };
428
+
429
+ export default InlineEdit;
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+ import type { ButtonIcon } from "@solara/button";
3
+
4
+ export type InlineEditSize =
5
+ | "xs"
6
+ | "sm"
7
+ | "default"
8
+ | "lg"
9
+ | "icon"
10
+ | "small"
11
+ | "medium"
12
+ | "large";
13
+
14
+ export type InlineEditButtonVariant =
15
+ | "ghost"
16
+ | "outline"
17
+ | "default"
18
+ | "link"
19
+ | "destructive";
20
+
21
+ export type InlineEditInputVariant = "borderless" | "default";
22
+
23
+ export type InlineEditEditor = "text" | "textarea" | "dropdown";
24
+
25
+ export type InlineEditDropdownOption = {
26
+ label: string;
27
+ value: string;
28
+ };
29
+
30
+ export type InlineEditProps = {
31
+ /** The current value to display. */
32
+ value: string | number | React.ReactNode;
33
+ /** Callback when the value is saved. */
34
+ onSave: (value: string) => Promise<any> | void;
35
+ /** Whether the field is disabled. */
36
+ disabled?: boolean;
37
+ /** Placeholder text when value is empty. */
38
+ placeholder?: string;
39
+ /** Additional class names. */
40
+ className?: string;
41
+ /** Size of the component. */
42
+ size?: InlineEditSize;
43
+ /** Variant of the edit button. */
44
+ variant?: InlineEditButtonVariant;
45
+ /** Label for the edit button. */
46
+ editLabel?: string;
47
+ /** Optional icon to render on the edit button. */
48
+ editIconLeft?: ButtonIcon;
49
+ /** Optional trailing icon to render on the edit button. */
50
+ editIconRight?: ButtonIcon;
51
+ /** Children to render in display mode. */
52
+ children?: (props: { isEditing: boolean }) => React.ReactNode;
53
+ /** Whether clicking the display area should trigger editing. */
54
+ editTrigger?: "button" | "click";
55
+ /** Input style variant. */
56
+ inputVariant?: InlineEditInputVariant;
57
+ /** Optional controlled editing state. */
58
+ editing?: boolean;
59
+ /** Optional handler for controlled editing state changes. */
60
+ onEditingChange?: (editing: boolean) => void;
61
+ /** Allow switching to multiline editor when display wraps. */
62
+ allowMultilineWhenWrapped?: boolean;
63
+ /** Control container width, when false the component hugs content width. */
64
+ fullWidth?: boolean;
65
+ /** Which editor to render when editing. */
66
+ editor?: InlineEditEditor;
67
+ /** Options for dropdown editor. */
68
+ dropdownOptions?: InlineEditDropdownOption[];
69
+ /** Variant for dropdown editor. */
70
+ dropdownVariant?: "primary" | "default" | "tertiary" | "ghost";
71
+ };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./InlineEdit";
2
+ export type { InlineEditProps } from "./InlineEdit.types";