Skip to content

Commit

Permalink
feat(inline-edit): forward ref and spread additional props (#946)
Browse files Browse the repository at this point in the history
* feat(inline-edit): forward ref

* fix: adjust onConfirm type

* feat: spread additional props

* chore: add changeset
  • Loading branch information
Niznikr authored Aug 2, 2023
1 parent 9bbdd9e commit f6ea128
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 135 deletions.
6 changes: 6 additions & 0 deletions .changeset/shy-garlics-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@launchpad-ui/inline-edit': patch
'@launchpad-ui/core': patch
---

[InlineEdit] Forward ref and spread additional props
2 changes: 1 addition & 1 deletion packages/inline-edit/__tests__/InlineEdit.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InlineEditProps>) => {
const InlineEditComponent = ({ ...props }: Omit<Partial<InlineEditProps>, 'ref'>) => {
const [editValue, setEditValue] = useState('');

return (
Expand Down
277 changes: 145 additions & 132 deletions packages/inline-edit/src/InlineEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
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';
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';

type InlineEditProps = ComponentProps<'div'> &
InlineVariants &
Pick<ComponentProps<'input'>, 'defaultValue'> & {
'data-test-id'?: string;
onConfirm: Dispatch<SetStateAction<string>>;
onConfirm: (value: string) => void;
hideEdit?: boolean;
renderInput?: ReactElement<TextFieldProps | TextAreaProps>;
isEditing?: boolean;
Expand All @@ -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 = <TextField />,
'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<HTMLInputElement>(null);
const editRef = useRef<HTMLButtonElement>(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<HTMLInputElement> = (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<HTMLInputElement, InlineEditProps>(
(
{
'aria-label': editButtonLabel,
elementType: 'span',
onPress: handleEdit,
},
editRef
);

const renderReadContent = hideEdit ? (
<span ref={editRef} {...buttonProps} className={readButton}>
{children}
</span>
) : (
<>
{children}
<IconButton
ref={editRef}
icon={<Icon name="edit" />}
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 = <TextField />,
'aria-label': ariaLabel,
})
);

return isEditing ? (
<div className={cx(container, inline({ layout }))} data-test-id={testId} {...focusWithinProps}>
{input}
<ButtonGroup spacing="compact">
<IconButton
kind="primary"
icon={<Icon name="check" />}
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<HTMLInputElement>(null);
const editRef = useRef<HTMLButtonElement>(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<HTMLInputElement> = (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 ? (
<span ref={editRef} {...buttonProps} className={readButton}>
{children}
</span>
) : (
<>
{children}
<IconButton
kind="default"
icon={<Icon name="close" />}
aria-label={cancelButtonLabel}
className={cancelButton}
onClick={handleCancel}
ref={editRef}
icon={<Icon name="edit" />}
aria-label={editButtonLabel}
size="small"
onClick={handleEdit}
/>
</ButtonGroup>
</div>
) : (
<div className={cx(!hideEdit && container)} data-test-id={testId} {...focusWithinProps}>
{renderReadContent}
</div>
);
};
</>
);

const input = cloneElement(
renderInput,
mergeProps(renderInput.props, {
ref: mergeRefs(inputRef, ref),
defaultValue,
onKeyDown: handleKeyDown,
'aria-label': ariaLabel,
})
);

return isEditing ? (
<div
{...rest}
className={cx(container, inline({ layout }), className)}
data-test-id={testId}
{...focusWithinProps}
>
{input}
<ButtonGroup spacing="compact">
<IconButton
kind="primary"
icon={<Icon name="check" />}
aria-label={confirmButtonLabel}
onClick={handleConfirm}
/>
<IconButton
kind="default"
icon={<Icon name="close" />}
aria-label={cancelButtonLabel}
className={cancelButton}
onClick={handleCancel}
/>
</ButtonGroup>
</div>
) : (
<div
{...rest}
className={cx(!hideEdit && container, className)}
data-test-id={testId}
{...focusWithinProps}
>
{renderReadContent}
</div>
);
}
);

InlineEdit.displayName = 'InlineEdit';

export { InlineEdit };
export type { InlineEditProps };
3 changes: 1 addition & 2 deletions packages/inline-edit/stories/InlineEdit.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -126,7 +126,6 @@ export const Controlled: Story = {
isEditing={isEditing}
onCancel={() => setEditing(false)}
onEdit={() => setEditing(true)}
{...args}
onConfirm={(value) => {
setEditValue(value);
setEditing(false);
Expand Down

0 comments on commit f6ea128

Please sign in to comment.