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

feat: StepFormDialogコンポーネントの作成 #5004

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { tv } from 'tailwind-variants'
import { useHandleEscape } from '../../hooks/useHandleEscape'

import { DialogOverlap } from './DialogOverlap'
import { FocusTrap } from './FocusTrap'
import { FocusTrap, FocusTrapRef } from './FocusTrap'
import { useBodyScrollLock } from './useBodyScrollLock'

export type DialogContentInnerProps = PropsWithChildren<{
Expand Down Expand Up @@ -51,6 +51,10 @@ export type DialogContentInnerProps = PropsWithChildren<{
* ダイアログの `aria-labelledby`
*/
ariaLabelledby?: string
/**
* ダイアログトップのフォーカストラップへの ref
*/
focusTrapRef?: RefObject<FocusTrapRef>
}>
type ElementProps = Omit<ComponentProps<'div'>, keyof DialogContentInnerProps>

Expand Down Expand Up @@ -79,6 +83,7 @@ export const DialogContentInner: FC<DialogContentInnerProps & ElementProps> = ({
ariaLabelledby,
children,
className,
focusTrapRef,
...rest
}) => {
const { layoutStyleProps, innerStyle, backgroundStyle } = useMemo(() => {
Expand Down Expand Up @@ -128,7 +133,9 @@ export const DialogContentInner: FC<DialogContentInnerProps & ElementProps> = ({
aria-modal="true"
className={innerStyle}
>
<FocusTrap firstFocusTarget={firstFocusTarget}>{children}</FocusTrap>
<FocusTrap firstFocusTarget={firstFocusTarget} ref={focusTrapRef}>
{children}
</FocusTrap>
</div>
</div>
</DialogOverlap>
Expand Down
36 changes: 29 additions & 7 deletions packages/smarthr-ui/src/components/Dialog/FocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
import React, { FC, PropsWithChildren, RefObject, useCallback, useEffect, useRef } from 'react'
import React, {
PropsWithChildren,
RefObject,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react'

import { tabbable } from '../../libs/tabbable'

type Props = PropsWithChildren<{
firstFocusTarget?: RefObject<HTMLElement>
}>

export const FocusTrap: FC<Props> = ({ firstFocusTarget, children }) => {
const ref = useRef<HTMLDivElement | null>(null)
export type FocusTrapRef = {
focus: () => void
}

export const FocusTrap = forwardRef<FocusTrapRef, Props>(({ firstFocusTarget, children }, ref) => {
const innerRef = useRef<HTMLDivElement | null>(null)
const dummyFocusRef = useRef<HTMLDivElement>(null)

useImperativeHandle(ref, () => ({
focus: () => {
if (firstFocusTarget?.current) {
firstFocusTarget.current.focus()
} else {
dummyFocusRef.current?.focus()
}
},
}))

const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key !== 'Tab' || ref.current === null) {
if (e.key !== 'Tab' || innerRef.current === null) {
return
}
const tabbables = tabbable(ref.current).filter((elm) => elm.tabIndex >= 0)
const tabbables = tabbable(innerRef.current).filter((elm) => elm.tabIndex >= 0)
if (tabbables.length === 0) {
return
}
Expand Down Expand Up @@ -57,10 +79,10 @@ export const FocusTrap: FC<Props> = ({ firstFocusTarget, children }) => {
}, [firstFocusTarget])

return (
<div ref={ref}>
<div ref={innerRef}>
{/* dummy element for focus management. */}
<div ref={dummyFocusRef} tabIndex={-1} />
{children}
</div>
)
}
})
161 changes: 161 additions & 0 deletions packages/smarthr-ui/src/components/Dialog/StepFormDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { action } from '@storybook/addon-actions'
import { StoryFn } from '@storybook/react'
import React, { ComponentProps, useState } from 'react'
import styled from 'styled-components'

import { Button } from '../Button'
import { CheckBox } from '../CheckBox'
import { Fieldset } from '../Fieldset'
import { FormControl } from '../FormControl'
import { Input } from '../Input'
import { Cluster } from '../Layout'
import { RadioButton } from '../RadioButton'

import { StepFormDialog, StepFormDialogItem } from './StepFormDialog'

import { ActionDialog, ActionDialogContent, DialogTrigger } from '.'

export default {
title: 'Dialog(ダイアログ)/StepFormDialog',
component: StepFormDialog,
subcomponents: {
DialogTrigger,
ActionDialogContent,
},
parameters: {
docs: {
source: {
type: 'code',
},
story: {
inline: false,
iframeHeight: '500px',
},
},
withTheming: true,
},
}

export const Default: StoryFn = () => {
const [openedDialog, setOpenedDialog] = useState<'normal' | 'opened' | null>(null)
const [value, setValue] = React.useState('Apple')
const [responseMessage, setResponseMessage] =
useState<ComponentProps<typeof ActionDialog>['responseMessage']>()
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.currentTarget.name)
const stepOrder = [
{ id: 'a', stepNumber: 1 },
{
id: 'b',
stepNumber: 2,
},
{ id: 'c', stepNumber: 3 },
]

return (
<Cluster>
<Button
onClick={() => setOpenedDialog('normal')}
aria-haspopup="dialog"
aria-controls="dialog-form"
data-test="dialog-trigger"
>
StepFormDialog
</Button>
{/* // eslint-disable-next-line smarthr/a11y-delegate-element-has-role-presentation */}
<StepFormDialog
isOpen={openedDialog === 'normal'}
title="StepFormDialog"
submitLabel="保存"
firstStep={stepOrder[0]}
onSubmit={(closeDialog, e, currentStep) => {
action('onSubmit')()
setResponseMessage(undefined)
if (currentStep.id === stepOrder[2].id) {
closeDialog()
}
if (currentStep.id === stepOrder[0].id) {
const skip = e.currentTarget.elements.namedItem('skip') as HTMLInputElement
if (skip.checked) {
return stepOrder[2]
}
}
const currentStepIndex = stepOrder.findIndex((step) => step.id === currentStep.id)
return stepOrder.at(currentStepIndex + 1)
}}
onClickClose={() => {
action('closed')()
setOpenedDialog(null)
setResponseMessage(undefined)
}}
onClickBack={() => {
action('back')()
}}
stepLength={3}
responseMessage={responseMessage}
id="dialog-form"
data-test="form-dialog-content"
width="40em"
>
<StepFormDialogItem {...stepOrder[0]}>
<Fieldset title="fruits" innerMargin={0.5}>
<RadioListCluster forwardedAs="ul">
<li>
<RadioButton name="Apple" checked={value === 'Apple'} onChange={onChange}>
Apple
</RadioButton>
</li>
<li>
<RadioButton name="Orange" checked={value === 'Orange'} onChange={onChange}>
Orange
</RadioButton>
</li>
<li>
<RadioButton name="skip" checked={value === 'skip'} onChange={onChange}>
これを選ぶとステップ2を飛ばして3に進みます
</RadioButton>
</li>
</RadioListCluster>
</Fieldset>
</StepFormDialogItem>
<StepFormDialogItem {...stepOrder[1]}>
<FormControl id="b" title="Sample">
<Input type="text" name="text" />
</FormControl>
</StepFormDialogItem>
<StepFormDialogItem {...stepOrder[2]}>
<Fieldset title="fruits" innerMargin={0.5}>
<ul>
<li>
<CheckBox name="1">CheckBox</CheckBox>
</li>

<li>
<CheckBox name="error" error>
CheckBox / error
</CheckBox>
</li>

<li>
<CheckBox name="disabled" disabled>
CheckBox / disabled
</CheckBox>
</li>
</ul>
</Fieldset>
</StepFormDialogItem>
</StepFormDialog>
</Cluster>
)
}

Default.parameters = {
docs: {
description: {
story: '`StepFormDialog` is a form dialog that can be divided into multiple steps.',
},
},
}

const RadioListCluster = styled(Cluster).attrs({ gap: 1.25 })`
list-style: none;
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { userEvent } from '@storybook/test'
import { act, render, screen, waitFor } from '@testing-library/react'
import React, { useState } from 'react'

import { Button } from '../../Button'
import { Text } from '../../Text'

import { StepFormDialog } from './StepFormDialog'
import { StepFormDialogItem } from './StepFormDialogItem'

describe('StepFormDialog', () => {
const DialogTemplate: React.FC = () => {
const [isOpen, setIsOpen] = useState<boolean>(false)
const steps = [
{ id: 'a', stepNumber: 1 },
{ id: 'b', stepNumber: 2 },
]
return (
<>
<Button onClick={() => setIsOpen(true)}>StepFormDialog</Button>
<StepFormDialog
isOpen={isOpen}
title="StepFormDialog"
submitLabel="保存"
stepLength={2}
firstStep={steps[0]}
onSubmit={(closeDialog, _, currentStep) => {
closeDialog()
const nextStep = steps.find((step) => step.stepNumber === currentStep.stepNumber + 1)
return nextStep
}}
onClickClose={() => {
setIsOpen(false)
}}
>
<StepFormDialogItem {...steps[0]}>
<Text>Step1</Text>
</StepFormDialogItem>
<StepFormDialogItem {...steps[1]}>
<Text>Step2</Text>
</StepFormDialogItem>
</StepFormDialog>
</>
)
}
it('ダイアログが開閉できること', async () => {
render(<DialogTemplate />)

expect(screen.queryByRole('dialog', { name: 'StepFormDialog 1/2' })).toBeNull()
await act(() => userEvent.tab())
await act(() => userEvent.keyboard('{enter}'))
expect(screen.getByRole('dialog', { name: 'StepFormDialog 1/2' })).toBeVisible()

await act(() => userEvent.click(screen.getByRole('button', { name: 'キャンセル' })))
await waitFor(
() => {
expect(screen.queryByRole('dialog', { name: 'StepFormDialog 1/2' })).toBeNull()
},
{ timeout: 1000 },
)

// ダイアログを閉じた後、トリガがフォーカスされることを確認
expect(screen.getByRole('button', { name: 'StepFormDialog' })).toHaveFocus()
})

it('ダイアログのステップの移動ができること', async () => {
render(<DialogTemplate />)

await act(() => userEvent.tab())
await act(() => userEvent.keyboard('{enter}'))
expect(screen.getByRole('dialog', { name: 'StepFormDialog 1/2' })).toBeVisible()

await act(() => userEvent.click(screen.getByRole('button', { name: '次へ' })))
expect(screen.getByRole('dialog', { name: 'StepFormDialog 2/2' })).toBeVisible()

await act(() => userEvent.click(screen.getByRole('button', { name: '戻る' })))
expect(screen.getByRole('dialog', { name: 'StepFormDialog 1/2' })).toBeVisible()

await act(() => userEvent.click(screen.getByRole('button', { name: '次へ' })))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HELP

今は通っているんですが、この次へを押した次に 2/2 ステップ目かの assertion を入れるとCI上でだけ落ちる事件が発生していました。。。原因わからずです

await act(() => userEvent.click(screen.getByRole('button', { name: '保存' })))
await waitFor(
() => {
expect(screen.queryByRole('dialog', { name: 'StepFormDialog 2/2' })).toBeNull()
},
{ timeout: 1000 },
)

// ダイアログを閉じた後、トリガがフォーカスされることを確認
expect(screen.getByRole('button', { name: 'StepFormDialog' })).toHaveFocus()
})
})
Loading
Loading