Skip to content

Commit

Permalink
chore: useActiveOption.test.tsを復活させる
Browse files Browse the repository at this point in the history
  • Loading branch information
AtsushiM committed Feb 16, 2025
1 parent ab838fe commit 856ccf7
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { act, renderHook } from '@testing-library/react'

import { useActiveOption } from '../useActiveOption'

describe('useActiveOption', () => {
const initialProps = {
options: [
{ id: 'id1', selected: false, isNew: false, item: { label: 'label1', value: 'value1' } },
{ id: 'id2', selected: false, isNew: false, item: { label: 'label2', value: 'value2' } },
{
id: 'id3',
selected: false,
isNew: false,
item: { label: 'label3', value: 'value3', disabled: true },
},
{ id: 'id4', selected: false, isNew: false, item: { label: 'label3', value: 'value3' } },
],
}
const resultDummy = renderHook((props) => useActiveOption(props), {
initialProps,
})

let result: (typeof resultDummy)['result']
let rerender: (typeof resultDummy)['rerender']
beforeEach(() => {
const renderHookResult = renderHook((props) => useActiveOption(props), { initialProps })
result = renderHookResult.result
rerender = renderHookResult.rerender
})

it('options が変更されても activeOption が維持されること', () => {
act(() => result.current.moveActiveOptionIndex(result.current.activeOption, 1))
expect(result.current.activeOption).toEqual(initialProps.options[0])
rerender({
options: [
...initialProps.options,
{ id: 'id4', selected: false, isNew: false, item: { label: 'label4', value: 'value4' } },
],
})
expect(result.current.activeOption).toEqual(initialProps.options[0])
})

it('options から activeOption と一致する option が消えたとき、activeOption がリセットされること', () => {
act(() => result.current.moveActiveOptionIndex(result.current.activeOption, 1))
expect(result.current.activeOption).toEqual(initialProps.options[0])
rerender({
options: initialProps.options.slice(1),
})
expect(result.current.activeOption).toBeNull()
})

it('activeOption をセットできること', () => {
act(() => result.current.setActiveOption(initialProps.options[2]))
expect(result.current.activeOption).toEqual(initialProps.options[2])
})

describe('moveActiveOptionIndex(xxx, 1)', () => {
it('activeOption が未設定のとき、最初に先頭のアイテムが選択されること', () => {
act(() => result.current.moveActiveOptionIndex(result.current.activeOption, 1))
expect(result.current.activeOption).toEqual(initialProps.options[0])
})

it('disabled な option が飛ばされること', () => {
act(() => result.current.setActiveOption(initialProps.options[1]))
act(() => result.current.moveActiveOptionIndex(result.current.activeOption, 1))
expect(result.current.activeOption).toEqual(initialProps.options[3])
})

it('末尾の option から 先頭の option にループすること', () => {
act(() =>
result.current.setActiveOption(initialProps.options[initialProps.options.length - 1]),
)
act(() => result.current.moveActiveOptionIndex(result.current.activeOption, 1))
expect(result.current.activeOption).toEqual(initialProps.options[0])
})
})

describe('moveActiveOptionIndex(xxx, - 1)', () => {
it('activeOption を変更できること', () => {
expect(result.current.activeOption).toBeNull()

act(() => result.current.moveActiveOptionIndex(result.current.activeOption, -1))
expect(result.current.activeOption).toEqual(initialProps.options[3])

act(() => result.current.moveActiveOptionIndex(result.current.activeOption, -1))
act(() => result.current.moveActiveOptionIndex(result.current.activeOption, -1))
expect(result.current.activeOption).toEqual(initialProps.options[0])

act(() => result.current.moveActiveOptionIndex(result.current.activeOption, -1))
expect(result.current.activeOption).toEqual(initialProps.options[3])
})
})
})
130 changes: 130 additions & 0 deletions packages/smarthr-ui/src/components/ComboBox/useActiveOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, {
KeyboardEvent,
ReactNode,
RefObject,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} 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'
import { FaInfoCircleIcon } from '../Icon'
import { Loader } from '../Loader'
import { VisuallyHiddenText } from '../VisuallyHiddenText'

import { ListBoxItemButton } from './ListBoxItemButton'
import { ComboBoxItem, ComboBoxOption } from './types'
import { usePartialRendering } from './usePartialRendering'

type Props<T> = {
options: Array<ComboBoxOption<T>>
dropdownHelpMessage?: ReactNode
dropdownWidth?: string | number
onAdd?: (label: string) => void
onSelect: (item: ComboBoxItem<T>) => void
isExpanded: boolean
isLoading?: boolean
triggerRef: RefObject<HTMLElement>
decorators?: DecoratorsType<DecoratorKeyTypes>
}

