@varialkit/editabletitle 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 +51 -0
- package/examples/index.tsx +249 -0
- package/package.json +27 -0
- package/src/EditableTitle.scss +193 -0
- package/src/EditableTitle.tsx +140 -0
- package/src/EditableTitle.types.ts +36 -0
- package/src/index.ts +6 -0
package/docs.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# EditableTitle
|
|
2
|
+
|
|
3
|
+
EditableTitle provides inline editing for short text labels such as document titles. It supports async saves, keyboard
|
|
4
|
+
shortcuts, and optional read-only mode.
|
|
5
|
+
|
|
6
|
+
## How to Use
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
import { EditableTitle } from "@solara/editabletitle";
|
|
10
|
+
|
|
11
|
+
export function Example() {
|
|
12
|
+
const handleSave = async (nextTitle: string) => {
|
|
13
|
+
await api.updateTitle(nextTitle);
|
|
14
|
+
return true;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
return <EditableTitle title="Quarterly Report" onSave={handleSave} />;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Best Practices
|
|
22
|
+
|
|
23
|
+
- Keep titles short so they remain scannable when not focused.
|
|
24
|
+
- Return `false` from `onSave` to rollback on errors.
|
|
25
|
+
- Use `readOnly` when the title should not be editable.
|
|
26
|
+
|
|
27
|
+
## Props
|
|
28
|
+
|
|
29
|
+
| Prop | Type | Default | Description |
|
|
30
|
+
| --- | --- | --- | --- |
|
|
31
|
+
| `title` | `string` | _Required_ | Current title text. |
|
|
32
|
+
| `onSave` | `(newTitle: string) => Promise<boolean>` | _Required_ | Async save handler; return `true` to accept changes. |
|
|
33
|
+
| `readOnly` | `boolean` | `false` | Prevent editing when true. |
|
|
34
|
+
| `size` | `"xs" \| "sm" \| "base" \| "lg" \| "xl" \| "2xl" \| "3xl" \| "4xl" \| "5xl" \| "6xl"` | `"lg"` | Typography size. |
|
|
35
|
+
| `weight` | `"normal" \| "medium" \| "semibold" \| "bold"` | | Font weight. |
|
|
36
|
+
| `fullWidth` | `boolean` | `false` | When `true`, the component will take up the full width of its container. |
|
|
37
|
+
| `iconLeft` | `SolaraIconName \| IconProps` | | Optional leading icon before the title. |
|
|
38
|
+
| `className` | `string` | | Custom class name. |
|
|
39
|
+
|
|
40
|
+
## Accessibility
|
|
41
|
+
|
|
42
|
+
- The button uses a clear focus ring for keyboard navigation.
|
|
43
|
+
- The input preserves the current title value and supports Enter/Escape actions.
|
|
44
|
+
|
|
45
|
+
## Icons
|
|
46
|
+
|
|
47
|
+
You can render a leading icon before the title. Icons inherit the title text color.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
<EditableTitle title="Project Alpha" onSave={handleSave} iconLeft="data_spreadsheet_search_24" />
|
|
51
|
+
```
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import { iconNames } from "@solara/icons";
|
|
4
|
+
import type { SolaraIconName } from "@solara/icons";
|
|
5
|
+
import { EditableTitle } from "../src/EditableTitle";
|
|
6
|
+
import type { EditableTitleSize, EditableTitleWeight } from "../src/EditableTitle.types";
|
|
7
|
+
|
|
8
|
+
type StoryControlOption = {
|
|
9
|
+
label: string;
|
|
10
|
+
value: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type StoryControl = {
|
|
14
|
+
name: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
type: "select" | "text" | "boolean" | "number";
|
|
17
|
+
options?: Array<string | StoryControlOption>;
|
|
18
|
+
min?: number;
|
|
19
|
+
max?: number;
|
|
20
|
+
step?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type StoryDefinition = {
|
|
24
|
+
title: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
render: (props: Record<string, unknown>) => ReactElement;
|
|
27
|
+
controls?: StoryControl[];
|
|
28
|
+
initialProps?: Record<string, unknown>;
|
|
29
|
+
showProps?: boolean;
|
|
30
|
+
applyPropsToPreview?: boolean;
|
|
31
|
+
code?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const simulateSave = async (nextTitle: string) => {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
36
|
+
return Boolean(nextTitle);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const stories: Record<string, StoryDefinition> = {
|
|
40
|
+
playground: {
|
|
41
|
+
title: "Playground",
|
|
42
|
+
description: "Edit the title inline and explore typography options.",
|
|
43
|
+
render: (props) => (
|
|
44
|
+
<EditableTitlePlayground
|
|
45
|
+
title={(props.title as string) ?? "Project Alpha"}
|
|
46
|
+
size={props.size as EditableTitleSize}
|
|
47
|
+
weight={props.weight as EditableTitleWeight}
|
|
48
|
+
readOnly={props.readOnly as boolean}
|
|
49
|
+
fullWidth={props.fullWidth as boolean}
|
|
50
|
+
disabled={props.disabled as boolean}
|
|
51
|
+
iconLeft={(props.iconLeft as SolaraIconName) || undefined}
|
|
52
|
+
/>
|
|
53
|
+
),
|
|
54
|
+
controls: [
|
|
55
|
+
{ name: "title", label: "Title", type: "text" },
|
|
56
|
+
{
|
|
57
|
+
name: "size",
|
|
58
|
+
label: "Size",
|
|
59
|
+
type: "select",
|
|
60
|
+
options: ["xs", "sm", "base", "lg", "xl", "2xl", "3xl"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "weight",
|
|
64
|
+
label: "Weight",
|
|
65
|
+
type: "select",
|
|
66
|
+
options: ["normal", "medium", "semibold", "bold"],
|
|
67
|
+
},
|
|
68
|
+
{ name: "readOnly", label: "Read Only", type: "boolean" },
|
|
69
|
+
{ name: "fullWidth", label: "Full Width", type: "boolean" },
|
|
70
|
+
{ name: "disabled", label: "Disabled", type: "boolean" },
|
|
71
|
+
{
|
|
72
|
+
name: "iconLeft",
|
|
73
|
+
label: "Icon Left",
|
|
74
|
+
type: "select",
|
|
75
|
+
options: ["", ...iconNames],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
initialProps: {
|
|
79
|
+
title: "Project Alpha",
|
|
80
|
+
size: "lg",
|
|
81
|
+
weight: "semibold",
|
|
82
|
+
readOnly: false,
|
|
83
|
+
fullWidth: false,
|
|
84
|
+
disabled: false,
|
|
85
|
+
iconLeft: "",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
sizes: {
|
|
89
|
+
title: "Sizes",
|
|
90
|
+
showProps: false,
|
|
91
|
+
render: () => (
|
|
92
|
+
<div style={{ display: "grid", gap: "0.75rem" }}>
|
|
93
|
+
<EditableTitle title="Heading XS" onSave={simulateSave} size="xs" />
|
|
94
|
+
<EditableTitle title="Heading SM" onSave={simulateSave} size="sm" />
|
|
95
|
+
<EditableTitle title="Heading Base" onSave={simulateSave} size="base" />
|
|
96
|
+
<EditableTitle title="Heading LG" onSave={simulateSave} size="lg" />
|
|
97
|
+
<EditableTitle title="Heading XL" onSave={simulateSave} size="xl" />
|
|
98
|
+
<EditableTitle title="Heading 2XL" onSave={simulateSave} size="2xl" />
|
|
99
|
+
</div>
|
|
100
|
+
),
|
|
101
|
+
code: `import { EditableTitle } from "@solara/editabletitle";
|
|
102
|
+
|
|
103
|
+
const simulateSave = async (nextTitle: string) => {
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
105
|
+
return Boolean(nextTitle);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function Example() {
|
|
109
|
+
return (
|
|
110
|
+
<div style={{ display: "grid", gap: "0.75rem" }}>
|
|
111
|
+
<EditableTitle title="Heading XS" onSave={simulateSave} size="xs" />
|
|
112
|
+
<EditableTitle title="Heading SM" onSave={simulateSave} size="sm" />
|
|
113
|
+
<EditableTitle title="Heading Base" onSave={simulateSave} size="base" />
|
|
114
|
+
<EditableTitle title="Heading LG" onSave={simulateSave} size="lg" />
|
|
115
|
+
<EditableTitle title="Heading XL" onSave={simulateSave} size="xl" />
|
|
116
|
+
<EditableTitle title="Heading 2XL" onSave={simulateSave} size="2xl" />
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
`,
|
|
121
|
+
},
|
|
122
|
+
readonly: {
|
|
123
|
+
title: "Read Only",
|
|
124
|
+
showProps: false,
|
|
125
|
+
render: () => (
|
|
126
|
+
<EditableTitle
|
|
127
|
+
title="Read-only title"
|
|
128
|
+
onSave={simulateSave}
|
|
129
|
+
readOnly
|
|
130
|
+
weight="medium"
|
|
131
|
+
/>
|
|
132
|
+
),
|
|
133
|
+
code: `import { EditableTitle } from "@solara/editabletitle";
|
|
134
|
+
|
|
135
|
+
const simulateSave = async (nextTitle: string) => {
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
137
|
+
return Boolean(nextTitle);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function Example() {
|
|
141
|
+
return (
|
|
142
|
+
<EditableTitle
|
|
143
|
+
title="Read-only title"
|
|
144
|
+
onSave={simulateSave}
|
|
145
|
+
readOnly
|
|
146
|
+
weight="medium"
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
`,
|
|
151
|
+
},
|
|
152
|
+
icons: {
|
|
153
|
+
title: "Icons",
|
|
154
|
+
showProps: false,
|
|
155
|
+
render: () => (
|
|
156
|
+
<div style={{ display: "grid", gap: "0.75rem" }}>
|
|
157
|
+
<EditableTitle title="Design systems" onSave={simulateSave} iconLeft="data_spreadsheet_search_24" />
|
|
158
|
+
<EditableTitle title="Launch plan" onSave={simulateSave} iconLeft="arrow_line_up_16" />
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
code: `import { EditableTitle } from "@solara/editabletitle";
|
|
162
|
+
|
|
163
|
+
const simulateSave = async (nextTitle: string) => {
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
165
|
+
return Boolean(nextTitle);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export function Example() {
|
|
169
|
+
return (
|
|
170
|
+
<div style={{ display: "grid", gap: "0.75rem" }}>
|
|
171
|
+
<EditableTitle title="Design systems" onSave={simulateSave} iconLeft="data_spreadsheet_search_24" />
|
|
172
|
+
<EditableTitle title="Launch plan" onSave={simulateSave} iconLeft="arrow_line_up_16" />
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
`,
|
|
177
|
+
},
|
|
178
|
+
fullWidth: {
|
|
179
|
+
title: "Full Width",
|
|
180
|
+
showProps: false,
|
|
181
|
+
render: () => (
|
|
182
|
+
<div style={{ display: "grid", gap: "0.75rem", width: "400px" }}>
|
|
183
|
+
<EditableTitle title="This is a long title that should wrap" onSave={simulateSave} fullWidth />
|
|
184
|
+
</div>
|
|
185
|
+
),
|
|
186
|
+
code: `import { EditableTitle } from "@solara/editabletitle";
|
|
187
|
+
|
|
188
|
+
const simulateSave = async (nextTitle: string) => {
|
|
189
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
190
|
+
return Boolean(nextTitle);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export function Example() {
|
|
194
|
+
return (
|
|
195
|
+
<div style={{ display: "grid", gap: "0.75rem", width: "400px" }}>
|
|
196
|
+
<EditableTitle title="This is a long title that should wrap" onSave={simulateSave} fullWidth />
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
`,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
type EditableTitlePlaygroundProps = {
|
|
205
|
+
title: string;
|
|
206
|
+
size?: EditableTitleSize;
|
|
207
|
+
weight?: EditableTitleWeight;
|
|
208
|
+
readOnly?: boolean;
|
|
209
|
+
fullWidth?: boolean;
|
|
210
|
+
disabled?: boolean;
|
|
211
|
+
iconLeft?: SolaraIconName;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const EditableTitlePlayground = ({
|
|
215
|
+
title,
|
|
216
|
+
size,
|
|
217
|
+
weight,
|
|
218
|
+
readOnly,
|
|
219
|
+
fullWidth,
|
|
220
|
+
disabled,
|
|
221
|
+
iconLeft,
|
|
222
|
+
}: EditableTitlePlaygroundProps) => {
|
|
223
|
+
const [currentTitle, setCurrentTitle] = React.useState(title);
|
|
224
|
+
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
setCurrentTitle(title);
|
|
227
|
+
}, [title]);
|
|
228
|
+
|
|
229
|
+
const handleSave = async (nextTitle: string) => {
|
|
230
|
+
const ok = await simulateSave(nextTitle);
|
|
231
|
+
if (ok) {
|
|
232
|
+
setCurrentTitle(nextTitle);
|
|
233
|
+
}
|
|
234
|
+
return ok;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<EditableTitle
|
|
239
|
+
title={currentTitle}
|
|
240
|
+
onSave={handleSave}
|
|
241
|
+
size={size}
|
|
242
|
+
weight={weight}
|
|
243
|
+
readOnly={readOnly}
|
|
244
|
+
fullWidth={fullWidth}
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
iconLeft={iconLeft}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@varialkit/editabletitle",
|
|
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/icons": "0.1.0"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"docs.md",
|
|
17
|
+
"examples",
|
|
18
|
+
"examples.tsx"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": "^19.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "19.0.10",
|
|
25
|
+
"react": "19.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
.solara-editable-title {
|
|
2
|
+
--editable-title-font-size: var(--font-size-h5-scaled);
|
|
3
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
4
|
+
|
|
5
|
+
position: relative;
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
width: auto;
|
|
8
|
+
max-width: 100%;
|
|
9
|
+
color: var(--color-text-primary);
|
|
10
|
+
font-family: var(--font-body);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.solara-editable-title--full-width {
|
|
14
|
+
width: 100%;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.solara-editable-title__input,
|
|
18
|
+
.solara-editable-title__button {
|
|
19
|
+
font-size: var(--editable-title-font-size);
|
|
20
|
+
line-height: var(--editable-title-line-height);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.solara-editable-title__input {
|
|
24
|
+
background: transparent;
|
|
25
|
+
border: none;
|
|
26
|
+
outline: none;
|
|
27
|
+
padding: calc(var(--space-1) * var(--spacing-multiplier))
|
|
28
|
+
calc(var(--space-2) * var(--spacing-multiplier));
|
|
29
|
+
margin: calc(var(--space-1) * -1 * var(--spacing-multiplier))
|
|
30
|
+
calc(var(--space-2) * -1 * var(--spacing-multiplier));
|
|
31
|
+
width: calc(100% + (var(--space-2) * 2 * var(--spacing-multiplier)));
|
|
32
|
+
transition: color 0.2s ease, background-color 0.2s ease;
|
|
33
|
+
color: inherit;
|
|
34
|
+
font-family: inherit;
|
|
35
|
+
display: block;
|
|
36
|
+
border-radius: var(--radius-2);
|
|
37
|
+
|
|
38
|
+
&::placeholder {
|
|
39
|
+
color: var(--color-text-secondary);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.solara-editable-title__button {
|
|
44
|
+
background: none;
|
|
45
|
+
border: none;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
text-align: left;
|
|
48
|
+
padding: calc(var(--space-1) * var(--spacing-multiplier))
|
|
49
|
+
calc(var(--space-2) * var(--spacing-multiplier));
|
|
50
|
+
margin: calc(var(--space-1) * -1 * var(--spacing-multiplier))
|
|
51
|
+
calc(var(--space-2) * -1 * var(--spacing-multiplier));
|
|
52
|
+
border-radius: var(--radius-2);
|
|
53
|
+
transition: background-color 0.2s ease, color 0.2s ease;
|
|
54
|
+
width: 100%;
|
|
55
|
+
color: inherit;
|
|
56
|
+
font-family: inherit;
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: calc(var(--space-2) * var(--spacing-multiplier));
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
text-overflow: ellipsis;
|
|
63
|
+
|
|
64
|
+
&:hover:not(:disabled) {
|
|
65
|
+
background-color: var(--color-surface-100);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&:focus-visible {
|
|
69
|
+
outline: none;
|
|
70
|
+
box-shadow: 0 0 0 3px var(--color-focus-halo);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
&:disabled {
|
|
74
|
+
cursor: default;
|
|
75
|
+
background-color: transparent;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.solara-editable-title__icon {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
color: currentColor;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.solara-editable-title__icon .solara-icon [stroke]:not([stroke="none"]) {
|
|
88
|
+
stroke: currentColor;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.solara-editable-title__icon .solara-icon [fill]:not([fill="none"]) {
|
|
92
|
+
fill: currentColor;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.solara-editable-title__label {
|
|
96
|
+
min-width: 0;
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
text-overflow: ellipsis;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
.solara-editable-title__spinner {
|
|
104
|
+
position: absolute;
|
|
105
|
+
right: calc(var(--space-2) * var(--spacing-multiplier));
|
|
106
|
+
top: 50%;
|
|
107
|
+
width: calc(var(--space-3) * var(--spacing-multiplier));
|
|
108
|
+
height: calc(var(--space-3) * var(--spacing-multiplier));
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
border: 2px solid var(--color-divider-secondary);
|
|
111
|
+
border-top-color: var(--color-accent-primary);
|
|
112
|
+
transform: translateY(-50%);
|
|
113
|
+
animation: solara-editable-title-spin 1s linear infinite;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.solara-editable-title--size-xs {
|
|
117
|
+
--editable-title-font-size: var(--font-size-footnote-scaled);
|
|
118
|
+
--editable-title-line-height: var(--line-height-footnote-scaled);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.solara-editable-title--size-sm {
|
|
122
|
+
--editable-title-font-size: var(--font-size-caption-scaled);
|
|
123
|
+
--editable-title-line-height: var(--line-height-caption-scaled);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.solara-editable-title--size-base {
|
|
127
|
+
--editable-title-font-size: var(--font-size-body-scaled);
|
|
128
|
+
--editable-title-line-height: var(--line-height-body-scaled);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.solara-editable-title--size-lg {
|
|
132
|
+
--editable-title-font-size: var(--font-size-subhead-scaled);
|
|
133
|
+
--editable-title-line-height: var(--line-height-body-scaled);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.solara-editable-title--size-xl {
|
|
137
|
+
--editable-title-font-size: var(--font-size-h5-scaled);
|
|
138
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.solara-editable-title--size-2xl {
|
|
142
|
+
--editable-title-font-size: var(--font-size-h4-scaled);
|
|
143
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.solara-editable-title--size-3xl {
|
|
147
|
+
--editable-title-font-size: var(--font-size-h3-scaled);
|
|
148
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.solara-editable-title--size-4xl {
|
|
152
|
+
--editable-title-font-size: var(--font-size-h2-scaled);
|
|
153
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.solara-editable-title--size-5xl {
|
|
157
|
+
--editable-title-font-size: var(--font-size-h1-scaled);
|
|
158
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.solara-editable-title--size-6xl {
|
|
162
|
+
--editable-title-font-size: calc(var(--font-size-h1-scaled) * 1.125);
|
|
163
|
+
--editable-title-line-height: var(--line-height-heading-scaled);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.solara-editable-title--weight-normal .solara-editable-title__input,
|
|
167
|
+
.solara-editable-title--weight-normal .solara-editable-title__button {
|
|
168
|
+
font-weight: 400;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.solara-editable-title--weight-medium .solara-editable-title__input,
|
|
172
|
+
.solara-editable-title--weight-medium .solara-editable-title__button {
|
|
173
|
+
font-weight: 500;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.solara-editable-title--weight-semibold .solara-editable-title__input,
|
|
177
|
+
.solara-editable-title--weight-semibold .solara-editable-title__button {
|
|
178
|
+
font-weight: 600;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.solara-editable-title--weight-bold .solara-editable-title__input,
|
|
182
|
+
.solara-editable-title--weight-bold .solara-editable-title__button {
|
|
183
|
+
font-weight: 700;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@keyframes solara-editable-title-spin {
|
|
187
|
+
from {
|
|
188
|
+
transform: translateY(-50%) rotate(0deg);
|
|
189
|
+
}
|
|
190
|
+
to {
|
|
191
|
+
transform: translateY(-50%) rotate(360deg);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Icon } from "@solara/icons";
|
|
3
|
+
import type { IconProps } from "@solara/icons";
|
|
4
|
+
import type { EditableTitleProps } from "./EditableTitle.types";
|
|
5
|
+
import "./EditableTitle.scss";
|
|
6
|
+
|
|
7
|
+
type EditableTitleIcon = IconProps | IconProps["name"];
|
|
8
|
+
|
|
9
|
+
const normalizeIconProps = (icon: EditableTitleIcon): IconProps =>
|
|
10
|
+
typeof icon === "string" ? { name: icon } : icon;
|
|
11
|
+
|
|
12
|
+
const resolveIconProps = (icon: EditableTitleIcon): IconProps => {
|
|
13
|
+
const iconProps = normalizeIconProps(icon);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
...iconProps,
|
|
17
|
+
style: {
|
|
18
|
+
...iconProps.style,
|
|
19
|
+
color: "currentColor",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const EditableTitle = forwardRef<HTMLButtonElement, EditableTitleProps>(
|
|
25
|
+
(
|
|
26
|
+
{
|
|
27
|
+
title,
|
|
28
|
+
onSave,
|
|
29
|
+
className,
|
|
30
|
+
readOnly = false,
|
|
31
|
+
size = "lg",
|
|
32
|
+
weight,
|
|
33
|
+
fullWidth = false,
|
|
34
|
+
disabled,
|
|
35
|
+
iconLeft,
|
|
36
|
+
...buttonProps
|
|
37
|
+
},
|
|
38
|
+
ref
|
|
39
|
+
) => {
|
|
40
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
41
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
42
|
+
const [value, setValue] = useState(title);
|
|
43
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
44
|
+
|
|
45
|
+
const isLocked = readOnly || Boolean(disabled);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setValue(title);
|
|
49
|
+
}, [title]);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (isEditing && inputRef.current) {
|
|
53
|
+
inputRef.current.select();
|
|
54
|
+
}
|
|
55
|
+
}, [isEditing]);
|
|
56
|
+
|
|
57
|
+
const handleSave = async () => {
|
|
58
|
+
if (isLocked || isSaving) return;
|
|
59
|
+
|
|
60
|
+
const trimmedValue = value.trim();
|
|
61
|
+
if (!trimmedValue || trimmedValue === title) {
|
|
62
|
+
setValue(title);
|
|
63
|
+
setIsEditing(false);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setIsSaving(true);
|
|
68
|
+
const success = await onSave(trimmedValue);
|
|
69
|
+
setIsSaving(false);
|
|
70
|
+
|
|
71
|
+
if (success) {
|
|
72
|
+
setIsEditing(false);
|
|
73
|
+
} else {
|
|
74
|
+
setValue(title);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
79
|
+
if (event.key === "Enter") {
|
|
80
|
+
handleSave();
|
|
81
|
+
} else if (event.key === "Escape") {
|
|
82
|
+
setValue(title);
|
|
83
|
+
setIsEditing(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const rootClasses = [
|
|
88
|
+
"solara-editable-title",
|
|
89
|
+
size ? `solara-editable-title--size-${size}` : null,
|
|
90
|
+
weight ? `solara-editable-title--weight-${weight}` : null,
|
|
91
|
+
fullWidth ? "solara-editable-title--full-width" : null,
|
|
92
|
+
className,
|
|
93
|
+
]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(" ");
|
|
96
|
+
|
|
97
|
+
if (isEditing) {
|
|
98
|
+
return (
|
|
99
|
+
<div className={rootClasses} aria-busy={isSaving}>
|
|
100
|
+
<input
|
|
101
|
+
ref={inputRef}
|
|
102
|
+
type="text"
|
|
103
|
+
value={value}
|
|
104
|
+
onChange={(event) => setValue(event.target.value)}
|
|
105
|
+
onKeyDown={handleKeyDown}
|
|
106
|
+
onBlur={handleSave}
|
|
107
|
+
className="solara-editable-title__input"
|
|
108
|
+
disabled={isSaving}
|
|
109
|
+
autoFocus
|
|
110
|
+
/>
|
|
111
|
+
{isSaving ? (
|
|
112
|
+
<span className="solara-editable-title__spinner" aria-hidden="true" />
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className={rootClasses}>
|
|
120
|
+
<button
|
|
121
|
+
ref={ref}
|
|
122
|
+
type={buttonProps.type ?? "button"}
|
|
123
|
+
onClick={isLocked ? undefined : () => setIsEditing(true)}
|
|
124
|
+
className="solara-editable-title__button"
|
|
125
|
+
title={isLocked ? title : "Click to edit"}
|
|
126
|
+
disabled={isLocked}
|
|
127
|
+
{...buttonProps}>
|
|
128
|
+
{iconLeft ? (
|
|
129
|
+
<span className="solara-editable-title__icon" aria-hidden="true">
|
|
130
|
+
<Icon {...resolveIconProps(iconLeft)} />
|
|
131
|
+
</span>
|
|
132
|
+
) : null}
|
|
133
|
+
<span className="solara-editable-title__label">{title}</span>
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
EditableTitle.displayName = "EditableTitle";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import type { IconProps } from "@solara/icons";
|
|
3
|
+
|
|
4
|
+
export type EditableTitleSize =
|
|
5
|
+
| "xs"
|
|
6
|
+
| "sm"
|
|
7
|
+
| "base"
|
|
8
|
+
| "lg"
|
|
9
|
+
| "xl"
|
|
10
|
+
| "2xl"
|
|
11
|
+
| "3xl"
|
|
12
|
+
| "4xl"
|
|
13
|
+
| "5xl"
|
|
14
|
+
| "6xl";
|
|
15
|
+
|
|
16
|
+
export type EditableTitleWeight = "normal" | "medium" | "semibold" | "bold";
|
|
17
|
+
|
|
18
|
+
export interface EditableTitleProps
|
|
19
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "title" | "onClick"> {
|
|
20
|
+
/** The title text displayed in view mode. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** Called when the title is saved. Return true to accept the change. */
|
|
23
|
+
onSave: (newTitle: string) => Promise<boolean>;
|
|
24
|
+
/** Prevent editing when true. */
|
|
25
|
+
readOnly?: boolean;
|
|
26
|
+
/** Typography size token. */
|
|
27
|
+
size?: EditableTitleSize;
|
|
28
|
+
/** Typography weight token. */
|
|
29
|
+
weight?: EditableTitleWeight;
|
|
30
|
+
/** When true, the component will take up the full width of its container. */
|
|
31
|
+
fullWidth?: boolean;
|
|
32
|
+
/** Optional leading icon displayed before the title. */
|
|
33
|
+
iconLeft?: IconProps | IconProps["name"];
|
|
34
|
+
/** Additional class name. */
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|