Skip to content

Commit 692ee18

Browse files
committed
feat: better editable
1 parent 38b5b5b commit 692ee18

File tree

4 files changed

+112
-65
lines changed

4 files changed

+112
-65
lines changed

docs/app/(home)/pg/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ export default function PlaygroundPage() {
4646
<Editable.Input />
4747
</Editable.Area>
4848
<Editable.Toolbar>
49-
<Editable.Cancel />
50-
<Editable.Submit />
49+
<Editable.Cancel asChild>
50+
<Button variant="outline" size="sm">
51+
Cancel
52+
</Button>
53+
</Editable.Cancel>
54+
<Editable.Submit asChild>
55+
<Button size="sm">Submit</Button>
56+
</Editable.Submit>
5157
</Editable.Toolbar>
5258
</Editable.Root>
5359
<Textarea

docs/registry/default/ui/editable.tsx

Lines changed: 101 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useComposedRefs } from "@/lib/composition";
4+
import { composeEventHandlers } from "@/lib/composition";
45
import { cn } from "@/lib/utils";
56
import { Slot } from "@radix-ui/react-slot";
67
import * as React from "react";
@@ -27,17 +28,22 @@ const EDITABLE_ERROR = {
2728

2829
interface EditableContextValue {
2930
id: string;
31+
inputId: string;
32+
labelId: string;
3033
value: string;
3134
defaultValue: string;
3235
isEditing: boolean;
33-
isDisabled?: boolean;
3436
placeholder?: string;
3537
onValueChange?: (value: string) => void;
3638
onSubmit?: (value: string) => void;
3739
onCancel?: () => void;
3840
onEdit?: () => void;
3941
setIsEditing: (isEditing: boolean) => void;
4042
setValue: (value: string) => void;
43+
disabled?: boolean;
44+
readOnly?: boolean;
45+
required?: boolean;
46+
invalid?: boolean;
4147
}
4248

4349
const EditableContext = React.createContext<EditableContextValue | null>(null);
@@ -62,6 +68,9 @@ interface EditableRootProps
6268
onCancel?: () => void;
6369
onEdit?: () => void;
6470
disabled?: boolean;
71+
readOnly?: boolean;
72+
required?: boolean;
73+
invalid?: boolean;
6574
asChild?: boolean;
6675
}
6776

@@ -77,13 +86,17 @@ const EditableRoot = React.forwardRef<HTMLDivElement, EditableRootProps>(
7786
onCancel,
7887
onEdit,
7988
disabled,
89+
required,
90+
readOnly,
8091
asChild,
8192
className,
8293
...rootProps
8394
} = props;
8495

8596
const [isEditing, setIsEditing] = React.useState(false);
8697
const [internalValue, setInternalValue] = React.useState(defaultValue);
98+
const inputId = React.useId();
99+
const labelId = React.useId();
87100

