diff --git a/packages/smarthr-ui/src/components/ComboBox/ListBoxItemButton.tsx b/packages/smarthr-ui/src/components/ComboBox/ListBoxItemButton.tsx index 48f1cfa29d..eb0a7cb200 100644 --- a/packages/smarthr-ui/src/components/ComboBox/ListBoxItemButton.tsx +++ b/packages/smarthr-ui/src/components/ComboBox/ListBoxItemButton.tsx @@ -1,4 +1,4 @@ -import React, { type RefObject, useCallback, useMemo } from 'react' +import React, { type ReactNode, type RefObject, useCallback, useMemo } from 'react' import { tv } from 'tailwind-variants' import { FaPlusCircleIcon } from '../Icon' @@ -92,13 +92,15 @@ const AddButton = ({ onMouseOver={onMouseOver} className={className} > - 「{option.item.label}」を追加} - /> + ) } + +const MemoizedNewIconWithText = React.memo<{ label: ReactNode }>(({ label }) => ( + 「{label}」を追加} /> +)) + const SelectButton = ({ activeRef, option, diff --git a/packages/smarthr-ui/src/components/ComboBox/SingleComboBox/SingleComboBox.tsx b/packages/smarthr-ui/src/components/ComboBox/SingleComboBox/SingleComboBox.tsx index aa550fc4dd..d30a49ad9b 100644 --- a/packages/smarthr-ui/src/components/ComboBox/SingleComboBox/SingleComboBox.tsx +++ b/packages/smarthr-ui/src/components/ComboBox/SingleComboBox/SingleComboBox.tsx @@ -17,7 +17,7 @@ import innerText from 'react-innertext' import { tv } from 'tailwind-variants' import { useClick } from '../../../hooks/useClick' -import { type DecoratorsType } from '../../../hooks/useDecorators' +import { type DecoratorsType, useDecorators } from '../../../hooks/useDecorators' import { genericsForwardRef } from '../../../libs/util' import { textColor } from '../../../themes' import { UnstyledButton } from '../../Button' @@ -62,19 +62,26 @@ type Props = BaseProps & { * コンポーネントからフォーカスが外れた時に発火するコールバック関数 */ onBlur?: () => void + // HINT: useListBox内でnoResultText, loadingTextは実行される /** * コンポーネント内のテキストを変更する関数/ */ - decorators?: DecoratorsType<'noResultText'> & { - destroyButtonIconAlt?: (text: string) => string - } + decorators?: DecoratorsType } type ElementProps = Omit, keyof Props> -const DESTROY_BUTTON_TEXT = '削除' +const DECORATOR_DEFAULT_TEXTS = { + destroyButtonIconAlt: '削除', +} as const +type DecoratorKeyTypes = keyof typeof DECORATOR_DEFAULT_TEXTS -const singleCombobox = tv({ +const NOOP = () => undefined + +const ESCAPE_KEY_REGEX = /^Esc(ape)?$/ +const ARROW_UP_DOWN_REGEX = /^(Arrow)?(Up|Down)$/ + +const classNameGenerator = tv({ slots: { wrapper: 'smarthr-ui-SingleComboBox shr-inline-block', input: 'smarthr-ui-SingleComboBox-input shr-w-full', @@ -161,6 +168,7 @@ const ActualSingleComboBox = ( inputValue, isFilteringDisabled: !isEditing, }) + const { renderListBox, activeOption, @@ -174,13 +182,15 @@ const ActualSingleComboBox = ( onAdd, onSelect: useCallback( (selected: ComboBoxItem) => { - if (onSelect) onSelect(selected) - if (onChangeSelected) onChangeSelected(selected) + onSelect?.(selected) + onChangeSelected?.(selected) + // HINT: Dropdown系コンポーネント内でComboBoxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする requestAnimationFrame(() => { setIsExpanded(false) }) + setIsEditing(false) }, [onChangeSelected, onSelect], @@ -191,9 +201,16 @@ const ActualSingleComboBox = ( decorators, }) + const selectDefaultItem = useMemo( + () => (onSelect && defaultItem ? () => onSelect(defaultItem) : NOOP), + [onSelect, defaultItem], + ) + const focus = useCallback(() => { - if (onFocus) onFocus() + onFocus?.() + setIsFocused(true) + if (!isFocused) { setIsExpanded(true) } @@ -201,36 +218,38 @@ const ActualSingleComboBox = ( const unfocus = useCallback(() => { if (!isFocused) return - if (onBlur) onBlur() + onBlur?.() + setIsFocused(false) setIsExpanded(false) setIsEditing(false) if (!selectedItem && defaultItem) { setInputValue(innerText(defaultItem.label)) - if (onSelect) onSelect(defaultItem) + + selectDefaultItem() } - }, [isFocused, onBlur, selectedItem, defaultItem, onSelect]) + }, [isFocused, onBlur, selectedItem, defaultItem, selectDefaultItem]) const onClickClear = useCallback( (e: MouseEvent) => { e.stopPropagation() let isExecutedPreventDefault = false - if (onClearClick) { - onClearClick({ - ...e, - preventDefault: () => { - e.preventDefault() - isExecutedPreventDefault = true - }, - }) - } + onClearClick?.({ + ...e, + preventDefault: () => { + e.preventDefault() + isExecutedPreventDefault = true + }, + }) if (!isExecutedPreventDefault) { - if (onClear) onClear() - if (onChangeSelected) onChangeSelected(null) + onClear?.() + onChangeSelected?.(null) + inputRef.current?.focus() + setIsFocused(true) setIsExpanded(true) } @@ -241,21 +260,23 @@ const ActualSingleComboBox = ( (e: MouseEvent) => { if (disabled) { e.stopPropagation() + return } - if (inputRef.current) { - inputRef.current.focus() - } + + inputRef.current?.focus() + if (!isExpanded) { setIsExpanded(true) } }, - [disabled, inputRef, isExpanded, setIsExpanded], + [disabled, inputRef, isExpanded], ) const actualOnChangeInput = useCallback( (e: ChangeEvent) => { - if (onChange) onChange(e) - if (onChangeInput) onChangeInput(e) + onChange?.(e) + onChangeInput?.(e) + if (!isEditing) setIsEditing(true) const { value } = e.currentTarget @@ -263,25 +284,21 @@ const ActualSingleComboBox = ( setInputValue(value) if (value === '') { - if (onClear) onClear() - if (onChangeSelected) onChangeSelected(null) + onClear?.() + onChangeSelected?.(null) } }, - [isEditing, setIsEditing, setInputValue, onChange, onChangeInput, onClear, onChangeSelected], + [isEditing, onChange, onChangeInput, onClear, onChangeSelected], ) - const handleFocus = useCallback(() => { - if (!isFocused) { - focus() - } - }, [isFocused, focus]) - const onCompositionStart = useCallback(() => setIsComposing(true), [setIsComposing]) - const onCompositionEnd = useCallback(() => setIsComposing(false), [setIsComposing]) + const onCompositionStart = useCallback(() => setIsComposing(true), []) + const onCompositionEnd = useCallback(() => setIsComposing(false), []) const onKeyDownInput = useCallback( (e: React.KeyboardEvent) => { if (isComposing) { return } - if (['Escape', 'Esc'].includes(e.key)) { + + if (ESCAPE_KEY_REGEX.test(e.key)) { if (isExpanded) { e.stopPropagation() setIsExpanded(false) @@ -289,17 +306,19 @@ const ActualSingleComboBox = ( } else if (e.key === 'Tab') { unfocus() } else { - if (['Down', 'ArrowDown', 'Up', 'ArrowUp'].includes(e.key)) { + if (ARROW_UP_DOWN_REGEX.test(e.key)) { e.preventDefault() } + inputRef.current?.focus() + if (!isExpanded) { setIsExpanded(true) } } handleListBoxKeyDown(e) }, - [isComposing, isExpanded, setIsExpanded, unfocus, handleListBoxKeyDown], + [isComposing, isExpanded, unfocus, handleListBoxKeyDown], ) // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で @@ -308,7 +327,8 @@ const ActualSingleComboBox = ( const handleKeyPress = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') e.preventDefault() - if (onKeyPress) onKeyPress(e) + + onKeyPress?.(e) }, [onKeyPress], ) @@ -316,125 +336,101 @@ const ActualSingleComboBox = ( const caretIconColor = useMemo(() => { if (isFocused) return textColor.black if (disabled) return textColor.disabled + return textColor.grey }, [disabled, isFocused]) useClick( [outerRef, listBoxRef, clearButtonRef], - useCallback(() => { - if (!isFocused && onSelect && !selectedItem && defaultItem) { - onSelect(defaultItem) - } - }, [isFocused, selectedItem, onSelect, defaultItem]), - useCallback(() => { - unfocus() - }, [unfocus]), + isFocused || selectedItem ? NOOP : selectDefaultItem, + unfocus, ) useEffect(() => { - if (selectedItem) { - setInputValue(innerText(selectedItem.label)) - } else { - setInputValue('') - } + setInputValue(selectedItem ? innerText(selectedItem.label) : '') if (isFocused && inputRef.current) { inputRef.current.focus() - } else if (!selectedItem && defaultItem) { - if (onSelect) onSelect(defaultItem) + } else if (!selectedItem) { + selectDefaultItem() } - }, [isFocused, selectedItem, defaultItem, onSelect]) + }, [isFocused, selectedItem, selectDefaultItem]) - const needsClearButton = selectedItem !== null && !disabled + const wrapperStyle = useMemo( + () => ({ + ...style, + width: typeof width === 'number' ? `${width}px` : width, + }), + [style, width], + ) + + const notSelected = selectedItem === null + + const classNames = useMemo(() => { + const { wrapper, input, caretDownLayout, caretDownIcon, clearButton, clearButtonIcon } = + classNameGenerator() - const { wrapper, input, caretDownLayout, caretDownIcon, clearButton, clearButtonIcon } = - singleCombobox() - const { - wrapperStyleProps, - inputStyle, - caretDownLayoutStyle, - caretDownIconStyle, - clearButtonStyle, - clearButtonIconStyle, - } = useMemo(() => { - const wrapperWidth = typeof width === 'number' ? `${width}px` : width return { - wrapperStyleProps: { - style: { - ...style, - width: wrapperWidth, - }, - className: wrapper({ disabled, className }), - }, - inputStyle: input(), - caretDownLayoutStyle: caretDownLayout(), - caretDownIconStyle: caretDownIcon(), - clearButtonStyle: clearButton({ hidden: !needsClearButton }), - clearButtonIconStyle: clearButtonIcon(), + wrapper: wrapper({ disabled, className }), + input: input(), + caretDownLayout: caretDownLayout(), + caretDownIcon: caretDownIcon(), + clearButton: clearButton({ hidden: notSelected || disabled }), + clearButtonIcon: clearButtonIcon(), } - }, [ - width, - style, - wrapper, - disabled, - className, - input, - caretDownLayout, - caretDownIcon, - clearButton, - needsClearButton, - clearButtonIcon, - ]) + }, [notSelected, disabled, className]) + + const decorated = useDecorators(DECORATOR_DEFAULT_TEXTS, decorators) return ( -
+
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions, smarthr/a11y-delegate-element-has-role-presentation */} - - + {/* eslint-disable-next-line smarthr/a11y-delegate-element-has-role-presentation */} + + } - onClick={onClickInput} - onChange={actualOnChangeInput} - onFocus={handleFocus} - onCompositionStart={onCompositionStart} - onCompositionEnd={onCompositionEnd} - onKeyDown={onKeyDownInput} - onKeyPress={handleKeyPress} - ref={inputRef} - autoComplete={autoComplete ?? 'off'} - role="combobox" - aria-haspopup="listbox" - aria-controls={listBoxId} - aria-expanded={isFocused} - aria-activedescendant={activeOption?.id} - aria-autocomplete="list" - className={inputStyle} + className={classNames.input} + data-smarthr-ui-input="true" /> {renderListBox()}