11"use client" ;
22
33import { useComposedRefs } from "@/lib/composition" ;
4+ import { composeEventHandlers } from "@/lib/composition" ;
45import { cn } from "@/lib/utils" ;
56import { Slot } from "@radix-ui/react-slot" ;
67import * as React from "react" ;
@@ -27,17 +28,22 @@ const EDITABLE_ERROR = {
2728
2829interface 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
4349const 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
150171const 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
244275const 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
314354interface EditableToolbarProps extends React . HTMLAttributes < HTMLDivElement > {
315355 asChild ?: boolean ;
356+ orientation ?: "horizontal" | "vertical" ;
316357}
317358
318359const 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) ;
0 commit comments