diff --git a/packages/smarthr-ui/src/components/Dialog/DialogContentInner.tsx b/packages/smarthr-ui/src/components/Dialog/DialogContentInner.tsx index a668e6538e..969d218581 100644 --- a/packages/smarthr-ui/src/components/Dialog/DialogContentInner.tsx +++ b/packages/smarthr-ui/src/components/Dialog/DialogContentInner.tsx @@ -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<{ @@ -51,6 +51,10 @@ export type DialogContentInnerProps = PropsWithChildren<{ * ダイアログの `aria-labelledby` */ ariaLabelledby?: string + /** + * ダイアログトップのフォーカストラップへの ref + */ + focusTrapRef?: RefObject }> type ElementProps = Omit, keyof DialogContentInnerProps> @@ -79,6 +83,7 @@ export const DialogContentInner: FC = ({ ariaLabelledby, children, className, + focusTrapRef, ...rest }) => { const { layoutStyleProps, innerStyle, backgroundStyle } = useMemo(() => { @@ -128,7 +133,9 @@ export const DialogContentInner: FC = ({ aria-modal="true" className={innerStyle} > - {children} + + {children} + diff --git a/packages/smarthr-ui/src/components/Dialog/FocusTrap.tsx b/packages/smarthr-ui/src/components/Dialog/FocusTrap.tsx index 50d2308360..02cf7f3483 100644 --- a/packages/smarthr-ui/src/components/Dialog/FocusTrap.tsx +++ b/packages/smarthr-ui/src/components/Dialog/FocusTrap.tsx @@ -1,4 +1,12 @@ -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' @@ -6,15 +14,29 @@ type Props = PropsWithChildren<{ firstFocusTarget?: RefObject }> -export const FocusTrap: FC = ({ firstFocusTarget, children }) => { - const ref = useRef(null) +export type FocusTrapRef = { + focus: () => void +} + +export const FocusTrap = forwardRef(({ firstFocusTarget, children }, ref) => { + const innerRef = useRef(null) const dummyFocusRef = useRef(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 } @@ -57,10 +79,10 @@ export const FocusTrap: FC = ({ firstFocusTarget, children }) => { }, [firstFocusTarget]) return ( -
+
{/* dummy element for focus management. */}
{children}
) -} +}) diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog.stories.tsx b/packages/smarthr-ui/src/components/Dialog/StepFormDialog.stories.tsx new file mode 100644 index 0000000000..03f2405de8 --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog.stories.tsx @@ -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['responseMessage']>() + const onChange = (e: React.ChangeEvent) => setValue(e.currentTarget.name) + const stepOrder = [ + { id: 'a', stepNumber: 1 }, + { + id: 'b', + stepNumber: 2, + }, + { id: 'c', stepNumber: 3 }, + ] + + return ( + + + {/* // eslint-disable-next-line smarthr/a11y-delegate-element-has-role-presentation */} + { + 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" + > + +
+ +
  • + + Apple + +
  • +
  • + + Orange + +
  • +
  • + + これを選ぶとステップ2を飛ばして3に進みます + +
  • +
    +
    +
    + + + + + + +
    +
      +
    • + CheckBox +
    • + +
    • + + CheckBox / error + +
    • + +
    • + + CheckBox / disabled + +
    • +
    +
    +
    +
    +
    + ) +} + +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; +` diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialog.test.tsx b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialog.test.tsx new file mode 100644 index 0000000000..ee7d396218 --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialog.test.tsx @@ -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(false) + const steps = [ + { id: 'a', stepNumber: 1 }, + { id: 'b', stepNumber: 2 }, + ] + return ( + <> + + { + closeDialog() + const nextStep = steps.find((step) => step.stepNumber === currentStep.stepNumber + 1) + return nextStep + }} + onClickClose={() => { + setIsOpen(false) + }} + > + + Step1 + + + Step2 + + + + ) + } + it('ダイアログが開閉できること', async () => { + render() + + 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() + + 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: '次へ' }))) + 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() + }) +}) diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialog.tsx b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialog.tsx new file mode 100644 index 0000000000..64951959bf --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialog.tsx @@ -0,0 +1,107 @@ +import React, { ComponentProps, FormEvent, useCallback, useId, useRef } from 'react' + +import { DialogContentInner } from '../DialogContentInner' +import { FocusTrapRef } from '../FocusTrap' +import { DialogProps /** コンテンツなにもないDialogの基本props */ } from '../types' +import { useDialogPortal } from '../useDialogPortal' + +import { + StepFormDialogContentInner, + StepFormDialogContentInnerProps, +} from './StepFormDialogContentInner' +import { StepFormDialogProvider, type StepItem } from './StepFormDialogProvider' + +type Props = Omit & DialogProps + +type ElementProps = Omit, keyof Props> + +export const StepFormDialog: React.FC = ({ + children, + title, + subtitle, + stepLength, + titleTag, + contentBgColor, + contentPadding, + actionTheme, + submitLabel, + firstStep, + onSubmit, + onClickClose, + onClickBack, + onPressEscape = onClickClose, + responseMessage, + actionDisabled = false, + closeDisabled, + className, + portalParent, + decorators, + id, + ...props +}) => { + const { createPortal } = useDialogPortal(portalParent, id) + const titleId = useId() + const focusTrapRef = useRef(null) + + const handleClickClose = useCallback(() => { + if (!props.isOpen) { + return + } + focusTrapRef.current?.focus() + onClickClose() + }, [onClickClose, props.isOpen]) + + const handleSubmitAction = useCallback( + (close: () => void, e: FormEvent, currentStep: StepItem) => { + if (!props.isOpen) { + return undefined + } + focusTrapRef.current?.focus() + return onSubmit(close, e, currentStep) + }, + [onSubmit, props.isOpen], + ) + + const handleBackSteps = useCallback(() => { + if (!props.isOpen) { + return + } + focusTrapRef.current?.focus() + onClickBack?.() + }, [props.isOpen, onClickBack]) + + return createPortal( + + + {/* eslint-disable-next-line smarthr/a11y-delegate-element-has-role-presentation */} + + {children} + + + , + ) +} diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogContentInner.tsx b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogContentInner.tsx new file mode 100644 index 0000000000..a164900e85 --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogContentInner.tsx @@ -0,0 +1,187 @@ +import React, { + type FC, + type FormEvent, + type PropsWithChildren, + type ReactNode, + useCallback, + useContext, + useMemo, +} from 'react' + +import { Button } from '../../Button' +import { Cluster, Stack } from '../../Layout' +import { ResponseMessage } from '../../ResponseMessage' +import { Section } from '../../SectioningContent' +import { DialogBody, Props as DialogBodyProps } from '../DialogBody' +import { DialogHeader, type Props as DialogHeaderProps } from '../DialogHeader' +import { dialogContentInner } from '../dialogInnerStyle' + +import { + StepFormDialogActionContext, + StepFormDialogContext, + StepItem, +} from './StepFormDialogProvider' + +import type { DecoratorsType, ResponseMessageType } from '../../../types' + +export type BaseProps = PropsWithChildren< + DialogHeaderProps & + DialogBodyProps & { + /** アクションボタンのラベル */ + submitLabel: ReactNode + /** アクションボタンのスタイル */ + actionTheme?: 'primary' | 'secondary' | 'danger' + /** + * アクションボタンをクリックした時に発火するコールバック関数 + * @param closeDialog ダイアログを閉じる関数 + * @param currentStep onSubmitが発火した時のステップ + * @returns 次のステップに遷移する場合は次のステップ、遷移しない場合はundefined + */ + onSubmit: ( + closeDialog: () => void, + e: FormEvent, + currentStep: StepItem, + ) => StepItem | undefined + /** アクションボタンを無効にするかどうか */ + actionDisabled?: boolean + /** 閉じるボタンを無効にするかどうか */ + closeDisabled?: boolean + /** コンポーネント内の文言を変更するための関数を設定 */ + decorators?: DecoratorsType<'closeButtonLabel' | 'nextButtonLabel' | 'backButtonLabel'> + } +> + +export type StepFormDialogContentInnerProps = BaseProps & { + firstStep: StepItem + onClickClose: () => void + responseMessage?: ResponseMessageType + stepLength: number + onClickBack?: () => void +} + +const CLOSE_BUTTON_LABEL = 'キャンセル' +const NEXT_BUTTON_LABEL = '次へ' +const BACK_BUTTON_LABEL = '戻る' + +export const StepFormDialogContentInner: FC = ({ + children, + title, + titleId, + subtitle, + contentBgColor, + contentPadding, + submitLabel, + actionTheme = 'primary', + stepLength, + firstStep, + onSubmit, + onClickClose, + responseMessage, + actionDisabled = false, + closeDisabled, + decorators, + onClickBack, +}) => { + const { currentStep, stepQueue } = useContext(StepFormDialogContext) + const { setCurrentStep } = useContext(StepFormDialogActionContext) + const activeStep = useMemo(() => currentStep?.stepNumber ?? 1, [currentStep]) + + const handleCloseAction = useCallback(() => { + onClickClose() + setTimeout(() => { + // HINT: ダイアログが閉じるtransitionが完了してから初期化をしている + stepQueue.current = [] + setCurrentStep(firstStep) + }, 300) + }, [firstStep, stepQueue, setCurrentStep, onClickClose]) + + const handleSubmitAction = useCallback( + (e: FormEvent) => { + e.preventDefault() + // HINT: React Potals などで擬似的にformがネストしている場合など、stopPropagationを実行しないと + // 親formが意図せずsubmitされてしまう場合がある + e.stopPropagation() + + stepQueue.current.push(currentStep) + const next = onSubmit(handleCloseAction, e, currentStep) + if (!next) { + return + } + setCurrentStep(next) + }, + [currentStep, onSubmit, setCurrentStep, handleCloseAction], + ) + const handleBackAction = useCallback(() => { + if (onClickBack) { + onClickBack() + } + const prev = stepQueue.current.pop() ?? firstStep + setCurrentStep(prev) + }, [firstStep, stepQueue, onClickBack, setCurrentStep]) + + const isRequestProcessing = responseMessage && responseMessage.status === 'processing' + + const { wrapper, actionArea, buttonArea, message } = dialogContentInner() + + const actionText = + activeStep === stepLength + ? submitLabel + : decorators?.nextButtonLabel?.(NEXT_BUTTON_LABEL) || NEXT_BUTTON_LABEL + + return ( + // eslint-disable-next-line smarthr/a11y-heading-in-sectioning-content +
    +
    + {/* eslint-disable-next-line smarthr/best-practice-for-layouts */} + + + + {children} + + + + {activeStep > 1 && ( + + )} + + + + + + {(responseMessage?.status === 'success' || responseMessage?.status === 'error') && ( +
    + + {responseMessage.text} + +
    + )} +
    +
    +
    +
    + ) +} diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogItem.tsx b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogItem.tsx new file mode 100644 index 0000000000..1346247669 --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogItem.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren, useContext } from 'react' + +import { StepFormDialogContext, type StepItem } from './StepFormDialogProvider' + +type Props = PropsWithChildren + +export const StepFormDialogItem: React.FC = ({ children, id }) => { + const { currentStep } = useContext(StepFormDialogContext) + + if (currentStep.id !== id) return null + + return children +} diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogProvider.tsx b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogProvider.tsx new file mode 100644 index 0000000000..03ccaaea74 --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/StepFormDialogProvider.tsx @@ -0,0 +1,41 @@ +import React, { ReactNode, createContext, useRef, useState } from 'react' + +export type StepItem = { + /** StepのID */ + id: string + /** 何ステップ目か */ + stepNumber: number +} + +type StepFormDialogContextType = { + stepQueue: React.MutableRefObject + currentStep: StepItem +} +export const StepFormDialogContext = createContext({ + stepQueue: { current: [] }, + currentStep: { id: '', stepNumber: 0 }, +}) + +type StepFormDialogActionType = { + setCurrentStep: (step: StepItem) => void +} +export const StepFormDialogActionContext = createContext({ + setCurrentStep: () => {}, +}) + +type Props = { + children: ReactNode + firstStep: StepItem +} +export const StepFormDialogProvider: React.FC = ({ children, firstStep }) => { + const [currentStep, setCurrentStep] = useState(firstStep) + const stepQueue = useRef([]) + + return ( + + + {children} + + + ) +} diff --git a/packages/smarthr-ui/src/components/Dialog/StepFormDialog/index.ts b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/index.ts new file mode 100644 index 0000000000..1035b1ec7d --- /dev/null +++ b/packages/smarthr-ui/src/components/Dialog/StepFormDialog/index.ts @@ -0,0 +1,2 @@ +export { StepFormDialog } from './StepFormDialog' +export { StepFormDialogItem } from './StepFormDialogItem' diff --git a/packages/smarthr-ui/src/components/Dialog/index.ts b/packages/smarthr-ui/src/components/Dialog/index.ts index 71b9717d0b..cc2054139b 100644 --- a/packages/smarthr-ui/src/components/Dialog/index.ts +++ b/packages/smarthr-ui/src/components/Dialog/index.ts @@ -3,6 +3,7 @@ export { MessageDialog, MessageDialogContent } from './MessageDialog' export { ActionDialog, ActionDialogContent } from './ActionDialog' export { ActionDialogWithTrigger } from './ActionDialogWithTrigger' export { FormDialog, FormDialogContent } from './FormDialog' +export { StepFormDialog, StepFormDialogItem } from './StepFormDialog' export { DialogWrapper } from './DialogWrapper' export { DialogTrigger } from './DialogTrigger' export { DialogContent } from './DialogContent' diff --git a/packages/smarthr-ui/src/index.ts b/packages/smarthr-ui/src/index.ts index b3cd1d4834..33c70f5c23 100644 --- a/packages/smarthr-ui/src/index.ts +++ b/packages/smarthr-ui/src/index.ts @@ -38,6 +38,8 @@ export { RemoteTriggerActionDialog, RemoteTriggerFormDialog, RemoteTriggerMessageDialog, + StepFormDialog, + StepFormDialogItem, } from './components/Dialog' export { Pagination } from './components/Pagination' export { RadioButton } from './components/RadioButton'