88101
const isControlled = valueProp !== undefined;
89102
const value = isControlled ? valueProp : internalValue;
@@ -101,10 +114,14 @@ const EditableRoot = React.forwardRef<HTMLDivElement, EditableRootProps>(
101114
const contextValue = React.useMemo(
102115
() => ({
103116
id,
117+
inputId,
118+
labelId,
104119
value,
105120
defaultValue,
106121
isEditing,
107-
disabled,
122+
isDisabled: disabled,
123+
isRequired: required,
124+
isReadOnly: readOnly,
108125
placeholder,
109126
onValueChange,
110127
onSubmit,
@@ -115,10 +132,14 @@ const EditableRoot = React.forwardRef<HTMLDivElement, EditableRootProps>(
115132
}),
116133
[
117134
id,
135+
inputId,
136+
labelId,
118137
value,
119138
defaultValue,
120139
isEditing,
121140
disabled,
141+
required,
142+
readOnly,
122143
placeholder,
123144
onValueChange,
124145
onSubmit,
@@ -149,22 +170,27 @@ interface EditableLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
149170

150171
const EditableLabel = React.forwardRef<HTMLLabelElement, EditableLabelProps>(
151172
(props, forwardedRef) => {
152-
const { asChild, className, ...labelProps } = props;
173+
const { asChild, className, children, ...labelProps } = props;
153174
const context = useEditableContext(LABEL_NAME);
154175

155176
const LabelSlot = asChild ? Slot : "label";
156177

157178
return (
158179
<LabelSlot
180+
data-disabled={context.disabled ? "" : undefined}
181+
data-invalid={context.invalid ? "" : undefined}
182+
data-required={context.required ? "" : undefined}
159183
{...labelProps}
160184
ref={forwardedRef}
161-
data-disabled={context.isDisabled ? "" : undefined}
162-
data-invalid={context.isDisabled ? "" : undefined}
185+
id={context.labelId}
186+
htmlFor={context.inputId}
163187
className={cn(
164-
"font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
188+
"font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 data-[required]:after:ml-0.5 data-[required]:after:text-destructive data-[required]:after:content-['*']",
165189
className,
166190
)}
167-
/>
191+
>
192+
{children}
193+
</LabelSlot>
168194
);
169195
},
170196
);
@@ -183,10 +209,11 @@ const EditableArea = React.forwardRef<HTMLDivElement, EditableAreaProps>(
183209

184210
return (
185211
<AreaSlot
212+
role="group"
213+
data-disabled={context.disabled ? "" : undefined}
214+
data-editing={context.isEditing ? "" : undefined}
186215
{...areaProps}
187216
ref={forwardedRef}
188-
data-disabled={context.isDisabled ? "" : undefined}
189-
data-editing={context.isEditing ? "" : undefined}
190217
className={cn(
191218
"relative inline-block min-w-[200px] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
192219
className,
@@ -214,18 +241,22 @@ const EditablePreview = React.forwardRef<HTMLDivElement, EditablePreviewProps>(
214241

215242
return (
216243
<PreviewSlot
244+
role="button"
245+
aria-disabled={context.disabled || context.readOnly}
246+
data-placeholder-shown={!context.value ? "" : undefined}
247+
data-disabled={context.disabled ? "" : undefined}
248+
data-readonly={context.readOnly ? "" : undefined}
249+
tabIndex={context.disabled || context.readOnly ? undefined : 0}
217250
{...previewProps}
218251
ref={forwardedRef}
219252
onClick={() => {
220-
if (!context.isDisabled) {
221-
context.onEdit?.();
222-
context.setIsEditing(true);
223-
}
253+
if (context.disabled || context.readOnly) return;
254+
255+
context.onEdit?.();
256+
context.setIsEditing(true);
224257
}}
225-
data-placeholder-shown={!context.value ? "" : undefined}
226-
data-disabled={context.isDisabled ? "" : undefined}
227258
className={cn(
228-
"cursor-text rounded-md px-3 py-2 text-sm hover:bg-accent/50 data-[disabled]:cursor-not-allowed data-[placeholder-shown]:text-muted-foreground data-[disabled]:opacity-50",
259+
"cursor-text rounded-md px-3 py-2 text-sm hover:bg-accent/50 data-[disabled]:cursor-not-allowed data-[readonly]:cursor-default data-[placeholder-shown]:text-muted-foreground data-[disabled]:opacity-50",
229260
className,
230261
)}
231262
>
@@ -243,20 +274,24 @@ interface EditableInputProps
243274

244275
const EditableInput = React.forwardRef<HTMLInputElement, EditableInputProps>(
245276
(props, forwardedRef) => {
246-
const { asChild, className, ...inputProps } = props;
277+
const { asChild, className, disabled, readOnly, required, ...inputProps } =
278+
props;
247279
const context = useEditableContext(INPUT_NAME);
248280
const inputRef = React.useRef<HTMLInputElement>(null);
249281
const composedRef = useComposedRefs(forwardedRef, inputRef);
250282

283+
const isDisabled = disabled || context.disabled;
284+
const isReadOnly = readOnly || context.readOnly;
285+
const isRequired = required || context.required;
286+
251287
React.useEffect(() => {
252-
if (context.isEditing) {
253-
// Focus and select all text when entering edit mode
288+
if (context.isEditing && !isReadOnly) {
254289
requestAnimationFrame(() => {
255290
inputRef.current?.focus();
256291
inputRef.current?.select();
257292
});
258293
}
259-
}, [context.isEditing]);
294+
}, [context.isEditing, isReadOnly]);
260295

261296
React.useEffect(() => {
262297
function onClickOutside(e: MouseEvent) {
@@ -276,32 +311,37 @@ const EditableInput = React.forwardRef<HTMLInputElement, EditableInputProps>(
276311

277312
const InputSlot = asChild ? Slot : "input";
278313

279-
if (!context.isEditing) {
280-
return null;
281-
}
314+
if (!context.isEditing && !isReadOnly) return null;
282315

283316
return (
284317
<InputSlot
318+
aria-required={isRequired}
319+
aria-invalid={context.invalid}
320+
aria-labelledby={context.labelId}
321+
disabled={isDisabled}
322+
readOnly={isReadOnly}
323+
required={isRequired}
285324
{...inputProps}
286325
ref={composedRef}
287-
value={context.value}
326+
id={context.inputId}
288327
placeholder={context.placeholder}
289-
onChange={(e) => {
290-
inputProps.onChange?.(e);
291-
context.setValue(e.target.value);
292-
}}
293-
onKeyDown={(e) => {
294-
inputProps.onKeyDown?.(e);
295-
if (e.key === "Escape") {
328+
value={context.value}
329+
onChange={composeEventHandlers(inputProps.onChange, (event) => {
330+
if (isReadOnly) return;
331+
context.setValue(event.target.value);
332+
})}
333+
onKeyDown={composeEventHandlers(inputProps.onKeyDown, (event) => {
334+
if (isReadOnly) return;
335+
if (event.key === "Escape") {
296336
context.onCancel?.();
297337
context.setIsEditing(false);
298-
} else if (e.key === "Enter") {
338+
} else if (event.key === "Enter") {
299339
context.onSubmit?.(context.value);
300340
context.setIsEditing(false);
301341
}
302-
}}
342+
})}
303343
className={cn(
304-
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
344+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground read-only:cursor-default read-only:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
305345
className,
306346
)}
307347
/>
@@ -313,18 +353,32 @@ EditableInput.displayName = INPUT_NAME;
313353

314354
interface EditableToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
315355
asChild?: boolean;
356+
orientation?: "horizontal" | "vertical";
316357
}
317358

318359
const EditableToolbar = React.forwardRef<HTMLDivElement, EditableToolbarProps>(
319360
(props, forwardedRef) => {
320-
const { asChild, className, ...toolbarProps } = props;
361+
const {
362+
asChild,
363+
className,
364+
orientation = "horizontal",
365+
...toolbarProps
366+
} = props;
367+
const context = useEditableContext(TOOLBAR_NAME);
321368
const ToolbarSlot = asChild ? Slot : "div";
322369

323370
return (
324371
<ToolbarSlot
372+
role="toolbar"
373+
aria-controls={context.id}
374+
aria-orientation={orientation}
325375
{...toolbarProps}
326376
ref={forwardedRef}
327-
className={cn("mt-2 flex items-center gap-2", className)}
377+
className={cn(
378+
"mt-2 flex items-center gap-2",
379+
orientation === "vertical" && "flex-col",
380+
className,
381+
)}
328382
/>
329383
);
330384
},
@@ -343,28 +397,20 @@ const EditableCancel = React.forwardRef<HTMLButtonElement, EditableCancelProps>(
343397

344398
const CancelSlot = asChild ? Slot : "button";
345399

346-
if (!context.isEditing) {
347-
return null;
348-
}
400+
if (!context.isEditing && !context.readOnly) return null;
349401

350402
return (
351403
<CancelSlot
352404
type="button"
353-
aria-label="Cancel editing"
405+
aria-controls={context.id}
354406
{...cancelProps}
355-
onClick={() => {
407+
onClick={composeEventHandlers(cancelProps.onClick, () => {
356408
context.onCancel?.();
357409
context.setIsEditing(false);
358410
context.setValue(context.defaultValue);
359-
}}
411+
})}
360412
ref={forwardedRef}
361-
className={cn(
362-
"inline-flex h-8 items-center justify-center rounded-md border bg-transparent px-3 font-medium text-sm shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
363-
cancelProps.className,
364-
)}
365-
>
366-
{cancelProps.children ?? "Cancel"}
367-
</CancelSlot>
413+
/>
368414
);
369415
},
370416
);
@@ -382,27 +428,19 @@ const EditableSubmit = React.forwardRef<HTMLButtonElement, EditableSubmitProps>(
382428

383429
const SubmitSlot = asChild ? Slot : "button";
384430

385-
if (!context.isEditing) {
386-
return null;
387-
}
431+
if (!context.isEditing && !context.readOnly) return null;
388432

389433
return (
390434
<SubmitSlot
391435
type="button"
392-
aria-label="Submit changes"
436+
aria-controls={context.id}
393437
{...submitProps}
394-
onClick={() => {
438+
ref={forwardedRef}
439+
onClick={composeEventHandlers(submitProps.onClick, () => {
395440
context.onSubmit?.(context.value);
396441
context.setIsEditing(false);
397-
}}
398-
ref={forwardedRef}
399-
className={cn(
400-
"inline-flex h-8 items-center justify-center rounded-md border bg-primary px-3 font-medium text-primary-foreground text-sm shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
401-
submitProps.className,
402-
)}
403-
>
404-
{submitProps.children ?? "Save"}
405-
</SubmitSlot>
442+
})}
443+
/>
406444
);
407445
},
408446
);

docs/registry/default/ui/kanban.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,7 @@ const KanbanColumnHandle = React.forwardRef<
830830

831831
return (
832832
<HandleSlot
833+
type="button"
833834
aria-controls={columnContext.id}
834835
data-dragging={columnContext.isDragging ? "" : undefined}
835836
{...columnHandleProps}
@@ -992,6 +993,7 @@ const KanbanItemHandle = React.forwardRef<
992993

993994
return (
994995
<HandleSlot
996+
type="button"
995997
aria-controls={itemContext.id}
996998
data-dragging={itemContext.isDragging ? "" : undefined}
997999
{...itemHandleProps}

docs/registry/default/ui/sortable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ const SortableItemHandle = React.forwardRef<
458458

459459
return (
460460
<HandleSlot
461+
type="button"
461462
aria-controls={itemContext.id}
462463
aria-roledescription="sortable item handle"
463464
data-dragging={itemContext.isDragging ? "" : undefined}

0 commit comments

Comments
 (0)