-
Notifications
You must be signed in to change notification settings - Fork 141
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: Create models chore: Derive prev/next from parent chore: Improve keyboard trap chore: wip
- Loading branch information
Showing
20 changed files
with
3,279 additions
and
1,597 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
152 changes: 152 additions & 0 deletions
152
packages/smarthr-ui/src/components/Browser/Browser.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
34
packages/smarthr-ui/src/components/Browser/BrowserColumn.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
76
packages/smarthr-ui/src/components/Browser/BrowserItem.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { Browser } from './Browser' |
Oops, something went wrong.