smarthr-ui 99.3.0 → 99.3.1
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/Checkbox.cjs +1 -1
- package/lib/components/Checkbox/Checkbox.cjs.map +1 -1
- package/lib/components/Checkbox/Checkbox.js +1 -1
- package/lib/components/Checkbox/Checkbox.js.map +1 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.cjs +1 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.cjs.map +1 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.js +1 -1
- package/lib/components/Combobox/MultiCombobox/MultiCombobox.js.map +1 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.cjs +1 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.cjs.map +1 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.js +1 -1
- package/lib/components/Combobox/SingleCombobox/SingleCombobox.js.map +1 -1
- package/lib/components/DatePicker/DatePicker.cjs +1 -1
- package/lib/components/DatePicker/DatePicker.cjs.map +1 -1
- package/lib/components/DatePicker/DatePicker.js +1 -1
- package/lib/components/DatePicker/DatePicker.js.map +1 -1
- package/lib/components/DatePicker/Portal.cjs +1 -1
- package/lib/components/DatePicker/Portal.cjs.map +1 -1
- package/lib/components/DatePicker/Portal.js +1 -1
- package/lib/components/DatePicker/Portal.js.map +1 -1
- package/lib/components/DropZone/DropZone.cjs +1 -1
- package/lib/components/DropZone/DropZone.cjs.map +1 -1
- package/lib/components/DropZone/DropZone.js +1 -1
- package/lib/components/DropZone/DropZone.js.map +1 -1
- package/lib/components/Heading/PageHeading/PageHeading.cjs +1 -1
- package/lib/components/Heading/PageHeading/PageHeading.cjs.map +1 -1
- package/lib/components/Heading/PageHeading/PageHeading.js +1 -1
- package/lib/components/Heading/PageHeading/PageHeading.js.map +1 -1
- package/lib/components/Input/CurrencyInput/CurrencyInput.cjs +1 -1
- package/lib/components/Input/CurrencyInput/CurrencyInput.cjs.map +1 -1
- package/lib/components/Input/CurrencyInput/CurrencyInput.js +1 -1
- package/lib/components/Input/CurrencyInput/CurrencyInput.js.map +1 -1
- package/lib/components/Input/Input.cjs +1 -1
- package/lib/components/Input/Input.cjs.map +1 -1
- package/lib/components/Input/Input.js +1 -1
- package/lib/components/Input/Input.js.map +1 -1
- package/lib/components/InputFile/InputFileMultiplyAppendable.cjs +1 -1
- package/lib/components/InputFile/InputFileMultiplyAppendable.cjs.map +1 -1
- package/lib/components/InputFile/InputFileMultiplyAppendable.js +1 -1
- package/lib/components/InputFile/InputFileMultiplyAppendable.js.map +1 -1
- package/lib/components/InputFile/InputFileNative.cjs +1 -1
- package/lib/components/InputFile/InputFileNative.cjs.map +1 -1
- package/lib/components/InputFile/InputFileNative.js +1 -1
- package/lib/components/InputFile/InputFileNative.js.map +1 -1
- package/lib/components/Scroller/Scroller.cjs +3 -1
- package/lib/components/Scroller/Scroller.cjs.map +1 -1
- package/lib/components/Scroller/Scroller.js +3 -1
- package/lib/components/Scroller/Scroller.js.map +1 -1
- package/lib/components/Table/TableReel.cjs +67 -3
- package/lib/components/Table/TableReel.cjs.map +1 -1
- package/lib/components/Table/TableReel.js +68 -4
- package/lib/components/Table/TableReel.js.map +1 -1
- package/lib/components/Textarea/Textarea.cjs +1 -1
- package/lib/components/Textarea/Textarea.cjs.map +1 -1
- package/lib/components/Textarea/Textarea.js +1 -1
- package/lib/components/Textarea/Textarea.js.map +1 -1
- package/metadata.json +1 -1
- package/package.json +1 -1
- package/lib/components/Table/useReelCells.cjs +0 -83
- package/lib/components/Table/useReelCells.cjs.map +0 -1
- package/lib/components/Table/useReelCells.d.ts +0 -3
- package/lib/components/Table/useReelCells.js +0 -81
- package/lib/components/Table/useReelCells.js.map +0 -1
|
@@ -61,7 +61,7 @@ const Checkbox = React.forwardRef(({ checked, mixed, error, className, children,
|
|
|
61
61
|
const inputRef = React.useRef(null);
|
|
62
62
|
const defaultId = React.useId();
|
|
63
63
|
const checkBoxId = id || defaultId;
|
|
64
|
-
React.useImperativeHandle(ref, () => inputRef.current);
|
|
64
|
+
React.useImperativeHandle(ref, () => inputRef.current, []);
|
|
65
65
|
React.useEffect(() => {
|
|
66
66
|
if (inputRef.current) {
|
|
67
67
|
inputRef.current.indeterminate = !!(checked && mixed);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.cjs","sources":["../../../src/components/Checkbox/Checkbox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ComponentPropsWithRef,\n type PropsWithChildren,\n forwardRef,\n memo,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { FaCheckIcon, FaMinusIcon } from '../Icon'\n\nexport type Props = PropsWithChildren<\n ComponentPropsWithRef<'input'> & {\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n }\n>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: 'smarthr-ui-Checkbox shr-inline-flex shr-items-baseline',\n box: [\n 'shr-border-shorthand shr-pointer-events-none shr-absolute shr-box-border shr-h-full shr-w-full shr-rounded-s shr-bg-white',\n 'contrast-more:shr-border-high-contrast',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-hidden',\n 'peer-checked:shr-border-main peer-checked:shr-bg-main contrast-more:peer-checked:shr-border-high-contrast',\n 'peer-indeterminate:shr-border-main peer-indeterminate:shr-bg-main contrast-more:peer-indeterminate:shr-border-high-contrast',\n 'peer-disabled:shr-border-disabled peer-disabled:shr-bg-white-darken',\n 'peer-disabled:peer-checked:shr-border-default peer-disabled:peer-checked:shr-bg-border',\n 'peer-disabled:peer-indeterminate:shr-border-default peer-disabled:peer-indeterminate:shr-bg-border',\n 'peer-focus-visible:shr-focus-indicator--outer',\n 'peer-hover:shr-shadow-input-hover',\n 'shr-border-default',\n 'peer-[[aria-invalid]]:shr-border-danger',\n ],\n input: [\n 'smarthr-ui-Checkbox-checkbox shr-peer shr-absolute shr-left-0 shr-top-0 shr-m-0 shr-h-full shr-w-full shr-cursor-pointer shr-opacity-0 disabled:shr-pointer-events-none',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-static forced-colors:shr-opacity-100',\n ],\n icon: 'shr-fill-current',\n iconWrap: [\n 'shr-pointer-events-none shr-absolute shr-left-1/2 shr-top-1/2 shr-inline-block shr-h-[theme(fontSize.2xs)] shr-w-[theme(fontSize.2xs)] -shr-translate-x-1/2 -shr-translate-y-1/2 shr-text-2xs',\n 'shr-text-transparent peer-checked:shr-text-white peer-indeterminate:shr-text-white',\n 'peer-disabled:peer-checked:shr-text-white-darken peer-disabled:peer-indeterminate:shr-text-white-darken',\n 'forced-colors:shr-hidden',\n ],\n innerWrapper:\n 'shr-relative shr-box-border shr-inline-block shr-h-[theme(fontSize.base)] shr-w-[theme(fontSize.base)] shr-shrink-0 shr-translate-y-[0.125em] shr-leading-none',\n label: [\n 'smarthr-ui-Checkbox-label shr-ms-0.5 shr-cursor-pointer shr-text-base shr-leading-tight',\n '[[data-disabled=true]>&]:shr-pointer-events-none [[data-disabled=true]>&]:shr-cursor-not-allowed [[data-disabled=true]>&]:shr-text-disabled',\n ],\n },\n})\n\nexport const Checkbox = forwardRef<HTMLInputElement, Props>(\n ({ checked, mixed, error, className, children, disabled, id, ...rest }, ref) => {\n const classNames = useMemo(() => {\n const { wrapper, innerWrapper, box, input, iconWrap, icon, label } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n innerWrapper: innerWrapper(),\n box: box(),\n input: input(),\n iconWrap: iconWrap(),\n icon: icon(),\n label: label(),\n }\n }, [className])\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const defaultId = useId()\n const checkBoxId = id || defaultId\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n )\n\n useEffect(() => {\n if (inputRef.current) {\n inputRef.current.indeterminate = !!(checked && mixed)\n }\n }, [checked, mixed])\n\n return (\n <span data-disabled={disabled} className={classNames.wrapper}>\n <span className={classNames.innerWrapper}>\n <input\n {...rest}\n ref={inputRef}\n type=\"checkbox\"\n id={checkBoxId}\n checked={checked}\n disabled={disabled}\n aria-invalid={error || undefined}\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n <AriaHiddenBox className={classNames.box} />\n <CheckIconArea mixed={mixed} classNames={classNames} />\n </span>\n\n {children && (\n <label htmlFor={checkBoxId} className={classNames.label}>\n {children}\n </label>\n )}\n </span>\n )\n },\n)\n\nconst AriaHiddenBox = memo<{ className: string }>(({ className }) => (\n <span className={className} aria-hidden=\"true\" />\n))\n\nconst CheckIconArea = memo<\n Pick<Props, 'mixed'> & { classNames: { iconWrap: string; icon: string } }\n>(({ mixed, classNames }) => {\n const Icon = mixed ? FaMinusIcon : FaCheckIcon\n\n return (\n <span className={classNames.iconWrap}>\n <Icon className={classNames.icon} />\n </span>\n )\n})\n"],"names":[],"mappings":";;;;;;;;;;AA0BA;AACE;AACE;AACA;;;;;;;;;;;;;;AAcC;AACD;;;;AAIC;AACD;AACA;;;;;AAKC;AACD;AAEA;;;AAGC;AACF;AACF;AAEM;AAEH;AACE;;AAGE;;;;;;;;AAQJ;AAEA;AAEA;AACA
|
|
1
|
+
{"version":3,"file":"Checkbox.cjs","sources":["../../../src/components/Checkbox/Checkbox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ComponentPropsWithRef,\n type PropsWithChildren,\n forwardRef,\n memo,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { FaCheckIcon, FaMinusIcon } from '../Icon'\n\nexport type Props = PropsWithChildren<\n ComponentPropsWithRef<'input'> & {\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n }\n>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: 'smarthr-ui-Checkbox shr-inline-flex shr-items-baseline',\n box: [\n 'shr-border-shorthand shr-pointer-events-none shr-absolute shr-box-border shr-h-full shr-w-full shr-rounded-s shr-bg-white',\n 'contrast-more:shr-border-high-contrast',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-hidden',\n 'peer-checked:shr-border-main peer-checked:shr-bg-main contrast-more:peer-checked:shr-border-high-contrast',\n 'peer-indeterminate:shr-border-main peer-indeterminate:shr-bg-main contrast-more:peer-indeterminate:shr-border-high-contrast',\n 'peer-disabled:shr-border-disabled peer-disabled:shr-bg-white-darken',\n 'peer-disabled:peer-checked:shr-border-default peer-disabled:peer-checked:shr-bg-border',\n 'peer-disabled:peer-indeterminate:shr-border-default peer-disabled:peer-indeterminate:shr-bg-border',\n 'peer-focus-visible:shr-focus-indicator--outer',\n 'peer-hover:shr-shadow-input-hover',\n 'shr-border-default',\n 'peer-[[aria-invalid]]:shr-border-danger',\n ],\n input: [\n 'smarthr-ui-Checkbox-checkbox shr-peer shr-absolute shr-left-0 shr-top-0 shr-m-0 shr-h-full shr-w-full shr-cursor-pointer shr-opacity-0 disabled:shr-pointer-events-none',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-static forced-colors:shr-opacity-100',\n ],\n icon: 'shr-fill-current',\n iconWrap: [\n 'shr-pointer-events-none shr-absolute shr-left-1/2 shr-top-1/2 shr-inline-block shr-h-[theme(fontSize.2xs)] shr-w-[theme(fontSize.2xs)] -shr-translate-x-1/2 -shr-translate-y-1/2 shr-text-2xs',\n 'shr-text-transparent peer-checked:shr-text-white peer-indeterminate:shr-text-white',\n 'peer-disabled:peer-checked:shr-text-white-darken peer-disabled:peer-indeterminate:shr-text-white-darken',\n 'forced-colors:shr-hidden',\n ],\n innerWrapper:\n 'shr-relative shr-box-border shr-inline-block shr-h-[theme(fontSize.base)] shr-w-[theme(fontSize.base)] shr-shrink-0 shr-translate-y-[0.125em] shr-leading-none',\n label: [\n 'smarthr-ui-Checkbox-label shr-ms-0.5 shr-cursor-pointer shr-text-base shr-leading-tight',\n '[[data-disabled=true]>&]:shr-pointer-events-none [[data-disabled=true]>&]:shr-cursor-not-allowed [[data-disabled=true]>&]:shr-text-disabled',\n ],\n },\n})\n\nexport const Checkbox = forwardRef<HTMLInputElement, Props>(\n ({ checked, mixed, error, className, children, disabled, id, ...rest }, ref) => {\n const classNames = useMemo(() => {\n const { wrapper, innerWrapper, box, input, iconWrap, icon, label } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n innerWrapper: innerWrapper(),\n box: box(),\n input: input(),\n iconWrap: iconWrap(),\n icon: icon(),\n label: label(),\n }\n }, [className])\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const defaultId = useId()\n const checkBoxId = id || defaultId\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n useEffect(() => {\n if (inputRef.current) {\n inputRef.current.indeterminate = !!(checked && mixed)\n }\n }, [checked, mixed])\n\n return (\n <span data-disabled={disabled} className={classNames.wrapper}>\n <span className={classNames.innerWrapper}>\n <input\n {...rest}\n ref={inputRef}\n type=\"checkbox\"\n id={checkBoxId}\n checked={checked}\n disabled={disabled}\n aria-invalid={error || undefined}\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n <AriaHiddenBox className={classNames.box} />\n <CheckIconArea mixed={mixed} classNames={classNames} />\n </span>\n\n {children && (\n <label htmlFor={checkBoxId} className={classNames.label}>\n {children}\n </label>\n )}\n </span>\n )\n },\n)\n\nconst AriaHiddenBox = memo<{ className: string }>(({ className }) => (\n <span className={className} aria-hidden=\"true\" />\n))\n\nconst CheckIconArea = memo<\n Pick<Props, 'mixed'> & { classNames: { iconWrap: string; icon: string } }\n>(({ mixed, classNames }) => {\n const Icon = mixed ? FaMinusIcon : FaCheckIcon\n\n return (\n <span className={classNames.iconWrap}>\n <Icon className={classNames.icon} />\n </span>\n )\n})\n"],"names":[],"mappings":";;;;;;;;;;AA0BA;AACE;AACE;AACA;;;;;;;;;;;;;;AAcC;AACD;;;;AAIC;AACD;AACA;;;;;AAKC;AACD;AAEA;;;AAGC;AACF;AACF;AAEM;AAEH;AACE;;AAGE;;;;;;;;AAQJ;AAEA;AAEA;AACA;AAEA;;AAOE;AACE;;AAEJ;;AA2BF;AAGF;AAIA;;AAKE;AAKF;;"}
|
|
@@ -59,7 +59,7 @@ const Checkbox = forwardRef(({ checked, mixed, error, className, children, disab
|
|
|
59
59
|
const inputRef = useRef(null);
|
|
60
60
|
const defaultId = useId();
|
|
61
61
|
const checkBoxId = id || defaultId;
|
|
62
|
-
useImperativeHandle(ref, () => inputRef.current);
|
|
62
|
+
useImperativeHandle(ref, () => inputRef.current, []);
|
|
63
63
|
useEffect(() => {
|
|
64
64
|
if (inputRef.current) {
|
|
65
65
|
inputRef.current.indeterminate = !!(checked && mixed);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.js","sources":["../../../src/components/Checkbox/Checkbox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ComponentPropsWithRef,\n type PropsWithChildren,\n forwardRef,\n memo,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { FaCheckIcon, FaMinusIcon } from '../Icon'\n\nexport type Props = PropsWithChildren<\n ComponentPropsWithRef<'input'> & {\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n }\n>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: 'smarthr-ui-Checkbox shr-inline-flex shr-items-baseline',\n box: [\n 'shr-border-shorthand shr-pointer-events-none shr-absolute shr-box-border shr-h-full shr-w-full shr-rounded-s shr-bg-white',\n 'contrast-more:shr-border-high-contrast',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-hidden',\n 'peer-checked:shr-border-main peer-checked:shr-bg-main contrast-more:peer-checked:shr-border-high-contrast',\n 'peer-indeterminate:shr-border-main peer-indeterminate:shr-bg-main contrast-more:peer-indeterminate:shr-border-high-contrast',\n 'peer-disabled:shr-border-disabled peer-disabled:shr-bg-white-darken',\n 'peer-disabled:peer-checked:shr-border-default peer-disabled:peer-checked:shr-bg-border',\n 'peer-disabled:peer-indeterminate:shr-border-default peer-disabled:peer-indeterminate:shr-bg-border',\n 'peer-focus-visible:shr-focus-indicator--outer',\n 'peer-hover:shr-shadow-input-hover',\n 'shr-border-default',\n 'peer-[[aria-invalid]]:shr-border-danger',\n ],\n input: [\n 'smarthr-ui-Checkbox-checkbox shr-peer shr-absolute shr-left-0 shr-top-0 shr-m-0 shr-h-full shr-w-full shr-cursor-pointer shr-opacity-0 disabled:shr-pointer-events-none',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-static forced-colors:shr-opacity-100',\n ],\n icon: 'shr-fill-current',\n iconWrap: [\n 'shr-pointer-events-none shr-absolute shr-left-1/2 shr-top-1/2 shr-inline-block shr-h-[theme(fontSize.2xs)] shr-w-[theme(fontSize.2xs)] -shr-translate-x-1/2 -shr-translate-y-1/2 shr-text-2xs',\n 'shr-text-transparent peer-checked:shr-text-white peer-indeterminate:shr-text-white',\n 'peer-disabled:peer-checked:shr-text-white-darken peer-disabled:peer-indeterminate:shr-text-white-darken',\n 'forced-colors:shr-hidden',\n ],\n innerWrapper:\n 'shr-relative shr-box-border shr-inline-block shr-h-[theme(fontSize.base)] shr-w-[theme(fontSize.base)] shr-shrink-0 shr-translate-y-[0.125em] shr-leading-none',\n label: [\n 'smarthr-ui-Checkbox-label shr-ms-0.5 shr-cursor-pointer shr-text-base shr-leading-tight',\n '[[data-disabled=true]>&]:shr-pointer-events-none [[data-disabled=true]>&]:shr-cursor-not-allowed [[data-disabled=true]>&]:shr-text-disabled',\n ],\n },\n})\n\nexport const Checkbox = forwardRef<HTMLInputElement, Props>(\n ({ checked, mixed, error, className, children, disabled, id, ...rest }, ref) => {\n const classNames = useMemo(() => {\n const { wrapper, innerWrapper, box, input, iconWrap, icon, label } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n innerWrapper: innerWrapper(),\n box: box(),\n input: input(),\n iconWrap: iconWrap(),\n icon: icon(),\n label: label(),\n }\n }, [className])\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const defaultId = useId()\n const checkBoxId = id || defaultId\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n )\n\n useEffect(() => {\n if (inputRef.current) {\n inputRef.current.indeterminate = !!(checked && mixed)\n }\n }, [checked, mixed])\n\n return (\n <span data-disabled={disabled} className={classNames.wrapper}>\n <span className={classNames.innerWrapper}>\n <input\n {...rest}\n ref={inputRef}\n type=\"checkbox\"\n id={checkBoxId}\n checked={checked}\n disabled={disabled}\n aria-invalid={error || undefined}\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n <AriaHiddenBox className={classNames.box} />\n <CheckIconArea mixed={mixed} classNames={classNames} />\n </span>\n\n {children && (\n <label htmlFor={checkBoxId} className={classNames.label}>\n {children}\n </label>\n )}\n </span>\n )\n },\n)\n\nconst AriaHiddenBox = memo<{ className: string }>(({ className }) => (\n <span className={className} aria-hidden=\"true\" />\n))\n\nconst CheckIconArea = memo<\n Pick<Props, 'mixed'> & { classNames: { iconWrap: string; icon: string } }\n>(({ mixed, classNames }) => {\n const Icon = mixed ? FaMinusIcon : FaCheckIcon\n\n return (\n <span className={classNames.iconWrap}>\n <Icon className={classNames.icon} />\n </span>\n )\n})\n"],"names":[],"mappings":";;;;;;;;AA0BA;AACE;AACE;AACA;;;;;;;;;;;;;;AAcC;AACD;;;;AAIC;AACD;AACA;;;;;AAKC;AACD;AAEA;;;AAGC;AACF;AACF;AAEM;AAEH;AACE;;AAGE;;;;;;;;AAQJ;AAEA;AAEA;AACA
|
|
1
|
+
{"version":3,"file":"Checkbox.js","sources":["../../../src/components/Checkbox/Checkbox.tsx"],"sourcesContent":["'use client'\n\nimport {\n type ComponentPropsWithRef,\n type PropsWithChildren,\n forwardRef,\n memo,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { FaCheckIcon, FaMinusIcon } from '../Icon'\n\nexport type Props = PropsWithChildren<\n ComponentPropsWithRef<'input'> & {\n /** `true` のとき、チェック状態を `mixed` にする */\n mixed?: boolean\n /** チェックボックスにエラーがあるかどうか */\n error?: boolean\n }\n>\n\nconst classNameGenerator = tv({\n slots: {\n wrapper: 'smarthr-ui-Checkbox shr-inline-flex shr-items-baseline',\n box: [\n 'shr-border-shorthand shr-pointer-events-none shr-absolute shr-box-border shr-h-full shr-w-full shr-rounded-s shr-bg-white',\n 'contrast-more:shr-border-high-contrast',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-hidden',\n 'peer-checked:shr-border-main peer-checked:shr-bg-main contrast-more:peer-checked:shr-border-high-contrast',\n 'peer-indeterminate:shr-border-main peer-indeterminate:shr-bg-main contrast-more:peer-indeterminate:shr-border-high-contrast',\n 'peer-disabled:shr-border-disabled peer-disabled:shr-bg-white-darken',\n 'peer-disabled:peer-checked:shr-border-default peer-disabled:peer-checked:shr-bg-border',\n 'peer-disabled:peer-indeterminate:shr-border-default peer-disabled:peer-indeterminate:shr-bg-border',\n 'peer-focus-visible:shr-focus-indicator--outer',\n 'peer-hover:shr-shadow-input-hover',\n 'shr-border-default',\n 'peer-[[aria-invalid]]:shr-border-danger',\n ],\n input: [\n 'smarthr-ui-Checkbox-checkbox shr-peer shr-absolute shr-left-0 shr-top-0 shr-m-0 shr-h-full shr-w-full shr-cursor-pointer shr-opacity-0 disabled:shr-pointer-events-none',\n /* 強制カラーモードのときは、ブラウザ標準のUIを表示する */\n 'forced-colors:shr-static forced-colors:shr-opacity-100',\n ],\n icon: 'shr-fill-current',\n iconWrap: [\n 'shr-pointer-events-none shr-absolute shr-left-1/2 shr-top-1/2 shr-inline-block shr-h-[theme(fontSize.2xs)] shr-w-[theme(fontSize.2xs)] -shr-translate-x-1/2 -shr-translate-y-1/2 shr-text-2xs',\n 'shr-text-transparent peer-checked:shr-text-white peer-indeterminate:shr-text-white',\n 'peer-disabled:peer-checked:shr-text-white-darken peer-disabled:peer-indeterminate:shr-text-white-darken',\n 'forced-colors:shr-hidden',\n ],\n innerWrapper:\n 'shr-relative shr-box-border shr-inline-block shr-h-[theme(fontSize.base)] shr-w-[theme(fontSize.base)] shr-shrink-0 shr-translate-y-[0.125em] shr-leading-none',\n label: [\n 'smarthr-ui-Checkbox-label shr-ms-0.5 shr-cursor-pointer shr-text-base shr-leading-tight',\n '[[data-disabled=true]>&]:shr-pointer-events-none [[data-disabled=true]>&]:shr-cursor-not-allowed [[data-disabled=true]>&]:shr-text-disabled',\n ],\n },\n})\n\nexport const Checkbox = forwardRef<HTMLInputElement, Props>(\n ({ checked, mixed, error, className, children, disabled, id, ...rest }, ref) => {\n const classNames = useMemo(() => {\n const { wrapper, innerWrapper, box, input, iconWrap, icon, label } = classNameGenerator()\n\n return {\n wrapper: wrapper({ className }),\n innerWrapper: innerWrapper(),\n box: box(),\n input: input(),\n iconWrap: iconWrap(),\n icon: icon(),\n label: label(),\n }\n }, [className])\n\n const inputRef = useRef<HTMLInputElement>(null)\n\n const defaultId = useId()\n const checkBoxId = id || defaultId\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n useEffect(() => {\n if (inputRef.current) {\n inputRef.current.indeterminate = !!(checked && mixed)\n }\n }, [checked, mixed])\n\n return (\n <span data-disabled={disabled} className={classNames.wrapper}>\n <span className={classNames.innerWrapper}>\n <input\n {...rest}\n ref={inputRef}\n type=\"checkbox\"\n id={checkBoxId}\n checked={checked}\n disabled={disabled}\n aria-invalid={error || undefined}\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n <AriaHiddenBox className={classNames.box} />\n <CheckIconArea mixed={mixed} classNames={classNames} />\n </span>\n\n {children && (\n <label htmlFor={checkBoxId} className={classNames.label}>\n {children}\n </label>\n )}\n </span>\n )\n },\n)\n\nconst AriaHiddenBox = memo<{ className: string }>(({ className }) => (\n <span className={className} aria-hidden=\"true\" />\n))\n\nconst CheckIconArea = memo<\n Pick<Props, 'mixed'> & { classNames: { iconWrap: string; icon: string } }\n>(({ mixed, classNames }) => {\n const Icon = mixed ? FaMinusIcon : FaCheckIcon\n\n return (\n <span className={classNames.iconWrap}>\n <Icon className={classNames.icon} />\n </span>\n )\n})\n"],"names":[],"mappings":";;;;;;;;AA0BA;AACE;AACE;AACA;;;;;;;;;;;;;;AAcC;AACD;;;;AAIC;AACD;AACA;;;;;AAKC;AACD;AAEA;;;AAGC;AACF;AACF;AAEM;AAEH;AACE;;AAGE;;;;;;;;AAQJ;AAEA;AAEA;AACA;AAEA;;AAOE;AACE;;AAEJ;;AA2BF;AAGF;AAIA;;AAKE;AAKF;;"}
|
|
@@ -298,7 +298,7 @@ const ActualMultiCombobox = ({ items, selectedItems, name, disabled = false, req
|
|
|
298
298
|
};
|
|
299
299
|
}, [listBoxFunctions, latest]);
|
|
300
300
|
hooks_useOuterClick.useOuterClick(React.useMemo(() => [triggerRef, listBoxRef], [listBoxRef]), functions.blur);
|
|
301
|
-
React.useImperativeHandle(ref, () => inputRef.current);
|
|
301
|
+
React.useImperativeHandle(ref, () => inputRef.current, []);
|
|
302
302
|
React.useEffect(() => {
|
|
303
303
|
if (latest.highlighted) {
|
|
304
304
|
setHighlighted(false);
|
|
@@ -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 useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useOuterClick } from '../../../hooks/useOuterClick'\nimport { useTheme } from '../../../hooks/useTheme'\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 selectedListId = useId()\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\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 })\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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, handleKeyDownListBox, listBoxId, listBoxRef } = 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 })\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 handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\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 useOuterClick(\n useMemo(() => [triggerRef, listBoxRef], [listBoxRef]),\n functions.blur,\n )\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(ref, () => inputRef.current)\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, inputRef, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled, inputRef])\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={triggerRef}\n role=\"group\"\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n aria-label={localized.selectedListAriaLabel}\n className={classNames.selectedList}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n item={selectedItem}\n disabled={disabled}\n handleDelete={functions.handleDelete}\n enableEllipsis={selectedItemEllipsis}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n data-smarthr-ui-input=\"true\"\n type=\"text\"\n name={name}\n value={inputValue}\n disabled={disabled}\n required={required && selectedItems.length === 0}\n ref={inputRef}\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n role=\"combobox\"\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 className={classNames.input}\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} />\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA6EA;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;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;;;;;;;;AASC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;;AAII;AACF;;AAEJ;;;AAIE;;;;;AAQI;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAUC;;;;;;;;;;;;;;;AAgBA;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;;;;;AAKI;;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;;;;AAUE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;;;AAIC;AAQI;AACA;;AA4DR;;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 useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useOuterClick } from '../../../hooks/useOuterClick'\nimport { useTheme } from '../../../hooks/useTheme'\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 selectedListId = useId()\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\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 })\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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, handleKeyDownListBox, listBoxId, listBoxRef } = 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 })\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 handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\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 useOuterClick(\n useMemo(() => [triggerRef, listBoxRef], [listBoxRef]),\n functions.blur,\n )\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, inputRef, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled, inputRef])\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={triggerRef}\n role=\"group\"\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n aria-label={localized.selectedListAriaLabel}\n className={classNames.selectedList}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n item={selectedItem}\n disabled={disabled}\n handleDelete={functions.handleDelete}\n enableEllipsis={selectedItemEllipsis}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n data-smarthr-ui-input=\"true\"\n type=\"text\"\n name={name}\n value={inputValue}\n disabled={disabled}\n required={required && selectedItems.length === 0}\n ref={inputRef}\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n role=\"combobox\"\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 className={classNames.input}\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} />\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA6EA;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;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;;;;;;;;AASC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;;AAII;AACF;;AAEJ;;;AAIE;;;;;AAQI;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAUC;;;;;;;;;;;;;;;AAgBA;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;;;;;AAKI;;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;;AAOE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;;;AAIC;AAQI;AACA;;AA4DR;;AAIA;AAQE;;AAEE;AACA;AACE;AACA;;AAON;;"}
|
|
@@ -296,7 +296,7 @@ const ActualMultiCombobox = ({ items, selectedItems, name, disabled = false, req
|
|
|
296
296
|
};
|
|
297
297
|
}, [listBoxFunctions, latest]);
|
|
298
298
|
useOuterClick(useMemo(() => [triggerRef, listBoxRef], [listBoxRef]), functions.blur);
|
|
299
|
-
useImperativeHandle(ref, () => inputRef.current);
|
|
299
|
+
useImperativeHandle(ref, () => inputRef.current, []);
|
|
300
300
|
useEffect(() => {
|
|
301
301
|
if (latest.highlighted) {
|
|
302
302
|
setHighlighted(false);
|
|
@@ -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 useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useOuterClick } from '../../../hooks/useOuterClick'\nimport { useTheme } from '../../../hooks/useTheme'\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 selectedListId = useId()\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\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 })\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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, handleKeyDownListBox, listBoxId, listBoxRef } = 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 })\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 handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\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 useOuterClick(\n useMemo(() => [triggerRef, listBoxRef], [listBoxRef]),\n functions.blur,\n )\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(ref, () => inputRef.current)\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, inputRef, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled, inputRef])\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={triggerRef}\n role=\"group\"\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n aria-label={localized.selectedListAriaLabel}\n className={classNames.selectedList}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n item={selectedItem}\n disabled={disabled}\n handleDelete={functions.handleDelete}\n enableEllipsis={selectedItemEllipsis}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n data-smarthr-ui-input=\"true\"\n type=\"text\"\n name={name}\n value={inputValue}\n disabled={disabled}\n required={required && selectedItems.length === 0}\n ref={inputRef}\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n role=\"combobox\"\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 className={classNames.input}\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} />\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":";;;;;;;;;;;;;;;;;;;;;;;;;AA6EA;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;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;;;;;;;;AASC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;;AAII;AACF;;AAEJ;;;AAIE;;;;;AAQI;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAUC;;;;;;;;;;;;;;;AAgBA;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;;;;;AAKI;;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;;;;AAUE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;;;AAIC;AAQI;AACA;;AA4DR;;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 useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useOuterClick } from '../../../hooks/useOuterClick'\nimport { useTheme } from '../../../hooks/useTheme'\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 selectedListId = useId()\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\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 })\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\n handlers.forEach((h) => h(item))\n })\n }\n }\n\n return {\n handleDelete,\n handleSelect: (selected: ComboboxItem<T>) => {\n // HINT: Dropdown系コンポーネント内でComboboxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう\n // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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, handleKeyDownListBox, listBoxId, listBoxRef } = 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 })\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 handleDelegateKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {\n if (latest.isComposing) return\n\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\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 useOuterClick(\n useMemo(() => [triggerRef, listBoxRef], [listBoxRef]),\n functions.blur,\n )\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n useEffect(() => {\n if (latest.highlighted) {\n setHighlighted(false)\n inputRef.current?.select()\n } else {\n setInputValueIfUncontrolled('')\n }\n }, [selectedItems, setInputValueIfUncontrolled, inputRef, latest])\n\n useEffect(() => {\n if (isExpanded) {\n inputRef.current?.focus()\n }\n }, [isExpanded, selectedItems, isInputControlled, inputRef])\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={triggerRef}\n role=\"group\"\n onClick={functions.handleDelegateClick}\n onKeyDown={functions.handleDelegateKeyDown}\n onKeyPress={functions.handleDelegateKeyPress}\n className={classNames.wrapper}\n style={{\n ...style,\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n >\n <Scroller className={classNames.inputArea}>\n <ul\n id={selectedListId}\n aria-label={localized.selectedListAriaLabel}\n className={classNames.selectedList}\n >\n {selectedItems.map((selectedItem) => (\n <li key={`${selectedItem.label}-${innerText(selectedItem.value)}`}>\n <MultiSelectedItem\n item={selectedItem}\n disabled={disabled}\n handleDelete={functions.handleDelete}\n enableEllipsis={selectedItemEllipsis}\n />\n </li>\n ))}\n </ul>\n\n <div className={classNames.inputWrapper}>\n <input\n {...rest}\n data-smarthr-ui-input=\"true\"\n type=\"text\"\n name={name}\n value={inputValue}\n disabled={disabled}\n required={required && selectedItems.length === 0}\n ref={inputRef}\n onChange={functions.handleChangeInput}\n onFocus={functions.handleFocusInput}\n onCompositionStart={functions.handleCompositionStart}\n onCompositionEnd={functions.handleCompositionEnd}\n onKeyDown={functions.handleKeyDownInput}\n autoComplete={autoComplete ?? 'off'}\n tabIndex={0}\n role=\"combobox\"\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 className={classNames.input}\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} />\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":";;;;;;;;;;;;;;;;;;;;;;;;;AA6EA;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;AAEA;;;AAGA;AAEA;;AAEE;;;;AAID;AACD;;;;;;;;AASC;AAED;AACE;;AAGE;AACE;;AAEF;AACE;;AASF;;;;AAII;AACF;;AAEJ;;;AAIE;;;;;AAQI;AACE;AACA;;AAGA;;AACK;;;AAGT;;;AAGN;AAEA;;;;;;;;;;AAUC;;;;;;;;;;;;;;;AAgBA;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;;;;;AAKI;;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;;AAOE;;AAEE;;;;;;;;AAQA;;;AAIJ;;;AAaI;;;;;;;;;AASJ;;AAGE;AACE;AACA;AACD;AACF;;;AAIC;AAQI;AACA;;AA4DR;;AAIA;AAQE;;AAEE;AACA;AACE;AACA;;AAON;;"}
|
|
@@ -67,7 +67,7 @@ const ActualSingleCombobox = ({ items, selectedItem, defaultItem, name, disabled
|
|
|
67
67
|
const [inputValue, setInputValue] = React.useState('');
|
|
68
68
|
const [isComposing, setIsComposing] = React.useState(false);
|
|
69
69
|
const [isEditing, setIsEditing] = React.useState(false);
|
|
70
|
-
React.useImperativeHandle(ref, () => inputRef.current);
|
|
70
|
+
React.useImperativeHandle(ref, () => inputRef.current, []);
|
|
71
71
|
const { options } = components_Combobox_useOptions.useSingleOptions({
|
|
72
72
|
items,
|
|
73
73
|
selected: selectedItem,
|
|
@@ -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 useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useClick } from '../../../hooks/useClick'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useTheme } from '../../../hooks/useTheme'\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 NOOP = () => undefined\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 onClick={handleClickClear}\n ref={clearButtonRef}\n className={classNames.clearButton}\n >\n <FaCircleXmarkIcon\n color=\"TEXT_BLACK\"\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n onClick={handleDelegateClickIcon}\n className={classNames.caretDownLayout}\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 [inputValue, setInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(ref, () => inputRef.current)\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const { listBoxProps, activeOption, handleKeyDownListBox, listBoxId, listBoxRef } = useListbox<T>(\n {\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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\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 })\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 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 useClick(\n useMemo(() => [triggerRef, listBoxRef, clearButtonRef], [listBoxRef]),\n isFocused || selectedItem ? NOOP : functions.selectDefaultItem,\n functions.unfocus,\n )\n\n // selectedItem.label はプリミティブ値でないデータ型の可能性があり、そのまま useEffect の依存配列に入れると意図せぬエフェクトの実行を引き起こしてしまう可能性があるので、プリミティブ値である string 型に変換したものを依存配列に入れています。\n const selectedItemLabelText = innerText(selectedItem?.label)\n useEffect(() => {\n setInputValue(selectedItemLabelText)\n }, [selectedItemLabelText])\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={inputRef}\n type=\"text\"\n role=\"combobox\"\n name={name}\n value={inputValue}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n autoComplete={autoComplete ?? 'off'}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\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 error={error}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n classNames={classNames}\n />\n }\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n {!readOnly && <ListBox {...listBoxProps} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AAEA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;;AASA;;AAEE;;;;AAID;AAED;;;;;;AAOI;AACE;AACA;;;;;;;;;;;AAYE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;AAsBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;;AAMA;;;;AAGE;;AAEJ;;;;;AAMI;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;;;;;AAUA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;AACD;;;AA6CP;;;"}
|
|
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 useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useClick } from '../../../hooks/useClick'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useTheme } from '../../../hooks/useTheme'\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 NOOP = () => undefined\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 onClick={handleClickClear}\n ref={clearButtonRef}\n className={classNames.clearButton}\n >\n <FaCircleXmarkIcon\n color=\"TEXT_BLACK\"\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n onClick={handleDelegateClickIcon}\n className={classNames.caretDownLayout}\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 [inputValue, setInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const { listBoxProps, activeOption, handleKeyDownListBox, listBoxId, listBoxRef } = useListbox<T>(\n {\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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\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 })\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 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 useClick(\n useMemo(() => [triggerRef, listBoxRef, clearButtonRef], [listBoxRef]),\n isFocused || selectedItem ? NOOP : functions.selectDefaultItem,\n functions.unfocus,\n )\n\n // selectedItem.label はプリミティブ値でないデータ型の可能性があり、そのまま useEffect の依存配列に入れると意図せぬエフェクトの実行を引き起こしてしまう可能性があるので、プリミティブ値である string 型に変換したものを依存配列に入れています。\n const selectedItemLabelText = innerText(selectedItem?.label)\n useEffect(() => {\n setInputValue(selectedItemLabelText)\n }, [selectedItemLabelText])\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={inputRef}\n type=\"text\"\n role=\"combobox\"\n name={name}\n value={inputValue}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n autoComplete={autoComplete ?? 'off'}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\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 error={error}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n classNames={classNames}\n />\n }\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n {!readOnly && <ListBox {...listBoxProps} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AAEA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;AAOA;AAMA;;AAEE;;;;AAID;AAED;;;;;;AAOI;AACE;AACA;;;;;;;;;;;AAYE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;AAsBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;;AAMA;;;;AAGE;;AAEJ;;;;;AAMI;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;;;;;AAUA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;AACD;;;AA6CP;;;"}
|
|
@@ -65,7 +65,7 @@ const ActualSingleCombobox = ({ items, selectedItem, defaultItem, name, disabled
|
|
|
65
65
|
const [inputValue, setInputValue] = useState('');
|
|
66
66
|
const [isComposing, setIsComposing] = useState(false);
|
|
67
67
|
const [isEditing, setIsEditing] = useState(false);
|
|
68
|
-
useImperativeHandle(ref, () => inputRef.current);
|
|
68
|
+
useImperativeHandle(ref, () => inputRef.current, []);
|
|
69
69
|
const { options } = useSingleOptions({
|
|
70
70
|
items,
|
|
71
71
|
selected: selectedItem,
|
|
@@ -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 useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useClick } from '../../../hooks/useClick'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useTheme } from '../../../hooks/useTheme'\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 NOOP = () => undefined\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 onClick={handleClickClear}\n ref={clearButtonRef}\n className={classNames.clearButton}\n >\n <FaCircleXmarkIcon\n color=\"TEXT_BLACK\"\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n onClick={handleDelegateClickIcon}\n className={classNames.caretDownLayout}\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 [inputValue, setInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(ref, () => inputRef.current)\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const { listBoxProps, activeOption, handleKeyDownListBox, listBoxId, listBoxRef } = useListbox<T>(\n {\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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\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 })\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 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 useClick(\n useMemo(() => [triggerRef, listBoxRef, clearButtonRef], [listBoxRef]),\n isFocused || selectedItem ? NOOP : functions.selectDefaultItem,\n functions.unfocus,\n )\n\n // selectedItem.label はプリミティブ値でないデータ型の可能性があり、そのまま useEffect の依存配列に入れると意図せぬエフェクトの実行を引き起こしてしまう可能性があるので、プリミティブ値である string 型に変換したものを依存配列に入れています。\n const selectedItemLabelText = innerText(selectedItem?.label)\n useEffect(() => {\n setInputValue(selectedItemLabelText)\n }, [selectedItemLabelText])\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={inputRef}\n type=\"text\"\n role=\"combobox\"\n name={name}\n value={inputValue}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n autoComplete={autoComplete ?? 'off'}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\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 error={error}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n classNames={classNames}\n />\n }\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n {!readOnly && <ListBox {...listBoxProps} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AAEA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;;AASA;;AAEE;;;;AAID;AAED;;;;;;AAOI;AACE;AACA;;;;;;;;;;;AAYE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;AAsBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;;AAMA;;;;AAGE;;AAEJ;;;;;AAMI;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;;;;;AAUA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;AACD;;;AA6CP;;;"}
|
|
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 useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport innerText from 'react-innertext'\nimport { tv } from 'tailwind-variants'\n\nimport { useClick } from '../../../hooks/useClick'\nimport { useLatest } from '../../../hooks/useLatest'\nimport { useTheme } from '../../../hooks/useTheme'\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 NOOP = () => undefined\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 onClick={handleClickClear}\n ref={clearButtonRef}\n className={classNames.clearButton}\n >\n <FaCircleXmarkIcon\n color=\"TEXT_BLACK\"\n alt={\n <Localizer id=\"smarthr-ui/SingleCombobox/destroyButtonIconAlt\" defaultText=\"クリア\" />\n }\n className={classNames.clearButtonIcon}\n />\n </UnstyledButton>\n <span\n role=\"presentation\"\n onClick={handleDelegateClickIcon}\n className={classNames.caretDownLayout}\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 [inputValue, setInputValue] = useState('')\n const [isComposing, setIsComposing] = useState(false)\n const [isEditing, setIsEditing] = useState(false)\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n const { options } = useSingleOptions({\n items,\n selected: selectedItem,\n creatable,\n inputValue,\n isFilteringDisabled: !isEditing,\n })\n\n const { listBoxProps, activeOption, handleKeyDownListBox, listBoxId, listBoxRef } = useListbox<T>(\n {\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 // requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする\n requestAnimationFrame(() => {\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\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 })\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 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 useClick(\n useMemo(() => [triggerRef, listBoxRef, clearButtonRef], [listBoxRef]),\n isFocused || selectedItem ? NOOP : functions.selectDefaultItem,\n functions.unfocus,\n )\n\n // selectedItem.label はプリミティブ値でないデータ型の可能性があり、そのまま useEffect の依存配列に入れると意図せぬエフェクトの実行を引き起こしてしまう可能性があるので、プリミティブ値である string 型に変換したものを依存配列に入れています。\n const selectedItemLabelText = innerText(selectedItem?.label)\n useEffect(() => {\n setInputValue(selectedItemLabelText)\n }, [selectedItemLabelText])\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={inputRef}\n type=\"text\"\n role=\"combobox\"\n name={name}\n value={inputValue}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n autoComplete={autoComplete ?? 'off'}\n aria-haspopup=\"listbox\"\n aria-controls={listBoxId}\n aria-expanded={isFocused}\n aria-activedescendant={activeOption?.id}\n aria-autocomplete=\"list\"\n /* eslint-disable-next-line smarthr/a11y-prohibit-input-placeholder */\n placeholder={placeholder}\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 error={error}\n prefix={prefix}\n suffix={\n <SuffixButtons\n clearButtonRef={clearButtonRef}\n caretIconColor={caretIconColor}\n handleClickClear={functions.handleClickClear}\n handleClickIcon={functions.handleClickInput}\n classNames={classNames}\n />\n }\n className={classNames.input}\n data-smarthr-ui-input=\"true\"\n />\n {!readOnly && <ListBox {...listBoxProps} />}\n </div>\n )\n}\n\nexport const SingleCombobox = genericsForwardRef(ActualSingleCombobox)\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA0EA;AAEA;AACA;AAEA;AACE;AACA;;AAGF;AACE;AACE;AAEA;AACA;;;;AAIC;AACD;AACA;;;;;;AAMC;AACD;;;AAGC;AACF;AACF;AAeD;AAiCA;AAmCE;AACA;AACA;AACA;;;;;;AAOA;AAMA;;AAEE;;;;AAID;AAED;;;;;;AAOI;AACE;AACA;;;;;;;;;;;AAYE;AACF;;;;;;;AAQH;;;;;;;;;;;;;;;;;;;;AAsBF;AAED;;;AAGM;;AAEJ;;;;AAIE;;;;AAMA;;;;AAGE;;AAEJ;;;;;AAMI;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;;;;;AAUA;AAEA;AACE;;AAIE;;;;;;;AAOJ;AAEA;AAMM;AACA;AACD;;;AA6CP;;;"}
|
|
@@ -161,7 +161,7 @@ const DatePicker = React.forwardRef(({ value, name, from = DEFAULT_FROM, to, dis
|
|
|
161
161
|
},
|
|
162
162
|
};
|
|
163
163
|
}, [latest]);
|
|
164
|
-
React.useImperativeHandle(ref, () => inputRef.current);
|
|
164
|
+
React.useImperativeHandle(ref, () => inputRef.current, []);
|
|
165
165
|
React.useEffect(() => {
|
|
166
166
|
if (value === undefined || !inputRef.current) {
|
|
167
167
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DatePicker.cjs","sources":["../../../src/components/DatePicker/DatePicker.tsx"],"sourcesContent":["'use client'\n\nimport dayjs from 'dayjs'\nimport {\n type ChangeEvent,\n type ComponentProps,\n type ComponentPropsWithRef,\n type MouseEvent,\n type ReactNode,\n forwardRef,\n memo,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../hooks/useLatest'\nimport { useOuterClick } from '../../hooks/useOuterClick'\nimport { useTheme } from '../../hooks/useTheme'\nimport { Calendar } from '../Calendar'\nimport { FaCalendarDaysIcon } from '../Icon'\nimport { Input } from '../Input'\n\nimport { Portal } from './Portal'\nimport { parseJpnDateString } from './datePickerHelper'\n\ntype ChangeLikeEvent = ChangeEvent | React.KeyboardEvent | MouseEvent\ntype BaseProps = {\n /** input 要素の `value` 属性の値 */\n value?: string | null\n /** input 要素の `name` 属性の値 */\n name?: string\n /** 選択可能な期間の開始日 */\n from?: Date\n /** 選択可能な期間の終了日 */\n to?: Date\n /** フォームを無効にするかどうか */\n disabled?: boolean\n /**\n * placeholder属性は非推奨です。別途ヒント用要素を設置するか、それらの領域を確保出来ない場合はTooltipコンポーネントの利用を検討してください。\n */\n placeholder?: string\n /** フォームにエラーがあるかどうか */\n error?: boolean\n /** コンポーネントの幅 */\n width?: number | string\n /** 入力を独自にパースする場合に、パース処理を記述する関数 */\n parseInput?: (input: string) => Date | null\n /** 表示する日付を独自にフォーマットする場合に、フォーマット処理を記述する関数 */\n formatDate?: (date: Date | null) => string\n /** 入出力用文字列と併記する別フォーマット処理を記述する関数 */\n showAlternative?: (date: Date | null) => ReactNode\n /** @deprecated onChangeDate は非推奨です。onChange を使ってください。 */\n onChangeDate?: (date: Date | null, value: string, other: { errors: string[] }) => void\n /** 選択された日付が変わった時に発火するコールバック関数 */\n onChange?: (\n e: ChangeEvent<HTMLInputElement>,\n other: { date: Date | null; formatValue: string; errors: string[] },\n ) => void\n}\ntype Props = BaseProps &\n Omit<\n ComponentPropsWithRef<'input'>,\n | keyof BaseProps\n | 'type'\n | 'onChange'\n | 'onKeyPress'\n | 'onFocus'\n | 'aria-expanded'\n | 'aria-controls'\n | 'aria-haspopup'\n >\n\nexport const DEFAULT_FROM = new Date(1900, 0, 1)\n\nconst classNameGenerator = tv({\n slots: {\n container: 'smarthr-ui-DatePicker shr-inline-block',\n inputSuffixLayout: 'shr-box-border shr-h-full shr-py-0.5',\n inputSuffixWrapper:\n 'shr-border-l-shorthand shr-box-border shr-flex shr-h-full shr-items-center shr-justify-center shr-ps-0.5 shr-text-base',\n inputSuffixText: 'shr-text-gray shr-me-0.5 shr-text-sm',\n },\n})\n\nconst DEFAULT_DATE_TO_STRING_FORMAT = 'YYYY/MM/DD'\nconst DEFAULT_DATE_TO_STRING = (d: Date | null) =>\n d ? dayjs(d).format(DEFAULT_DATE_TO_STRING_FORMAT) : ''\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\n\nconst parseStringDate = (\n str: string | null | undefined,\n parseInputFn: ((input: string) => Date | null) | undefined,\n) => {\n if (!str) return null\n return parseInputFn ? parseInputFn(str) : parseJpnDateString(str)\n}\n\n/** @deprecated DatePicker は非推奨です。Input[type=date] を使ってください。 */\nexport const DatePicker = forwardRef<HTMLInputElement, Props>(\n (\n {\n value,\n name,\n from = DEFAULT_FROM,\n to,\n disabled,\n width,\n error,\n className,\n parseInput,\n formatDate,\n showAlternative,\n onChangeDate,\n onChange,\n onBlur,\n ...rest\n },\n ref,\n ) => {\n const theme = useTheme()\n const classNames = useMemo(() => {\n const { container, inputSuffixLayout, inputSuffixWrapper, inputSuffixText } =\n classNameGenerator()\n\n return {\n container: container({ className }),\n inputSuffixLayout: inputSuffixLayout(),\n inputSuffixWrapper: inputSuffixWrapper(),\n inputSuffixText: inputSuffixText(),\n }\n }, [className])\n\n const [isInputFocused, setIsInputFocused] = useState(false)\n const inputRef = useRef<HTMLInputElement>(null)\n const inputWrapperRef = useRef<HTMLDivElement>(null)\n const calendarPortalRef = useRef<HTMLDivElement>(null)\n const [inputRect, setInputRect] = useState<DOMRect | null>(null)\n const [isCalendarShown, setIsCalendarShown] = useState(false)\n const [alternativeFormat, setAlternativeFormat] = useState<null | ReactNode>(null)\n const calenderId = useId()\n\n const [selectedDate, setSelectedDate] = useState<Date | null>(() =>\n parseStringDate(value, parseInput),\n )\n\n const latest = useLatest({\n onChange,\n onChangeDate,\n parseInput,\n formatDate,\n showAlternative,\n onBlur,\n isInputFocused,\n selectedDate,\n })\n\n const functions = useMemo(() => {\n const dateToString = (date: Date | null) =>\n latest.formatDate ? latest.formatDate(date) : DEFAULT_DATE_TO_STRING(date)\n\n const dateToAlternativeFormat = (d: Date | null) => {\n if (!latest.showAlternative) return null\n return d ? latest.showAlternative(d) : null\n }\n\n const stringToDate = (str: string | null | undefined) =>\n parseStringDate(str, latest.parseInput)\n\n const updateDate = (e: ChangeLikeEvent, newDate: Date | null) => {\n if (\n !inputRef.current ||\n newDate === latest.selectedDate ||\n (newDate && latest.selectedDate && newDate.getTime() === latest.selectedDate.getTime())\n ) {\n // Do not update date if the new date is same with the old one.\n return\n }\n\n const isValid = !newDate || dayjs(newDate).isValid()\n const errors: string[] = []\n\n if (!isValid) {\n errors.push('INVALID_DATE')\n }\n\n const nextDate = isValid ? newDate : null\n const formatValue = dateToString(nextDate)\n\n inputRef.current.value = formatValue\n setAlternativeFormat(dateToAlternativeFormat(nextDate))\n setSelectedDate(nextDate)\n\n if (latest.onChange) {\n e.preventDefault()\n e.stopPropagation()\n\n const event = new Event('change', { bubbles: true })\n const input = inputRef.current\n\n input.dispatchEvent(event)\n latest.onChange(\n // HINT: 型問題のため別途オブジェクトをイベントに見立てる\n {\n stopPropagation: () => {\n event.stopPropagation()\n },\n preventDefault: () => {\n event.preventDefault()\n },\n target: input,\n currentTarget: input,\n } as ChangeEvent<HTMLInputElement>,\n { date: nextDate, formatValue, errors },\n )\n } else if (latest.onChangeDate) {\n latest.onChangeDate(nextDate, formatValue, { errors })\n }\n }\n\n const closeCalendar = () => setIsCalendarShown(false)\n\n const openCalendar = () => {\n if (inputWrapperRef.current) {\n setIsCalendarShown(true)\n setInputRect(inputWrapperRef.current.getBoundingClientRect())\n }\n }\n\n return {\n stringToDate,\n dateToString,\n dateToAlternativeFormat,\n closeCalendar,\n openCalendar,\n handleBlur: (e: React.FocusEvent<HTMLInputElement>) => {\n setIsInputFocused(false)\n updateDate(e, e.target.value ? stringToDate(e.target.value) : null)\n latest.onBlur?.(e)\n },\n handleDelegateKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n // delay hiding calendar because calendar will be displayed when input is focused\n requestAnimationFrame(closeCalendar)\n\n if (inputRef.current) inputRef.current.focus()\n }\n },\n handleKeyPressInput: (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') {\n const isExpanded = e.currentTarget.getAttribute('aria-expanded') === 'true'\n ;(isExpanded ? closeCalendar : openCalendar)()\n updateDate(e, stringToDate(e.currentTarget.value))\n }\n },\n handleFocusInput: () => {\n setIsInputFocused(true)\n openCalendar()\n },\n handleSelectDateCalendar: (e: ChangeLikeEvent, selected: Date | null) => {\n updateDate(e, selected)\n // delay hiding calendar because calendar will be displayed when input is focused\n requestAnimationFrame(closeCalendar)\n\n if (inputRef.current) inputRef.current.focus()\n },\n }\n }, [latest])\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n )\n\n useEffect(() => {\n if (value === undefined || !inputRef.current) {\n return\n }\n\n /**\n * Do not format the given value in the following cases\n * - while input element is focused.\n * - if the given value is not date formattable.\n */\n if (!isInputFocused) {\n const newDate = functions.stringToDate(value)\n\n if (newDate && dayjs(newDate).isValid()) {\n inputRef.current.value = functions.dateToString(newDate)\n setAlternativeFormat(functions.dateToAlternativeFormat(newDate))\n setSelectedDate(newDate)\n\n return\n }\n\n setSelectedDate(null)\n }\n\n inputRef.current.value = value || ''\n }, [value, isInputFocused, functions])\n\n useOuterClick(\n useMemo(() => [inputWrapperRef, calendarPortalRef], [inputWrapperRef, calendarPortalRef]),\n functions.closeCalendar,\n )\n\n useEffect(() => {\n const handleKeyDown = (e: KeyboardEvent) => {\n if (!inputRef.current || !calendarPortalRef.current || e.key !== 'Tab') {\n return\n }\n\n const calendarButtons = calendarPortalRef.current.querySelectorAll('button')\n\n if (calendarButtons.length === 0) {\n return\n }\n\n const firstCalendarButton = calendarButtons[0]\n\n if (latest.isInputFocused) {\n if (e.shiftKey) {\n // move focus from Input to previous elements of DatePicker\n functions.closeCalendar()\n\n return\n }\n\n // move focus from Input to Calendar\n e.preventDefault()\n firstCalendarButton.focus()\n\n return\n }\n\n const calendarButtonAry = Array.from(calendarButtons)\n const currentFocused = calendarButtonAry.find((button) => button === e.target)\n\n if (e.shiftKey) {\n if (currentFocused === firstCalendarButton) {\n // move focus from Calendar to Input\n inputRef.current.focus()\n e.preventDefault()\n }\n } else if (currentFocused === calendarButtonAry.at(-1)) {\n // move focus from Calendar to next elements of DatePicker\n inputRef.current.focus()\n functions.closeCalendar()\n }\n }\n\n window.addEventListener('keydown', handleKeyDown)\n\n return () => {\n window.removeEventListener('keydown', handleKeyDown)\n }\n }, [functions, latest])\n\n const caretIconColor =\n isInputFocused || isCalendarShown\n ? theme.textColor.black\n : disabled\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n return (\n // eslint-disable-next-line smarthr/best-practice-for-interactive-element\n <div\n onClick={!isCalendarShown && !disabled ? functions.openCalendar : undefined}\n onKeyDown={isCalendarShown ? functions.handleDelegateKeyDown : undefined}\n role=\"presentation\"\n className={classNames.container}\n style={{\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n >\n <div ref={inputWrapperRef}>\n <Input\n {...rest}\n data-smarthr-ui-input=\"true\"\n width=\"100%\"\n name={name}\n onChange={isCalendarShown ? functions.closeCalendar : undefined}\n onKeyPress={functions.handleKeyPressInput}\n onFocus={functions.handleFocusInput}\n onBlur={functions.handleBlur}\n suffix={\n <InputSuffixIcon\n alternativeFormat={showAlternative ? alternativeFormat : null}\n caretIconColor={caretIconColor}\n classNames={classNames}\n />\n }\n disabled={disabled}\n error={error}\n ref={inputRef}\n className=\"smarthr-ui-DatePicker-inputContainer\"\n aria-expanded={isCalendarShown}\n aria-controls={calenderId}\n aria-haspopup={true}\n />\n </div>\n {isCalendarShown && inputRect && (\n <Portal inputRect={inputRect} ref={calendarPortalRef}>\n <Calendar\n id={calenderId}\n value={selectedDate || undefined}\n from={from}\n to={to}\n onSelectDate={functions.handleSelectDateCalendar}\n />\n </Portal>\n )}\n </div>\n )\n },\n)\n\nconst InputSuffixIcon = memo<{\n classNames: { inputSuffixLayout: string; inputSuffixWrapper: string; inputSuffixText: string }\n alternativeFormat: null | ReactNode\n caretIconColor: ComponentProps<typeof FaCalendarDaysIcon>['color']\n}>(({ classNames, alternativeFormat, caretIconColor }) => (\n <span className={classNames.inputSuffixLayout}>\n <span className={classNames.inputSuffixWrapper}>\n {alternativeFormat && <span className={classNames.inputSuffixText}>{alternativeFormat}</span>}\n <FaCalendarDaysIcon color={caretIconColor} />\n </span>\n </span>\n))\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AA6EO;AAEP;AACE;AACE;AACA;AACA;AAEA;AACD;AACF;AAED;AACA;AAEA;AAEA;AAIE;AAAU;AACV;AACF;AAEA;;AAsBI;AACA;AACE;;AAIE;;;;;AAKJ;;AAGA;AACA;AACA;;;;AAIA;AAEA;;;;;;;;;;AAaC;AAED;;AAIE;;AAC+B;AAC7B;AACF;AAEA;AAGA;;;AAII;;;;AAMF;;;AAIE;;;AAIF;AAEA;AACA;;AAGA;;;AAIE;AACA;AAEA;AACA;;AAEE;;;;;;;AAOE;AACA;;;AAIC;;;AAGT;;;AAKE;;;;AAIF;;;;;;;AAQE;;;AAGE;;AAEF;;;;;;AAM0B;;;AAG1B;AACE;AACE;;AAEA;;;;;AAKF;;AAEF;AACE;;;;AAIsB;;;AAG5B;;;;;;AAYE;;;;AAIG;;;;;;;;;;;;;;;AAwBH;AACE;;;;AAMA;;;AAIA;AAEA;AACE;;;;;;;;;;;AAeF;AAEA;AACE;;AAEE;;;;;;AAKF;;;AAGJ;AAEA;AAEA;AACE;AACF;AACF;AAEA;AAEI;AACA;AACE;AACA;;;AAIJ;AAMI;AACD;AAyCP;AAGF;;;"}
|
|
1
|
+
{"version":3,"file":"DatePicker.cjs","sources":["../../../src/components/DatePicker/DatePicker.tsx"],"sourcesContent":["'use client'\n\nimport dayjs from 'dayjs'\nimport {\n type ChangeEvent,\n type ComponentProps,\n type ComponentPropsWithRef,\n type MouseEvent,\n type ReactNode,\n forwardRef,\n memo,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from 'react'\nimport { tv } from 'tailwind-variants'\n\nimport { useLatest } from '../../hooks/useLatest'\nimport { useOuterClick } from '../../hooks/useOuterClick'\nimport { useTheme } from '../../hooks/useTheme'\nimport { Calendar } from '../Calendar'\nimport { FaCalendarDaysIcon } from '../Icon'\nimport { Input } from '../Input'\n\nimport { Portal } from './Portal'\nimport { parseJpnDateString } from './datePickerHelper'\n\ntype ChangeLikeEvent = ChangeEvent | React.KeyboardEvent | MouseEvent\ntype BaseProps = {\n /** input 要素の `value` 属性の値 */\n value?: string | null\n /** input 要素の `name` 属性の値 */\n name?: string\n /** 選択可能な期間の開始日 */\n from?: Date\n /** 選択可能な期間の終了日 */\n to?: Date\n /** フォームを無効にするかどうか */\n disabled?: boolean\n /**\n * placeholder属性は非推奨です。別途ヒント用要素を設置するか、それらの領域を確保出来ない場合はTooltipコンポーネントの利用を検討してください。\n */\n placeholder?: string\n /** フォームにエラーがあるかどうか */\n error?: boolean\n /** コンポーネントの幅 */\n width?: number | string\n /** 入力を独自にパースする場合に、パース処理を記述する関数 */\n parseInput?: (input: string) => Date | null\n /** 表示する日付を独自にフォーマットする場合に、フォーマット処理を記述する関数 */\n formatDate?: (date: Date | null) => string\n /** 入出力用文字列と併記する別フォーマット処理を記述する関数 */\n showAlternative?: (date: Date | null) => ReactNode\n /** @deprecated onChangeDate は非推奨です。onChange を使ってください。 */\n onChangeDate?: (date: Date | null, value: string, other: { errors: string[] }) => void\n /** 選択された日付が変わった時に発火するコールバック関数 */\n onChange?: (\n e: ChangeEvent<HTMLInputElement>,\n other: { date: Date | null; formatValue: string; errors: string[] },\n ) => void\n}\ntype Props = BaseProps &\n Omit<\n ComponentPropsWithRef<'input'>,\n | keyof BaseProps\n | 'type'\n | 'onChange'\n | 'onKeyPress'\n | 'onFocus'\n | 'aria-expanded'\n | 'aria-controls'\n | 'aria-haspopup'\n >\n\nexport const DEFAULT_FROM = new Date(1900, 0, 1)\n\nconst classNameGenerator = tv({\n slots: {\n container: 'smarthr-ui-DatePicker shr-inline-block',\n inputSuffixLayout: 'shr-box-border shr-h-full shr-py-0.5',\n inputSuffixWrapper:\n 'shr-border-l-shorthand shr-box-border shr-flex shr-h-full shr-items-center shr-justify-center shr-ps-0.5 shr-text-base',\n inputSuffixText: 'shr-text-gray shr-me-0.5 shr-text-sm',\n },\n})\n\nconst DEFAULT_DATE_TO_STRING_FORMAT = 'YYYY/MM/DD'\nconst DEFAULT_DATE_TO_STRING = (d: Date | null) =>\n d ? dayjs(d).format(DEFAULT_DATE_TO_STRING_FORMAT) : ''\nconst ESCAPE_KEY_REGEX = /^Esc(ape)?$/\n\nconst parseStringDate = (\n str: string | null | undefined,\n parseInputFn: ((input: string) => Date | null) | undefined,\n) => {\n if (!str) return null\n return parseInputFn ? parseInputFn(str) : parseJpnDateString(str)\n}\n\n/** @deprecated DatePicker は非推奨です。Input[type=date] を使ってください。 */\nexport const DatePicker = forwardRef<HTMLInputElement, Props>(\n (\n {\n value,\n name,\n from = DEFAULT_FROM,\n to,\n disabled,\n width,\n error,\n className,\n parseInput,\n formatDate,\n showAlternative,\n onChangeDate,\n onChange,\n onBlur,\n ...rest\n },\n ref,\n ) => {\n const theme = useTheme()\n const classNames = useMemo(() => {\n const { container, inputSuffixLayout, inputSuffixWrapper, inputSuffixText } =\n classNameGenerator()\n\n return {\n container: container({ className }),\n inputSuffixLayout: inputSuffixLayout(),\n inputSuffixWrapper: inputSuffixWrapper(),\n inputSuffixText: inputSuffixText(),\n }\n }, [className])\n\n const [isInputFocused, setIsInputFocused] = useState(false)\n const inputRef = useRef<HTMLInputElement>(null)\n const inputWrapperRef = useRef<HTMLDivElement>(null)\n const calendarPortalRef = useRef<HTMLDivElement>(null)\n const [inputRect, setInputRect] = useState<DOMRect | null>(null)\n const [isCalendarShown, setIsCalendarShown] = useState(false)\n const [alternativeFormat, setAlternativeFormat] = useState<null | ReactNode>(null)\n const calenderId = useId()\n\n const [selectedDate, setSelectedDate] = useState<Date | null>(() =>\n parseStringDate(value, parseInput),\n )\n\n const latest = useLatest({\n onChange,\n onChangeDate,\n parseInput,\n formatDate,\n showAlternative,\n onBlur,\n isInputFocused,\n selectedDate,\n })\n\n const functions = useMemo(() => {\n const dateToString = (date: Date | null) =>\n latest.formatDate ? latest.formatDate(date) : DEFAULT_DATE_TO_STRING(date)\n\n const dateToAlternativeFormat = (d: Date | null) => {\n if (!latest.showAlternative) return null\n return d ? latest.showAlternative(d) : null\n }\n\n const stringToDate = (str: string | null | undefined) =>\n parseStringDate(str, latest.parseInput)\n\n const updateDate = (e: ChangeLikeEvent, newDate: Date | null) => {\n if (\n !inputRef.current ||\n newDate === latest.selectedDate ||\n (newDate && latest.selectedDate && newDate.getTime() === latest.selectedDate.getTime())\n ) {\n // Do not update date if the new date is same with the old one.\n return\n }\n\n const isValid = !newDate || dayjs(newDate).isValid()\n const errors: string[] = []\n\n if (!isValid) {\n errors.push('INVALID_DATE')\n }\n\n const nextDate = isValid ? newDate : null\n const formatValue = dateToString(nextDate)\n\n inputRef.current.value = formatValue\n setAlternativeFormat(dateToAlternativeFormat(nextDate))\n setSelectedDate(nextDate)\n\n if (latest.onChange) {\n e.preventDefault()\n e.stopPropagation()\n\n const event = new Event('change', { bubbles: true })\n const input = inputRef.current\n\n input.dispatchEvent(event)\n latest.onChange(\n // HINT: 型問題のため別途オブジェクトをイベントに見立てる\n {\n stopPropagation: () => {\n event.stopPropagation()\n },\n preventDefault: () => {\n event.preventDefault()\n },\n target: input,\n currentTarget: input,\n } as ChangeEvent<HTMLInputElement>,\n { date: nextDate, formatValue, errors },\n )\n } else if (latest.onChangeDate) {\n latest.onChangeDate(nextDate, formatValue, { errors })\n }\n }\n\n const closeCalendar = () => setIsCalendarShown(false)\n\n const openCalendar = () => {\n if (inputWrapperRef.current) {\n setIsCalendarShown(true)\n setInputRect(inputWrapperRef.current.getBoundingClientRect())\n }\n }\n\n return {\n stringToDate,\n dateToString,\n dateToAlternativeFormat,\n closeCalendar,\n openCalendar,\n handleBlur: (e: React.FocusEvent<HTMLInputElement>) => {\n setIsInputFocused(false)\n updateDate(e, e.target.value ? stringToDate(e.target.value) : null)\n latest.onBlur?.(e)\n },\n handleDelegateKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (ESCAPE_KEY_REGEX.test(e.key)) {\n e.stopPropagation()\n // delay hiding calendar because calendar will be displayed when input is focused\n requestAnimationFrame(closeCalendar)\n\n if (inputRef.current) inputRef.current.focus()\n }\n },\n handleKeyPressInput: (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (e.key === 'Enter') {\n const isExpanded = e.currentTarget.getAttribute('aria-expanded') === 'true'\n ;(isExpanded ? closeCalendar : openCalendar)()\n updateDate(e, stringToDate(e.currentTarget.value))\n }\n },\n handleFocusInput: () => {\n setIsInputFocused(true)\n openCalendar()\n },\n handleSelectDateCalendar: (e: ChangeLikeEvent, selected: Date | null) => {\n updateDate(e, selected)\n // delay hiding calendar because calendar will be displayed when input is focused\n requestAnimationFrame(closeCalendar)\n\n if (inputRef.current) inputRef.current.focus()\n },\n }\n }, [latest])\n\n useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(\n ref,\n () => inputRef.current,\n [],\n )\n\n useEffect(() => {\n if (value === undefined || !inputRef.current) {\n return\n }\n\n /**\n * Do not format the given value in the following cases\n * - while input element is focused.\n * - if the given value is not date formattable.\n */\n if (!isInputFocused) {\n const newDate = functions.stringToDate(value)\n\n if (newDate && dayjs(newDate).isValid()) {\n inputRef.current.value = functions.dateToString(newDate)\n setAlternativeFormat(functions.dateToAlternativeFormat(newDate))\n setSelectedDate(newDate)\n\n return\n }\n\n setSelectedDate(null)\n }\n\n inputRef.current.value = value || ''\n }, [value, isInputFocused, functions])\n\n useOuterClick(\n useMemo(() => [inputWrapperRef, calendarPortalRef], [inputWrapperRef, calendarPortalRef]),\n functions.closeCalendar,\n )\n\n useEffect(() => {\n const handleKeyDown = (e: KeyboardEvent) => {\n if (!inputRef.current || !calendarPortalRef.current || e.key !== 'Tab') {\n return\n }\n\n const calendarButtons = calendarPortalRef.current.querySelectorAll('button')\n\n if (calendarButtons.length === 0) {\n return\n }\n\n const firstCalendarButton = calendarButtons[0]\n\n if (latest.isInputFocused) {\n if (e.shiftKey) {\n // move focus from Input to previous elements of DatePicker\n functions.closeCalendar()\n\n return\n }\n\n // move focus from Input to Calendar\n e.preventDefault()\n firstCalendarButton.focus()\n\n return\n }\n\n const calendarButtonAry = Array.from(calendarButtons)\n const currentFocused = calendarButtonAry.find((button) => button === e.target)\n\n if (e.shiftKey) {\n if (currentFocused === firstCalendarButton) {\n // move focus from Calendar to Input\n inputRef.current.focus()\n e.preventDefault()\n }\n } else if (currentFocused === calendarButtonAry.at(-1)) {\n // move focus from Calendar to next elements of DatePicker\n inputRef.current.focus()\n functions.closeCalendar()\n }\n }\n\n window.addEventListener('keydown', handleKeyDown)\n\n return () => {\n window.removeEventListener('keydown', handleKeyDown)\n }\n }, [functions, latest])\n\n const caretIconColor =\n isInputFocused || isCalendarShown\n ? theme.textColor.black\n : disabled\n ? theme.textColor.disabled\n : theme.textColor.grey\n\n return (\n // eslint-disable-next-line smarthr/best-practice-for-interactive-element\n <div\n onClick={!isCalendarShown && !disabled ? functions.openCalendar : undefined}\n onKeyDown={isCalendarShown ? functions.handleDelegateKeyDown : undefined}\n role=\"presentation\"\n className={classNames.container}\n style={{\n width: typeof width === 'number' ? `${width}px` : width,\n }}\n >\n <div ref={inputWrapperRef}>\n <Input\n {...rest}\n data-smarthr-ui-input=\"true\"\n width=\"100%\"\n name={name}\n onChange={isCalendarShown ? functions.closeCalendar : undefined}\n onKeyPress={functions.handleKeyPressInput}\n onFocus={functions.handleFocusInput}\n onBlur={functions.handleBlur}\n suffix={\n <InputSuffixIcon\n alternativeFormat={showAlternative ? alternativeFormat : null}\n caretIconColor={caretIconColor}\n classNames={classNames}\n />\n }\n disabled={disabled}\n error={error}\n ref={inputRef}\n className=\"smarthr-ui-DatePicker-inputContainer\"\n aria-expanded={isCalendarShown}\n aria-controls={calenderId}\n aria-haspopup={true}\n />\n </div>\n {isCalendarShown && inputRect && (\n <Portal inputRect={inputRect} ref={calendarPortalRef}>\n <Calendar\n id={calenderId}\n value={selectedDate || undefined}\n from={from}\n to={to}\n onSelectDate={functions.handleSelectDateCalendar}\n />\n </Portal>\n )}\n </div>\n )\n },\n)\n\nconst InputSuffixIcon = memo<{\n classNames: { inputSuffixLayout: string; inputSuffixWrapper: string; inputSuffixText: string }\n alternativeFormat: null | ReactNode\n caretIconColor: ComponentProps<typeof FaCalendarDaysIcon>['color']\n}>(({ classNames, alternativeFormat, caretIconColor }) => (\n <span className={classNames.inputSuffixLayout}>\n <span className={classNames.inputSuffixWrapper}>\n {alternativeFormat && <span className={classNames.inputSuffixText}>{alternativeFormat}</span>}\n <FaCalendarDaysIcon color={caretIconColor} />\n </span>\n </span>\n))\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AA6EO;AAEP;AACE;AACE;AACA;AACA;AAEA;AACD;AACF;AAED;AACA;AAEA;AAEA;AAIE;AAAU;AACV;AACF;AAEA;;AAsBI;AACA;AACE;;AAIE;;;;;AAKJ;;AAGA;AACA;AACA;;;;AAIA;AAEA;;;;;;;;;;AAaC;AAED;;AAIE;;AAC+B;AAC7B;AACF;AAEA;AAGA;;;AAII;;;;AAMF;;;AAIE;;;AAIF;AAEA;AACA;;AAGA;;;AAIE;AACA;AAEA;AACA;;AAEE;;;;;;;AAOE;AACA;;;AAIC;;;AAGT;;;AAKE;;;;AAIF;;;;;;;AAQE;;;AAGE;;AAEF;;;;;;AAM0B;;;AAG1B;AACE;AACE;;AAEA;;;;;AAKF;;AAEF;AACE;;;;AAIsB;;;AAG5B;AAEA;;;;;AAWE;;;;AAIG;;;;;;;;;;;;;;;AAwBH;AACE;;;;AAMA;;;AAIA;AAEA;AACE;;;;;;;;;;;AAeF;AAEA;AACE;;AAEE;;;;;;AAKF;;;AAGJ;AAEA;AAEA;AACE;AACF;AACF;AAEA;AAEI;AACA;AACE;AACA;;;AAIJ;AAMI;AACD;AAyCP;AAGF;;;"}
|
|
@@ -159,7 +159,7 @@ const DatePicker = forwardRef(({ value, name, from = DEFAULT_FROM, to, disabled,
|
|
|
159
159
|
},
|
|
160
160
|
};
|
|
161
161
|
}, [latest]);
|
|
162
|
-
useImperativeHandle(ref, () => inputRef.current);
|
|
162
|
+
useImperativeHandle(ref, () => inputRef.current, []);
|
|
163
163
|
useEffect(() => {
|
|
164
164
|
if (value === undefined || !inputRef.current) {
|
|
165
165
|
return;
|