feat(component): new inline-edit component (#5517)

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://github.com/toeverything/AFFiNE/assets/39363750/6dad59f0-5e63-4c25-a81c-dff397da1d34">
  <img height="100" alt="" src="https://github.com/toeverything/AFFiNE/assets/39363750/7c30d7b2-55c9-49eb-82e7-a0882f2e0493">
</picture>
This commit is contained in:
Cats Juice 2024-01-18 03:33:46 +00:00
parent f419867437
commit 943ede4ffd
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
5 changed files with 419 additions and 0 deletions

View File

@ -5,6 +5,7 @@ export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/date-picker';
export * from './ui/divider';
export * from './ui/editable';
export * from './ui/empty';
export * from './ui/input';
export * from './ui/layout';

View File

@ -0,0 +1 @@
export * from './inline-edit';

View File

@ -0,0 +1,51 @@
import { style } from '@vanilla-extract/css';
export const inlineEditWrapper = style({
position: 'relative',
borderRadius: 4,
padding: 4,
display: 'inline-block',
minWidth: 50,
minHeight: 28,
});
export const inlineEdit = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'pre',
wordWrap: 'break-word',
// to avoid shrinking when <input /> show up
border: '1px solid transparent',
selectors: {
[`.${inlineEditWrapper}[data-editing="true"] &`]: {
opacity: 0,
visibility: 'hidden',
},
},
});
export const inlineEditInput = style({
position: 'absolute',
width: '100%',
height: '100%',
left: 0,
top: 0,
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
selectors: {
[`.${inlineEditWrapper}[data-editing="true"] &`]: {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
},
});
export const placeholder = style({
opacity: 0.8,
});

View File

@ -0,0 +1,140 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useCallback, useRef, useState } from 'react';
import { Button } from '../button';
import { ResizePanel } from '../resize-panel/resize-panel';
import { InlineEdit, type InlineEditHandle } from './inline-edit';
export default {
title: 'UI/Editable/Inline Edit',
component: InlineEdit,
} satisfies Meta<typeof InlineEdit>;
const Template: StoryFn<typeof InlineEdit> = args => {
const [value, setValue] = useState(args.value || '');
return (
<div style={{ marginBottom: 40 }}>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 10 }}>
<span>
<b>Value: </b>
</span>
<span
style={{
padding: '2px 4px',
backgroundColor: 'rgba(100, 100, 100, 0.1)',
}}
>
{value}
</span>
</div>
</div>
<ResizePanel
width={600}
height={36}
minHeight={36}
minWidth={200}
maxWidth={1400}
horizontal={true}
vertical={false}
style={{ display: 'flex', alignItems: 'center' }}
>
<InlineEdit
style={{ maxWidth: '100%' }}
value={value}
onChange={v => setValue(v)}
{...args}
/>
</ResizePanel>
</div>
);
};
export const Basic: StoryFn<typeof InlineEdit> = Template.bind(undefined);
Basic.args = {
editable: true,
placeholder: 'Untitled',
trigger: 'doubleClick',
autoSelect: true,
};
export const CustomizeText: StoryFn<typeof InlineEdit> =
Template.bind(undefined);
CustomizeText.args = {
value: 'Customize Text',
editable: true,
placeholder: 'Untitled',
style: {
fontSize: 20,
fontWeight: 500,
padding: '10px 20px',
},
};
export const TriggerEdit: StoryFn<typeof InlineEdit> = args => {
const ref = useRef<InlineEditHandle>(null);
const triggerEdit = useCallback(() => {
if (!ref.current) return;
ref.current.triggerEdit();
}, []);
return (
<>
<div style={{ marginBottom: 12 }}>
<Button onClick={triggerEdit}>Edit</Button>
</div>
<ResizePanel
width={600}
height={36}
minHeight={36}
minWidth={200}
maxWidth={1400}
horizontal={true}
vertical={false}
style={{ display: 'flex', alignItems: 'center' }}
>
<InlineEdit {...args} handleRef={ref} />
</ResizePanel>
</>
);
};
TriggerEdit.args = {
value: 'Trigger edit mode in parent component by `handleRef`',
editable: true,
autoSelect: true,
};
export const UpdateValue: StoryFn<typeof InlineEdit> = args => {
const [value, setValue] = useState(args.value || '');
const appendA = useCallback(() => {
setValue(v => v + 'a');
}, []);
return (
<>
<div style={{ marginBottom: 12 }}>
<Button onClick={appendA}>Append &quot;a&quot;</Button>
</div>
<ResizePanel
width={600}
height={36}
minHeight={36}
minWidth={200}
maxWidth={1400}
horizontal={true}
vertical={false}
style={{ display: 'flex', alignItems: 'center' }}
>
<InlineEdit {...args} value={value} onChange={setValue} />
</ResizePanel>
</>
);
};
UpdateValue.args = {
value: 'Update value in parent component by `value`',
editable: true,
autoSelect: true,
};

