Skip to content

Commit

Permalink
feat: Browserコンポーネントを実装
Browse files Browse the repository at this point in the history
chore: Create models

chore: Derive prev/next from parent

chore: Improve keyboard trap

chore: wip
  • Loading branch information
neet committed Nov 25, 2024
1 parent 63f91bb commit 11bf5e5
Show file tree
Hide file tree
Showing 20 changed files with 3,279 additions and 1,597 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,9 @@ module.exports = {
checkType: 'allow-spread-attributes',
}
],
'smarthr/a11y-delegate-element-has-role-presentation': [
'error',
{ additionalInteractiveComponentRegex: ['^Browser(Column|Item)?$'] }
]
},
}
152 changes: 152 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,152 @@
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 onChange = vi.fn()
render(<Browser items={[{ value: '1', label: 'アイテム1' }]} onChange={onChange} />)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム1' }))
expect(onChange).toHaveBeenCalledWith('1')
})

test('ArrowUpを押すと、選択中の最下層のオプションの前のオプションを選択する', async () => {
const onChange = vi.fn()
render(
<Browser
items={[
{ value: '1', label: 'アイテム1' },
{ value: '2', label: 'アイテム2' },
]}
value="2"
onChange={onChange}
/>,
)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム2' }))
await userEvent.keyboard('[ArrowUp]')
expect(onChange).toHaveBeenCalledWith('1')
})

test('ArrowDownを押すと、選択中の最下層のオプションの次のオプションを選択する', async () => {
const onChange = vi.fn()
render(
<Browser
items={[
{ value: '1', label: 'アイテム1' },
{ value: '2', label: 'アイテム2' },
]}
value="1"
onChange={onChange}
/>,
)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム1' }))
await userEvent.keyboard('[ArrowDown]')
expect(onChange).toHaveBeenCalledWith('2')
})

test('ArrowRightを押すと、選択中のオプションの子オプションの最初のオプションを選択する', async () => {
const onChange = vi.fn()
render(
<Browser
items={[
{
value: '1',
label: 'アイテム1',
children: [
{ value: '2', label: 'アイテム2' },
{ value: '3', label: 'アイテム3' },
],
},
]}
value="1"
onChange={onChange}
/>,
)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム1' }))
await userEvent.keyboard('[ArrowRight]')
expect(onChange).toHaveBeenCalledWith('2')
})

test('Enterを押すと、選択中のオプションの子オプションの最初のオプションを選択する', async () => {
const onChange = vi.fn()
render(
<Browser
items={[
{
value: '1',
label: 'アイテム1',
children: [
{ value: '2', label: 'アイテム2' },
{ value: '3', label: 'アイテム3' },
],
},
]}
value="1"
onChange={onChange}
/>,
)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム1' }))
await userEvent.keyboard('[Enter]')
expect(onChange).toHaveBeenCalledWith('2')
})

test('Spaceを押すと、選択中のオプションの子オプションの最初のオプションを選択する', async () => {
const onChange = vi.fn()
render(
<Browser
items={[
{
value: '1',
label: 'アイテム1',
children: [
{ value: '2', label: 'アイテム2' },
{ value: '3', label: 'アイテム3' },
],
},
]}
value="1"
onChange={onChange}
/>,
)

await userEvent.click(screen.getByRole('radio', { name: 'アイテム1' }))
await userEvent.keyboard('[Space]')
expect(onChange).toHaveBeenCalledWith('2')
})

test('ArrowLeftを押すと、選択中のオプションの親オプションを選択する', async () => {
const onChange = vi.fn()
render(
<Browser
items={[
{
value: '1',
label: 'アイテム1',
children: [
{ value: '2', label: 'アイテム2' },
{ value: '3', label: 'アイテム3' },
],
},
]}
value="2"
onChange={onChange}
/>,
)

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

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 を取る。 */
onChange?: (value?: string) => void
}

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

const rootNode = useMemo(
() => new RootNode(props.items.map((item) => ItemNode.from(item))),
[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.getSiblings()?.at(-1)
if (target) {
e.preventDefault()
onChange?.(target.value)
document.getElementById(getElementIdFromNode(target))?.focus()
}
}

if (e.key === 'ArrowDown' && selectedNode) {
const target = selectedNode.getNext() ?? selectedNode.getSiblings()?.at(0)
if (target) {
e.preventDefault()
onChange?.(target.value)
document.getElementById(getElementIdFromNode(target))?.focus()
}
}

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

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

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}
level={index + 1}
value={value}
onChange={onChange}
/>
))
) : (
<Text>
該当する項目がありません。
<br />
別の条件を試してください。
</Text>
)}
</div>
</>
)
}
34 changes: 34 additions & 0 deletions packages/smarthr-ui/src/components/Browser/BrowserColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { FC } from 'react'

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

const getLevelId = (level: number) => `level-${level}`

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

export const BrowserColumn: FC<Props> = (props) => {
const { items, level, value, onChange } = 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={getLevelId(level)}>
{items.map((item, index) => {
const selected = item.value === value
const ariaOwns = selected && item.children.length > 0 ? getLevelId(level + 1) : undefined

return (
<li key={`${level}-${index}`} aria-owns={ariaOwns}>
<BrowserItem item={item} level={level} selected={selected} onChange={onChange} />
</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 = {
item: ItemNode
level: number
selected: boolean
onChange?: (id: string) => void
}

export const BrowserItem: FC<Props> = (props) => {
const { item, level, selected, onChange } = props

const inputId = getElementIdFromNode(item)
const hasChildren = item.children.length > 0
const tabIndex = selected ? 0 : -1

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={`level-${level}`}
value={item.value}
tabIndex={tabIndex}
onKeyDown={handleKeyDown}
onChange={() => onChange?.(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 11bf5e5

Please sign in to comment.