Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: ComboBox/useListBox の内部処理を整理する #5333

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3bb40d3
chore: decoratorsのデフォルト文字列の持ち方を調整する
AtsushiM Jan 23, 2025
5ef9eda
chore: DecoratorsTypeの設置場所を移動
AtsushiM Jan 23, 2025
0d284e1
chore: useDecoratorsを定義
AtsushiM Jan 23, 2025
139d213
Merge branch 'master' of https://github.com/kufu/smarthr-ui into chor…
AtsushiM Jan 24, 2025
7f05277
chore: useDecoratorsにわたすgenericsを調整
AtsushiM Jan 24, 2025
10baf9d
chore: libs/decorator を hooks/useDecorators に移動
AtsushiM Jan 24, 2025
660292c
chore: fix stories
AtsushiM Jan 24, 2025
d32fe62
chore: ComboBoxのuseListBox内でsetTriggerWidth(0)の初期化処理は実施済みのため削除
AtsushiM Jan 26, 2025
db14a34
chore: ComboBoxのuseListBox内でisActiveTopOutside,isActiveBottomOutsideの…
AtsushiM Jan 26, 2025
70ca015
chore: ComboBoxのuseListBoxでhandleKeyDownの条件分岐を最適化
AtsushiM Jan 26, 2025
b262f5d
chore: ComboBoxのuseListBoxでhandleAddのmemoを最適化する
AtsushiM Jan 26, 2025
1f30d3a
chore: ComboBoxのuseListBoxでstyle, classNameのmemo化を分離する
AtsushiM Jan 26, 2025
b818a9d
chore: ComboBoxのuseListBoxでstyleの生成を完全にmemo化
AtsushiM Jan 26, 2025
5187612
Merge branch 'chore-add-use-decorators' into chore-refactoring-ComboB…
AtsushiM Jan 26, 2025
d1a6ead
chore: ComboBoxのuseListBoxでdecoratorsをmemo化
AtsushiM Jan 26, 2025
45437c2
chore: ComboBoxのuseListBoxでloadingTextの表示ロジックを調整
AtsushiM Jan 26, 2025
de097ed
chore: fix ci
AtsushiM Jan 27, 2025
0969d97
chore: ComboBox/useLitBoxのkeyの判定を正規表現に行う
AtsushiM Jan 27, 2025
b8d522d
chore: ComboBox/useLitBoxのhookの依存関係を整理
AtsushiM Jan 27, 2025
74f1c62
Merge branch 'master' of https://github.com/kufu/smarthr-ui into chor…
AtsushiM Jan 28, 2025
ee9486f
Merge branch 'chore-add-use-decorators' into chore-refactoring-ComboB…
AtsushiM Jan 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/smarthr-ui/src/components/Browser/Browser.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { FC, KeyboardEventHandler, useCallback, useMemo } from 'react'
import { tv } from 'tailwind-variants'

import { DecoratorsType } from '../../types'
import { type DecoratorsType } from '../../hooks/useDecorators'
import { Text } from '../Text'

import { BrowserColumn } from './BrowserColumn'
Expand Down
2 changes: 1 addition & 1 deletion packages/smarthr-ui/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import React, { ButtonHTMLAttributes, forwardRef, useMemo } from 'react'
import { tv } from 'tailwind-variants'

