smarthr-ui 99.7.0 → 99.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/Checkbox/client/ActualCheckbox.cjs +2 -1
- package/lib/components/Checkbox/client/ActualCheckbox.cjs.map +1 -1
- package/lib/components/Checkbox/client/ActualCheckbox.js +2 -1
- package/lib/components/Checkbox/client/ActualCheckbox.js.map +1 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.cjs +2 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.cjs.map +1 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.js +2 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.js.map +1 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.cjs +6 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.cjs.map +1 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.js +6 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.js.map +1 -1
- package/lib/components/DropZone/client/DropZone.cjs +2 -1
- package/lib/components/DropZone/client/DropZone.cjs.map +1 -1
- package/lib/components/DropZone/client/DropZone.js +2 -1
- package/lib/components/DropZone/client/DropZone.js.map +1 -1
- package/lib/components/FormGroup/client/FormGroup.cjs +6 -1
- package/lib/components/FormGroup/client/FormGroup.cjs.map +1 -1
- package/lib/components/FormGroup/client/FormGroup.js +7 -2
- package/lib/components/FormGroup/client/FormGroup.js.map +1 -1
- package/lib/components/FormGroup/client/constants.cjs +2 -0
- package/lib/components/FormGroup/client/constants.cjs.map +1 -1
- package/lib/components/FormGroup/client/constants.d.ts +1 -0
- package/lib/components/FormGroup/client/constants.js +2 -1
- package/lib/components/FormGroup/client/constants.js.map +1 -1
- package/lib/components/Input/client/Input.cjs +2 -1
- package/lib/components/Input/client/Input.cjs.map +1 -1
- package/lib/components/Input/client/Input.js +2 -1
- package/lib/components/Input/client/Input.js.map +1 -1
- package/lib/components/InputFile/client/InputFileMultiplyAppendable.cjs +2 -1
- package/lib/components/InputFile/client/InputFileMultiplyAppendable.cjs.map +1 -1
- package/lib/components/InputFile/client/InputFileMultiplyAppendable.js +2 -1
- package/lib/components/InputFile/client/InputFileMultiplyAppendable.js.map +1 -1
- package/lib/components/InputFile/client/InputFileNative.cjs +2 -1
- package/lib/components/InputFile/client/InputFileNative.cjs.map +1 -1
- package/lib/components/InputFile/client/InputFileNative.js +2 -1
- package/lib/components/InputFile/client/InputFileNative.js.map +1 -1
- package/lib/components/Picker/DatetimeLocalPicker.cjs +2 -1
- package/lib/components/Picker/DatetimeLocalPicker.cjs.map +1 -1
- package/lib/components/Picker/DatetimeLocalPicker.js +2 -1
- package/lib/components/Picker/DatetimeLocalPicker.js.map +1 -1
- package/lib/components/Picker/MonthPicker.cjs +2 -1
- package/lib/components/Picker/MonthPicker.cjs.map +1 -1
- package/lib/components/Picker/MonthPicker.js +2 -1
- package/lib/components/Picker/MonthPicker.js.map +1 -1
- package/lib/components/Picker/TimePicker.cjs +2 -1
- package/lib/components/Picker/TimePicker.cjs.map +1 -1
- package/lib/components/Picker/TimePicker.js +2 -1
- package/lib/components/Picker/TimePicker.js.map +1 -1
- package/lib/components/Select/client/ActualSelect.cjs +20 -17
- package/lib/components/Select/client/ActualSelect.cjs.map +1 -1
- package/lib/components/Select/client/ActualSelect.js +20 -17
- package/lib/components/Select/client/ActualSelect.js.map +1 -1
- package/lib/components/Textarea/client/Textarea.cjs +2 -1
- package/lib/components/Textarea/client/Textarea.cjs.map +1 -1
- package/lib/components/Textarea/client/Textarea.js +2 -1
- package/lib/components/Textarea/client/Textarea.js.map +1 -1
- package/metadata.json +1 -1
- package/package.json +1 -1
|
@@ -28,7 +28,8 @@ const ActualCheckbox = ({ checkboxRef, checked, mixed, error, ...rest }) => {
|
|
|
28
28
|
// HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している
|
|
29
29
|
// もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する
|
|
30
30
|
const mergedRef = hooks_client_useMergeRefs_useMergeRefs.useMergeRefs(callbackRef, checkboxRef);
|
|
31
|
-
|
|
31
|
+
const errorAttr = error || undefined;
|
|
32
|
+
return (jsxRuntime.jsx("input", { ...rest, ref: mergedRef, type: "checkbox", checked: checked, "aria-invalid": errorAttr, "data-smarthr-ui-input-error": errorAttr, "data-smarthr-ui-input": "true", "data-checked": checked || undefined, "data-mixed": mixed || undefined }));
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
exports.ActualCheckbox = ActualCheckbox;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ActualCheckbox.cjs","sources":["../../../../src/components/Checkbox/client/ActualCheckbox.tsx"],"sourcesContent":["'use client'\n\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\n\nimport type { ComponentPropsWithoutRef, FC, Ref } from 'react'\n\nexport type Props = ComponentPropsWithoutRef<'input'> & {\n checkboxRef?: Ref<HTMLInputElement>\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n}\n\nconst callbackRef = (node: HTMLInputElement | null) => {\n if (!node) {\n return\n }\n\n // checkedはcontrolled propとしてinput.checkedプロパティにのみ反映され、\n // HTML属性としては反映されないため、data-checked属性で代替判定する\n const action = () => {\n node.indeterminate =\n node.getAttribute('data-checked') === 'true' && node.getAttribute('data-mixed') === 'true'\n }\n\n action()\n\n const observer = new MutationObserver(action)\n\n observer.observe(node, {\n attributes: true,\n attributeFilter: ['data-checked', 'data-mixed'],\n })\n\n return () => {\n observer.disconnect()\n }\n}\n\nexport const ActualCheckbox: FC<Props> = ({ checkboxRef, checked, mixed, error, ...rest }) => {\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(callbackRef, checkboxRef)\n\n return (\n <input\n {...rest}\n ref={mergedRef}\n type=\"checkbox\"\n checked={checked}\n aria-invalid={error
|
|
1
|
+
{"version":3,"file":"ActualCheckbox.cjs","sources":["../../../../src/components/Checkbox/client/ActualCheckbox.tsx"],"sourcesContent":["'use client'\n\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\n\nimport type { ComponentPropsWithoutRef, FC, Ref } from 'react'\n\nexport type Props = ComponentPropsWithoutRef<'input'> & {\n checkboxRef?: Ref<HTMLInputElement>\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n}\n\nconst callbackRef = (node: HTMLInputElement | null) => {\n if (!node) {\n return\n }\n\n // checkedはcontrolled propとしてinput.checkedプロパティにのみ反映され、\n // HTML属性としては反映されないため、data-checked属性で代替判定する\n const action = () => {\n node.indeterminate =\n node.getAttribute('data-checked') === 'true' && node.getAttribute('data-mixed') === 'true'\n }\n\n action()\n\n const observer = new MutationObserver(action)\n\n observer.observe(node, {\n attributes: true,\n attributeFilter: ['data-checked', 'data-mixed'],\n })\n\n return () => {\n observer.disconnect()\n }\n}\n\nexport const ActualCheckbox: FC<Props> = ({ checkboxRef, checked, mixed, error, ...rest }) => {\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(callbackRef, checkboxRef)\n const errorAttr = error || undefined\n\n return (\n <input\n {...rest}\n ref={mergedRef}\n type=\"checkbox\"\n checked={checked}\n aria-invalid={errorAttr}\n data-smarthr-ui-input-error={errorAttr}\n data-smarthr-ui-input=\"true\"\n // checkedはDOM属性ではなくプロパティとしてのみ反映されるため、data-checkedをMutationObserverで監視\n data-checked={checked || undefined}\n data-mixed={mixed || undefined}\n />\n )\n}\n"],"names":[],"mappings":";;;;;;AAcA;;;;;;;AAQI;AACE;AACJ;AAEA;AAEA;AAEA;AACE;AACA;AACD;AAED;;AAEA;AACF;AAEO;;;;AAIL;AAEA;AAcF;;"}
|
|
@@ -26,7 +26,8 @@ const ActualCheckbox = ({ checkboxRef, checked, mixed, error, ...rest }) => {
|
|
|
26
26
|
// HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している
|
|
27
27
|
// もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する
|
|
28
28
|
const mergedRef = useMergeRefs(callbackRef, checkboxRef);
|
|
29
|
-
|
|
29
|
+
const errorAttr = error || undefined;
|
|
30
|
+
return (jsx("input", { ...rest, ref: mergedRef, type: "checkbox", checked: checked, "aria-invalid": errorAttr, "data-smarthr-ui-input-error": errorAttr, "data-smarthr-ui-input": "true", "data-checked": checked || undefined, "data-mixed": mixed || undefined }));
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
export { ActualCheckbox };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ActualCheckbox.js","sources":["../../../../src/components/Checkbox/client/ActualCheckbox.tsx"],"sourcesContent":["'use client'\n\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\n\nimport type { ComponentPropsWithoutRef, FC, Ref } from 'react'\n\nexport type Props = ComponentPropsWithoutRef<'input'> & {\n checkboxRef?: Ref<HTMLInputElement>\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n}\n\nconst callbackRef = (node: HTMLInputElement | null) => {\n if (!node) {\n return\n }\n\n // checkedはcontrolled propとしてinput.checkedプロパティにのみ反映され、\n // HTML属性としては反映されないため、data-checked属性で代替判定する\n const action = () => {\n node.indeterminate =\n node.getAttribute('data-checked') === 'true' && node.getAttribute('data-mixed') === 'true'\n }\n\n action()\n\n const observer = new MutationObserver(action)\n\n observer.observe(node, {\n attributes: true,\n attributeFilter: ['data-checked', 'data-mixed'],\n })\n\n return () => {\n observer.disconnect()\n }\n}\n\nexport const ActualCheckbox: FC<Props> = ({ checkboxRef, checked, mixed, error, ...rest }) => {\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(callbackRef, checkboxRef)\n\n return (\n <input\n {...rest}\n ref={mergedRef}\n type=\"checkbox\"\n checked={checked}\n aria-invalid={error
|
|
1
|
+
{"version":3,"file":"ActualCheckbox.js","sources":["../../../../src/components/Checkbox/client/ActualCheckbox.tsx"],"sourcesContent":["'use client'\n\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\n\nimport type { ComponentPropsWithoutRef, FC, Ref } from 'react'\n\nexport type Props = ComponentPropsWithoutRef<'input'> & {\n checkboxRef?: Ref<HTMLInputElement>\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n}\n\nconst callbackRef = (node: HTMLInputElement | null) => {\n if (!node) {\n return\n }\n\n // checkedはcontrolled propとしてinput.checkedプロパティにのみ反映され、\n // HTML属性としては反映されないため、data-checked属性で代替判定する\n const action = () => {\n node.indeterminate =\n node.getAttribute('data-checked') === 'true' && node.getAttribute('data-mixed') === 'true'\n }\n\n action()\n\n const observer = new MutationObserver(action)\n\n observer.observe(node, {\n attributes: true,\n attributeFilter: ['data-checked', 'data-mixed'],\n })\n\n return () => {\n observer.disconnect()\n }\n}\n\nexport const ActualCheckbox: FC<Props> = ({ checkboxRef, checked, mixed, error, ...rest }) => {\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(callbackRef, checkboxRef)\n const errorAttr = error || undefined\n\n return (\n <input\n {...rest}\n ref={mergedRef}\n type=\"checkbox\"\n checked={checked}\n aria-invalid={errorAttr}\n data-smarthr-ui-input-error={errorAttr}\n data-smarthr-ui-input=\"true\"\n // checkedはDOM属性ではなくプロパティとしてのみ反映されるため、data-checkedをMutationObserverで監視\n data-checked={checked || undefined}\n data-mixed={mixed || undefined}\n />\n )\n}\n"],"names":[],"mappings":";;;;AAcA;;;;;;;AAQI;AACE;AACJ;AAEA;AAEA;AAEA;AACE;AACA;AACD;AAED;;AAEA;AACF;AAEO;;;;AAIL;AAEA;AAcF;;"}
|
|
@@ -348,12 +348,13 @@ const ActualMultiCombobox = ({ items, selectedItems, name, disabled = false, req
|
|
|
348
348
|
defaultText: '選択済みアイテム',
|
|
349
349
|
},
|
|
350
350
|
});
|
|
351
|
+
const errorAttr = error || undefined;
|
|
351
352
|
return (
|
|
352
353
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
353
354
|
jsxRuntime.jsxs("div", { ref: mergedTriggerRef, role: "group", className: classNames.wrapper, style: {
|
|
354
355
|
...style,
|
|
355
356
|
width: typeof width === 'number' ? `${width}px` : width,
|
|
356
|
-
}, onClick: functions.handleDelegateClick, onKeyDown: functions.handleDelegateKeyDown, onKeyPress: functions.handleDelegateKeyPress, children: [jsxRuntime.jsxs(components_Scroller_client_Scroller.Scroller, { className: classNames.inputArea, children: [jsxRuntime.jsx("ul", { id: selectedListId, className: classNames.selectedList, "aria-label": localized.selectedListAriaLabel, children: selectedItems.map((selectedItem) => (jsxRuntime.jsx("li", { children: jsxRuntime.jsx(components_Combobox_MultiCombobox_MultiSelectedItem.MultiSelectedItem, { disabled: disabled, item: selectedItem, enableEllipsis: selectedItemEllipsis, handleDelete: functions.handleDelete }) }, `${selectedItem.label}-${index$1(selectedItem.value)}`))) }), jsxRuntime.jsx("div", { className: classNames.inputWrapper, children: jsxRuntime.jsx("input", { ...rest, ref: mergedInputRef, role: "combobox", type: "text", name: name, required: required && selectedItems.length === 0, disabled: disabled, value: inputValue, autoComplete: autoComplete ?? 'off', tabIndex: 0, className: classNames.input, "aria-activedescendant": activeOption?.id, "aria-controls": `${listBoxId} ${selectedListId}`, "aria-haspopup": "listbox", "aria-expanded": isExpanded, "aria-invalid":
|
|
357
|
+
}, onClick: functions.handleDelegateClick, onKeyDown: functions.handleDelegateKeyDown, onKeyPress: functions.handleDelegateKeyPress, children: [jsxRuntime.jsxs(components_Scroller_client_Scroller.Scroller, { className: classNames.inputArea, children: [jsxRuntime.jsx("ul", { id: selectedListId, className: classNames.selectedList, "aria-label": localized.selectedListAriaLabel, children: selectedItems.map((selectedItem) => (jsxRuntime.jsx("li", { children: jsxRuntime.jsx(components_Combobox_MultiCombobox_MultiSelectedItem.MultiSelectedItem, { disabled: disabled, item: selectedItem, enableEllipsis: selectedItemEllipsis, handleDelete: functions.handleDelete }) }, `${selectedItem.label}-${index$1(selectedItem.value)}`))) }), jsxRuntime.jsx("div", { className: classNames.inputWrapper, children: jsxRuntime.jsx("input", { ...rest, ref: mergedInputRef, role: "combobox", type: "text", name: name, required: required && selectedItems.length === 0, disabled: disabled, value: inputValue, autoComplete: autoComplete ?? 'off', tabIndex: 0, className: classNames.input, "aria-activedescendant": activeOption?.id, "aria-controls": `${listBoxId} ${selectedListId}`, "aria-haspopup": "listbox", "aria-expanded": isExpanded, "aria-invalid": errorAttr, "aria-disabled": disabled, "aria-autocomplete": "list", "data-smarthr-ui-input-error": errorAttr, "data-smarthr-ui-input": "true", onChange: functions.handleChangeInput, onFocus: functions.handleFocusInput, onCompositionStart: functions.handleCompositionStart, onCompositionEnd: functions.handleCompositionEnd, onKeyDown: functions.handleKeyDownInput }) }), selectedItems.length === 0 && placeholder && !isExpanded && (jsxRuntime.jsx("p", { className: classNames.placeholder, children: placeholder }))] }), jsxRuntime.jsx(MemoizedCaretDown, { disabled: disabled, isExpanded: isExpanded, classNames: classNames }), jsxRuntime.jsx(components_Combobox_useListbox.ListBox, { ...listBoxProps, callbackRef: listBoxCallbackRef })] }));
|
|
357
358
|
};
|
|
358
359
|
const MultiCombobox = libs_util.genericsForwardRef(ActualMultiCombobox);
|
|
359
360
|
const MemoizedCaretDown = React.memo(({ disabled, isExpanded, classNames }) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MultiCombobox.cjs","sources":["../../../../src/components/Combobox/MultiCombobox/MultiCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n memo,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useLocalize } from '../../../intl'\nimport { findDelegateTarget } from '../../../libs/delegate'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { FaCaretDownIcon } from '../../Icon'\nimport { Scroller } from '../../Scroller'\nimport { areItemsEqual } from '../helper'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useMultiOptions } from '../useOptions'\n\nimport { DELETE_BUTTON_SELECTOR, MultiSelectedItem } from './MultiSelectedItem'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテムのリスト\n */\n selectedItems: Array<ComboboxItem<T> & { deletable?: boolean }>\n /**\n * 選択されているアイテムのラベルを省略表示するかどうか\n */\n selectedItemEllipsis?: boolean\n /**\n * テキストボックスの `value` 属性の値。\n * `onChangeInput` と併せて設定することで、テキストボックスの挙動が制御可能になる。\n */\n inputValue?: string\n /**\n * 選択されているアイテムの削除ボタンがクリックされた時に発火するコールバック関数\n */\n onDelete?: (item: ComboboxItem<T>) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItems: Array<ComboboxItem<T>>) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * アイテムが選択されたときに選択済みかどうかを判定するコールバック関数/\n */\n isItemSelected?: (targetItem: ComboboxItem<T>, selectedItems: Array<ComboboxItem<T>>) => boolean\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst NOOP = () => undefined\n\nconst preventDefaultWithPressEnter = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n }\n}\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_LEFT_KEY_REGEX = /^(Arrow)?Left$/\nconst ARROW_RIGHT_KEY_REGEX = /^(Arrow)?Right/\nconst ARROW_UP_AND_DOWN_KEY_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst DELETE_BUTTON_CLASSNAME = `.${DELETE_BUTTON_SELECTOR}`\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: [\n 'smarthr-ui-MultiCombobox',\n 'shr-box-border shr-inline-flex shr-min-w-[15em] shr-rounded-m shr-border shr-border-solid shr-px-0.5 shr-py-0.25 shr-align-bottom',\n 'contrast-more:shr-border-high-contrast',\n 'has-[[role=combobox][aria-expanded=true]]:shr-focus-indicator',\n 'shr-cursor-text shr-border-default shr-bg-white',\n 'has-[[role=combobox]:disabled]:shr-cursor-not-allowed',\n 'has-[[role=combobox]:disabled]:shr-border-default/50',\n 'has-[[role=combobox]:disabled]:shr-bg-white-darken',\n 'has-[[role=combobox]:disabled]:shr-text-disabled',\n // HINT: disabled と詳細度が同じ [0,2,0] のため、CSS ソース順でこちらを後に置くことで error 時の border 色を優先させる\n 'has-[[role=combobox][aria-invalid]]:shr-border-danger',\n ],\n inputArea: 'shr-flex shr-flex-1 shr-flex-wrap shr-gap-0.5',\n selectedList:\n 'smarthr-ui-MultiCombobox-selectedList shr-contents shr-list-none [&_li]:shr-min-w-0',\n inputWrapper: [\n 'shr-flex shr-flex-1 shr-items-center',\n 'has-[[role=combobox][aria-expanded=false]]:shr-pointer-events-none',\n 'has-[[role=combobox][aria-expanded=false]]:shr-absolute',\n 'has-[[role=combobox][aria-expanded=false]]:shr-opacity-0',\n ],\n input: [\n 'smarthr-ui-MultiCombobox-input',\n 'shr-w-full shr-min-w-[5em] shr-border-none shr-text-base shr-text-black shr-outline-none shr-outline-0',\n 'disabled:shr-hidden',\n ],\n placeholderEl: 'smarthr-ui-MultiCombobox-placeholder shr-my-0 shr-self-center',\n suffixWrapper: [\n 'shr-relative -shr-me-0.5 shr-ms-0.5 shr-p-0.5',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n suffixIcon: 'shr-block',\n },\n})\n\nconst ActualMultiCombobox = <T,>(\n {\n items,\n selectedItems,\n name,\n disabled = false,\n required = false,\n error = false,\n creatable = false,\n placeholder = '',\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n selectedItemEllipsis,\n width,\n dropdownWidth = 'auto',\n inputValue: controlledInputValue,\n className,\n onChange,\n onChangeInput,\n onAdd,\n onDelete,\n onSelect,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n isItemSelected,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const triggerRef = useRef<HTMLDivElement>(null)\n const [isExpanded, setIsExpanded] = useState(false)\n const [highlighted, setHighlighted] = useState(false)\n const [uncontrolledInputValue, setUncontrolledInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n\n const baseId = useId()\n const selectedListId = `${baseId}-selected`\n\n const isInputControlled = controlledInputValue !== undefined\n const inputValue = isInputControlled ? controlledInputValue : uncontrolledInputValue\n const setInputValueIfUncontrolled = isInputControlled ? NOOP : setUncontrolledInputValue\n const isInputEmpty = !inputValue\n\n const { options } = useMultiOptions({\n items,\n selected: selectedItems,\n creatable,\n inputValue,\n isItemSelected,\n })\n const inputRef = useRef<HTMLInputElement>(null)\n const deleteFrame = useAnimationFrame()\n const selectFrame = useAnimationFrame()\n\n // eslint-disable-next-line local-rules/best-practice-for-use-latest\n const latestForListBox = useLatest({\n onDelete,\n onChangeSelected,\n onSelect,\n onChangeInput,\n selectedItems,\n deleteFrame,\n selectFrame,\n })\n\n const listBoxFunctions = useMemo(() => {\n const handleDelete = (item: ComboboxItem<T>) => {\n const handlers: Array<(deletingItem: ComboboxItem<T>) => void> = []\n\n if (latestForListBox.onDelete) {\n handlers.push((deletingItem: ComboboxItem<T>) => latestForListBox.onDelete!(deletingItem))\n }\n if (latestForListBox.onChangeSelected) {\n handlers.push((deletingItem: ComboboxItem<T>) =>\n latestForListBox.onChangeSelected!(\n latestForListBox.selectedItems.filter(\n (selected) => !areItemsEqual(selected, deletingItem),\n ),\n ),\n )\n }\n\n if (handlers.length > 0) {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.deleteFrame.request(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n cleanupListBoxCallbackRef: () => () => {\n latestForListBox.deleteFrame.cancel()\n latestForListBox.selectFrame.cancel()\n },\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.selectFrame.request(() => {\n const matchedSelectedItem = latestForListBox.selectedItems.find((item) =>\n areItemsEqual(item, selected),\n )\n\n if (matchedSelectedItem === undefined) {\n latestForListBox.onSelect?.(selected)\n latestForListBox.onChangeSelected?.(latestForListBox.selectedItems.concat(selected))\n\n // 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n latestForListBox.onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n } else if (matchedSelectedItem.deletable !== false) {\n handleDelete(selected)\n }\n })\n },\n }\n }, [latestForListBox])\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n onSelect: listBoxFunctions.handleSelect,\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onFocus,\n onBlur,\n onKeyPress,\n disabled,\n isExpanded,\n highlighted,\n isComposing,\n isInputEmpty,\n selectedItems,\n setInputValueIfUncontrolled,\n handleKeyDownListBox,\n cleanupAddFrame,\n })\n\n const functions = useMemo(() => {\n const handleDelete = listBoxFunctions.handleDelete\n\n const getDeletionButtons = () => {\n if (triggerRef.current) {\n const buttons =\n triggerRef.current.querySelectorAll<HTMLButtonElement>(DELETE_BUTTON_CLASSNAME)\n\n if (buttons.length > 0) {\n const actualButtons = Array.from(buttons)\n\n return {\n buttons: actualButtons,\n currentIndex: actualButtons.indexOf(document.activeElement as HTMLButtonElement),\n }\n }\n }\n\n return null\n }\n\n const focusPrevDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex !== -1) {\n buttons[Math.max(currentIndex - 1, 0)].focus()\n } else if (inputRef.current?.selectionStart === 0) {\n buttons[buttons.length - 1].focus()\n }\n }\n\n const focusNextDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex === -1) return\n\n const nextIndex = currentIndex + 1\n\n if (nextIndex < buttons.length) {\n buttons[nextIndex].focus()\n } else {\n // キー入力が input に影響しないようにフォーカスタイミングを遅らせる\n setTimeout(() => {\n inputRef.current?.focus()\n })\n }\n }\n\n const focus = () => {\n latest.onFocus?.()\n setIsExpanded(true)\n }\n\n const blur = () => {\n if (latest.isExpanded) {\n latest.onBlur?.()\n setIsExpanded(false)\n }\n }\n\n return {\n handleDelete,\n blur,\n cleanupCallbackRef: () => latest.cleanupAddFrame,\n handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n }\n\n blur()\n } else if (e.key === 'Tab') {\n if (latest.isExpanded) {\n // フォーカスがコンポーネントを抜けるように先に input をフォーカスしておく\n inputRef.current?.focus()\n }\n\n blur()\n } else if (ARROW_LEFT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusPrevDeletionButton()\n } else if (ARROW_RIGHT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusNextDeletionButton()\n } else if (\n e.key === 'Backspace' &&\n latest.isInputEmpty &&\n latest.selectedItems.length > 0 &&\n latest.selectedItems[latest.selectedItems.length - 1].deletable !== false\n ) {\n e.preventDefault()\n e.stopPropagation()\n\n const lastItem = latest.selectedItems[latest.selectedItems.length - 1]\n\n handleDelete(lastItem)\n setHighlighted(true)\n latest.setInputValueIfUncontrolled(innerText(lastItem.label))\n } else {\n e.stopPropagation()\n inputRef.current?.focus()\n }\n\n latest.handleKeyDownListBox(e)\n },\n handleDelegateClick: (e: MouseEvent<HTMLElement>) => {\n if (\n !latest.disabled &&\n !latest.isExpanded &&\n !findDelegateTarget(e, DELETE_BUTTON_CLASSNAME)\n ) {\n focus()\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n const handlers = [latest.onChange, latest.onChangeInput].filter((h) => !!h)\n\n handlers.forEach((h) => h(e))\n latest.setInputValueIfUncontrolled(e.currentTarget.value)\n },\n handleFocusInput: () => {\n if (!latest.isExpanded) {\n focus()\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (ARROW_UP_AND_DOWN_KEY_REGEX.test(e.key)) {\n // 上下キー入力はリストボックスの activeDescendant の移動に用いるため、input 内では作用させない\n e.preventDefault()\n }\n },\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleDelegateKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n preventDefaultWithPressEnter(e)\n latest.onKeyPress?.(e)\n },\n }\n }, [listBoxFunctions, latest])\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isExpanded ? [triggerRef] : null,\n functions.blur,\n )\n\n const mergedInputRef = useMergeRefs(inputRef, listBoxFunctions.cleanupListBoxCallbackRef, ref)\n const mergedTriggerRef = useMergeRefs(triggerRef, functions.cleanupCallbackRef)\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled])\n\n const classNames = useMemo(() => {\n const {\n wrapper,\n inputArea,\n selectedList,\n inputWrapper,\n input,\n placeholderEl,\n suffixWrapper,\n suffixIcon,\n } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n inputArea: inputArea(),\n selectedList: selectedList(),\n inputWrapper: inputWrapper(),\n input: input(),\n placeholder: placeholderEl(),\n suffixWrapper: suffixWrapper(),\n suffixIcon: suffixIcon(),\n }\n }, [className])\n\n const localized = useLocalize({\n selectedListAriaLabel: {\n id: 'smarthr-ui/MultiCombobox/selectedListAriaLabel',\n defaultText: '選択済みアイテム',\n },\n })\n\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={mergedTriggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n className={classNames.selectedList}\n aria-label={localized.selectedListAriaLabel}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n disabled={disabled}\n item={selectedItem}\n enableEllipsis={selectedItemEllipsis}\n handleDelete={functions.handleDelete}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n ref={mergedInputRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required && selectedItems.length === 0}\n disabled={disabled}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n className={classNames.input}\n aria-activedescendant={activeOption?.id}\n aria-controls={`${listBoxId} ${selectedListId}`}\n aria-haspopup=\"listbox\"\n aria-expanded={isExpanded}\n aria-invalid={error || undefined}\n aria-disabled={disabled}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input=\"true\"\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n />\n </div>\n\n {selectedItems.length === 0 && placeholder && !isExpanded && (\n <p className={classNames.placeholder}>{placeholder}</p>\n )}\n </Scroller>\n\n <MemoizedCaretDown disabled={disabled} isExpanded={isExpanded} classNames={classNames} />\n\n <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />\n </div>\n )\n}\n\nexport const MultiCombobox = genericsForwardRef(ActualMultiCombobox)\n\nconst MemoizedCaretDown = memo<{\n disabled: boolean\n isExpanded: boolean\n classNames: {\n suffixWrapper: string\n suffixIcon: string\n }\n}>(({ disabled, isExpanded, classNames }) => {\n const theme = useTheme()\n const caretIconColor = isExpanded\n ? theme.textColor.black\n : disabled\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n return (\n <div className={classNames.suffixWrapper}>\n <FaCaretDownIcon color={caretIconColor} className={classNames.suffixIcon} />\n </div>\n )\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8EA;AAEA;AACE;;;AAGF;AAEA;AACA;AACA;AACA;AAEA;AACE;AACA;;AAGF;AAEA;AACE;AACE;;;;;;;;;;;;AAYC;AACD;AACA;AAEA;;;;;AAKC;AACD;;;;AAIC;AACD;AACA;;;AAGC;AACD;AACD;AACF;AAED;AAkCE;;;;;AAMA;AACA;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;AACA;AACA;;;;;;;;;;AAWC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;AAGE;AACE;AACF;;AAEJ;;AAGE;AACE;AACA;;;AAGF;;;AAGE;;AAKE;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAWG;;;;;;;;;;;;;;;;AAiBF;AAED;AACE;;AAGE;;AAIE;;;AAII;;;;;AAMN;AACF;;AAGE;AAEA;;AAEA;AAEA;AACE;;;;;AAIJ;;AAGE;AAEA;;AAEA;;;AAIA;AAEA;AACE;;;;;AAIE;AACF;;AAEJ;;AAGE;;AAEF;;AAGE;AACE;;;AAGJ;;;;AAKE;AACA;;;;AAII;;;AAIA;;AACK;AACL;;AAEE;;AAGF;;;;AAGA;;;;AAGA;;AACK;AAEL;AACA;AACA;;;AAKA;;;;;;;AAOA;;AAGF;;AAEF;;;AAII;AAEA;;;AAGJ;;AAGE;;;;AAIA;AACE;;;AAGJ;AACA;AACA;;;;;;;;;AASA;;AAEE;;;AAGN;;AAOA;;;AAIE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;;;AAIC;AAKI;AACA;AACD;AA8DP;;AAIA;AAQE;;AAEE;AACA;AACE;AACA;;AAON;;"}
|
|
1
|
+
{"version":3,"file":"MultiCombobox.cjs","sources":["../../../../src/components/Combobox/MultiCombobox/MultiCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n memo,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useLocalize } from '../../../intl'\nimport { findDelegateTarget } from '../../../libs/delegate'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { FaCaretDownIcon } from '../../Icon'\nimport { Scroller } from '../../Scroller'\nimport { areItemsEqual } from '../helper'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useMultiOptions } from '../useOptions'\n\nimport { DELETE_BUTTON_SELECTOR, MultiSelectedItem } from './MultiSelectedItem'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテムのリスト\n */\n selectedItems: Array<ComboboxItem<T> & { deletable?: boolean }>\n /**\n * 選択されているアイテムのラベルを省略表示するかどうか\n */\n selectedItemEllipsis?: boolean\n /**\n * テキストボックスの `value` 属性の値。\n * `onChangeInput` と併せて設定することで、テキストボックスの挙動が制御可能になる。\n */\n inputValue?: string\n /**\n * 選択されているアイテムの削除ボタンがクリックされた時に発火するコールバック関数\n */\n onDelete?: (item: ComboboxItem<T>) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItems: Array<ComboboxItem<T>>) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * アイテムが選択されたときに選択済みかどうかを判定するコールバック関数/\n */\n isItemSelected?: (targetItem: ComboboxItem<T>, selectedItems: Array<ComboboxItem<T>>) => boolean\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst NOOP = () => undefined\n\nconst preventDefaultWithPressEnter = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n }\n}\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_LEFT_KEY_REGEX = /^(Arrow)?Left$/\nconst ARROW_RIGHT_KEY_REGEX = /^(Arrow)?Right/\nconst ARROW_UP_AND_DOWN_KEY_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst DELETE_BUTTON_CLASSNAME = `.${DELETE_BUTTON_SELECTOR}`\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: [\n 'smarthr-ui-MultiCombobox',\n 'shr-box-border shr-inline-flex shr-min-w-[15em] shr-rounded-m shr-border shr-border-solid shr-px-0.5 shr-py-0.25 shr-align-bottom',\n 'contrast-more:shr-border-high-contrast',\n 'has-[[role=combobox][aria-expanded=true]]:shr-focus-indicator',\n 'shr-cursor-text shr-border-default shr-bg-white',\n 'has-[[role=combobox]:disabled]:shr-cursor-not-allowed',\n 'has-[[role=combobox]:disabled]:shr-border-default/50',\n 'has-[[role=combobox]:disabled]:shr-bg-white-darken',\n 'has-[[role=combobox]:disabled]:shr-text-disabled',\n // HINT: disabled と詳細度が同じ [0,2,0] のため、CSS ソース順でこちらを後に置くことで error 時の border 色を優先させる\n 'has-[[role=combobox][aria-invalid]]:shr-border-danger',\n ],\n inputArea: 'shr-flex shr-flex-1 shr-flex-wrap shr-gap-0.5',\n selectedList:\n 'smarthr-ui-MultiCombobox-selectedList shr-contents shr-list-none [&_li]:shr-min-w-0',\n inputWrapper: [\n 'shr-flex shr-flex-1 shr-items-center',\n 'has-[[role=combobox][aria-expanded=false]]:shr-pointer-events-none',\n 'has-[[role=combobox][aria-expanded=false]]:shr-absolute',\n 'has-[[role=combobox][aria-expanded=false]]:shr-opacity-0',\n ],\n input: [\n 'smarthr-ui-MultiCombobox-input',\n 'shr-w-full shr-min-w-[5em] shr-border-none shr-text-base shr-text-black shr-outline-none shr-outline-0',\n 'disabled:shr-hidden',\n ],\n placeholderEl: 'smarthr-ui-MultiCombobox-placeholder shr-my-0 shr-self-center',\n suffixWrapper: [\n 'shr-relative -shr-me-0.5 shr-ms-0.5 shr-p-0.5',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n suffixIcon: 'shr-block',\n },\n})\n\nconst ActualMultiCombobox = <T,>(\n {\n items,\n selectedItems,\n name,\n disabled = false,\n required = false,\n error = false,\n creatable = false,\n placeholder = '',\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n selectedItemEllipsis,\n width,\n dropdownWidth = 'auto',\n inputValue: controlledInputValue,\n className,\n onChange,\n onChangeInput,\n onAdd,\n onDelete,\n onSelect,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n isItemSelected,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const triggerRef = useRef<HTMLDivElement>(null)\n const [isExpanded, setIsExpanded] = useState(false)\n const [highlighted, setHighlighted] = useState(false)\n const [uncontrolledInputValue, setUncontrolledInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n\n const baseId = useId()\n const selectedListId = `${baseId}-selected`\n\n const isInputControlled = controlledInputValue !== undefined\n const inputValue = isInputControlled ? controlledInputValue : uncontrolledInputValue\n const setInputValueIfUncontrolled = isInputControlled ? NOOP : setUncontrolledInputValue\n const isInputEmpty = !inputValue\n\n const { options } = useMultiOptions({\n items,\n selected: selectedItems,\n creatable,\n inputValue,\n isItemSelected,\n })\n const inputRef = useRef<HTMLInputElement>(null)\n const deleteFrame = useAnimationFrame()\n const selectFrame = useAnimationFrame()\n\n // eslint-disable-next-line local-rules/best-practice-for-use-latest\n const latestForListBox = useLatest({\n onDelete,\n onChangeSelected,\n onSelect,\n onChangeInput,\n selectedItems,\n deleteFrame,\n selectFrame,\n })\n\n const listBoxFunctions = useMemo(() => {\n const handleDelete = (item: ComboboxItem<T>) => {\n const handlers: Array<(deletingItem: ComboboxItem<T>) => void> = []\n\n if (latestForListBox.onDelete) {\n handlers.push((deletingItem: ComboboxItem<T>) => latestForListBox.onDelete!(deletingItem))\n }\n if (latestForListBox.onChangeSelected) {\n handlers.push((deletingItem: ComboboxItem<T>) =>\n latestForListBox.onChangeSelected!(\n latestForListBox.selectedItems.filter(\n (selected) => !areItemsEqual(selected, deletingItem),\n ),\n ),\n )\n }\n\n if (handlers.length > 0) {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.deleteFrame.request(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n cleanupListBoxCallbackRef: () => () => {\n latestForListBox.deleteFrame.cancel()\n latestForListBox.selectFrame.cancel()\n },\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.selectFrame.request(() => {\n const matchedSelectedItem = latestForListBox.selectedItems.find((item) =>\n areItemsEqual(item, selected),\n )\n\n if (matchedSelectedItem === undefined) {\n latestForListBox.onSelect?.(selected)\n latestForListBox.onChangeSelected?.(latestForListBox.selectedItems.concat(selected))\n\n // 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n latestForListBox.onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n } else if (matchedSelectedItem.deletable !== false) {\n handleDelete(selected)\n }\n })\n },\n }\n }, [latestForListBox])\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n onSelect: listBoxFunctions.handleSelect,\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onFocus,\n onBlur,\n onKeyPress,\n disabled,\n isExpanded,\n highlighted,\n isComposing,\n isInputEmpty,\n selectedItems,\n setInputValueIfUncontrolled,\n handleKeyDownListBox,\n cleanupAddFrame,\n })\n\n const functions = useMemo(() => {\n const handleDelete = listBoxFunctions.handleDelete\n\n const getDeletionButtons = () => {\n if (triggerRef.current) {\n const buttons =\n triggerRef.current.querySelectorAll<HTMLButtonElement>(DELETE_BUTTON_CLASSNAME)\n\n if (buttons.length > 0) {\n const actualButtons = Array.from(buttons)\n\n return {\n buttons: actualButtons,\n currentIndex: actualButtons.indexOf(document.activeElement as HTMLButtonElement),\n }\n }\n }\n\n return null\n }\n\n const focusPrevDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex !== -1) {\n buttons[Math.max(currentIndex - 1, 0)].focus()\n } else if (inputRef.current?.selectionStart === 0) {\n buttons[buttons.length - 1].focus()\n }\n }\n\n const focusNextDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex === -1) return\n\n const nextIndex = currentIndex + 1\n\n if (nextIndex < buttons.length) {\n buttons[nextIndex].focus()\n } else {\n // キー入力が input に影響しないようにフォーカスタイミングを遅らせる\n setTimeout(() => {\n inputRef.current?.focus()\n })\n }\n }\n\n const focus = () => {\n latest.onFocus?.()\n setIsExpanded(true)\n }\n\n const blur = () => {\n if (latest.isExpanded) {\n latest.onBlur?.()\n setIsExpanded(false)\n }\n }\n\n return {\n handleDelete,\n blur,\n cleanupCallbackRef: () => latest.cleanupAddFrame,\n handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n }\n\n blur()\n } else if (e.key === 'Tab') {\n if (latest.isExpanded) {\n // フォーカスがコンポーネントを抜けるように先に input をフォーカスしておく\n inputRef.current?.focus()\n }\n\n blur()\n } else if (ARROW_LEFT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusPrevDeletionButton()\n } else if (ARROW_RIGHT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusNextDeletionButton()\n } else if (\n e.key === 'Backspace' &&\n latest.isInputEmpty &&\n latest.selectedItems.length > 0 &&\n latest.selectedItems[latest.selectedItems.length - 1].deletable !== false\n ) {\n e.preventDefault()\n e.stopPropagation()\n\n const lastItem = latest.selectedItems[latest.selectedItems.length - 1]\n\n handleDelete(lastItem)\n setHighlighted(true)\n latest.setInputValueIfUncontrolled(innerText(lastItem.label))\n } else {\n e.stopPropagation()\n inputRef.current?.focus()\n }\n\n latest.handleKeyDownListBox(e)\n },\n handleDelegateClick: (e: MouseEvent<HTMLElement>) => {\n if (\n !latest.disabled &&\n !latest.isExpanded &&\n !findDelegateTarget(e, DELETE_BUTTON_CLASSNAME)\n ) {\n focus()\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n const handlers = [latest.onChange, latest.onChangeInput].filter((h) => !!h)\n\n handlers.forEach((h) => h(e))\n latest.setInputValueIfUncontrolled(e.currentTarget.value)\n },\n handleFocusInput: () => {\n if (!latest.isExpanded) {\n focus()\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (ARROW_UP_AND_DOWN_KEY_REGEX.test(e.key)) {\n // 上下キー入力はリストボックスの activeDescendant の移動に用いるため、input 内では作用させない\n e.preventDefault()\n }\n },\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleDelegateKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n preventDefaultWithPressEnter(e)\n latest.onKeyPress?.(e)\n },\n }\n }, [listBoxFunctions, latest])\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isExpanded ? [triggerRef] : null,\n functions.blur,\n )\n\n const mergedInputRef = useMergeRefs(inputRef, listBoxFunctions.cleanupListBoxCallbackRef, ref)\n const mergedTriggerRef = useMergeRefs(triggerRef, functions.cleanupCallbackRef)\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled])\n\n const classNames = useMemo(() => {\n const {\n wrapper,\n inputArea,\n selectedList,\n inputWrapper,\n input,\n placeholderEl,\n suffixWrapper,\n suffixIcon,\n } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n inputArea: inputArea(),\n selectedList: selectedList(),\n inputWrapper: inputWrapper(),\n input: input(),\n placeholder: placeholderEl(),\n suffixWrapper: suffixWrapper(),\n suffixIcon: suffixIcon(),\n }\n }, [className])\n\n const localized = useLocalize({\n selectedListAriaLabel: {\n id: 'smarthr-ui/MultiCombobox/selectedListAriaLabel',\n defaultText: '選択済みアイテム',\n },\n })\n\n const errorAttr = error || undefined\n\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={mergedTriggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n className={classNames.selectedList}\n aria-label={localized.selectedListAriaLabel}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n disabled={disabled}\n item={selectedItem}\n enableEllipsis={selectedItemEllipsis}\n handleDelete={functions.handleDelete}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n ref={mergedInputRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required && selectedItems.length === 0}\n disabled={disabled}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n className={classNames.input}\n aria-activedescendant={activeOption?.id}\n aria-controls={`${listBoxId} ${selectedListId}`}\n aria-haspopup=\"listbox\"\n aria-expanded={isExpanded}\n aria-invalid={errorAttr}\n aria-disabled={disabled}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input-error={errorAttr}\n data-smarthr-ui-input=\"true\"\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n />\n </div>\n\n {selectedItems.length === 0 && placeholder && !isExpanded && (\n <p className={classNames.placeholder}>{placeholder}</p>\n )}\n </Scroller>\n\n <MemoizedCaretDown disabled={disabled} isExpanded={isExpanded} classNames={classNames} />\n\n <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />\n </div>\n )\n}\n\nexport const MultiCombobox = genericsForwardRef(ActualMultiCombobox)\n\nconst MemoizedCaretDown = memo<{\n disabled: boolean\n isExpanded: boolean\n classNames: {\n suffixWrapper: string\n suffixIcon: string\n }\n}>(({ disabled, isExpanded, classNames }) => {\n const theme = useTheme()\n const caretIconColor = isExpanded\n ? theme.textColor.black\n : disabled\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n return (\n <div className={classNames.suffixWrapper}>\n <FaCaretDownIcon color={caretIconColor} className={classNames.suffixIcon} />\n </div>\n )\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8EA;AAEA;AACE;;;AAGF;AAEA;AACA;AACA;AACA;AAEA;AACE;AACA;;AAGF;AAEA;AACE;AACE;;;;;;;;;;;;AAYC;AACD;AACA;AAEA;;;;;AAKC;AACD;;;;AAIC;AACD;AACA;;;AAGC;AACD;AACD;AACF;AAED;AAkCE;;;;;AAMA;AACA;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;AACA;AACA;;;;;;;;;;AAWC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;AAGE;AACE;AACF;;AAEJ;;AAGE;AACE;AACA;;;AAGF;;;AAGE;;AAKE;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAWG;;;;;;;;;;;;;;;;AAiBF;AAED;AACE;;AAGE;;AAIE;;;AAII;;;;;AAMN;AACF;;AAGE;AAEA;;AAEA;AAEA;AACE;;;;;AAIJ;;AAGE;AAEA;;AAEA;;;AAIA;AAEA;AACE;;;;;AAIE;AACF;;AAEJ;;AAGE;;AAEF;;AAGE;AACE;;;AAGJ;;;;AAKE;AACA;;;;AAII;;;AAIA;;AACK;AACL;;AAEE;;AAGF;;;;AAGA;;;;AAGA;;AACK;AAEL;AACA;AACA;;;AAKA;;;;;;;AAOA;;AAGF;;AAEF;;;AAII;AAEA;;;AAGJ;;AAGE;;;;AAIA;AACE;;;AAGJ;AACA;AACA;;;;;;;;;AASA;;AAEE;;;AAGN;;AAOA;;;AAIE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;AAED;;;AAIE;AAKI;AACA;AACD;AA+DP;;AAIA;AAQE;;AAEE;AACA;AACE;AACA;;AAON;;"}
|
|
@@ -346,12 +346,13 @@ const ActualMultiCombobox = ({ items, selectedItems, name, disabled = false, req
|
|
|
346
346
|
defaultText: '選択済みアイテム',
|
|
347
347
|
},
|
|
348
348
|
});
|
|
349
|
+
const errorAttr = error || undefined;
|
|
349
350
|
return (
|
|
350
351
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
351
352
|
jsxs("div", { ref: mergedTriggerRef, role: "group", className: classNames.wrapper, style: {
|
|
352
353
|
...style,
|
|
353
354
|
width: typeof width === 'number' ? `${width}px` : width,
|
|
354
|
-
}, onClick: functions.handleDelegateClick, onKeyDown: functions.handleDelegateKeyDown, onKeyPress: functions.handleDelegateKeyPress, children: [jsxs(Scroller, { className: classNames.inputArea, children: [jsx("ul", { id: selectedListId, className: classNames.selectedList, "aria-label": localized.selectedListAriaLabel, children: selectedItems.map((selectedItem) => (jsx("li", { children: jsx(MultiSelectedItem, { disabled: disabled, item: selectedItem, enableEllipsis: selectedItemEllipsis, handleDelete: functions.handleDelete }) }, `${selectedItem.label}-${innerText(selectedItem.value)}`))) }), jsx("div", { className: classNames.inputWrapper, children: jsx("input", { ...rest, ref: mergedInputRef, role: "combobox", type: "text", name: name, required: required && selectedItems.length === 0, disabled: disabled, value: inputValue, autoComplete: autoComplete ?? 'off', tabIndex: 0, className: classNames.input, "aria-activedescendant": activeOption?.id, "aria-controls": `${listBoxId} ${selectedListId}`, "aria-haspopup": "listbox", "aria-expanded": isExpanded, "aria-invalid":
|
|
355
|
+
}, onClick: functions.handleDelegateClick, onKeyDown: functions.handleDelegateKeyDown, onKeyPress: functions.handleDelegateKeyPress, children: [jsxs(Scroller, { className: classNames.inputArea, children: [jsx("ul", { id: selectedListId, className: classNames.selectedList, "aria-label": localized.selectedListAriaLabel, children: selectedItems.map((selectedItem) => (jsx("li", { children: jsx(MultiSelectedItem, { disabled: disabled, item: selectedItem, enableEllipsis: selectedItemEllipsis, handleDelete: functions.handleDelete }) }, `${selectedItem.label}-${innerText(selectedItem.value)}`))) }), jsx("div", { className: classNames.inputWrapper, children: jsx("input", { ...rest, ref: mergedInputRef, role: "combobox", type: "text", name: name, required: required && selectedItems.length === 0, disabled: disabled, value: inputValue, autoComplete: autoComplete ?? 'off', tabIndex: 0, className: classNames.input, "aria-activedescendant": activeOption?.id, "aria-controls": `${listBoxId} ${selectedListId}`, "aria-haspopup": "listbox", "aria-expanded": isExpanded, "aria-invalid": errorAttr, "aria-disabled": disabled, "aria-autocomplete": "list", "data-smarthr-ui-input-error": errorAttr, "data-smarthr-ui-input": "true", onChange: functions.handleChangeInput, onFocus: functions.handleFocusInput, onCompositionStart: functions.handleCompositionStart, onCompositionEnd: functions.handleCompositionEnd, onKeyDown: functions.handleKeyDownInput }) }), selectedItems.length === 0 && placeholder && !isExpanded && (jsx("p", { className: classNames.placeholder, children: placeholder }))] }), jsx(MemoizedCaretDown, { disabled: disabled, isExpanded: isExpanded, classNames: classNames }), jsx(ListBox, { ...listBoxProps, callbackRef: listBoxCallbackRef })] }));
|
|
355
356
|
};
|
|
356
357
|
const MultiCombobox = genericsForwardRef(ActualMultiCombobox);
|
|
357
358
|
const MemoizedCaretDown = memo(({ disabled, isExpanded, classNames }) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MultiCombobox.js","sources":["../../../../src/components/Combobox/MultiCombobox/MultiCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n memo,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useLocalize } from '../../../intl'\nimport { findDelegateTarget } from '../../../libs/delegate'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { FaCaretDownIcon } from '../../Icon'\nimport { Scroller } from '../../Scroller'\nimport { areItemsEqual } from '../helper'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useMultiOptions } from '../useOptions'\n\nimport { DELETE_BUTTON_SELECTOR, MultiSelectedItem } from './MultiSelectedItem'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテムのリスト\n */\n selectedItems: Array<ComboboxItem<T> & { deletable?: boolean }>\n /**\n * 選択されているアイテムのラベルを省略表示するかどうか\n */\n selectedItemEllipsis?: boolean\n /**\n * テキストボックスの `value` 属性の値。\n * `onChangeInput` と併せて設定することで、テキストボックスの挙動が制御可能になる。\n */\n inputValue?: string\n /**\n * 選択されているアイテムの削除ボタンがクリックされた時に発火するコールバック関数\n */\n onDelete?: (item: ComboboxItem<T>) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItems: Array<ComboboxItem<T>>) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * アイテムが選択されたときに選択済みかどうかを判定するコールバック関数/\n */\n isItemSelected?: (targetItem: ComboboxItem<T>, selectedItems: Array<ComboboxItem<T>>) => boolean\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst NOOP = () => undefined\n\nconst preventDefaultWithPressEnter = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n }\n}\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_LEFT_KEY_REGEX = /^(Arrow)?Left$/\nconst ARROW_RIGHT_KEY_REGEX = /^(Arrow)?Right/\nconst ARROW_UP_AND_DOWN_KEY_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst DELETE_BUTTON_CLASSNAME = `.${DELETE_BUTTON_SELECTOR}`\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: [\n 'smarthr-ui-MultiCombobox',\n 'shr-box-border shr-inline-flex shr-min-w-[15em] shr-rounded-m shr-border shr-border-solid shr-px-0.5 shr-py-0.25 shr-align-bottom',\n 'contrast-more:shr-border-high-contrast',\n 'has-[[role=combobox][aria-expanded=true]]:shr-focus-indicator',\n 'shr-cursor-text shr-border-default shr-bg-white',\n 'has-[[role=combobox]:disabled]:shr-cursor-not-allowed',\n 'has-[[role=combobox]:disabled]:shr-border-default/50',\n 'has-[[role=combobox]:disabled]:shr-bg-white-darken',\n 'has-[[role=combobox]:disabled]:shr-text-disabled',\n // HINT: disabled と詳細度が同じ [0,2,0] のため、CSS ソース順でこちらを後に置くことで error 時の border 色を優先させる\n 'has-[[role=combobox][aria-invalid]]:shr-border-danger',\n ],\n inputArea: 'shr-flex shr-flex-1 shr-flex-wrap shr-gap-0.5',\n selectedList:\n 'smarthr-ui-MultiCombobox-selectedList shr-contents shr-list-none [&_li]:shr-min-w-0',\n inputWrapper: [\n 'shr-flex shr-flex-1 shr-items-center',\n 'has-[[role=combobox][aria-expanded=false]]:shr-pointer-events-none',\n 'has-[[role=combobox][aria-expanded=false]]:shr-absolute',\n 'has-[[role=combobox][aria-expanded=false]]:shr-opacity-0',\n ],\n input: [\n 'smarthr-ui-MultiCombobox-input',\n 'shr-w-full shr-min-w-[5em] shr-border-none shr-text-base shr-text-black shr-outline-none shr-outline-0',\n 'disabled:shr-hidden',\n ],\n placeholderEl: 'smarthr-ui-MultiCombobox-placeholder shr-my-0 shr-self-center',\n suffixWrapper: [\n 'shr-relative -shr-me-0.5 shr-ms-0.5 shr-p-0.5',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n suffixIcon: 'shr-block',\n },\n})\n\nconst ActualMultiCombobox = <T,>(\n {\n items,\n selectedItems,\n name,\n disabled = false,\n required = false,\n error = false,\n creatable = false,\n placeholder = '',\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n selectedItemEllipsis,\n width,\n dropdownWidth = 'auto',\n inputValue: controlledInputValue,\n className,\n onChange,\n onChangeInput,\n onAdd,\n onDelete,\n onSelect,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n isItemSelected,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const triggerRef = useRef<HTMLDivElement>(null)\n const [isExpanded, setIsExpanded] = useState(false)\n const [highlighted, setHighlighted] = useState(false)\n const [uncontrolledInputValue, setUncontrolledInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n\n const baseId = useId()\n const selectedListId = `${baseId}-selected`\n\n const isInputControlled = controlledInputValue !== undefined\n const inputValue = isInputControlled ? controlledInputValue : uncontrolledInputValue\n const setInputValueIfUncontrolled = isInputControlled ? NOOP : setUncontrolledInputValue\n const isInputEmpty = !inputValue\n\n const { options } = useMultiOptions({\n items,\n selected: selectedItems,\n creatable,\n inputValue,\n isItemSelected,\n })\n const inputRef = useRef<HTMLInputElement>(null)\n const deleteFrame = useAnimationFrame()\n const selectFrame = useAnimationFrame()\n\n // eslint-disable-next-line local-rules/best-practice-for-use-latest\n const latestForListBox = useLatest({\n onDelete,\n onChangeSelected,\n onSelect,\n onChangeInput,\n selectedItems,\n deleteFrame,\n selectFrame,\n })\n\n const listBoxFunctions = useMemo(() => {\n const handleDelete = (item: ComboboxItem<T>) => {\n const handlers: Array<(deletingItem: ComboboxItem<T>) => void> = []\n\n if (latestForListBox.onDelete) {\n handlers.push((deletingItem: ComboboxItem<T>) => latestForListBox.onDelete!(deletingItem))\n }\n if (latestForListBox.onChangeSelected) {\n handlers.push((deletingItem: ComboboxItem<T>) =>\n latestForListBox.onChangeSelected!(\n latestForListBox.selectedItems.filter(\n (selected) => !areItemsEqual(selected, deletingItem),\n ),\n ),\n )\n }\n\n if (handlers.length > 0) {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.deleteFrame.request(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n cleanupListBoxCallbackRef: () => () => {\n latestForListBox.deleteFrame.cancel()\n latestForListBox.selectFrame.cancel()\n },\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.selectFrame.request(() => {\n const matchedSelectedItem = latestForListBox.selectedItems.find((item) =>\n areItemsEqual(item, selected),\n )\n\n if (matchedSelectedItem === undefined) {\n latestForListBox.onSelect?.(selected)\n latestForListBox.onChangeSelected?.(latestForListBox.selectedItems.concat(selected))\n\n // 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n latestForListBox.onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n } else if (matchedSelectedItem.deletable !== false) {\n handleDelete(selected)\n }\n })\n },\n }\n }, [latestForListBox])\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n onSelect: listBoxFunctions.handleSelect,\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onFocus,\n onBlur,\n onKeyPress,\n disabled,\n isExpanded,\n highlighted,\n isComposing,\n isInputEmpty,\n selectedItems,\n setInputValueIfUncontrolled,\n handleKeyDownListBox,\n cleanupAddFrame,\n })\n\n const functions = useMemo(() => {\n const handleDelete = listBoxFunctions.handleDelete\n\n const getDeletionButtons = () => {\n if (triggerRef.current) {\n const buttons =\n triggerRef.current.querySelectorAll<HTMLButtonElement>(DELETE_BUTTON_CLASSNAME)\n\n if (buttons.length > 0) {\n const actualButtons = Array.from(buttons)\n\n return {\n buttons: actualButtons,\n currentIndex: actualButtons.indexOf(document.activeElement as HTMLButtonElement),\n }\n }\n }\n\n return null\n }\n\n const focusPrevDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex !== -1) {\n buttons[Math.max(currentIndex - 1, 0)].focus()\n } else if (inputRef.current?.selectionStart === 0) {\n buttons[buttons.length - 1].focus()\n }\n }\n\n const focusNextDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex === -1) return\n\n const nextIndex = currentIndex + 1\n\n if (nextIndex < buttons.length) {\n buttons[nextIndex].focus()\n } else {\n // キー入力が input に影響しないようにフォーカスタイミングを遅らせる\n setTimeout(() => {\n inputRef.current?.focus()\n })\n }\n }\n\n const focus = () => {\n latest.onFocus?.()\n setIsExpanded(true)\n }\n\n const blur = () => {\n if (latest.isExpanded) {\n latest.onBlur?.()\n setIsExpanded(false)\n }\n }\n\n return {\n handleDelete,\n blur,\n cleanupCallbackRef: () => latest.cleanupAddFrame,\n handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n }\n\n blur()\n } else if (e.key === 'Tab') {\n if (latest.isExpanded) {\n // フォーカスがコンポーネントを抜けるように先に input をフォーカスしておく\n inputRef.current?.focus()\n }\n\n blur()\n } else if (ARROW_LEFT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusPrevDeletionButton()\n } else if (ARROW_RIGHT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusNextDeletionButton()\n } else if (\n e.key === 'Backspace' &&\n latest.isInputEmpty &&\n latest.selectedItems.length > 0 &&\n latest.selectedItems[latest.selectedItems.length - 1].deletable !== false\n ) {\n e.preventDefault()\n e.stopPropagation()\n\n const lastItem = latest.selectedItems[latest.selectedItems.length - 1]\n\n handleDelete(lastItem)\n setHighlighted(true)\n latest.setInputValueIfUncontrolled(innerText(lastItem.label))\n } else {\n e.stopPropagation()\n inputRef.current?.focus()\n }\n\n latest.handleKeyDownListBox(e)\n },\n handleDelegateClick: (e: MouseEvent<HTMLElement>) => {\n if (\n !latest.disabled &&\n !latest.isExpanded &&\n !findDelegateTarget(e, DELETE_BUTTON_CLASSNAME)\n ) {\n focus()\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n const handlers = [latest.onChange, latest.onChangeInput].filter((h) => !!h)\n\n handlers.forEach((h) => h(e))\n latest.setInputValueIfUncontrolled(e.currentTarget.value)\n },\n handleFocusInput: () => {\n if (!latest.isExpanded) {\n focus()\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (ARROW_UP_AND_DOWN_KEY_REGEX.test(e.key)) {\n // 上下キー入力はリストボックスの activeDescendant の移動に用いるため、input 内では作用させない\n e.preventDefault()\n }\n },\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleDelegateKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n preventDefaultWithPressEnter(e)\n latest.onKeyPress?.(e)\n },\n }\n }, [listBoxFunctions, latest])\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isExpanded ? [triggerRef] : null,\n functions.blur,\n )\n\n const mergedInputRef = useMergeRefs(inputRef, listBoxFunctions.cleanupListBoxCallbackRef, ref)\n const mergedTriggerRef = useMergeRefs(triggerRef, functions.cleanupCallbackRef)\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled])\n\n const classNames = useMemo(() => {\n const {\n wrapper,\n inputArea,\n selectedList,\n inputWrapper,\n input,\n placeholderEl,\n suffixWrapper,\n suffixIcon,\n } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n inputArea: inputArea(),\n selectedList: selectedList(),\n inputWrapper: inputWrapper(),\n input: input(),\n placeholder: placeholderEl(),\n suffixWrapper: suffixWrapper(),\n suffixIcon: suffixIcon(),\n }\n }, [className])\n\n const localized = useLocalize({\n selectedListAriaLabel: {\n id: 'smarthr-ui/MultiCombobox/selectedListAriaLabel',\n defaultText: '選択済みアイテム',\n },\n })\n\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={mergedTriggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n className={classNames.selectedList}\n aria-label={localized.selectedListAriaLabel}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n disabled={disabled}\n item={selectedItem}\n enableEllipsis={selectedItemEllipsis}\n handleDelete={functions.handleDelete}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n ref={mergedInputRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required && selectedItems.length === 0}\n disabled={disabled}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n className={classNames.input}\n aria-activedescendant={activeOption?.id}\n aria-controls={`${listBoxId} ${selectedListId}`}\n aria-haspopup=\"listbox\"\n aria-expanded={isExpanded}\n aria-invalid={error || undefined}\n aria-disabled={disabled}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input=\"true\"\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n />\n </div>\n\n {selectedItems.length === 0 && placeholder && !isExpanded && (\n <p className={classNames.placeholder}>{placeholder}</p>\n )}\n </Scroller>\n\n <MemoizedCaretDown disabled={disabled} isExpanded={isExpanded} classNames={classNames} />\n\n <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />\n </div>\n )\n}\n\nexport const MultiCombobox = genericsForwardRef(ActualMultiCombobox)\n\nconst MemoizedCaretDown = memo<{\n disabled: boolean\n isExpanded: boolean\n classNames: {\n suffixWrapper: string\n suffixIcon: string\n }\n}>(({ disabled, isExpanded, classNames }) => {\n const theme = useTheme()\n const caretIconColor = isExpanded\n ? theme.textColor.black\n : disabled\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n return (\n <div className={classNames.suffixWrapper}>\n <FaCaretDownIcon color={caretIconColor} className={classNames.suffixIcon} />\n </div>\n )\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA8EA;AAEA;AACE;;;AAGF;AAEA;AACA;AACA;AACA;AAEA;AACE;AACA;;AAGF;AAEA;AACE;AACE;;;;;;;;;;;;AAYC;AACD;AACA;AAEA;;;;;AAKC;AACD;;;;AAIC;AACD;AACA;;;AAGC;AACD;AACD;AACF;AAED;AAkCE;;;;;AAMA;AACA;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;AACA;AACA;;;;;;;;;;AAWC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;AAGE;AACE;AACF;;AAEJ;;AAGE;AACE;AACA;;;AAGF;;;AAGE;;AAKE;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAWG;;;;;;;;;;;;;;;;AAiBF;AAED;AACE;;AAGE;;AAIE;;;AAII;;;;;AAMN;AACF;;AAGE;AAEA;;AAEA;AAEA;AACE;;;;;AAIJ;;AAGE;AAEA;;AAEA;;;AAIA;AAEA;AACE;;;;;AAIE;AACF;;AAEJ;;AAGE;;AAEF;;AAGE;AACE;;;AAGJ;;;;AAKE;AACA;;;;AAII;;;AAIA;;AACK;AACL;;AAEE;;AAGF;;;;AAGA;;;;AAGA;;AACK;AAEL;AACA;AACA;;;AAKA;;;;;;;AAOA;;AAGF;;AAEF;;;AAII;AAEA;;;AAGJ;;AAGE;;;;AAIA;AACE;;;AAGJ;AACA;AACA;;;;;;;;;AASA;;AAEE;;;AAGN;;AAOA;;;AAIE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;;;AAIC;AAKI;AACA;AACD;AA8DP;;AAIA;AAQE;;AAEE;AACA;AACE;AACA;;AAON;;"}
|
|
1
|
+
{"version":3,"file":"MultiCombobox.js","sources":["../../../../src/components/Combobox/MultiCombobox/MultiCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n memo,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useLocalize } from '../../../intl'\nimport { findDelegateTarget } from '../../../libs/delegate'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { FaCaretDownIcon } from '../../Icon'\nimport { Scroller } from '../../Scroller'\nimport { areItemsEqual } from '../helper'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useMultiOptions } from '../useOptions'\n\nimport { DELETE_BUTTON_SELECTOR, MultiSelectedItem } from './MultiSelectedItem'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテムのリスト\n */\n selectedItems: Array<ComboboxItem<T> & { deletable?: boolean }>\n /**\n * 選択されているアイテムのラベルを省略表示するかどうか\n */\n selectedItemEllipsis?: boolean\n /**\n * テキストボックスの `value` 属性の値。\n * `onChangeInput` と併せて設定することで、テキストボックスの挙動が制御可能になる。\n */\n inputValue?: string\n /**\n * 選択されているアイテムの削除ボタンがクリックされた時に発火するコールバック関数\n */\n onDelete?: (item: ComboboxItem<T>) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItems: Array<ComboboxItem<T>>) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * アイテムが選択されたときに選択済みかどうかを判定するコールバック関数/\n */\n isItemSelected?: (targetItem: ComboboxItem<T>, selectedItems: Array<ComboboxItem<T>>) => boolean\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst NOOP = () => undefined\n\nconst preventDefaultWithPressEnter = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n }\n}\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_LEFT_KEY_REGEX = /^(Arrow)?Left$/\nconst ARROW_RIGHT_KEY_REGEX = /^(Arrow)?Right/\nconst ARROW_UP_AND_DOWN_KEY_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst DELETE_BUTTON_CLASSNAME = `.${DELETE_BUTTON_SELECTOR}`\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: [\n 'smarthr-ui-MultiCombobox',\n 'shr-box-border shr-inline-flex shr-min-w-[15em] shr-rounded-m shr-border shr-border-solid shr-px-0.5 shr-py-0.25 shr-align-bottom',\n 'contrast-more:shr-border-high-contrast',\n 'has-[[role=combobox][aria-expanded=true]]:shr-focus-indicator',\n 'shr-cursor-text shr-border-default shr-bg-white',\n 'has-[[role=combobox]:disabled]:shr-cursor-not-allowed',\n 'has-[[role=combobox]:disabled]:shr-border-default/50',\n 'has-[[role=combobox]:disabled]:shr-bg-white-darken',\n 'has-[[role=combobox]:disabled]:shr-text-disabled',\n // HINT: disabled と詳細度が同じ [0,2,0] のため、CSS ソース順でこちらを後に置くことで error 時の border 色を優先させる\n 'has-[[role=combobox][aria-invalid]]:shr-border-danger',\n ],\n inputArea: 'shr-flex shr-flex-1 shr-flex-wrap shr-gap-0.5',\n selectedList:\n 'smarthr-ui-MultiCombobox-selectedList shr-contents shr-list-none [&_li]:shr-min-w-0',\n inputWrapper: [\n 'shr-flex shr-flex-1 shr-items-center',\n 'has-[[role=combobox][aria-expanded=false]]:shr-pointer-events-none',\n 'has-[[role=combobox][aria-expanded=false]]:shr-absolute',\n 'has-[[role=combobox][aria-expanded=false]]:shr-opacity-0',\n ],\n input: [\n 'smarthr-ui-MultiCombobox-input',\n 'shr-w-full shr-min-w-[5em] shr-border-none shr-text-base shr-text-black shr-outline-none shr-outline-0',\n 'disabled:shr-hidden',\n ],\n placeholderEl: 'smarthr-ui-MultiCombobox-placeholder shr-my-0 shr-self-center',\n suffixWrapper: [\n 'shr-relative -shr-me-0.5 shr-ms-0.5 shr-p-0.5',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n suffixIcon: 'shr-block',\n },\n})\n\nconst ActualMultiCombobox = <T,>(\n {\n items,\n selectedItems,\n name,\n disabled = false,\n required = false,\n error = false,\n creatable = false,\n placeholder = '',\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n selectedItemEllipsis,\n width,\n dropdownWidth = 'auto',\n inputValue: controlledInputValue,\n className,\n onChange,\n onChangeInput,\n onAdd,\n onDelete,\n onSelect,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n isItemSelected,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const triggerRef = useRef<HTMLDivElement>(null)\n const [isExpanded, setIsExpanded] = useState(false)\n const [highlighted, setHighlighted] = useState(false)\n const [uncontrolledInputValue, setUncontrolledInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n\n const baseId = useId()\n const selectedListId = `${baseId}-selected`\n\n const isInputControlled = controlledInputValue !== undefined\n const inputValue = isInputControlled ? controlledInputValue : uncontrolledInputValue\n const setInputValueIfUncontrolled = isInputControlled ? NOOP : setUncontrolledInputValue\n const isInputEmpty = !inputValue\n\n const { options } = useMultiOptions({\n items,\n selected: selectedItems,\n creatable,\n inputValue,\n isItemSelected,\n })\n const inputRef = useRef<HTMLInputElement>(null)\n const deleteFrame = useAnimationFrame()\n const selectFrame = useAnimationFrame()\n\n // eslint-disable-next-line local-rules/best-practice-for-use-latest\n const latestForListBox = useLatest({\n onDelete,\n onChangeSelected,\n onSelect,\n onChangeInput,\n selectedItems,\n deleteFrame,\n selectFrame,\n })\n\n const listBoxFunctions = useMemo(() => {\n const handleDelete = (item: ComboboxItem<T>) => {\n const handlers: Array<(deletingItem: ComboboxItem<T>) => void> = []\n\n if (latestForListBox.onDelete) {\n handlers.push((deletingItem: ComboboxItem<T>) => latestForListBox.onDelete!(deletingItem))\n }\n if (latestForListBox.onChangeSelected) {\n handlers.push((deletingItem: ComboboxItem<T>) =>\n latestForListBox.onChangeSelected!(\n latestForListBox.selectedItems.filter(\n (selected) => !areItemsEqual(selected, deletingItem),\n ),\n ),\n )\n }\n\n if (handlers.length > 0) {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.deleteFrame.request(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n cleanupListBoxCallbackRef: () => () => {\n latestForListBox.deleteFrame.cancel()\n latestForListBox.selectFrame.cancel()\n },\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n latestForListBox.selectFrame.request(() => {\n const matchedSelectedItem = latestForListBox.selectedItems.find((item) =>\n areItemsEqual(item, selected),\n )\n\n if (matchedSelectedItem === undefined) {\n latestForListBox.onSelect?.(selected)\n latestForListBox.onChangeSelected?.(latestForListBox.selectedItems.concat(selected))\n\n // 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n latestForListBox.onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n } else if (matchedSelectedItem.deletable !== false) {\n handleDelete(selected)\n }\n })\n },\n }\n }, [latestForListBox])\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n onSelect: listBoxFunctions.handleSelect,\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onFocus,\n onBlur,\n onKeyPress,\n disabled,\n isExpanded,\n highlighted,\n isComposing,\n isInputEmpty,\n selectedItems,\n setInputValueIfUncontrolled,\n handleKeyDownListBox,\n cleanupAddFrame,\n })\n\n const functions = useMemo(() => {\n const handleDelete = listBoxFunctions.handleDelete\n\n const getDeletionButtons = () => {\n if (triggerRef.current) {\n const buttons =\n triggerRef.current.querySelectorAll<HTMLButtonElement>(DELETE_BUTTON_CLASSNAME)\n\n if (buttons.length > 0) {\n const actualButtons = Array.from(buttons)\n\n return {\n buttons: actualButtons,\n currentIndex: actualButtons.indexOf(document.activeElement as HTMLButtonElement),\n }\n }\n }\n\n return null\n }\n\n const focusPrevDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex !== -1) {\n buttons[Math.max(currentIndex - 1, 0)].focus()\n } else if (inputRef.current?.selectionStart === 0) {\n buttons[buttons.length - 1].focus()\n }\n }\n\n const focusNextDeletionButton = () => {\n const result = getDeletionButtons()\n\n if (!result) return\n\n const { buttons, currentIndex } = result\n\n if (currentIndex === -1) return\n\n const nextIndex = currentIndex + 1\n\n if (nextIndex < buttons.length) {\n buttons[nextIndex].focus()\n } else {\n // キー入力が input に影響しないようにフォーカスタイミングを遅らせる\n setTimeout(() => {\n inputRef.current?.focus()\n })\n }\n }\n\n const focus = () => {\n latest.onFocus?.()\n setIsExpanded(true)\n }\n\n const blur = () => {\n if (latest.isExpanded) {\n latest.onBlur?.()\n setIsExpanded(false)\n }\n }\n\n return {\n handleDelete,\n blur,\n cleanupCallbackRef: () => latest.cleanupAddFrame,\n handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n }\n\n blur()\n } else if (e.key === 'Tab') {\n if (latest.isExpanded) {\n // フォーカスがコンポーネントを抜けるように先に input をフォーカスしておく\n inputRef.current?.focus()\n }\n\n blur()\n } else if (ARROW_LEFT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusPrevDeletionButton()\n } else if (ARROW_RIGHT_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n focusNextDeletionButton()\n } else if (\n e.key === 'Backspace' &&\n latest.isInputEmpty &&\n latest.selectedItems.length > 0 &&\n latest.selectedItems[latest.selectedItems.length - 1].deletable !== false\n ) {\n e.preventDefault()\n e.stopPropagation()\n\n const lastItem = latest.selectedItems[latest.selectedItems.length - 1]\n\n handleDelete(lastItem)\n setHighlighted(true)\n latest.setInputValueIfUncontrolled(innerText(lastItem.label))\n } else {\n e.stopPropagation()\n inputRef.current?.focus()\n }\n\n latest.handleKeyDownListBox(e)\n },\n handleDelegateClick: (e: MouseEvent<HTMLElement>) => {\n if (\n !latest.disabled &&\n !latest.isExpanded &&\n !findDelegateTarget(e, DELETE_BUTTON_CLASSNAME)\n ) {\n focus()\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n const handlers = [latest.onChange, latest.onChangeInput].filter((h) => !!h)\n\n handlers.forEach((h) => h(e))\n latest.setInputValueIfUncontrolled(e.currentTarget.value)\n },\n handleFocusInput: () => {\n if (!latest.isExpanded) {\n focus()\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (ARROW_UP_AND_DOWN_KEY_REGEX.test(e.key)) {\n // 上下キー入力はリストボックスの activeDescendant の移動に用いるため、input 内では作用させない\n e.preventDefault()\n }\n },\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleDelegateKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n preventDefaultWithPressEnter(e)\n latest.onKeyPress?.(e)\n },\n }\n }, [listBoxFunctions, latest])\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isExpanded ? [triggerRef] : null,\n functions.blur,\n )\n\n const mergedInputRef = useMergeRefs(inputRef, listBoxFunctions.cleanupListBoxCallbackRef, ref)\n const mergedTriggerRef = useMergeRefs(triggerRef, functions.cleanupCallbackRef)\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled])\n\n const classNames = useMemo(() => {\n const {\n wrapper,\n inputArea,\n selectedList,\n inputWrapper,\n input,\n placeholderEl,\n suffixWrapper,\n suffixIcon,\n } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n inputArea: inputArea(),\n selectedList: selectedList(),\n inputWrapper: inputWrapper(),\n input: input(),\n placeholder: placeholderEl(),\n suffixWrapper: suffixWrapper(),\n suffixIcon: suffixIcon(),\n }\n }, [className])\n\n const localized = useLocalize({\n selectedListAriaLabel: {\n id: 'smarthr-ui/MultiCombobox/selectedListAriaLabel',\n defaultText: '選択済みアイテム',\n },\n })\n\n const errorAttr = error || undefined\n\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={mergedTriggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n className={classNames.selectedList}\n aria-label={localized.selectedListAriaLabel}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n disabled={disabled}\n item={selectedItem}\n enableEllipsis={selectedItemEllipsis}\n handleDelete={functions.handleDelete}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n ref={mergedInputRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required && selectedItems.length === 0}\n disabled={disabled}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n className={classNames.input}\n aria-activedescendant={activeOption?.id}\n aria-controls={`${listBoxId} ${selectedListId}`}\n aria-haspopup=\"listbox\"\n aria-expanded={isExpanded}\n aria-invalid={errorAttr}\n aria-disabled={disabled}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input-error={errorAttr}\n data-smarthr-ui-input=\"true\"\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n />\n </div>\n\n {selectedItems.length === 0 && placeholder && !isExpanded && (\n <p className={classNames.placeholder}>{placeholder}</p>\n )}\n </Scroller>\n\n <MemoizedCaretDown disabled={disabled} isExpanded={isExpanded} classNames={classNames} />\n\n <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />\n </div>\n )\n}\n\nexport const MultiCombobox = genericsForwardRef(ActualMultiCombobox)\n\nconst MemoizedCaretDown = memo<{\n disabled: boolean\n isExpanded: boolean\n classNames: {\n suffixWrapper: string\n suffixIcon: string\n }\n}>(({ disabled, isExpanded, classNames }) => {\n const theme = useTheme()\n const caretIconColor = isExpanded\n ? theme.textColor.black\n : disabled\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n return (\n <div className={classNames.suffixWrapper}>\n <FaCaretDownIcon color={caretIconColor} className={classNames.suffixIcon} />\n </div>\n )\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA8EA;AAEA;AACE;;;AAGF;AAEA;AACA;AACA;AACA;AAEA;AACE;AACA;;AAGF;AAEA;AACE;AACE;;;;;;;;;;;;AAYC;AACD;AACA;AAEA;;;;;AAKC;AACD;;;;AAIC;AACD;AACA;;;AAGC;AACD;AACD;AACF;AAED;AAkCE;;;;;AAMA;AACA;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;AACA;AACA;;;;;;;;;;AAWC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;AAGE;AACE;AACF;;AAEJ;;AAGE;AACE;AACA;;;AAGF;;;AAGE;;AAKE;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAWG;;;;;;;;;;;;;;;;AAiBF;AAED;AACE;;AAGE;;AAIE;;;AAII;;;;;AAMN;AACF;;AAGE;AAEA;;AAEA;AAEA;AACE;;;;;AAIJ;;AAGE;AAEA;;AAEA;;;AAIA;AAEA;AACE;;;;;AAIE;AACF;;AAEJ;;AAGE;;AAEF;;AAGE;AACE;;;AAGJ;;;;AAKE;AACA;;;;AAII;;;AAIA;;AACK;AACL;;AAEE;;AAGF;;;;AAGA;;;;AAGA;;AACK;AAEL;AACA;AACA;;;AAKA;;;;;;;AAOA;;AAGF;;AAEF;;;AAII;AAEA;;;AAGJ;;AAGE;;;;AAIA;AACE;;;AAGJ;AACA;AACA;;;;;;;;;AASA;;AAEE;;;AAGN;;AAOA;;;AAIE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;AAED;;;AAIE;AAKI;AACA;AACD;AA+DP;;AAIA;AAQE;;AAEE;AACA;AACE;AACA;;AAON;;"}
|
|
@@ -75,6 +75,8 @@ const ActualSingleCombobox = ({ items, selectedItem, defaultItem, name, disabled
|
|
|
75
75
|
if (selectedItemLabelText !== prevSelectedItemLabelText) {
|
|
76
76
|
setPrevSelectedItemLabelText(selectedItemLabelText);
|
|
77
77
|
setInputValue(selectedItemLabelText);
|
|
78
|
+
// inputValueを選択中アイテムのラベルで上書きした場合、それは手入力値ではないため絞り込みには使わない
|
|
79
|
+
setIsEditing(false);
|
|
78
80
|
}
|
|
79
81
|
const { options } = components_Combobox_useOptions.useSingleOptions({
|
|
80
82
|
items,
|
|
@@ -146,11 +148,14 @@ const ActualSingleCombobox = ({ items, selectedItem, defaultItem, name, disabled
|
|
|
146
148
|
latest.onBlur?.();
|
|
147
149
|
setIsFocused(false);
|
|
148
150
|
setIsExpanded(false);
|
|
149
|
-
setIsEditing(false);
|
|
150
151
|
if (latest.selectedItem) {
|
|
152
|
+
// inputValueを選択中アイテムのラベルへ戻すため、手入力による絞り込みも解除する
|
|
153
|
+
setIsEditing(false);
|
|
151
154
|
setInputValue(index$1(latest.selectedItem.label));
|
|
152
155
|
}
|
|
153
156
|
else {
|
|
157
|
+
// HINT: 未選択の場合はinputValueに手入力値が残るため、isEditingは解除しない
|
|
158
|
+
// 解除してしまうと「入力値が表示されているのに絞り込みが効いていない」状態になる
|
|
154
159
|
selectDefaultItem();
|
|
155
160
|
}
|
|
156
161
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SingleCombobox.cjs","sources":["../../../../src/components/Combobox/SingleCombobox/SingleCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n type RefObject,\n memo,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { Localizer } from '../../../intl'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { UnstyledButton } from '../../Button'\nimport { FaCaretDownIcon, FaCircleXmarkIcon } from '../../Icon'\nimport { Input } from '../../Input'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useSingleOptions } from '../useOptions'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテム\n */\n selectedItem: ComboboxItem<T> | null\n /**\n * デフォルトで選択されるアイテム\n */\n defaultItem?: ComboboxItem<T>\n /**\n * コンポーネント内の先頭に表示する内容\n */\n prefix?: ReactNode\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n */\n onClear?: () => void\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n * 指定している場合、クリア時にonClickを実行せずにonClearClickのみ実行する\n */\n onClearClick?: (e: MouseEvent) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItem: ComboboxItem<T> | null) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_UP_DOWN_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper:\n 'smarthr-ui-SingleCombobox shr-inline-block [&:has(.smarthr-ui-Input-input:disabled)]:shr-cursor-not-allowed',\n input: 'smarthr-ui-SingleCombobox-input shr-w-full',\n caretDownLayout: [\n 'shr-relative -shr-me-0.5 shr-p-0.5',\n 'before:shr-border-0',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n caretDownIcon: 'shr-block',\n clearButton: [\n 'smarthr-ui-SingleCombobox-clearButton',\n 'shr-group/clearButton',\n 'shr-me-0.5',\n 'focus-visible:shr-shadow-none',\n '[[data-clear-button-hidden=true]_&]:shr-hidden',\n ],\n clearButtonIcon: [\n 'shr-block',\n 'group-focus-visible/clearButton:shr-focus-indicator group-focus-visible/clearButton:shr-rounded-full',\n ],\n },\n})\n\ntype SuffixButtonsProps = {\n clearButtonRef: RefObject<HTMLButtonElement>\n caretIconColor: string\n handleClickClear: (e: MouseEvent) => void\n handleClickIcon: (e: MouseEvent) => void\n classNames: {\n clearButton: string\n clearButtonIcon: string\n caretDownLayout: string\n caretDownIcon: string\n }\n}\n\nconst SuffixButtons = memo<SuffixButtonsProps>(\n ({\n clearButtonRef,\n caretIconColor,\n handleClickClear,\n handleClickIcon: handleDelegateClickIcon,\n classNames,\n }) => (\n <>\n <UnstyledButton\n ref={clearButtonRef}\n className={classNames.clearButton}\n onClick={handleClickClear}\n >\n <FaCircleXmarkIcon\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n color=\"TEXT_BLACK\"\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n className={classNames.caretDownLayout}\n onClick={handleDelegateClickIcon}\n >\n <FaCaretDownIcon color={caretIconColor} className={classNames.caretDownIcon} />\n </span>\n </>\n ),\n)\n\nconst ActualSingleCombobox = <T,>(\n {\n items,\n selectedItem,\n defaultItem,\n name,\n disabled,\n readOnly,\n required,\n prefix,\n error,\n creatable,\n placeholder,\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n width,\n dropdownWidth = 'auto',\n className,\n onChange,\n onChangeInput,\n onAdd,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const theme = useTheme()\n const triggerRef = useRef<HTMLDivElement>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const clearButtonRef = useRef<HTMLButtonElement>(null)\n const [isFocused, setIsFocused] = useState(false)\n const [isExpanded, setIsExpanded] = useState(false)\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n const [inputValue, setInputValue] = useState('')\n // inputValueはユーザーの手入力値も兼ねるため、prevSelectedItemLabelTextの代わりにinputValueと比較すると、\n // 手入力中に selectedItemLabelText(不変)との不一致を検知して選択中アイテムのラベルへ強制的に戻ってしまう\n const [prevSelectedItemLabelText, setPrevSelectedItemLabelText] = useState('')\n\n const selectedItemLabelText = selectedItem ? innerText(selectedItem.label) : ''\n\n if (selectedItemLabelText !== prevSelectedItemLabelText) {\n setPrevSelectedItemLabelText(selectedItemLabelText)\n setInputValue(selectedItemLabelText)\n }\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const selectFrame = useAnimationFrame()\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox<T>({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n // HINT: memo化していないが、内部でuseLatestでstableにしているため最適化としてそのまま渡している\n onSelect: (selected: ComboboxItem<T>) => {\n onSelect?.(selected)\n onChangeSelected?.(selected)\n\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n selectFrame.request(() => {\n setIsExpanded(false)\n // HINT:\n // - 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n // - 対応するdropdownを閉じて以降にonChangeInputを発火する必要がある\n // - 先にclearしてしまうと意図せずこの要素のドロップダウンを閉じる前に他要素の再レンダリングを引き起こす可能性がある\n // - 例えばFilterDropdownなどで当comboboxを使っている場合、レイアウト上comboboxのdropdown以下の要素がクリックされた扱いになってしまい\n // FilterDropdownを意図せず閉じてしまうなどの挙動のバグを引き起こす可能性がある\n onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n })\n\n setIsEditing(false)\n },\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n handleKeyDownListBox,\n defaultItem,\n selectedItem,\n isFocused,\n isExpanded,\n isComposing,\n isEditing,\n disabled,\n readOnly,\n cleanupAddFrame,\n selectFrame,\n })\n\n const functions = useMemo(() => {\n const selectDefaultItem = () => {\n if (latest.onSelect && latest.defaultItem) {\n latest.onSelect(latest.defaultItem)\n }\n }\n const unfocus = () => {\n if (!latest.isFocused) return\n\n latest.onBlur?.()\n\n setIsFocused(false)\n setIsExpanded(false)\n setIsEditing(false)\n\n if (latest.selectedItem) {\n setInputValue(innerText(latest.selectedItem.label))\n } else {\n selectDefaultItem()\n }\n }\n\n return {\n selectDefaultItem,\n unfocus,\n cleanupCallbackRef: () => () => {\n latest.cleanupAddFrame()\n latest.selectFrame.cancel()\n },\n handleFocus: () => {\n latest.onFocus?.()\n inputRef.current?.focus()\n setIsFocused(true)\n\n if (!latest.isFocused) {\n setIsExpanded(true)\n }\n },\n handleClickClear: (e: MouseEvent) => {\n e.stopPropagation()\n\n let isExecutedPreventDefault = false\n\n latest.onClearClick?.({\n ...e,\n preventDefault: () => {\n e.preventDefault()\n isExecutedPreventDefault = true\n },\n })\n\n if (!isExecutedPreventDefault) {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n\n inputRef.current?.focus()\n\n setIsFocused(true)\n setIsExpanded(true)\n }\n },\n handleClickInput: (e: MouseEvent) => {\n if (latest.disabled || latest.readOnly) {\n e.stopPropagation()\n\n return\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n latest.onChange?.(e)\n latest.onChangeInput?.(e)\n\n if (!latest.isEditing) setIsEditing(true)\n\n const { value } = e.currentTarget\n\n setInputValue(value)\n\n if (value === '') {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') e.preventDefault()\n\n latest.onKeyPress?.(e)\n },\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (latest.isComposing) {\n return\n }\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n setIsExpanded(false)\n }\n } else if (e.key === 'Tab') {\n unfocus()\n } else {\n if (ARROW_UP_DOWN_REGEX.test(e.key)) {\n e.preventDefault()\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n }\n\n latest.handleKeyDownListBox(e)\n },\n }\n }, [latest])\n\n const caretIconColor = isFocused\n ? theme.textColor.black\n : disabled || readOnly\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isFocused ? [triggerRef, clearButtonRef] : null,\n functions.unfocus,\n isFocused || selectedItem ? undefined : functions.selectDefaultItem,\n )\n\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(inputRef, functions.cleanupCallbackRef, ref)\n\n const classNames = useMemo(() => {\n const { wrapper, input, caretDownLayout, caretDownIcon, clearButton, clearButtonIcon } =\n classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n input: input(),\n caretDownLayout: caretDownLayout(),\n caretDownIcon: caretDownIcon(),\n clearButton: clearButton(),\n clearButtonIcon: clearButtonIcon(),\n }\n }, [className])\n\n return (\n <div\n ref={triggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n data-clear-button-hidden={(selectedItem === null || disabled || readOnly || false).toString()}\n >\n <Input\n {...rest}\n ref={mergedRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required}\n disabled={disabled}\n readOnly={readOnly}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\n error={error}\n className={classNames.input}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input=\"true\"\n onClick={functions.handleClickInput}\n onChange={functions.handleChangeInput}\n onFocus={isFocused ? undefined : functions.handleFocus}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n onKeyPress={functions.handleKeyPress}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n classNames={classNames}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n />\n }\n />\n {!readOnly && <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;;;;AAWA;AAEA;;;;AAKA;;AAEE;;;;AAID;AAED;AAEA;;;;;;AAOI;AACE;AACA;;;AAIA;;;;;;;;AAQE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;;;AAuBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;;AAMA;;;;AAGE;;AAEJ;;;;AAKE;;AAEE;;;AAGA;AACA;;AAGA;;;;AAIF;;;;AAMI;;;;;AAKD;;AAGC;AACA;AAEA;;;;;AAMJ;;;;;AAOE;AAEA;;;;AAIF;AACE;AACA;;;AAIA;;AAIA;AACE;AACA;;;AAGJ;AACA;;;;AAIA;AACE;;AAEA;;AAEF;AACE;;;;AAKE;;;;;AAIK;AACL;;;;;;AAMA;AAEA;;;;AAKF;;;AAGN;;AAGE;;AAEE;AACA;AAEJ;;;AAQA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;;;AAgBA;AA8BR;;;"}
|
|
1
|
+
{"version":3,"file":"SingleCombobox.cjs","sources":["../../../../src/components/Combobox/SingleCombobox/SingleCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n type RefObject,\n memo,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { Localizer } from '../../../intl'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { UnstyledButton } from '../../Button'\nimport { FaCaretDownIcon, FaCircleXmarkIcon } from '../../Icon'\nimport { Input } from '../../Input'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useSingleOptions } from '../useOptions'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテム\n */\n selectedItem: ComboboxItem<T> | null\n /**\n * デフォルトで選択されるアイテム\n */\n defaultItem?: ComboboxItem<T>\n /**\n * コンポーネント内の先頭に表示する内容\n */\n prefix?: ReactNode\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n */\n onClear?: () => void\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n * 指定している場合、クリア時にonClickを実行せずにonClearClickのみ実行する\n */\n onClearClick?: (e: MouseEvent) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItem: ComboboxItem<T> | null) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_UP_DOWN_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper:\n 'smarthr-ui-SingleCombobox shr-inline-block [&:has(.smarthr-ui-Input-input:disabled)]:shr-cursor-not-allowed',\n input: 'smarthr-ui-SingleCombobox-input shr-w-full',\n caretDownLayout: [\n 'shr-relative -shr-me-0.5 shr-p-0.5',\n 'before:shr-border-0',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n caretDownIcon: 'shr-block',\n clearButton: [\n 'smarthr-ui-SingleCombobox-clearButton',\n 'shr-group/clearButton',\n 'shr-me-0.5',\n 'focus-visible:shr-shadow-none',\n '[[data-clear-button-hidden=true]_&]:shr-hidden',\n ],\n clearButtonIcon: [\n 'shr-block',\n 'group-focus-visible/clearButton:shr-focus-indicator group-focus-visible/clearButton:shr-rounded-full',\n ],\n },\n})\n\ntype SuffixButtonsProps = {\n clearButtonRef: RefObject<HTMLButtonElement>\n caretIconColor: string\n handleClickClear: (e: MouseEvent) => void\n handleClickIcon: (e: MouseEvent) => void\n classNames: {\n clearButton: string\n clearButtonIcon: string\n caretDownLayout: string\n caretDownIcon: string\n }\n}\n\nconst SuffixButtons = memo<SuffixButtonsProps>(\n ({\n clearButtonRef,\n caretIconColor,\n handleClickClear,\n handleClickIcon: handleDelegateClickIcon,\n classNames,\n }) => (\n <>\n <UnstyledButton\n ref={clearButtonRef}\n className={classNames.clearButton}\n onClick={handleClickClear}\n >\n <FaCircleXmarkIcon\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n color=\"TEXT_BLACK\"\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n className={classNames.caretDownLayout}\n onClick={handleDelegateClickIcon}\n >\n <FaCaretDownIcon color={caretIconColor} className={classNames.caretDownIcon} />\n </span>\n </>\n ),\n)\n\nconst ActualSingleCombobox = <T,>(\n {\n items,\n selectedItem,\n defaultItem,\n name,\n disabled,\n readOnly,\n required,\n prefix,\n error,\n creatable,\n placeholder,\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n width,\n dropdownWidth = 'auto',\n className,\n onChange,\n onChangeInput,\n onAdd,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const theme = useTheme()\n const triggerRef = useRef<HTMLDivElement>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const clearButtonRef = useRef<HTMLButtonElement>(null)\n const [isFocused, setIsFocused] = useState(false)\n const [isExpanded, setIsExpanded] = useState(false)\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n const [inputValue, setInputValue] = useState('')\n // inputValueはユーザーの手入力値も兼ねるため、prevSelectedItemLabelTextの代わりにinputValueと比較すると、\n // 手入力中に selectedItemLabelText(不変)との不一致を検知して選択中アイテムのラベルへ強制的に戻ってしまう\n const [prevSelectedItemLabelText, setPrevSelectedItemLabelText] = useState('')\n\n const selectedItemLabelText = selectedItem ? innerText(selectedItem.label) : ''\n\n if (selectedItemLabelText !== prevSelectedItemLabelText) {\n setPrevSelectedItemLabelText(selectedItemLabelText)\n setInputValue(selectedItemLabelText)\n // inputValueを選択中アイテムのラベルで上書きした場合、それは手入力値ではないため絞り込みには使わない\n setIsEditing(false)\n }\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const selectFrame = useAnimationFrame()\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox<T>({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n // HINT: memo化していないが、内部でuseLatestでstableにしているため最適化としてそのまま渡している\n onSelect: (selected: ComboboxItem<T>) => {\n onSelect?.(selected)\n onChangeSelected?.(selected)\n\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n selectFrame.request(() => {\n setIsExpanded(false)\n // HINT:\n // - 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n // - 対応するdropdownを閉じて以降にonChangeInputを発火する必要がある\n // - 先にclearしてしまうと意図せずこの要素のドロップダウンを閉じる前に他要素の再レンダリングを引き起こす可能性がある\n // - 例えばFilterDropdownなどで当comboboxを使っている場合、レイアウト上comboboxのdropdown以下の要素がクリックされた扱いになってしまい\n // FilterDropdownを意図せず閉じてしまうなどの挙動のバグを引き起こす可能性がある\n onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n })\n\n setIsEditing(false)\n },\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n handleKeyDownListBox,\n defaultItem,\n selectedItem,\n isFocused,\n isExpanded,\n isComposing,\n isEditing,\n disabled,\n readOnly,\n cleanupAddFrame,\n selectFrame,\n })\n\n const functions = useMemo(() => {\n const selectDefaultItem = () => {\n if (latest.onSelect && latest.defaultItem) {\n latest.onSelect(latest.defaultItem)\n }\n }\n const unfocus = () => {\n if (!latest.isFocused) return\n\n latest.onBlur?.()\n\n setIsFocused(false)\n setIsExpanded(false)\n\n if (latest.selectedItem) {\n // inputValueを選択中アイテムのラベルへ戻すため、手入力による絞り込みも解除する\n setIsEditing(false)\n setInputValue(innerText(latest.selectedItem.label))\n } else {\n // HINT: 未選択の場合はinputValueに手入力値が残るため、isEditingは解除しない\n // 解除してしまうと「入力値が表示されているのに絞り込みが効いていない」状態になる\n selectDefaultItem()\n }\n }\n\n return {\n selectDefaultItem,\n unfocus,\n cleanupCallbackRef: () => () => {\n latest.cleanupAddFrame()\n latest.selectFrame.cancel()\n },\n handleFocus: () => {\n latest.onFocus?.()\n inputRef.current?.focus()\n setIsFocused(true)\n\n if (!latest.isFocused) {\n setIsExpanded(true)\n }\n },\n handleClickClear: (e: MouseEvent) => {\n e.stopPropagation()\n\n let isExecutedPreventDefault = false\n\n latest.onClearClick?.({\n ...e,\n preventDefault: () => {\n e.preventDefault()\n isExecutedPreventDefault = true\n },\n })\n\n if (!isExecutedPreventDefault) {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n\n inputRef.current?.focus()\n\n setIsFocused(true)\n setIsExpanded(true)\n }\n },\n handleClickInput: (e: MouseEvent) => {\n if (latest.disabled || latest.readOnly) {\n e.stopPropagation()\n\n return\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n latest.onChange?.(e)\n latest.onChangeInput?.(e)\n\n if (!latest.isEditing) setIsEditing(true)\n\n const { value } = e.currentTarget\n\n setInputValue(value)\n\n if (value === '') {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') e.preventDefault()\n\n latest.onKeyPress?.(e)\n },\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (latest.isComposing) {\n return\n }\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n setIsExpanded(false)\n }\n } else if (e.key === 'Tab') {\n unfocus()\n } else {\n if (ARROW_UP_DOWN_REGEX.test(e.key)) {\n e.preventDefault()\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n }\n\n latest.handleKeyDownListBox(e)\n },\n }\n }, [latest])\n\n const caretIconColor = isFocused\n ? theme.textColor.black\n : disabled || readOnly\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isFocused ? [triggerRef, clearButtonRef] : null,\n functions.unfocus,\n isFocused || selectedItem ? undefined : functions.selectDefaultItem,\n )\n\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(inputRef, functions.cleanupCallbackRef, ref)\n\n const classNames = useMemo(() => {\n const { wrapper, input, caretDownLayout, caretDownIcon, clearButton, clearButtonIcon } =\n classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n input: input(),\n caretDownLayout: caretDownLayout(),\n caretDownIcon: caretDownIcon(),\n clearButton: clearButton(),\n clearButtonIcon: clearButtonIcon(),\n }\n }, [className])\n\n return (\n <div\n ref={triggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n data-clear-button-hidden={(selectedItem === null || disabled || readOnly || false).toString()}\n >\n <Input\n {...rest}\n ref={mergedRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required}\n disabled={disabled}\n readOnly={readOnly}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\n error={error}\n className={classNames.input}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input=\"true\"\n onClick={functions.handleClickInput}\n onChange={functions.handleChangeInput}\n onFocus={isFocused ? undefined : functions.handleFocus}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n onKeyPress={functions.handleKeyPress}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n classNames={classNames}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n />\n }\n />\n {!readOnly && <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;;;;AAWA;AAEA;;;;;;AAOA;;AAEE;;;;AAID;AAED;AAEA;;;;;;AAOI;AACE;AACA;;;AAIA;;;;;;;;AAQE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;;;AAuBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;AAKA;;;;;;;;AAOE;;AAEJ;;;;AAKE;;AAEE;;;AAGA;AACA;;AAGA;;;;AAIF;;;;AAMI;;;;;AAKD;;AAGC;AACA;AAEA;;;;;AAMJ;;;;;AAOE;AAEA;;;;AAIF;AACE;AACA;;;AAIA;;AAIA;AACE;AACA;;;AAGJ;AACA;;;;AAIA;AACE;;AAEA;;AAEF;AACE;;;;AAKE;;;;;AAIK;AACL;;;;;;AAMA;AAEA;;;;AAKF;;;AAGN;;AAGE;;AAEE;AACA;AAEJ;;;AAQA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;;;AAgBA;AA8BR;;;"}
|
|
@@ -73,6 +73,8 @@ const ActualSingleCombobox = ({ items, selectedItem, defaultItem, name, disabled
|
|
|
73
73
|
if (selectedItemLabelText !== prevSelectedItemLabelText) {
|
|
74
74
|
setPrevSelectedItemLabelText(selectedItemLabelText);
|
|
75
75
|
setInputValue(selectedItemLabelText);
|
|
76
|
+
// inputValueを選択中アイテムのラベルで上書きした場合、それは手入力値ではないため絞り込みには使わない
|
|
77
|
+
setIsEditing(false);
|
|
76
78
|
}
|
|
77
79
|
const { options } = useSingleOptions({
|
|
78
80
|
items,
|
|
@@ -144,11 +146,14 @@ const ActualSingleCombobox = ({ items, selectedItem, defaultItem, name, disabled
|
|
|
144
146
|
latest.onBlur?.();
|
|
145
147
|
setIsFocused(false);
|
|
146
148
|
setIsExpanded(false);
|
|
147
|
-
setIsEditing(false);
|
|
148
149
|
if (latest.selectedItem) {
|
|
150
|
+
// inputValueを選択中アイテムのラベルへ戻すため、手入力による絞り込みも解除する
|
|
151
|
+
setIsEditing(false);
|
|
149
152
|
setInputValue(innerText(latest.selectedItem.label));
|
|
150
153
|
}
|
|
151
154
|
else {
|
|
155
|
+
// HINT: 未選択の場合はinputValueに手入力値が残るため、isEditingは解除しない
|
|
156
|
+
// 解除してしまうと「入力値が表示されているのに絞り込みが効いていない」状態になる
|
|
152
157
|
selectDefaultItem();
|
|
153
158
|
}
|
|
154
159
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SingleCombobox.js","sources":["../../../../src/components/Combobox/SingleCombobox/SingleCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n type RefObject,\n memo,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { Localizer } from '../../../intl'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { UnstyledButton } from '../../Button'\nimport { FaCaretDownIcon, FaCircleXmarkIcon } from '../../Icon'\nimport { Input } from '../../Input'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useSingleOptions } from '../useOptions'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテム\n */\n selectedItem: ComboboxItem<T> | null\n /**\n * デフォルトで選択されるアイテム\n */\n defaultItem?: ComboboxItem<T>\n /**\n * コンポーネント内の先頭に表示する内容\n */\n prefix?: ReactNode\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n */\n onClear?: () => void\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n * 指定している場合、クリア時にonClickを実行せずにonClearClickのみ実行する\n */\n onClearClick?: (e: MouseEvent) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItem: ComboboxItem<T> | null) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_UP_DOWN_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper:\n 'smarthr-ui-SingleCombobox shr-inline-block [&:has(.smarthr-ui-Input-input:disabled)]:shr-cursor-not-allowed',\n input: 'smarthr-ui-SingleCombobox-input shr-w-full',\n caretDownLayout: [\n 'shr-relative -shr-me-0.5 shr-p-0.5',\n 'before:shr-border-0',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n caretDownIcon: 'shr-block',\n clearButton: [\n 'smarthr-ui-SingleCombobox-clearButton',\n 'shr-group/clearButton',\n 'shr-me-0.5',\n 'focus-visible:shr-shadow-none',\n '[[data-clear-button-hidden=true]_&]:shr-hidden',\n ],\n clearButtonIcon: [\n 'shr-block',\n 'group-focus-visible/clearButton:shr-focus-indicator group-focus-visible/clearButton:shr-rounded-full',\n ],\n },\n})\n\ntype SuffixButtonsProps = {\n clearButtonRef: RefObject<HTMLButtonElement>\n caretIconColor: string\n handleClickClear: (e: MouseEvent) => void\n handleClickIcon: (e: MouseEvent) => void\n classNames: {\n clearButton: string\n clearButtonIcon: string\n caretDownLayout: string\n caretDownIcon: string\n }\n}\n\nconst SuffixButtons = memo<SuffixButtonsProps>(\n ({\n clearButtonRef,\n caretIconColor,\n handleClickClear,\n handleClickIcon: handleDelegateClickIcon,\n classNames,\n }) => (\n <>\n <UnstyledButton\n ref={clearButtonRef}\n className={classNames.clearButton}\n onClick={handleClickClear}\n >\n <FaCircleXmarkIcon\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n color=\"TEXT_BLACK\"\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n className={classNames.caretDownLayout}\n onClick={handleDelegateClickIcon}\n >\n <FaCaretDownIcon color={caretIconColor} className={classNames.caretDownIcon} />\n </span>\n </>\n ),\n)\n\nconst ActualSingleCombobox = <T,>(\n {\n items,\n selectedItem,\n defaultItem,\n name,\n disabled,\n readOnly,\n required,\n prefix,\n error,\n creatable,\n placeholder,\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n width,\n dropdownWidth = 'auto',\n className,\n onChange,\n onChangeInput,\n onAdd,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const theme = useTheme()\n const triggerRef = useRef<HTMLDivElement>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const clearButtonRef = useRef<HTMLButtonElement>(null)\n const [isFocused, setIsFocused] = useState(false)\n const [isExpanded, setIsExpanded] = useState(false)\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n const [inputValue, setInputValue] = useState('')\n // inputValueはユーザーの手入力値も兼ねるため、prevSelectedItemLabelTextの代わりにinputValueと比較すると、\n // 手入力中に selectedItemLabelText(不変)との不一致を検知して選択中アイテムのラベルへ強制的に戻ってしまう\n const [prevSelectedItemLabelText, setPrevSelectedItemLabelText] = useState('')\n\n const selectedItemLabelText = selectedItem ? innerText(selectedItem.label) : ''\n\n if (selectedItemLabelText !== prevSelectedItemLabelText) {\n setPrevSelectedItemLabelText(selectedItemLabelText)\n setInputValue(selectedItemLabelText)\n }\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const selectFrame = useAnimationFrame()\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox<T>({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n // HINT: memo化していないが、内部でuseLatestでstableにしているため最適化としてそのまま渡している\n onSelect: (selected: ComboboxItem<T>) => {\n onSelect?.(selected)\n onChangeSelected?.(selected)\n\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n selectFrame.request(() => {\n setIsExpanded(false)\n // HINT:\n // - 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n // - 対応するdropdownを閉じて以降にonChangeInputを発火する必要がある\n // - 先にclearしてしまうと意図せずこの要素のドロップダウンを閉じる前に他要素の再レンダリングを引き起こす可能性がある\n // - 例えばFilterDropdownなどで当comboboxを使っている場合、レイアウト上comboboxのdropdown以下の要素がクリックされた扱いになってしまい\n // FilterDropdownを意図せず閉じてしまうなどの挙動のバグを引き起こす可能性がある\n onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n })\n\n setIsEditing(false)\n },\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n handleKeyDownListBox,\n defaultItem,\n selectedItem,\n isFocused,\n isExpanded,\n isComposing,\n isEditing,\n disabled,\n readOnly,\n cleanupAddFrame,\n selectFrame,\n })\n\n const functions = useMemo(() => {\n const selectDefaultItem = () => {\n if (latest.onSelect && latest.defaultItem) {\n latest.onSelect(latest.defaultItem)\n }\n }\n const unfocus = () => {\n if (!latest.isFocused) return\n\n latest.onBlur?.()\n\n setIsFocused(false)\n setIsExpanded(false)\n setIsEditing(false)\n\n if (latest.selectedItem) {\n setInputValue(innerText(latest.selectedItem.label))\n } else {\n selectDefaultItem()\n }\n }\n\n return {\n selectDefaultItem,\n unfocus,\n cleanupCallbackRef: () => () => {\n latest.cleanupAddFrame()\n latest.selectFrame.cancel()\n },\n handleFocus: () => {\n latest.onFocus?.()\n inputRef.current?.focus()\n setIsFocused(true)\n\n if (!latest.isFocused) {\n setIsExpanded(true)\n }\n },\n handleClickClear: (e: MouseEvent) => {\n e.stopPropagation()\n\n let isExecutedPreventDefault = false\n\n latest.onClearClick?.({\n ...e,\n preventDefault: () => {\n e.preventDefault()\n isExecutedPreventDefault = true\n },\n })\n\n if (!isExecutedPreventDefault) {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n\n inputRef.current?.focus()\n\n setIsFocused(true)\n setIsExpanded(true)\n }\n },\n handleClickInput: (e: MouseEvent) => {\n if (latest.disabled || latest.readOnly) {\n e.stopPropagation()\n\n return\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n latest.onChange?.(e)\n latest.onChangeInput?.(e)\n\n if (!latest.isEditing) setIsEditing(true)\n\n const { value } = e.currentTarget\n\n setInputValue(value)\n\n if (value === '') {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') e.preventDefault()\n\n latest.onKeyPress?.(e)\n },\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (latest.isComposing) {\n return\n }\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n setIsExpanded(false)\n }\n } else if (e.key === 'Tab') {\n unfocus()\n } else {\n if (ARROW_UP_DOWN_REGEX.test(e.key)) {\n e.preventDefault()\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n }\n\n latest.handleKeyDownListBox(e)\n },\n }\n }, [latest])\n\n const caretIconColor = isFocused\n ? theme.textColor.black\n : disabled || readOnly\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isFocused ? [triggerRef, clearButtonRef] : null,\n functions.unfocus,\n isFocused || selectedItem ? undefined : functions.selectDefaultItem,\n )\n\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(inputRef, functions.cleanupCallbackRef, ref)\n\n const classNames = useMemo(() => {\n const { wrapper, input, caretDownLayout, caretDownIcon, clearButton, clearButtonIcon } =\n classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n input: input(),\n caretDownLayout: caretDownLayout(),\n caretDownIcon: caretDownIcon(),\n clearButton: clearButton(),\n clearButtonIcon: clearButtonIcon(),\n }\n }, [className])\n\n return (\n <div\n ref={triggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n data-clear-button-hidden={(selectedItem === null || disabled || readOnly || false).toString()}\n >\n <Input\n {...rest}\n ref={mergedRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required}\n disabled={disabled}\n readOnly={readOnly}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\n error={error}\n className={classNames.input}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input=\"true\"\n onClick={functions.handleClickInput}\n onChange={functions.handleChangeInput}\n onFocus={isFocused ? undefined : functions.handleFocus}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n onKeyPress={functions.handleKeyPress}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n classNames={classNames}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n />\n }\n />\n {!readOnly && <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;;;;AAWA;AAEA;;;;AAKA;;AAEE;;;;AAID;AAED;AAEA;;;;;;AAOI;AACE;AACA;;;AAIA;;;;;;;;AAQE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;;;AAuBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;;AAMA;;;;AAGE;;AAEJ;;;;AAKE;;AAEE;;;AAGA;AACA;;AAGA;;;;AAIF;;;;AAMI;;;;;AAKD;;AAGC;AACA;AAEA;;;;;AAMJ;;;;;AAOE;AAEA;;;;AAIF;AACE;AACA;;;AAIA;;AAIA;AACE;AACA;;;AAGJ;AACA;;;;AAIA;AACE;;AAEA;;AAEF;AACE;;;;AAKE;;;;;AAIK;AACL;;;;;;AAMA;AAEA;;;;AAKF;;;AAGN;;AAGE;;AAEE;AACA;AAEJ;;;AAQA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;;;AAgBA;AA8BR;;;"}
|
|
1
|
+
{"version":3,"file":"SingleCombobox.js","sources":["../../../../src/components/Combobox/SingleCombobox/SingleCombobox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n type ReactNode,\n type Ref,\n type RefObject,\n memo,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useAnimationFrame } from '../../../hooks/client/useAnimationFrame'\nimport { useAreaClickCallbackRef } from '../../../hooks/client/useAreaClickCallbackRef'\nimport { useMergeRefs } from '../../../hooks/client/useMergeRefs'\nimport { useTheme } from '../../../hooks/client/useTheme'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { Localizer } from '../../../intl'\nimport { genericsForwardRef } from '../../../libs/util'\nimport { UnstyledButton } from '../../Button'\nimport { FaCaretDownIcon, FaCircleXmarkIcon } from '../../Icon'\nimport { Input } from '../../Input'\nimport { ListBox, useListbox } from '../useListbox'\nimport { useSingleOptions } from '../useOptions'\n\nimport type { ComboboxItem, BaseProps as ComboboxProps } from '../types'\n\ntype BaseProps<T> = ComboboxProps<T> & {\n /**\n * 選択されているアイテム\n */\n selectedItem: ComboboxItem<T> | null\n /**\n * デフォルトで選択されるアイテム\n */\n defaultItem?: ComboboxItem<T>\n /**\n * コンポーネント内の先頭に表示する内容\n */\n prefix?: ReactNode\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n */\n onClear?: () => void\n /**\n * 選択されているアイテムがクリアされた時に発火するコールバック関数\n * 指定している場合、クリア時にonClickを実行せずにonClearClickのみ実行する\n */\n onClearClick?: (e: MouseEvent) => void\n /**\n * 選択されているアイテムのリストが変わった時に発火するコールバック関数\n */\n onChangeSelected?: (selectedItem: ComboboxItem<T> | null) => void\n /**\n * コンポーネントがフォーカスされたときに発火するコールバック関数\n */\n onFocus?: () => void\n /**\n * コンポーネントからフォーカスが外れた時に発火するコールバック関数\n */\n onBlur?: () => void\n /**\n * 検索結果が0件の時に表示するコンテンツ\n */\n noResultText?: ReactNode\n}\ntype Props<T> = BaseProps<T> & Omit<ComponentPropsWithoutRef<'input'>, keyof BaseProps<unknown>>\n\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\nconst ARROW_UP_DOWN_REGEX = /^(Arrow)?(Up|Down)$/\n\nconst EMPTY_INPUT_CHANGE_EVENT = {\n currentTarget: { value: '' },\n target: { value: '' },\n} as ChangeEvent<HTMLInputElement>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper:\n 'smarthr-ui-SingleCombobox shr-inline-block [&:has(.smarthr-ui-Input-input:disabled)]:shr-cursor-not-allowed',\n input: 'smarthr-ui-SingleCombobox-input shr-w-full',\n caretDownLayout: [\n 'shr-relative -shr-me-0.5 shr-p-0.5',\n 'before:shr-border-0',\n 'before:shr-absolute before:shr-inset-x-0 before:shr-inset-y-0.25 before:shr-w-0 before:shr-border-l before:shr-border-solid before:shr-border-default before:shr-content-[\"\"]',\n ],\n caretDownIcon: 'shr-block',\n clearButton: [\n 'smarthr-ui-SingleCombobox-clearButton',\n 'shr-group/clearButton',\n 'shr-me-0.5',\n 'focus-visible:shr-shadow-none',\n '[[data-clear-button-hidden=true]_&]:shr-hidden',\n ],\n clearButtonIcon: [\n 'shr-block',\n 'group-focus-visible/clearButton:shr-focus-indicator group-focus-visible/clearButton:shr-rounded-full',\n ],\n },\n})\n\ntype SuffixButtonsProps = {\n clearButtonRef: RefObject<HTMLButtonElement>\n caretIconColor: string\n handleClickClear: (e: MouseEvent) => void\n handleClickIcon: (e: MouseEvent) => void\n classNames: {\n clearButton: string\n clearButtonIcon: string\n caretDownLayout: string\n caretDownIcon: string\n }\n}\n\nconst SuffixButtons = memo<SuffixButtonsProps>(\n ({\n clearButtonRef,\n caretIconColor,\n handleClickClear,\n handleClickIcon: handleDelegateClickIcon,\n classNames,\n }) => (\n <>\n <UnstyledButton\n ref={clearButtonRef}\n className={classNames.clearButton}\n onClick={handleClickClear}\n >\n <FaCircleXmarkIcon\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n color=\"TEXT_BLACK\"\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n className={classNames.caretDownLayout}\n onClick={handleDelegateClickIcon}\n >\n <FaCaretDownIcon color={caretIconColor} className={classNames.caretDownIcon} />\n </span>\n </>\n ),\n)\n\nconst ActualSingleCombobox = <T,>(\n {\n items,\n selectedItem,\n defaultItem,\n name,\n disabled,\n readOnly,\n required,\n prefix,\n error,\n creatable,\n placeholder,\n autoComplete,\n dropdownHelpMessage,\n isLoading,\n width,\n dropdownWidth = 'auto',\n className,\n onChange,\n onChangeInput,\n onAdd,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n noResultText,\n style,\n ...rest\n }: Props<T>,\n ref: Ref<HTMLInputElement>,\n) => {\n const theme = useTheme()\n const triggerRef = useRef<HTMLDivElement>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const clearButtonRef = useRef<HTMLButtonElement>(null)\n const [isFocused, setIsFocused] = useState(false)\n const [isExpanded, setIsExpanded] = useState(false)\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n const [inputValue, setInputValue] = useState('')\n // inputValueはユーザーの手入力値も兼ねるため、prevSelectedItemLabelTextの代わりにinputValueと比較すると、\n // 手入力中に selectedItemLabelText(不変)との不一致を検知して選択中アイテムのラベルへ強制的に戻ってしまう\n const [prevSelectedItemLabelText, setPrevSelectedItemLabelText] = useState('')\n\n const selectedItemLabelText = selectedItem ? innerText(selectedItem.label) : ''\n\n if (selectedItemLabelText !== prevSelectedItemLabelText) {\n setPrevSelectedItemLabelText(selectedItemLabelText)\n setInputValue(selectedItemLabelText)\n // inputValueを選択中アイテムのラベルで上書きした場合、それは手入力値ではないため絞り込みには使わない\n setIsEditing(false)\n }\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const selectFrame = useAnimationFrame()\n\n const { listBoxProps, activeOption, cleanupAddFrame, handleKeyDownListBox, listBoxId } =\n useListbox<T>({\n options,\n dropdownHelpMessage,\n dropdownWidth,\n onAdd,\n // HINT: memo化していないが、内部でuseLatestでstableにしているため最適化としてそのまま渡している\n onSelect: (selected: ComboboxItem<T>) => {\n onSelect?.(selected)\n onChangeSelected?.(selected)\n\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // 処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n selectFrame.request(() => {\n setIsExpanded(false)\n // HINT:\n // - 制御コンポーネントの場合に親側でinputValueを更新できるように、選択時にonChangeInputを空文字で発火する\n // - 対応するdropdownを閉じて以降にonChangeInputを発火する必要がある\n // - 先にclearしてしまうと意図せずこの要素のドロップダウンを閉じる前に他要素の再レンダリングを引き起こす可能性がある\n // - 例えばFilterDropdownなどで当comboboxを使っている場合、レイアウト上comboboxのdropdown以下の要素がクリックされた扱いになってしまい\n // FilterDropdownを意図せず閉じてしまうなどの挙動のバグを引き起こす可能性がある\n onChangeInput?.(EMPTY_INPUT_CHANGE_EVENT)\n })\n\n setIsEditing(false)\n },\n isExpanded,\n isLoading,\n triggerRef,\n noResultText,\n })\n\n const latest = useLatest({\n onChange,\n onChangeInput,\n onSelect,\n onClear,\n onClearClick,\n onChangeSelected,\n onFocus,\n onBlur,\n onKeyPress,\n handleKeyDownListBox,\n defaultItem,\n selectedItem,\n isFocused,\n isExpanded,\n isComposing,\n isEditing,\n disabled,\n readOnly,\n cleanupAddFrame,\n selectFrame,\n })\n\n const functions = useMemo(() => {\n const selectDefaultItem = () => {\n if (latest.onSelect && latest.defaultItem) {\n latest.onSelect(latest.defaultItem)\n }\n }\n const unfocus = () => {\n if (!latest.isFocused) return\n\n latest.onBlur?.()\n\n setIsFocused(false)\n setIsExpanded(false)\n\n if (latest.selectedItem) {\n // inputValueを選択中アイテムのラベルへ戻すため、手入力による絞り込みも解除する\n setIsEditing(false)\n setInputValue(innerText(latest.selectedItem.label))\n } else {\n // HINT: 未選択の場合はinputValueに手入力値が残るため、isEditingは解除しない\n // 解除してしまうと「入力値が表示されているのに絞り込みが効いていない」状態になる\n selectDefaultItem()\n }\n }\n\n return {\n selectDefaultItem,\n unfocus,\n cleanupCallbackRef: () => () => {\n latest.cleanupAddFrame()\n latest.selectFrame.cancel()\n },\n handleFocus: () => {\n latest.onFocus?.()\n inputRef.current?.focus()\n setIsFocused(true)\n\n if (!latest.isFocused) {\n setIsExpanded(true)\n }\n },\n handleClickClear: (e: MouseEvent) => {\n e.stopPropagation()\n\n let isExecutedPreventDefault = false\n\n latest.onClearClick?.({\n ...e,\n preventDefault: () => {\n e.preventDefault()\n isExecutedPreventDefault = true\n },\n })\n\n if (!isExecutedPreventDefault) {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n\n inputRef.current?.focus()\n\n setIsFocused(true)\n setIsExpanded(true)\n }\n },\n handleClickInput: (e: MouseEvent) => {\n if (latest.disabled || latest.readOnly) {\n e.stopPropagation()\n\n return\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n },\n handleChangeInput: (e: ChangeEvent<HTMLInputElement>) => {\n latest.onChange?.(e)\n latest.onChangeInput?.(e)\n\n if (!latest.isEditing) setIsEditing(true)\n\n const { value } = e.currentTarget\n\n setInputValue(value)\n\n if (value === '') {\n latest.onClear?.()\n latest.onChangeSelected?.(null)\n }\n },\n handleCompositionStart: () => setIsComposing(true),\n handleCompositionEnd: () => setIsComposing(false),\n // HINT: form内にcomboboxを設置 & 検索inputにfocusした状態で\n // アイテムをキーボードで選択し、Enterを押すとinput上でEnterを押したことになるため、\n // submitイベントが発生し、formが送信される場合がある\n handleKeyPress: (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') e.preventDefault()\n\n latest.onKeyPress?.(e)\n },\n handleKeyDownInput: (e: KeyboardEvent<HTMLInputElement>) => {\n if (latest.isComposing) {\n return\n }\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n if (latest.isExpanded) {\n e.stopPropagation()\n setIsExpanded(false)\n }\n } else if (e.key === 'Tab') {\n unfocus()\n } else {\n if (ARROW_UP_DOWN_REGEX.test(e.key)) {\n e.preventDefault()\n }\n\n inputRef.current?.focus()\n\n if (!latest.isExpanded) {\n setIsExpanded(true)\n }\n }\n\n latest.handleKeyDownListBox(e)\n },\n }\n }, [latest])\n\n const caretIconColor = isFocused\n ? theme.textColor.black\n : disabled || readOnly\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n const listBoxCallbackRef = useAreaClickCallbackRef(\n isFocused ? [triggerRef, clearButtonRef] : null,\n functions.unfocus,\n isFocused || selectedItem ? undefined : functions.selectDefaultItem,\n )\n\n // HINT: useMergeRefsはv18でもcallbackRefのcleanup関数に対応している\n // もしuseMergeRefsをなくす場合、react v18対応が不要になっているかどうか確認する\n const mergedRef = useMergeRefs(inputRef, functions.cleanupCallbackRef, ref)\n\n const classNames = useMemo(() => {\n const { wrapper, input, caretDownLayout, caretDownIcon, clearButton, clearButtonIcon } =\n classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n input: input(),\n caretDownLayout: caretDownLayout(),\n caretDownIcon: caretDownIcon(),\n clearButton: clearButton(),\n clearButtonIcon: clearButtonIcon(),\n }\n }, [className])\n\n return (\n <div\n ref={triggerRef}\n role=\"group\"\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n data-clear-button-hidden={(selectedItem === null || disabled || readOnly || false).toString()}\n >\n <Input\n {...rest}\n ref={mergedRef}\n role=\"combobox\"\n type=\"text\"\n name={name}\n required={required}\n disabled={disabled}\n readOnly={readOnly}\n value={inputValue}\n autoComplete={autoComplete ?? 'off'}\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\n error={error}\n className={classNames.input}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n data-smarthr-ui-input=\"true\"\n onClick={functions.handleClickInput}\n onChange={functions.handleChangeInput}\n onFocus={isFocused ? undefined : functions.handleFocus}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n onKeyPress={functions.handleKeyPress}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n classNames={classNames}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n />\n }\n />\n {!readOnly && <ListBox {...listBoxProps} callbackRef={listBoxCallbackRef} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;;;;AAWA;AAEA;;;;;;AAOA;;AAEE;;;;AAID;AAED;AAEA;;;;;;AAOI;AACE;AACA;;;AAIA;;;;;;;;AAQE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;;;AAuBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;AAKA;;;;;;;;AAOE;;AAEJ;;;;AAKE;;AAEE;;;AAGA;AACA;;AAGA;;;;AAIF;;;;AAMI;;;;;AAKD;;AAGC;AACA;AAEA;;;;;AAMJ;;;;;AAOE;AAEA;;;;AAIF;AACE;AACA;;;AAIA;;AAIA;AACE;AACA;;;AAGJ;AACA;;;;AAIA;AACE;;AAEA;;AAEF;AACE;;;;AAKE;;;;;AAIK;AACL;;;;;;AAMA;AAEA;;;;AAKF;;;AAGN;;AAGE;;AAEE;AACA;AAEJ;;;AAQA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;;;AAgBA;AA8BR;;;"}
|
|
@@ -77,9 +77,10 @@ const DropZone = React.forwardRef(({ children, onSelectFiles, multiple = true, d
|
|
|
77
77
|
},
|
|
78
78
|
};
|
|
79
79
|
}, [latest]);
|
|
80
|
+
const errorAttr = error || undefined;
|
|
80
81
|
return (
|
|
81
82
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
82
|
-
jsxRuntime.jsxs("div", { className: classNames.wrapper, "data-files-dragged-over": filesDraggedOver || undefined, onDrop: functions.handleDrop, onDragOver: functions.handleDragOver, onDragLeave: functions.handleDragLeave, children: [children, jsxRuntime.jsx(SelectButton, { disabled: disabled, error: error, className: classNames.button, handleClick: functions.handleClickButton, label: selectButtonLabel }), jsxRuntime.jsx(components_VisuallyHiddenText_VisuallyHiddenText.VisuallyHiddenText, { children: jsxRuntime.jsx("input", { ...rest, ref: ref, type: "file", disabled: disabled, multiple: multiple, tabIndex: -1, "aria-invalid": error
|
|
83
|
+
jsxRuntime.jsxs("div", { className: classNames.wrapper, "data-files-dragged-over": filesDraggedOver || undefined, onDrop: functions.handleDrop, onDragOver: functions.handleDragOver, onDragLeave: functions.handleDragLeave, children: [children, jsxRuntime.jsx(SelectButton, { disabled: disabled, error: error, className: classNames.button, handleClick: functions.handleClickButton, label: selectButtonLabel }), jsxRuntime.jsx(components_VisuallyHiddenText_VisuallyHiddenText.VisuallyHiddenText, { children: jsxRuntime.jsx("input", { ...rest, ref: ref, type: "file", disabled: disabled, multiple: multiple, tabIndex: -1, "aria-invalid": errorAttr, "data-smarthr-ui-input-error": errorAttr, "data-smarthr-ui-input": "true", onChange: functions.handleChange }) })] }));
|
|
83
84
|
});
|
|
84
85
|
const SelectButton = React.memo(({ label, disabled, error, handleClick, className }) => (jsxRuntime.jsx(components_Button_Button.Button, { disabled: disabled, className: className, "data-error": error || undefined, onClick: handleClick, prefix: jsxRuntime.jsx(components_Icon_FaIcon.FaFolderOpenIcon, {}), children: label || jsxRuntime.jsx(intl_Localizer.Localizer, { id: "smarthr-ui/DropZone/selectButtonLabel", defaultText: "\u30D5\u30A1\u30A4\u30EB\u3092\u9078\u629E" }) })));
|
|
85
86
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropZone.cjs","sources":["../../../../src/components/DropZone/client/DropZone.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithRef,\n type DragEvent,\n type MouseEvent,\n type PropsWithChildren,\n forwardRef,\n memo,\n useMemo,\n useState,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../../hooks/useLatest'\nimport { Localizer } from '../../../intl'\nimport { Button } from '../../Button'\nimport { FaFolderOpenIcon } from '../../Icon'\nimport { VisuallyHiddenText } from '../../VisuallyHiddenText'\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: [\n 'smarthr-ui-DropZone',\n 'shr-relative',\n 'shr-border-shorthand shr-flex shr-flex-col shr-items-center shr-justify-center shr-bg-column shr-p-2.5',\n 'has-[.smarthr-ui-DropZone-Button[aria-disabled]]:shr-cursor-not-allowed',\n '[&:not([data-files-dragged-over])]:shr-border-dashed',\n 'data-[files-dragged-over]:shr-border-main',\n ],\n button: ['smarthr-ui-DropZone-Button', 'data-[error]:shr-border-danger'],\n },\n})\n\ntype BaseProps = PropsWithChildren<{\n /**\n * ボタンまたはドラッグ&ドロップでファイルが追加された時に発火するコールバック関数\n */\n onSelectFiles: (\n e: DragEvent<HTMLElement> | ChangeEvent<HTMLInputElement>,\n files: FileList | null,\n ) => void\n /**\n * 許可するファイル型を表す1つ以上の固有ファイル型指定子\n * <b>(ドラッグ&ドロップの挙動には影響しません)</b>\n */\n accept?: string\n /** 複数ファイルを選択できるかどうか */\n multiple?: boolean\n name?: string\n disabled?: boolean\n /** フォームにエラーがあるかどうか */\n error?: boolean\n /** ファイル選択ボタンのラベル */\n selectButtonLabel?: string\n}>\ntype Props = BaseProps & Omit<ComponentPropsWithRef<'div'>, keyof BaseProps>\n\nconst overrideEventDefault = (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n}\n\nexport const DropZone = forwardRef<HTMLInputElement, Props>(\n (\n {\n children,\n onSelectFiles,\n multiple = true,\n disabled,\n error,\n selectButtonLabel,\n className,\n ...rest\n },\n ref,\n ) => {\n const [filesDraggedOver, setFilesDraggedOver] = useState(false)\n\n const classNames = useMemo(() => {\n const { wrapper, button } = classNameGenerator()\n return {\n wrapper: wrapper({ className }),\n button: button(),\n }\n }, [className])\n\n const latest = useLatest({ onSelectFiles })\n\n const functions = useMemo(() => {\n const inputFileSelector = 'input[type=\"file\"][data-smarthr-ui-input=\"true\"]'\n\n return {\n handleDrop: (e: DragEvent<HTMLElement>) => {\n overrideEventDefault(e)\n setFilesDraggedOver(false)\n\n if (e.dataTransfer.types.includes('Files')) {\n const input = e.currentTarget.querySelector<HTMLInputElement>(inputFileSelector)\n\n if (input) {\n input.files = e.dataTransfer.files\n }\n latest.onSelectFiles(e, e.dataTransfer.files)\n }\n },\n handleDragOver: (e: DragEvent<HTMLElement>) => {\n overrideEventDefault(e)\n setFilesDraggedOver(true)\n },\n handleDragLeave: () => {\n setFilesDraggedOver(false)\n },\n handleChange: (e: ChangeEvent<HTMLInputElement>) => {\n latest.onSelectFiles(e, e.target.files)\n },\n handleClickButton: (e: MouseEvent<HTMLButtonElement>) => {\n e.currentTarget\n .closest('.smarthr-ui-DropZone')\n ?.querySelector<HTMLInputElement>(inputFileSelector)\n ?.click()\n },\n }\n }, [latest])\n\n return (\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions\n <div\n className={classNames.wrapper}\n data-files-dragged-over={filesDraggedOver || undefined}\n onDrop={functions.handleDrop}\n onDragOver={functions.handleDragOver}\n onDragLeave={functions.handleDragLeave}\n >\n {children}\n <SelectButton\n disabled={disabled}\n error={error}\n className={classNames.button}\n handleClick={functions.handleClickButton}\n label={selectButtonLabel}\n />\n <VisuallyHiddenText>\n {/* TODO: この input にアクセシブルネームが設定されていない。VisuallyHiddenText で視覚的に隠されているが aria-hidden ではないためアクセシビリティツリーに残る。aria-label 等で適切なラベルを付与する必要がある */}\n {/* eslint-disable-next-line smarthr/a11y-input-in-form-control */}\n <input\n {...rest}\n ref={ref}\n type=\"file\"\n disabled={disabled}\n multiple={multiple}\n tabIndex={-1}\n aria-invalid={error
|
|
1
|
+
{"version":3,"file":"DropZone.cjs","sources":["../../../../src/components/DropZone/client/DropZone.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ChangeEvent,\n type ComponentPropsWithRef,\n type DragEvent,\n type MouseEvent,\n type PropsWithChildren,\n forwardRef,\n memo,\n useMemo,\n useState,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../../hooks/useLatest'\nimport { Localizer } from '../../../intl'\nimport { Button } from '../../Button'\nimport { FaFolderOpenIcon } from '../../Icon'\nimport { VisuallyHiddenText } from '../../VisuallyHiddenText'\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: [\n 'smarthr-ui-DropZone',\n 'shr-relative',\n 'shr-border-shorthand shr-flex shr-flex-col shr-items-center shr-justify-center shr-bg-column shr-p-2.5',\n 'has-[.smarthr-ui-DropZone-Button[aria-disabled]]:shr-cursor-not-allowed',\n '[&:not([data-files-dragged-over])]:shr-border-dashed',\n 'data-[files-dragged-over]:shr-border-main',\n ],\n button: ['smarthr-ui-DropZone-Button', 'data-[error]:shr-border-danger'],\n },\n})\n\ntype BaseProps = PropsWithChildren<{\n /**\n * ボタンまたはドラッグ&ドロップでファイルが追加された時に発火するコールバック関数\n */\n onSelectFiles: (\n e: DragEvent<HTMLElement> | ChangeEvent<HTMLInputElement>,\n files: FileList | null,\n ) => void\n /**\n * 許可するファイル型を表す1つ以上の固有ファイル型指定子\n * <b>(ドラッグ&ドロップの挙動には影響しません)</b>\n */\n accept?: string\n /** 複数ファイルを選択できるかどうか */\n multiple?: boolean\n name?: string\n disabled?: boolean\n /** フォームにエラーがあるかどうか */\n error?: boolean\n /** ファイル選択ボタンのラベル */\n selectButtonLabel?: string\n}>\ntype Props = BaseProps & Omit<ComponentPropsWithRef<'div'>, keyof BaseProps>\n\nconst overrideEventDefault = (e: DragEvent<HTMLElement>) => {\n e.preventDefault()\n e.stopPropagation()\n}\n\nexport const DropZone = forwardRef<HTMLInputElement, Props>(\n (\n {\n children,\n onSelectFiles,\n multiple = true,\n disabled,\n error,\n selectButtonLabel,\n className,\n ...rest\n },\n ref,\n ) => {\n const [filesDraggedOver, setFilesDraggedOver] = useState(false)\n\n const classNames = useMemo(() => {\n const { wrapper, button } = classNameGenerator()\n return {\n wrapper: wrapper({ className }),\n button: button(),\n }\n }, [className])\n\n const latest = useLatest({ onSelectFiles })\n\n const functions = useMemo(() => {\n const inputFileSelector = 'input[type=\"file\"][data-smarthr-ui-input=\"true\"]'\n\n return {\n handleDrop: (e: DragEvent<HTMLElement>) => {\n overrideEventDefault(e)\n setFilesDraggedOver(false)\n\n if (e.dataTransfer.types.includes('Files')) {\n const input = e.currentTarget.querySelector<HTMLInputElement>(inputFileSelector)\n\n if (input) {\n input.files = e.dataTransfer.files\n }\n latest.onSelectFiles(e, e.dataTransfer.files)\n }\n },\n handleDragOver: (e: DragEvent<HTMLElement>) => {\n overrideEventDefault(e)\n setFilesDraggedOver(true)\n },\n handleDragLeave: () => {\n setFilesDraggedOver(false)\n },\n handleChange: (e: ChangeEvent<HTMLInputElement>) => {\n latest.onSelectFiles(e, e.target.files)\n },\n handleClickButton: (e: MouseEvent<HTMLButtonElement>) => {\n e.currentTarget\n .closest('.smarthr-ui-DropZone')\n ?.querySelector<HTMLInputElement>(inputFileSelector)\n ?.click()\n },\n }\n }, [latest])\n\n const errorAttr = error || undefined\n\n return (\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions\n <div\n className={classNames.wrapper}\n data-files-dragged-over={filesDraggedOver || undefined}\n onDrop={functions.handleDrop}\n onDragOver={functions.handleDragOver}\n onDragLeave={functions.handleDragLeave}\n >\n {children}\n <SelectButton\n disabled={disabled}\n error={error}\n className={classNames.button}\n handleClick={functions.handleClickButton}\n label={selectButtonLabel}\n />\n <VisuallyHiddenText>\n {/* TODO: この input にアクセシブルネームが設定されていない。VisuallyHiddenText で視覚的に隠されているが aria-hidden ではないためアクセシビリティツリーに残る。aria-label 等で適切なラベルを付与する必要がある */}\n {/* eslint-disable-next-line smarthr/a11y-input-in-form-control */}\n <input\n {...rest}\n ref={ref}\n type=\"file\"\n disabled={disabled}\n multiple={multiple}\n tabIndex={-1}\n aria-invalid={errorAttr}\n data-smarthr-ui-input-error={errorAttr}\n data-smarthr-ui-input=\"true\"\n onChange={functions.handleChange}\n />\n </VisuallyHiddenText>\n </div>\n )\n },\n)\n\nconst SelectButton = memo<{\n label?: string\n disabled?: boolean\n error?: boolean\n handleClick: (e: MouseEvent<HTMLButtonElement>) => void\n className: string\n}>(({ label, disabled, error, handleClick, className }) => (\n <Button\n disabled={disabled}\n className={className}\n data-error={error || undefined}\n onClick={handleClick}\n prefix={<FaFolderOpenIcon />}\n >\n {label || <Localizer id=\"smarthr-ui/DropZone/selectButtonLabel\" defaultText=\"ファイルを選択\" />}\n </Button>\n))\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAqBA;AACE;AACE;;;;;;;AAOC;AACD;AACD;AACF;AA0BD;;;AAGA;AAEO;;AAgBH;;;AAGI;;;AAGJ;;AAIA;;;AAII;;;;;;;;;;;AAaA;;;;;;;AAOA;;;AAGA;AACE;;;;;;AAMN;AAEA;;;;AAqCF;AAGF;;"}
|