fix(core): handle composition event for Input component (#8351)

close AF-1065
This commit is contained in:
JimmFly
2024-09-25 02:02:23 +00:00
parent ed8e4e30f0
commit 2df2003bd7
10 changed files with 166 additions and 98 deletions
@@ -30,11 +30,7 @@ export const AuthInput = ({
className={clsx(className)} className={clsx(className)}
size="extraLarge" size="extraLarge"
status={error ? 'error' : 'default'} status={error ? 'error' : 'default'}
onKeyDown={e => { onEnter={onEnter}
if (e.key === 'Enter') {
onEnter?.();
}
}}
{...inputProps} {...inputProps}
/> />
{error && errorHint && !withoutHint ? ( {error && errorHint && !withoutHint ? (
@@ -1,3 +1,4 @@
export * from './input'; export * from './input';
export * from './row-input';
import { Input } from './input'; import { Input } from './input';
export default Input; export default Input;
@@ -1,16 +1,14 @@
import clsx from 'clsx'; import clsx from 'clsx';
import type { import type {
ChangeEvent,
CSSProperties, CSSProperties,
ForwardedRef, ForwardedRef,
InputHTMLAttributes, InputHTMLAttributes,
KeyboardEvent,
KeyboardEventHandler, KeyboardEventHandler,
ReactNode, ReactNode,
} from 'react'; } from 'react';
import { forwardRef, useCallback, useEffect } from 'react'; import { forwardRef } from 'react';
import { useAutoFocus, useAutoSelect } from '../../hooks'; import { RowInput } from './row-input';
import { input, inputWrapper } from './style.css'; import { input, inputWrapper } from './style.css';
export type InputProps = { export type InputProps = {
@@ -50,32 +48,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
}: InputProps, }: InputProps,
upstreamRef: ForwardedRef<HTMLInputElement> upstreamRef: ForwardedRef<HTMLInputElement>
) { ) {
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
const selectRef = useAutoSelect<HTMLInputElement>(autoSelect);
const inputRef = (el: HTMLInputElement | null) => {
focusRef.current = el;
selectRef.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
};
// use native blur event to get event after unmount
// don't use useLayoutEffect here, because the cleanup function will be called before unmount
useEffect(() => {
if (!onBlur) return;
selectRef.current?.addEventListener('blur', onBlur as any);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
selectRef.current?.removeEventListener('blur', onBlur as any);
};
}, [onBlur, selectRef]);
return ( return (
<div <div
className={clsx(inputWrapper, className, { className={clsx(inputWrapper, className, {
@@ -96,29 +68,20 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
}} }}
> >
{preFix} {preFix}
<input <RowInput
className={clsx(input, { className={clsx(input, {
large: size === 'large', large: size === 'large',
'extra-large': size === 'extraLarge', 'extra-large': size === 'extraLarge',
})} })}
ref={inputRef} ref={upstreamRef}
disabled={disabled} disabled={disabled}
style={inputStyle} style={inputStyle}
onChange={useCallback( onChange={propsOnChange}
(e: ChangeEvent<HTMLInputElement>) => { onEnter={onEnter}
propsOnChange?.(e.target.value); onKeyDown={onKeyDown}
}, onBlur={onBlur}
[propsOnChange] autoFocus={autoFocus}
)} autoSelect={autoSelect}
onKeyDown={useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onEnter?.();
}
onKeyDown?.(e);
},
[onKeyDown, onEnter]
)}
{...otherProps} {...otherProps}
/> />
{endFix} {endFix}
@@ -0,0 +1,112 @@
import type {
ChangeEvent,
CompositionEventHandler,
CSSProperties,
ForwardedRef,
InputHTMLAttributes,
KeyboardEvent,
KeyboardEventHandler,
} from 'react';
import { forwardRef, useCallback, useEffect, useState } from 'react';
import { useAutoFocus, useAutoSelect } from '../../hooks';
export type RowInputProps = {
disabled?: boolean;
onChange?: (value: string) => void;
onBlur?: (ev: FocusEvent & { currentTarget: HTMLInputElement }) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
autoSelect?: boolean;
type?: HTMLInputElement['type'];
style?: CSSProperties;
onEnter?: () => void;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
// RowInput component that is used in the selector layout for search input
// handles composition events and enter key press
export const RowInput = forwardRef<HTMLInputElement, RowInputProps>(
function RowInput(
{
disabled,
onChange: propsOnChange,
className,
style = {},
onEnter,
onKeyDown,
onBlur,
autoFocus,
autoSelect,
...otherProps
}: RowInputProps,
upstreamRef: ForwardedRef<HTMLInputElement>
) {
const [composing, setComposing] = useState(false);
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
const selectRef = useAutoSelect<HTMLInputElement>(autoSelect);
const inputRef = (el: HTMLInputElement | null) => {
focusRef.current = el;
selectRef.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
};
// use native blur event to get event after unmount
// don't use useLayoutEffect here, because the cleanup function will be called before unmount
useEffect(() => {
if (!onBlur) return;
selectRef.current?.addEventListener('blur', onBlur as any);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
selectRef.current?.removeEventListener('blur', onBlur as any);
};
}, [onBlur, selectRef]);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
propsOnChange?.(e.target.value);
},
[propsOnChange]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.key !== 'Enter' || composing) {
return;
}
onEnter?.();
},
[onKeyDown, composing, onEnter]
);
const handleCompositionStart: CompositionEventHandler<HTMLInputElement> =
useCallback(() => {
setComposing(true);
}, []);
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> =
useCallback(() => {
setComposing(false);
}, []);
return (
<input
className={className}
ref={inputRef}
disabled={disabled}
style={style}
onChange={handleChange}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
{...otherProps}
/>
);
}
);
@@ -1,5 +1,11 @@
import type { MenuProps } from '@affine/component'; import type { MenuProps } from '@affine/component';
import { IconButton, Input, Menu, Scrollable } from '@affine/component'; import {
IconButton,
Input,
Menu,
RowInput,
Scrollable,
} from '@affine/component';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/properties'; import { WorkspaceLegacyProperties } from '@affine/core/modules/properties';
import type { Tag } from '@affine/core/modules/tag'; import type { Tag } from '@affine/core/modules/tag';
@@ -239,12 +245,9 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
[setOpen, setSelectedTagIds] [setOpen, setSelectedTagIds]
); );
const onInputChange = useCallback( const onInputChange = useCallback((value: string) => {
(e: React.ChangeEvent<HTMLInputElement>) => { setInputValue(value);
setInputValue(e.target.value); }, []);
},
[]
);
const onToggleTag = useCallback( const onToggleTag = useCallback(
(id: string) => { (id: string) => {
@@ -297,14 +300,15 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
}, },
[onCreateTag, onToggleTag, focusInput, tagIds.length] [onCreateTag, onToggleTag, focusInput, tagIds.length]
); );
const onEnter = useCallback(() => {
if (safeFocusedIndex >= 0) {
onSelectTagOption(tagOptions[safeFocusedIndex]);
}
}, [onSelectTagOption, safeFocusedIndex, tagOptions]);
const onInputKeyDown = useCallback( const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => { (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
if (safeFocusedIndex >= 0) {
onSelectTagOption(tagOptions[safeFocusedIndex]);
}
} else if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
const tagToRemove = const tagToRemove =
safeInlineFocusedIndex < 0 || safeInlineFocusedIndex >= tagIds.length safeInlineFocusedIndex < 0 || safeInlineFocusedIndex >= tagIds.length
? tagIds.length - 1 ? tagIds.length - 1
@@ -341,7 +345,6 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
inputValue, inputValue,
tagIds, tagIds,
safeFocusedIndex, safeFocusedIndex,
onSelectTagOption,
tagOptions, tagOptions,
safeInlineFocusedIndex, safeInlineFocusedIndex,
tags, tags,
@@ -358,11 +361,12 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
focusedIndex={safeInlineFocusedIndex} focusedIndex={safeInlineFocusedIndex}
onRemove={focusInput} onRemove={focusInput}
> >
<input <RowInput
ref={inputRef} ref={inputRef}
value={inputValue} value={inputValue}
onChange={onInputChange} onChange={onInputChange}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
onEnter={onEnter}
autoFocus autoFocus
className={styles.searchInput} className={styles.searchInput}
placeholder="Type here ..." placeholder="Type here ..."
@@ -380,7 +384,7 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
> >
{tagOptions.map((tag, idx) => { {tagOptions.map((tag, idx) => {
const commonProps = { const commonProps = {
focused: safeFocusedIndex === idx, ...(safeFocusedIndex === idx ? { focused: 'true' } : {}),
onClick: () => onSelectTagOption(tag), onClick: () => onSelectTagOption(tag),
onMouseEnter: () => setFocusedIndex(idx), onMouseEnter: () => setFocusedIndex(idx),
['data-testid']: 'tag-selector-item', ['data-testid']: 'tag-selector-item',
@@ -6,6 +6,7 @@ import {
MenuTrigger, MenuTrigger,
RadioGroup, RadioGroup,
type RadioItem, type RadioItem,
RowInput,
Scrollable, Scrollable,
Switch, Switch,
useConfirmModal, useConfirmModal,
@@ -33,7 +34,6 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
type ChangeEvent,
forwardRef, forwardRef,
type HTMLAttributes, type HTMLAttributes,
type PropsWithChildren, type PropsWithChildren,
@@ -171,8 +171,8 @@ const FontMenuItems = ({ onSelect }: { onSelect: (font: string) => void }) => {
const searchText = useLiveData(systemFontFamily.searchText$); const searchText = useLiveData(systemFontFamily.searchText$);
const onInputChange = useCallback( const onInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (value: string) => {
systemFontFamily.search(e.target.value); systemFontFamily.search(value);
}, },
[systemFontFamily] [systemFontFamily]
); );
@@ -187,7 +187,7 @@ const FontMenuItems = ({ onSelect }: { onSelect: (font: string) => void }) => {
<div> <div>
<div className={styles.InputContainer}> <div className={styles.InputContainer}>
<SearchIcon className={styles.searchIcon} /> <SearchIcon className={styles.searchIcon} />
<input <RowInput
value={searchText ?? ''} value={searchText ?? ''}
onChange={onInputChange} onChange={onInputChange}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
@@ -2,6 +2,7 @@ import {
Button, Button,
Divider, Divider,
Menu, Menu,
RowInput,
Scrollable, Scrollable,
useConfirmModal, useConfirmModal,
} from '@affine/component'; } from '@affine/component';
@@ -285,12 +286,9 @@ export const SwitchTag = ({ onClick }: SwitchTagProps) => {
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$ inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
); );
const onInputChange = useCallback( const onInputChange = useCallback((value: string) => {
(e: React.ChangeEvent<HTMLInputElement>) => { setInputValue(value);
setInputValue(e.target.value); }, []);
},
[]
);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setInputValue(''); setInputValue('');
@@ -301,7 +299,7 @@ export const SwitchTag = ({ onClick }: SwitchTagProps) => {
<div className={styles.tagsEditorRoot}> <div className={styles.tagsEditorRoot}>
<div className={styles.tagsEditorSelectedTags}> <div className={styles.tagsEditorSelectedTags}>
<SearchIcon className={styles.searchIcon} /> <SearchIcon className={styles.searchIcon} />
<input <RowInput
value={inputValue} value={inputValue}
onChange={onInputChange} onChange={onInputChange}
autoFocus autoFocus
@@ -1,4 +1,4 @@
import { Button } from '@affine/component'; import { Button, RowInput } from '@affine/component';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { type PropsWithChildren, type ReactNode, useCallback } from 'react'; import { type PropsWithChildren, type ReactNode, useCallback } from 'react';
@@ -37,8 +37,8 @@ export const SelectorLayout = ({
const t = useI18n(); const t = useI18n();
const onSearchChange = useCallback( const onSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (value: string) => {
onSearch?.(e.target.value); onSearch?.(value);
}, },
[onSearch] [onSearch]
); );
@@ -46,7 +46,7 @@ export const SelectorLayout = ({
return ( return (
<div className={styles.root}> <div className={styles.root}>
<header className={styles.header}> <header className={styles.header}>
<input <RowInput
className={styles.search} className={styles.search}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
onChange={onSearchChange} onChange={onSearchChange}
@@ -15,7 +15,6 @@ import {
WorkspacesService, WorkspacesService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import type { KeyboardEvent } from 'react';
import { useCallback, useLayoutEffect, useState } from 'react'; import { useCallback, useLayoutEffect, useState } from 'react';
import { AuthService } from '../../../modules/cloud'; import { AuthService } from '../../../modules/cloud';
@@ -76,14 +75,11 @@ const NameWorkspaceContent = ({
); );
}, [enable, onConfirmName, workspaceName]); }, [enable, onConfirmName, workspaceName]);
const handleKeyDown = useCallback( const onEnter = useCallback(() => {
(event: KeyboardEvent<HTMLInputElement>) => { if (workspaceName) {
if (event.key === 'Enter' && workspaceName) { handleCreateWorkspace();
handleCreateWorkspace(); }
} }, [handleCreateWorkspace, workspaceName]);
},
[handleCreateWorkspace, workspaceName]
);
// Currently, when we create a new workspace and upload an avatar at the same time, // Currently, when we create a new workspace and upload an avatar at the same time,
// an error occurs after the creation is successful: get blob 404 not found // an error occurs after the creation is successful: get blob 404 not found
@@ -117,7 +113,7 @@ const NameWorkspaceContent = ({
<Input <Input
autoFocus autoFocus
data-testid="create-workspace-input" data-testid="create-workspace-input"
onKeyDown={handleKeyDown} onEnter={onEnter}
placeholder={t['com.affine.nameWorkspace.placeholder']()} placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64} maxLength={64}
minLength={0} minLength={0}
@@ -1,4 +1,4 @@
import { IconButton, observeResize } from '@affine/component'; import { IconButton, observeResize, RowInput } from '@affine/component';
import { import {
ArrowDownSmallIcon, ArrowDownSmallIcon,
ArrowUpSmallIcon, ArrowUpSmallIcon,
@@ -10,7 +10,6 @@ import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
type ChangeEventHandler,
type CompositionEventHandler, type CompositionEventHandler,
type KeyboardEventHandler, type KeyboardEventHandler,
type SetStateAction, type SetStateAction,
@@ -102,9 +101,8 @@ export const FindInPageModal = () => {
toggle(visible); toggle(visible);
}, [visible]); }, [visible]);
const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback( const handleValueChange = useCallback(
e => { (value: string) => {
const value = e.target.value;
setValue(value); setValue(value);
if (!composing) { if (!composing) {
findInPage.findInPage(value); findInPage.findInPage(value);
@@ -227,7 +225,7 @@ export const FindInPageModal = () => {
> >
<SearchIcon className={styles.searchIcon} /> <SearchIcon className={styles.searchIcon} />
<div className={styles.inputMain}> <div className={styles.inputMain}>
<input <RowInput
type="text" type="text"
autoFocus autoFocus
value={value} value={value}