import { type DecoratorsType } from '../../hooks/useDecorators'
import { usePortal } from '../../hooks/usePortal'
import { DecoratorsType } from '../../types'
import { Loader } from '../Loader'
import { VisuallyHiddenText } from '../VisuallyHiddenText'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ComboBoxOption } from './types'
type Props<T> = {
option: ComboBoxOption<T>
isActive: boolean
onAdd: (option: ComboBoxOption<T>) => void
onAdd?: (option: ComboBoxOption<T>) => void
onSelect: (option: ComboBoxOption<T>) => void
onMouseOver: (option: ComboBoxOption<T>) => void
activeRef: RefObject<HTMLButtonElement>
Expand Down Expand Up @@ -44,9 +44,15 @@ const ListBoxItemButton = <T,>({
const { item, selected, isNew } = option
const { label, disabled } = item

const handleAdd = useCallback(() => {
onAdd(option)
}, [onAdd, option])
const handleAdd = useMemo(
() =>
onAdd
? () => {
onAdd(option)
}
: undefined,
[option, onAdd],
)

const handleSelect = useCallback(() => {
onSelect(option)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useId } from 'react'
import innerText from 'react-innertext'
import { tv } from 'tailwind-variants'

import { type DecoratorsType } from '../../../hooks/useDecorators'
import { useOuterClick } from '../../../hooks/useOuterClick'
import { genericsForwardRef } from '../../../libs/util'
import { textColor } from '../../../themes'
Expand All @@ -26,7 +27,6 @@ import { useOptions } from '../useOptions'
import { MultiSelectedItem } from './MultiSelectedItem'
import { hasParentElementByClassName } from './multiComboBoxHelper'

import type { DecoratorsType } from '../../../types'
import type { BaseProps, ComboBoxItem } from '../types'

type Props<T> = BaseProps<T> & {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import innerText from 'react-innertext'
import { tv } from 'tailwind-variants'

import { useClick } from '../../../hooks/useClick'
import { type DecoratorsType } from '../../../hooks/useDecorators'
import { genericsForwardRef } from '../../../libs/util'
import { textColor } from '../../../themes'
import { UnstyledButton } from '../../Button'
Expand All @@ -25,7 +26,6 @@ import { Input } from '../../Input'
import { useListBox } from '../useListBox'
import { useOptions } from '../useOptions'

import type { DecoratorsType } from '../../../types'
import type { BaseProps, ComboBoxItem } from '../types'

type Props<T> = BaseProps<T> & {
Expand Down
214 changes: 102 additions & 112 deletions packages/smarthr-ui/src/components/ComboBox/useListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import React, {
} from 'react'
import { tv } from 'tailwind-variants'

import { type DecoratorsType, useDecorators } from '../../hooks/useDecorators'
import { useEnhancedEffect } from '../../hooks/useEnhancedEffect'
import { usePortal } from '../../hooks/usePortal'
import { spacing } from '../../themes'
Expand All @@ -23,8 +24,6 @@ import { ComboBoxItem, ComboBoxOption } from './types'
import { useActiveOption } from './useActiveOption'
import { usePartialRendering } from './usePartialRendering'

import type { DecoratorsType } from '../../types'

type Props<T> = {
options: Array<ComboBoxOption<T>>
dropdownHelpMessage?: ReactNode
Expand All @@ -34,7 +33,7 @@ type Props<T> = {
isExpanded: boolean
isLoading?: boolean
triggerRef: RefObject<HTMLElement>
decorators?: DecoratorsType<'noResultText' | 'loadingText'>
decorators?: DecoratorsType<DecoratorKeyTypes>
}

type Rect = {
Expand All @@ -43,8 +42,14 @@ type Rect = {
height?: number
}

const NO_RESULT_TEXT = '一致する選択肢がありません'
const LOADING_TEXT = '処理中'
const DECORATOR_DEFAULT_TEXTS = {
noResultText: '一致する選択肢がありません',
loadingText: '処理中',
} as const
type DecoratorKeyTypes = keyof typeof DECORATOR_DEFAULT_TEXTS

const KEY_DOWN_REGEX = /^(Arrow)?Down$/
const KEY_UP_REGEX = /^(Arrow)?Up/

const listbox = tv({
slots: {
Expand Down Expand Up @@ -96,7 +101,6 @@ export const useListBox = <T,>({

useEffect(() => {
if (!triggerRef.current) {
setTriggerWidth(0)
return
}

Expand Down Expand Up @@ -150,22 +154,20 @@ export const useListBox = <T,>({
useEffect(() => {
// actionOption の要素が表示される位置までリストボックス内をスクロールさせる
if (
navigationType !== 'key' ||
activeOption === null ||
!activeRef.current ||
!listBoxRef.current
!listBoxRef.current ||
activeOption === null ||
navigationType !== 'key'
) {
return
}

const activeRect = activeRef.current.getBoundingClientRect()
const containerRect = listBoxRef.current.getBoundingClientRect()

const isActiveTopOutside = activeRect.top < containerRect.top
const isActiveBottomOutside = activeRect.bottom > containerRect.bottom

if (isActiveTopOutside) {
if (activeRect.top < containerRect.top) {
listBoxRef.current.scrollTop -= containerRect.top - activeRect.top
} else if (isActiveBottomOutside) {
} else if (activeRect.bottom > containerRect.bottom) {
listBoxRef.current.scrollTop += activeRect.bottom - containerRect.bottom
}
}, [activeOption, listBoxRef, navigationType])
Expand All @@ -181,21 +183,23 @@ export const useListBox = <T,>({
(e: KeyboardEvent<HTMLElement>) => {
setNavigationType('key')

if (e.key === 'Down' || e.key === 'ArrowDown') {
if (KEY_DOWN_REGEX.test(e.key)) {
e.stopPropagation()
moveActivePositionDown()
} else if (e.key === 'Up' || e.key === 'ArrowUp') {
} else if (KEY_UP_REGEX.test(e.key)) {
e.stopPropagation()
moveActivePositionUp()
} else if (e.key === 'Enter') {
if (activeOption === null) {
return
}

e.stopPropagation()
if (activeOption.isNew) {
if (onAdd) onAdd(activeOption.item.value)
} else {

if (!activeOption.isNew) {
onSelect(activeOption.item)
} else if (onAdd) {
onAdd(activeOption.item.value)
}
} else {
setActiveOption(null)
Expand All @@ -214,14 +218,17 @@ export const useListBox = <T,>({
),
})

const handleAdd = useCallback(
(option: ComboBoxOption<T>) => {
// HINT: Dropdown系コンポーネント内でComboBoxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう
// requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする
requestAnimationFrame(() => {
if (onAdd) onAdd(option.item.value)
})
},
const handleAdd = useMemo(
() =>
onAdd
? (option: ComboBoxOption<T>) => {
// HINT: Dropdown系コンポーネント内でComboBoxを使うと、選択肢がportalで表現されている関係上Dropdownが閉じてしまう
// requestAnimationFrameを追加、処理を遅延させることで正常に閉じる/閉じないの判定を行えるようにする
requestAnimationFrame(() => {
onAdd(option.item.value)
})
}
: undefined,
[onAdd],
)
const handleSelect = useCallback(
Expand All @@ -238,82 +245,68 @@ export const useListBox = <T,>({
[setActiveOption],
)

const { wrapper, dropdownList, helpMessage, loaderWrapper, noItems } = listbox()
const {
wrapperStyleProps,
dropdownListStyleProps,
helpMessageStyle,
loaderWrapperStyle,
noItemsStyle,
} = useMemo(() => {
const { top, left, height } = listBoxRect
const wrapperStyleAttr = useMemo(() => {
const { top, left } = listBoxRect

return {
top: `${top}px`,
left: `${left}px`,
width: `${triggerWidth}px`,
}
}, [listBoxRect, triggerWidth])
const dropdownListStyleAttr = useMemo(() => {
const { left, height } = listBoxRect
const dropdownListWidth = dropdownWidth || triggerWidth

return {
width: typeof dropdownListWidth === 'string' ? dropdownListWidth : `${dropdownListWidth}px`,
maxWidth: `calc(100vw - ${left}px - ${spacing[0.5]})`,
height: height ? `${height}px` : undefined,
}
}, [listBoxRect, triggerWidth, dropdownWidth])

const styles = useMemo(() => {
const { wrapper, dropdownList, helpMessage, loaderWrapper, noItems } = listbox()

return {
wrapperStyleProps: {
className: wrapper(),
style: {
top: `${top}px`,
left: `${left}px`,
width: `${triggerWidth}px`,
},
},
dropdownListStyleProps: {
className: dropdownList(),
style: {
width:
typeof dropdownListWidth === 'string' ? dropdownListWidth : `${dropdownListWidth}px`,
maxWidth: `calc(100vw - ${left}px - ${spacing[0.5]})`,
height: height ? `${height}px` : undefined,
},
},
helpMessageStyle: helpMessage(),
loaderWrapperStyle: loaderWrapper(),
noItemsStyle: noItems(),
wrapper: wrapper(),
dropdownList: dropdownList(),
helpMessage: helpMessage(),
loaderWrapper: loaderWrapper(),
noItems: noItems(),
}
}, [
dropdownList,
dropdownWidth,
helpMessage,
listBoxRect,
triggerWidth,
loaderWrapper,
noItems,
wrapper,
])

const statusText = useMemo(() => {
const loadingText = decorators?.loadingText?.(LOADING_TEXT) ?? LOADING_TEXT
return isExpanded && isLoading ? loadingText : ''
}, [decorators, isExpanded, isLoading])
}, [])

const decorated = useDecorators<DecoratorKeyTypes>(DECORATOR_DEFAULT_TEXTS, decorators)

const renderListBox = useCallback(
() =>
createPortal(
<>
<VisuallyHiddenText role="status">{statusText}</VisuallyHiddenText>

<div {...wrapperStyleProps}>
<div
{...dropdownListStyleProps}
id={listBoxId}
ref={listBoxRef}
role="listbox"
aria-hidden={!isExpanded}
>
{dropdownHelpMessage && (
<p className={helpMessageStyle}>
<FaInfoCircleIcon color="TEXT_GREY" text={dropdownHelpMessage} iconGap={0.25} />
</p>
)}
{!isExpanded ? null : isLoading ? (
<div className={loaderWrapperStyle}>
<div className={styles.wrapper} style={wrapperStyleAttr}>
{isExpanded && isLoading && (
<VisuallyHiddenText role="status">{decorated.loadingText}</VisuallyHiddenText>
)}
<div
id={listBoxId}
ref={listBoxRef}
role="listbox"
aria-hidden={!isExpanded}
className={styles.dropdownList}
style={dropdownListStyleAttr}
>
{dropdownHelpMessage && (
<p className={styles.helpMessage}>
<FaInfoCircleIcon color="TEXT_GREY" text={dropdownHelpMessage} iconGap={0.25} />
</p>
)}
{isExpanded ? (
isLoading ? (
<div className={styles.loaderWrapper}>
<Loader aria-hidden />
</div>
) : options.length === 0 ? (
<p role="alert" aria-live="polite" className={noItemsStyle}>
{decorators?.noResultText
? decorators.noResultText(NO_RESULT_TEXT)
: NO_RESULT_TEXT}
<p role="alert" aria-live="polite" className={styles.noItems}>
{decorated.noResultText}
</p>
) : (
partialOptions.map((option) => (
Expand All @@ -327,32 +320,29 @@ export const useListBox = <T,>({
activeRef={activeRef}
/>
))
)}
{renderIntersection()}
</div>
)
) : null}
{renderIntersection()}
</div>
</>,
</div>,
),
[
createPortal,
wrapperStyleProps,
dropdownListStyleProps,
listBoxId,
activeOption?.id,
renderIntersection,
partialOptions,
options.length,
isExpanded,
dropdownHelpMessage,
helpMessageStyle,
isLoading,
loaderWrapperStyle,
options.length,
noItemsStyle,
decorators,
partialOptions,
renderIntersection,
activeOption?.id,
statusText,
dropdownHelpMessage,
listBoxId,
decorated,
handleAdd,
handleSelect,
handleHoverOption,
handleSelect,
styles,
dropdownListStyleAttr,
wrapperStyleAttr,
createPortal,
],
)

Expand Down
Loading