View File

@ -0,0 +1,226 @@
import clsx from 'clsx';
import {
type CSSProperties,
type ForwardedRef,
type HTMLAttributes,
type PropsWithChildren,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import Input from '../input';
import * as styles from './inline-edit.css';
export interface InlineEditHandle {
triggerEdit: () => void;
}
export interface InlineEditProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'onChange' | 'onInput'> {
/**
* Content to be displayed
*/
value?: string;
/**
* Whether the content is editable
*/
editable?: boolean;
onInput?: (v: string) => void;
onChange?: (v: string) => void;
/**
* Trigger edit by `click` or `doubleClick`
* @default `'doubleClick'`
*/
trigger?: 'click' | 'doubleClick';
/**
* whether to auto select all text when trigger edit
*/
autoSelect?: boolean;
/**
* Placeholder when value is empty
*/
placeholder?: string;
/**
* Custom placeholder `className`
*/
placeholderClassName?: string;
/**
* Custom placeholder `style`
*/
placeholderStyle?: CSSProperties;
handleRef?: ForwardedRef<InlineEditHandle>;
/**
* Customize attrs for the input
*/
inputAttrs?: Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onBlur'>;
}
export const InlineEdit = ({
value,
editable,
className,
style,
trigger = 'doubleClick',
autoSelect,
onInput,
onChange,
placeholder,
placeholderClassName,
placeholderStyle,
handleRef,
inputAttrs,
...attrs
}: InlineEditProps) => {
const [editing, setEditing] = useState(false);
const [editingValue, setEditingValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle<InlineEditHandle, InlineEditHandle>(handleRef, () => ({
triggerEdit,
}));
const triggerEdit = useCallback(() => {
if (!editable) return;
setEditing(true);
setTimeout(() => {
inputRef.current?.focus();
autoSelect && inputRef.current?.select();
}, 0);
}, [autoSelect, editable]);
const onDoubleClick = useCallback(() => {
if (trigger !== 'doubleClick') return;
triggerEdit();
}, [triggerEdit, trigger]);
const onClick = useCallback(() => {
if (trigger !== 'click') return;
triggerEdit();
}, [triggerEdit, trigger]);
const submit = useCallback(() => {
onChange?.(editingValue || '');
}, [editingValue, onChange]);
const onEnter = useCallback(() => {
inputRef.current?.blur();
}, []);
const onBlur = useCallback(() => {
setEditing(false);
submit();
// to reset input's scroll position to match actual display
inputRef.current?.scrollTo(0, 0);
}, [submit]);
const inputHandler = useCallback(
(v: string) => {
setEditingValue(v);
onInput?.(v);
},
[onInput]
);
// update editing value when value prop changes
useEffect(() => {
setEditingValue(value);
}, [value]);
// to make sure input's style is the same as displayed text
const inputWrapperInheritsStyles = {
margin: 'inherit',
padding: 'inherit',
borderRadius: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
fontWeight: 'inherit',
letterSpacing: 'inherit',
textAlign: 'inherit',
color: 'inherit',
backgroundColor: 'inherit',
} as CSSProperties;
const inputInheritsStyles = {
...inputWrapperInheritsStyles,
padding: undefined,
margin: undefined,
};
return (
<div
data-editing={editing}
className={clsx(styles.inlineEditWrapper, className)}
style={{ ...style }}
{...attrs}
>
{/* display area, will be transparent when input */}
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
className={clsx(styles.inlineEdit)}
>
{editingValue}
{!editingValue && (
<Placeholder
className={placeholderClassName}
label={placeholder}
style={placeholderStyle}
/>
)}
</div>
{/* actual input */}
{
<Input
ref={inputRef}
className={styles.inlineEditInput}
value={editingValue}
placeholder={placeholder}
onBlur={onBlur}
onEnter={onEnter}
onChange={inputHandler}
style={inputWrapperInheritsStyles}
inputStyle={inputInheritsStyles}
{...inputAttrs}
/>
}
</div>
);
};
interface PlaceholderProps
extends PropsWithChildren,
HTMLAttributes<HTMLSpanElement> {
label?: string;
}
const Placeholder = ({
label,
children,
className,
style,
...attrs
}: PlaceholderProps) => {
return (
<div
className={clsx(styles.placeholder, className)}
style={style}
{...attrs}
>
{children ?? label}
</div>
);
};