diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index d325cdb793..044b0a2d0b 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -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'; diff --git a/packages/frontend/component/src/ui/editable/index.ts b/packages/frontend/component/src/ui/editable/index.ts new file mode 100644 index 0000000000..2e6769bd51 --- /dev/null +++ b/packages/frontend/component/src/ui/editable/index.ts @@ -0,0 +1 @@ +export * from './inline-edit'; diff --git a/packages/frontend/component/src/ui/editable/inline-edit.css.ts b/packages/frontend/component/src/ui/editable/inline-edit.css.ts new file mode 100644 index 0000000000..b04f6896ed --- /dev/null +++ b/packages/frontend/component/src/ui/editable/inline-edit.css.ts @@ -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 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, +}); diff --git a/packages/frontend/component/src/ui/editable/inline-edit.stories.tsx b/packages/frontend/component/src/ui/editable/inline-edit.stories.tsx new file mode 100644 index 0000000000..b7ad44bfe0 --- /dev/null +++ b/packages/frontend/component/src/ui/editable/inline-edit.stories.tsx @@ -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; + +const Template: StoryFn = args => { + const [value, setValue] = useState(args.value || ''); + return ( + + + + + Value: + + + {value} + + + + + + setValue(v)} + {...args} + /> + + + ); +}; + +export const Basic: StoryFn = Template.bind(undefined); +Basic.args = { + editable: true, + placeholder: 'Untitled', + trigger: 'doubleClick', + autoSelect: true, +}; + +export const CustomizeText: StoryFn = + Template.bind(undefined); +CustomizeText.args = { + value: 'Customize Text', + editable: true, + placeholder: 'Untitled', + style: { + fontSize: 20, + fontWeight: 500, + padding: '10px 20px', + }, +}; + +export const TriggerEdit: StoryFn = args => { + const ref = useRef(null); + + const triggerEdit = useCallback(() => { + if (!ref.current) return; + ref.current.triggerEdit(); + }, []); + + return ( + <> + + Edit + + + + + > + ); +}; +TriggerEdit.args = { + value: 'Trigger edit mode in parent component by `handleRef`', + editable: true, + autoSelect: true, +}; + +export const UpdateValue: StoryFn = args => { + const [value, setValue] = useState(args.value || ''); + + const appendA = useCallback(() => { + setValue(v => v + 'a'); + }, []); + + return ( + <> + + Append "a" + + + + + > + ); +}; +UpdateValue.args = { + value: 'Update value in parent component by `value`', + editable: true, + autoSelect: true, +}; diff --git a/packages/frontend/component/src/ui/editable/inline-edit.tsx b/packages/frontend/component/src/ui/editable/inline-edit.tsx new file mode 100644 index 0000000000..845a3f4e8c --- /dev/null +++ b/packages/frontend/component/src/ui/editable/inline-edit.tsx @@ -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, '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; + + /** + * Customize attrs for the input + */ + inputAttrs?: Omit, '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(null); + + useImperativeHandle(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 ( + + {/* display area, will be transparent when input */} + + {editingValue} + + {!editingValue && ( + + )} + + + {/* actual input */} + { + + } + + ); +}; + +interface PlaceholderProps + extends PropsWithChildren, + HTMLAttributes { + label?: string; +} +const Placeholder = ({ + label, + children, + className, + style, + ...attrs +}: PlaceholderProps) => { + return ( + + {children ?? label} + + ); +};