type Rect = {
top: number
left: number
height?: number
}

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 classNameGenerator = tv({
slots: {
wrapper: 'shr-absolute',
dropdownList: [
'smarthr-ui-ComboBox-dropdownList',
'shr-absolute shr-z-overlap shr-box-border shr-min-w-full shr-overflow-y-auto shr-rounded-m shr-bg-white shr-py-0.5 shr-shadow-layer-3',
/* 縦スクロールに気づきやすくするために8個目のアイテムが半分見切れるように max-height を算出
= (アイテムのフォントサイズ + アイテムの上下padding) * 7.5 + コンテナの上padding */
'shr-max-h-[calc((theme(fontSize.base)_+_theme(spacing[0.5])_*_2)_*_7.5_+_theme(spacing[0.5]))]',
'aria-hidden:shr-hidden',
],
helpMessage:
'shr-whitespace-[initial] shr-border-b-shorthand shr-mx-0.5 shr-mb-0.5 shr-mt-0 shr-px-0.5 shr-pb-0.5 shr-pt-0 shr-text-sm',
loaderWrapper: 'shr-flex shr-items-center shr-justify-center shr-p-1',
noItems: 'smarthr-ui-ComboBox-noItems shr-my-0 shr-bg-white shr-px-1 shr-py-0.5 shr-text-base',
},
})

export const useListBox = <T>({
options,
dropdownHelpMessage,
dropdownWidth,
onAdd,
onSelect,
isExpanded,
isLoading,
triggerRef,
decorators,
}: Props<T>) => {
const [activeOption, setActiveOption] = useState<ComboBoxOption<T> | null>(null)

useEffect(() => {
// props の変更によって activeOption の状態が変わりうるので、実態を反映する
setActiveOption((current) => {
if (current === null) {
return null
}

return options.find((option) => current.id === option.id) ?? null
})
}, [options])

const moveActiveOptionIndex = useCallback(
(currentActive: ComboBoxOption<T> | null, delta: -1 | 1) => {
if (options.every((option) => option.item.disabled)) {
return
}

const currentActiveIndex =
currentActive === null ? -1 : options.findIndex((option) => option.id === currentActive.id)
let nextIndex = 0

if (currentActiveIndex !== -1) {
nextIndex = (currentActiveIndex + delta + options.length) % options.length
} else if (delta !== 1) {
nextIndex = options.length - 1
}

const nextActive = options[nextIndex]

if (nextActive) {
if (nextActive.item.disabled) {
// skip disabled item
moveActiveOptionIndex(nextActive, delta)
} else {
setActiveOption(nextActive)
}
}
},
[options],
)

return {
activeOption,
setActiveOption,
moveActiveOptionIndex,
}
}
44 changes: 2 additions & 42 deletions packages/smarthr-ui/src/components/ComboBox/useListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { VisuallyHiddenText } from '../VisuallyHiddenText'

import { ListBoxItemButton } from './ListBoxItemButton'
import { ComboBoxItem, ComboBoxOption } from './types'
import { useActiveOption } from './useActiveOption'
import { usePartialRendering } from './usePartialRendering'

type Props<T> = {
Expand Down Expand Up @@ -80,48 +81,7 @@ export const useListBox = <T,>({
decorators,
}: Props<T>) => {
const [navigationType, setNavigationType] = useState<'pointer' | 'key'>('pointer')
const [activeOption, setActiveOption] = useState<ComboBoxOption<T> | null>(null)

useEffect(() => {
// props の変更によって activeOption の状態が変わりうるので、実態を反映する
setActiveOption((current) => {
if (current === null) {
return null
}

return options.find((option) => current.id === option.id) ?? null
})
}, [options])

const moveActiveOptionIndex = useCallback(
(currentActive: ComboBoxOption<T> | null, delta: -1 | 1) => {
if (options.every((option) => option.item.disabled)) {
return
}

const currentActiveIndex =
currentActive === null ? -1 : options.findIndex((option) => option.id === currentActive.id)
let nextIndex = 0

if (currentActiveIndex !== -1) {
nextIndex = (currentActiveIndex + delta + options.length) % options.length
} else if (delta !== 1) {
nextIndex = options.length - 1
}

const nextActive = options[nextIndex]

if (nextActive) {
if (nextActive.item.disabled) {
// skip disabled item
moveActiveOptionIndex(nextActive, delta)
} else {
setActiveOption(nextActive)
}
}
},
[options],
)
const { activeOption, setActiveOption, moveActiveOptionIndex } = useActiveOption({ options })

useEffect(() => {
// 閉じたときに activeOption を初期化
Expand Down

0 comments on commit 856ccf7

Please sign in to comment.