Skip to content

Commit

Permalink
feat: Browserコンポーネントを実装 (#5125)
Browse files Browse the repository at this point in the history
  • Loading branch information
neet authored Dec 10, 2024
1 parent 34ab280 commit b1a1bc6
Show file tree
Hide file tree
Showing 19 changed files with 2,601 additions and 1,171 deletions.
99 changes: 99 additions & 0 deletions packages/smarthr-ui/src/components/Browser/Browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { userEvent } from '@storybook/test'
import { render, screen } from '@testing-library/react'
import React from 'react'

import { Browser } from './Browser'

describe('Browser', () => {
test('アイテムが空のとき', () => {
render(<Browser items={[]} />)
expect(screen.getByText(/該当する項目がありません/)).toBeInTheDocument()
})

test('アイテムが存在するとき、最初の要素がタブストップになる', async () => {
const onSelectItem = vi.fn()
render(
<>
<Browser
items={[
{ value: '1', label: 'アイテム1' },
{ value: '2', label: 'アイテム2' },
{ value: '3', label: 'アイテム3' },
]}
onSelectItem={onSelectItem}
/>
<a href="/">次の要素</a>
</>,
)
await userEvent.keyboard('[Tab]')
expect(screen.getByRole('radio', { name: 'アイテム1' })).toHaveFocus()
await userEvent.keyboard('[Tab]')
expect(screen.getByRole('link', { name: '次の要素' })).toHaveFocus()
})

test('値を指定すると、ラジオボタンがチェックされ、タブストップになる', async () => {
render(
<>
<Browser
items={[
{ value: '1', label: 'アイテム1' },
{ value: '2', label: 'アイテム2' },
{ value: '3', label: 'アイテム3' },
]}
value="2"
/>
<a href="/">次の要素</a>
</>,
)
expect(screen.getByRole('radio', { name: 'アイテム2' })).toBeChecked()
await userEvent.keyboard('[Tab]')
expect(screen.getByRole('radio', { name: 'アイテム2' })).toHaveFocus()
await userEvent.keyboard('[Tab]')
expect(screen.getByRole('link', { name: '次の要素' })).toHaveFocus()
})

test.each([
['ArrowUp', '2-1'],
['ArrowDown', '2-3'],
['ArrowRight', '3-1'],
['ArrowLeft', '1-1'],
])('%sを押すと、%sが選択される', async (key, expected) => {
const onSelectItem = vi.fn()
render(
<Browser
items={[
{
value: '1-1',
label: 'アイテム1-1',
children: [
{
value: '2-1',
label: 'アイテム2-1',
},
{
value: '2-2',
label: 'アイテム2-2',
children: [{ value: '3-1', label: 'アイテム3-1' }],
},
{
value: '2-3',
label: 'アイテム2-3',
},
],
},
{
value: '1-2',
label: 'アイテム1-2',
},
{ value: '1-3', label: 'アイテム1-3' },
]}
value="2-2"
onSelectItem={onSelectItem}
/>,
)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム2-1' }))
await userEvent.keyboard(`[${key}]`)
expect(onSelectItem).toHaveBeenCalledWith(expected)
})
})
132 changes: 132 additions & 0 deletions packages/smarthr-ui/src/components/Browser/Browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React, { FC, KeyboardEventHandler, useCallback, useMemo } from 'react'
import { tv } from 'tailwind-variants'

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

import { BrowserColumn } from './BrowserColumn'
import { ItemNode, ItemNodeLike, RootNode } from './models'
import { getElementIdFromNode } from './utils'

const optionsListWrapper = tv({
base: 'smarthr-ui-Browser shr-flex shr-flex-row shr-flex-nowrap shr-min-h-[355px]',
variants: {
columnCount: {
0: 'shr-justify-center shr-items-center',
1: '[&>div]:shr-flex-1',
2: '[&>div:nth-child(1)]:shr-flex-1 [&>div:nth-child(2)]:shr-flex-[2]',
3: '[&>div]:shr-flex-1',
},
},
defaultVariants: {
columnCount: 0,
},
})

type Props = {
/** 表示する item の配列 */
items: ItemNodeLike[]
/** 選択中の item の値 */
value?: string
/** 選択された際に呼び出されるコールバック。第一引数に item の value を取る。 */
onSelectItem?: (value: string) => void
/** コンポーネント内の文言を変更するための関数を設定 */
decorators?: DecoratorsType<'notFoundTitle' | 'notFoundDescription'>
}

const NOT_FOUND_TITLE = '該当する項目がありません。'
const NOT_FOUND_DESCRIPTION = '別の条件を試してください。'

export const Browser: FC<Props> = (props) => {
const { value, decorators, onSelectItem } = props

const decoratedTexts = useMemo(
() => ({
notFoundTitle: decorators?.notFoundTitle?.(NOT_FOUND_TITLE) ?? NOT_FOUND_TITLE,
notFoundDescription:
decorators?.notFoundDescription?.(NOT_FOUND_DESCRIPTION) ?? NOT_FOUND_DESCRIPTION,
}),
[decorators],
)

const rootNode = useMemo(() => RootNode.from({ children: props.items }), [props.items])

const selectedNode = useMemo(() => {
if (value) {
return rootNode.findByValue(value)
}
return
}, [rootNode, value])

const columns = useMemo(() => rootNode.toViewData(value), [rootNode, value])

// FIXME: focusメソッドのfocusVisibleが主要ブラウザでサポートされたら使うようにしたい(現状ではマウスクリックでもfocusのoutlineが出てしまう)
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/focus
const handleKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === 'ArrowUp' && selectedNode) {
const target = selectedNode.getPrev() ?? selectedNode.parent?.getLastChild()
if (target) {
e.preventDefault()
onSelectItem?.(target.value)
document.getElementById(getElementIdFromNode(target))?.focus()
}
}

if (e.key === 'ArrowDown' && selectedNode) {
const target = selectedNode.getNext() ?? selectedNode.parent?.getFirstChild()
if (target) {
e.preventDefault()
onSelectItem?.(target.value)
document.getElementById(getElementIdFromNode(target))?.focus()
}
}

if (e.key === 'ArrowLeft') {
const target = selectedNode?.parent
if (target instanceof ItemNode) {
e.preventDefault()
onSelectItem?.(target.value)
document.getElementById(getElementIdFromNode(target))?.focus()
}
}

if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
const target = selectedNode?.getFirstChild()
if (target) {
e.preventDefault()
onSelectItem?.(target.value)
document.getElementById(getElementIdFromNode(target))?.focus()
}
}
},
[selectedNode, onSelectItem],
)

