diff --git a/.changeset/shy-garlics-call.md b/.changeset/shy-garlics-call.md new file mode 100644 index 000000000..1ee9646f8 --- /dev/null +++ b/.changeset/shy-garlics-call.md @@ -0,0 +1,6 @@ +--- +'@launchpad-ui/inline-edit': patch +'@launchpad-ui/core': patch +--- + +[InlineEdit] Forward ref and spread additional props diff --git a/packages/inline-edit/__tests__/InlineEdit.spec.tsx b/packages/inline-edit/__tests__/InlineEdit.spec.tsx index 9c880f187..1449754fb 100644 --- a/packages/inline-edit/__tests__/InlineEdit.spec.tsx +++ b/packages/inline-edit/__tests__/InlineEdit.spec.tsx @@ -7,7 +7,7 @@ import { it, expect, describe, vi } from 'vitest'; import { render, screen, waitFor, userEvent } from '../../../test/utils'; import { InlineEdit } from '../src'; -const InlineEditComponent = ({ ...props }: Partial) => { +const InlineEditComponent = ({ ...props }: Omit, 'ref'>) => { const [editValue, setEditValue] = useState(''); return ( diff --git a/packages/inline-edit/src/InlineEdit.tsx b/packages/inline-edit/src/InlineEdit.tsx index 778bb0ac0..434a1a27d 100644 --- a/packages/inline-edit/src/InlineEdit.tsx +++ b/packages/inline-edit/src/InlineEdit.tsx @@ -1,12 +1,6 @@ import type { InlineVariants } from './styles/InlineEdit.css'; import type { TextAreaProps, TextFieldProps } from '@launchpad-ui/form'; -import type { - ComponentProps, - Dispatch, - KeyboardEventHandler, - ReactElement, - SetStateAction, -} from 'react'; +import type { ComponentProps, KeyboardEventHandler, ReactElement } from 'react'; import { ButtonGroup, IconButton } from '@launchpad-ui/button'; import { TextField } from '@launchpad-ui/form'; @@ -14,9 +8,9 @@ import { Icon } from '@launchpad-ui/icons'; import { useButton } from '@react-aria/button'; import { focusSafely } from '@react-aria/focus'; import { useFocusWithin } from '@react-aria/interactions'; -import { mergeProps, useUpdateEffect } from '@react-aria/utils'; +import { mergeProps, mergeRefs, useUpdateEffect } from '@react-aria/utils'; import { cx } from 'classix'; -import { cloneElement, useRef, useState } from 'react'; +import { cloneElement, forwardRef, useRef, useState } from 'react'; import { container, cancelButton, inline, readButton } from './styles/InlineEdit.css'; @@ -24,7 +18,7 @@ type InlineEditProps = ComponentProps<'div'> & InlineVariants & Pick, 'defaultValue'> & { 'data-test-id'?: string; - onConfirm: Dispatch>; + onConfirm: (value: string) => void; hideEdit?: boolean; renderInput?: ReactElement; isEditing?: boolean; @@ -35,133 +29,152 @@ type InlineEditProps = ComponentProps<'div'> & confirmButtonLabel?: string; }; -const InlineEdit = ({ - 'data-test-id': testId = 'inline-edit', - layout = 'horizontal', - children, - defaultValue, - onConfirm, - hideEdit = false, - renderInput = , - 'aria-label': ariaLabel, - isEditing: isEditingProp, - onCancel, - onEdit, - cancelButtonLabel = 'cancel', - editButtonLabel = 'edit', - confirmButtonLabel = 'confirm', -}: InlineEditProps) => { - const [isEditing, setEditing] = useState(isEditingProp ?? false); - const [isFocusWithin, setFocusWithin] = useState(false); - const inputRef = useRef(null); - const editRef = useRef(null); - const controlled = isEditingProp !== undefined; - - useUpdateEffect(() => { - if (controlled) { - setEditing(isEditingProp); - } - }, [isEditingProp]); - - useUpdateEffect(() => { - if (isFocusWithin) { - isEditing - ? inputRef.current && focusSafely(inputRef.current) - : editRef.current && focusSafely(editRef.current); - } - }, [isEditing]); - - const handleEdit = () => { - !controlled && setEditing(true); - onEdit?.(); - }; - - const handleCancel = () => { - !controlled && setEditing(false); - onCancel?.(); - }; - - const handleConfirm = () => { - onConfirm(inputRef.current?.value || ''); - !controlled && setEditing(false); - }; - - const handleKeyDown: KeyboardEventHandler = (event) => { - if (event.key === 'Enter') { - event.preventDefault(); - handleConfirm(); - } else if (event.key === 'Escape') { - event.preventDefault(); - handleCancel(); - } - }; - - const { focusWithinProps } = useFocusWithin({ - onBlurWithin: () => isEditing && handleCancel(), - onFocusWithinChange: (isFocusWithin) => setFocusWithin(isFocusWithin), - }); - - const { buttonProps } = useButton( +const InlineEdit = forwardRef( + ( { - 'aria-label': editButtonLabel, - elementType: 'span', - onPress: handleEdit, - }, - editRef - ); - - const renderReadContent = hideEdit ? ( - - {children} - - ) : ( - <> - {children} - } - aria-label={editButtonLabel} - size="small" - onClick={handleEdit} - /> - - ); - - const input = cloneElement( - renderInput, - mergeProps(renderInput.props, { - ref: inputRef, + 'data-test-id': testId = 'inline-edit', + layout = 'horizontal', + children, defaultValue, - onKeyDown: handleKeyDown, + onConfirm, + hideEdit = false, + renderInput = , 'aria-label': ariaLabel, - }) - ); - - return isEditing ? ( -
- {input} - - } - aria-label={confirmButtonLabel} - onClick={handleConfirm} - /> + isEditing: isEditingProp, + onCancel, + onEdit, + cancelButtonLabel = 'cancel', + editButtonLabel = 'edit', + confirmButtonLabel = 'confirm', + className, + ...rest + }, + ref + ) => { + const [isEditing, setEditing] = useState(isEditingProp ?? false); + const [isFocusWithin, setFocusWithin] = useState(false); + const inputRef = useRef(null); + const editRef = useRef(null); + const controlled = isEditingProp !== undefined; + + useUpdateEffect(() => { + if (controlled) { + setEditing(isEditingProp); + } + }, [isEditingProp]); + + useUpdateEffect(() => { + if (isFocusWithin) { + isEditing + ? inputRef.current && focusSafely(inputRef.current) + : editRef.current && focusSafely(editRef.current); + } + }, [isEditing]); + + const handleEdit = () => { + !controlled && setEditing(true); + onEdit?.(); + }; + + const handleCancel = () => { + !controlled && setEditing(false); + onCancel?.(); + }; + + const handleConfirm = () => { + onConfirm(inputRef.current?.value || ''); + !controlled && setEditing(false); + }; + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleConfirm(); + } else if (event.key === 'Escape') { + event.preventDefault(); + handleCancel(); + } + }; + + const { focusWithinProps } = useFocusWithin({ + onBlurWithin: () => isEditing && handleCancel(), + onFocusWithinChange: (isFocusWithin) => setFocusWithin(isFocusWithin), + }); + + const { buttonProps } = useButton( + { + 'aria-label': editButtonLabel, + elementType: 'span', + onPress: handleEdit, + }, + editRef + ); + + const renderReadContent = hideEdit ? ( + + {children} + + ) : ( + <> + {children} } - aria-label={cancelButtonLabel} - className={cancelButton} - onClick={handleCancel} + ref={editRef} + icon={} + aria-label={editButtonLabel} + size="small" + onClick={handleEdit} /> - -
- ) : ( -
- {renderReadContent} -
- ); -}; + + ); + + const input = cloneElement( + renderInput, + mergeProps(renderInput.props, { + ref: mergeRefs(inputRef, ref), + defaultValue, + onKeyDown: handleKeyDown, + 'aria-label': ariaLabel, + }) + ); + + return isEditing ? ( +
+ {input} + + } + aria-label={confirmButtonLabel} + onClick={handleConfirm} + /> + } + aria-label={cancelButtonLabel} + className={cancelButton} + onClick={handleCancel} + /> + +
+ ) : ( +
+ {renderReadContent} +
+ ); + } +); + +InlineEdit.displayName = 'InlineEdit'; export { InlineEdit }; export type { InlineEditProps }; diff --git a/packages/inline-edit/stories/InlineEdit.stories.tsx b/packages/inline-edit/stories/InlineEdit.stories.tsx index 2785c84b0..54b93bd5c 100644 --- a/packages/inline-edit/stories/InlineEdit.stories.tsx +++ b/packages/inline-edit/stories/InlineEdit.stories.tsx @@ -116,7 +116,7 @@ export const InForm: Story = { }; export const Controlled: Story = { - render: (args) => { + render: () => { const [editValue, setEditValue] = useState('edit me'); const [isEditing, setEditing] = useState(true); @@ -126,7 +126,6 @@ export const Controlled: Story = { isEditing={isEditing} onCancel={() => setEditing(false)} onEdit={() => setEditing(true)} - {...args} onConfirm={(value) => { setEditValue(value); setEditing(false);