return (
// eslint-disable-next-line smarthr/a11y-delegate-element-has-role-presentation, jsx-a11y/no-noninteractive-element-interactions
<div
className={optionsListWrapper({ columnCount: columns.length as 0 | 1 | 2 | 3 })}
onKeyDown={handleKeyDown}
role="application"
>
{columns.length > 0 ? (
columns.map((items, index) => (
<BrowserColumn
key={index}
items={items}
index={index}
value={value}
onSelectItem={onSelectItem}
/>
))
) : (
<Text>
{decoratedTexts.notFoundTitle}
<br />
{decoratedTexts.notFoundDescription}
</Text>
)}
</div>
)
}
51 changes: 51 additions & 0 deletions packages/smarthr-ui/src/components/Browser/BrowserColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { FC } from 'react'

import { BrowserItem } from './BrowserItem'
import { ItemNode } from './models'

const getColumnId = (column: number) => `column-${column}`

const getTabIndex = (selected: boolean, columnIndex: number, rowIndex: number, value?: string) => {
if (selected) {
return 0
}
if (!value && columnIndex === 0 && rowIndex === 0) {
return 0
}
return -1
}

type Props = {
value?: string
items: ItemNode[]
index: number
onSelectItem?: (value: string) => void
}

export const BrowserColumn: FC<Props> = (props) => {
const { items, index: columnIndex, value, onSelectItem } = props

return (
<div className="last:shr-flex-1 [&:not(:last-child)]:shr-w-[218px] [&:not(:last-child)]:shr-border-r-shorthand">
<ul className="shr-list-none shr-px-0.25 shr-py-0.5" id={getColumnId(columnIndex)}>
{items.map((item, rowIndex) => {
const selected = item.value === value
const ariaOwns =
selected && item.children.length > 0 ? getColumnId(columnIndex + 1) : undefined

return (
<li key={`${columnIndex}-${rowIndex}`} aria-owns={ariaOwns}>
<BrowserItem
selected={selected}
item={item}
columnIndex={columnIndex}
tabIndex={getTabIndex(selected, columnIndex, rowIndex, value)}
onSelectItem={onSelectItem}
/>
</li>
)
})}
</ul>
</div>
)
}
76 changes: 76 additions & 0 deletions packages/smarthr-ui/src/components/Browser/BrowserItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { FC, KeyboardEventHandler } from 'react'
import { tv } from 'tailwind-variants'

import { FaAngleRightIcon } from '../Icon'
import { Cluster } from '../Layout'
import { Text } from '../Text'

import { ItemNode } from './models'
import { getElementIdFromNode } from './utils'

const radioWrapperStyle = tv({
base: 'shr-block shr-px-1 shr-py-0.5 shr-rounded-m focus-within:shr-shadow-outline',
variants: {
selected: {
parent: 'shr-bg-white-darken',
last: 'shr-bg-main shr-text-white forced-colors:shr-bg-[Highlight]',
none: 'hover:shr-bg-white-darken',
},
},
defaultVariants: {
selected: 'none',
},
})

type Props = {
selected: boolean
item: ItemNode
tabIndex: 0 | -1
columnIndex: number
onSelectItem?: (id: string) => void
}

export const BrowserItem: FC<Props> = (props) => {
const { selected, item, tabIndex, columnIndex, onSelectItem } = props

const inputId = getElementIdFromNode(item)
const hasChildren = item.children.length > 0

const handleKeyDown: KeyboardEventHandler = (e) => {
if (
e.key === 'ArrowRight' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault()
}
}

return (
<label
htmlFor={inputId}
className={radioWrapperStyle({
selected: selected ? (hasChildren ? 'parent' : 'last') : 'none',
})}
>
<input
className="shr-sr-only"
type="radio"
id={inputId}
name={`column-${columnIndex}`}
value={item.value}
tabIndex={tabIndex}
onKeyDown={handleKeyDown}
onChange={() => onSelectItem?.(item.value)}
checked={selected}
/>
<Cluster align="center" justify="space-between">
<Text>{item.label}</Text>
{hasChildren && <FaAngleRightIcon />}
</Cluster>
</label>
)
}
1 change: 1 addition & 0 deletions packages/smarthr-ui/src/components/Browser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Browser } from './Browser'
Loading

0 comments on commit b1a1bc6

Please sign in to comment.