Skip to content

Commit f66b70d

Browse files
authoredAug 5, 2024··
feat: Stepper を追加 (#4817)
* feat: Stepper を追加 * docs: Story を整理 * fix: export を整理 * fix: HorizontalStepper の設計を見直し * fix: スタイリングを調整 * fix: 装飾用の内部的な props が表出していたので修正 * fix: 不要な型の修正漏れ * chore: Story の分類を修正
1 parent 40c09bf commit f66b70d

10 files changed

+535
-0
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React, { type FC } from 'react'
2+
import { tv } from 'tailwind-variants'
3+
4+
import { Heading } from '../Heading'
5+
import { SectioningFragment } from '../SectioningContent/SectioningContent'
6+
7+
import { StepCounter } from './StepCounter'
8+
9+
import type { HorizontalStep } from './types'
10+
11+
const horizontallStepItem = tv({
12+
slots: {
13+
wrapper: [
14+
'shr-group/stepItem',
15+
// 長いステップ名が来ても等幅にする
16+
'shr-flex-1',
17+
],
18+
headingWrapper: 'shr-flex shr-flex-col shr-items-center shr-gap-0.5',
19+
stepCounterWrapper: 'shr-self-stretch shr-flex shr-items-center',
20+
beforeLine: [
21+
'group-first/stepItem:shr-bg-transparent',
22+
'shr-grow shr-h-[theme(borderWidth.2)] shr-bg-border',
23+
'forced-colors:shr-bg-[ButtonBorder]',
24+
],
25+
afterLine: [
26+
'group-last/stepItem:shr-bg-transparent',
27+
// compoundSlots で書けるが、variants の上書きが複雑になるため切り出していない
28+
'shr-grow shr-h-[theme(borderWidth.2)] shr-bg-border',
29+
'forced-colors:shr-bg-[ButtonBorder]',
30+
],
31+
heading: 'shr-px-0.25 shr-text-sm shr-text-center',
32+
},
33+
variants: {
34+
status: {
35+
completed: {
36+
afterLine: ['shr-bg-main', 'forced-colors:shr-bg-[Highlight]'],
37+
},
38+
closed: {},
39+
},
40+
current: {
41+
true: {
42+
heading: 'shr-font-bold',
43+
},
44+
false: {},
45+
},
46+
isPrevStepCompleted: {
47+
true: {
48+
beforeLine: ['shr-bg-main', 'forced-colors:shr-bg-[Highlight]'],
49+
},
50+
false: {},
51+
},
52+
},
53+
compoundVariants: [
54+
{
55+
status: ['completed', 'closed'],
56+
current: false,
57+
className: {
58+
heading: 'shr-text-grey',
59+
},
60+
},
61+
],
62+
})
63+
64+
type Props = HorizontalStep & {
65+
/** ステップ数 */
66+
stepNumber?: number
67+
/** 現在地かどうか */
68+
current: boolean
69+
/** 前のステップが完了しているかどうか */
70+
isPrevStepCompleted?: boolean
71+
}
72+
73+
export const HorizontalStepItem: FC<Props> = ({
74+
stepNumber,
75+
label,
76+
status,
77+
current,
78+
isPrevStepCompleted,
79+
}) => {
80+
const statusType = typeof status === 'object' ? status.type : status
81+
const { wrapper, headingWrapper, stepCounterWrapper, beforeLine, afterLine, heading } =
82+
horizontallStepItem({
83+
status: statusType,
84+
current,
85+
isPrevStepCompleted,
86+
})
87+
88+
return (
89+
<li aria-current={current} className={wrapper()}>
90+
<SectioningFragment>
91+
<div className={headingWrapper()}>
92+
<div className={stepCounterWrapper()}>
93+
<span className={beforeLine()} />
94+
<StepCounter status={status} current={current} stepNumber={stepNumber} />
95+
<span className={afterLine()} />
96+
</div>
97+
<Heading type="sectionTitle" className={heading()}>
98+
{label}
99+
</Heading>
100+
</div>
101+
</SectioningFragment>
102+
</li>
103+
)
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { type FC } from 'react'
2+
import { tv } from 'tailwind-variants'
3+
4+
import { StepStatusIcon } from './StepStatusIcon'
5+
6+
import type { Step } from './types'
7+
8+
const style = tv({
9+
slots: {
10+
// StatusIcon の位置基準となる wrapper
11+
wrapper: 'shr-relative',
12+
counter:
13+
'shr-inline-flex shr-items-center shr-justify-center shr-rounded-full shr-border-shorthand shr-bg-white shr-tabular-nums shr-w-[2em] shr-h-[2em]',
14+
statusIcon: 'shr-absolute -shr-top-0.25 shr-left-1.5',
15+
},
16+
variants: {
17+
status: {
18+
completed: { counter: 'shr-border-main' },
19+
closed: { counter: 'shr-border-grey' },
20+
},
21+
current: {
22+
true: {
23+
counter: [
24+
'shr-bg-main shr-border-main shr-text-white shr-font-bold',
25+
'forced-colors:shr-bg-[Mark] forced-colors:shr-border-[Mark]',
26+
],
27+
},
28+
false: {},
29+
},
30+
},
31+
})
32+
33+
type Props = Pick<Step, 'status'> & {
34+
stepNumber?: number
35+
current: boolean
36+
}
37+
38+
export const StepCounter: FC<Props> = ({ status, current, stepNumber }) => {
39+
const statusType = typeof status === 'object' ? status.type : status
40+
const { wrapper, counter, statusIcon } = style({ status: statusType, current })
41+
return (
42+
<span className={wrapper()}>
43+
<span className={counter()} aria-hidden>
44+
{stepNumber}
45+
</span>
46+
<StepStatusIcon status={status} className={statusIcon()} />
47+
</span>
48+
)
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, { useMemo } from 'react'
2+
import { tv } from 'tailwind-variants'
3+
4+
import { FaCircleCheckIcon, FaCircleXmarkIcon } from '../Icon'
5+
6+
import { Step } from './types'
7+
8+
import type { ComponentProps, FC } from 'react'
9+
10+
const stepStatusIcon = tv({
11+
base: [
12+
'shr-bg-white shr-rounded-full shr-shadow-[0_0_0_theme(borderWidth.2)_theme(colors.white)]',
13+
'forced-colors:shr-fill-[Canvas] forced-colors:shr-bg-[CanvasText] forced-colors:shr-shadow-[0_0_0_theme(borderWidth.2)_Canvas]',
14+
],
15+
variants: {
16+
status: {
17+
completed: [
18+
'shr-text-main',
19+
'forced-colors:shr-fill-[Highlight] forced-colors:shr-bg-[Canvas]',
20+
],
21+
closed: ['shr-text-grey', 'forced-colors:shr-fill-[GrayText] forced-colors:shr-bg-[Canvas]'],
22+
},
23+
},
24+
})
25+
26+
export const StepStatusIcon: FC<
27+
ComponentProps<typeof FaCircleCheckIcon> & { status?: Step['status'] }
28+
> = ({ status, className, ...rest }) => {
29+
const [statusType, statusText] =
30+
typeof status === 'object' ? [status.type, status.text] : [status]
31+
const icon = useMemo(() => {
32+
switch (statusType) {
33+
case 'completed':
34+
return { Icon: FaCircleCheckIcon, alt: '完了' }
35+
case 'closed':
36+
return { Icon: FaCircleXmarkIcon, alt: '中断' }
37+
default:
38+
return null
39+
}
40+
}, [statusType])
41+
42+
if (!icon) return
43+
44+
const { Icon, alt } = icon
45+
const style = stepStatusIcon({ status: statusType, className })
46+
47+
return <Icon {...rest} alt={statusText || alt} className={style} />
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { StoryFn } from '@storybook/react'
2+
import React from 'react'
3+
4+
import { Base, BaseColumn } from '../Base'
5+
import { DefinitionList } from '../DefinitionList'
6+
import { FormControl } from '../FormControl'
7+
import { Heading } from '../Heading'
8+
import { Input } from '../Input'
9+
import { Center, Stack } from '../Layout'
10+
import { ResponseMessage } from '../ResponseMessage'
11+
12+
import { HorizontalStep, VerticalStep } from './types'
13+
14+
import { Stepper } from '.'
15+
16+
export default {
17+
title: 'Data Display(データ表示)/Stepper',
18+
component: Stepper,
19+
}
20+
21+
const hSteps: HorizontalStep[] = [
22+
{
23+
label: 'カジュアル面談',
24+
status: 'completed',
25+
},
26+
{
27+
label: '書類選考',
28+
status: 'completed',
29+
},
30+
{
31+
label: '一次面接',
32+
status: {
33+
type: 'completed',
34+
text: '任意のステータスを入れられます',
35+
},
36+
},
37+
{
38+
label: '二次面接',
39+
status: 'closed',
40+
},
41+
{
42+
label: '最終面接',
43+
},
44+
{
45+
label: 'リファレンスチェック',
46+
},
47+
{
48+
label: 'オファー面談',
49+
},
50+
]
51+
const vSteps: VerticalStep[] = [
52+
{
53+
label: '提出',
54+
status: 'completed',
55+
children: <DefinitionList items={[{ term: '申請者', description: '須磨 栄子' }]} />,
56+
},
57+
{
58+
label: '承認ステップ1',
59+
status: 'completed',
60+
children: (
61+
<Stack>
62+
<DefinitionList items={[{ term: '承認条件', description: '営業部に所属する1名が承認' }]} />
63+
<Base overflow="hidden">
64+
<BaseColumn>
65+
<p>
66+
<ResponseMessage type="info">承認済みのアカウントはありません。</ResponseMessage>
67+
</p>
68+
</BaseColumn>
69+
</Base>
70+
</Stack>
71+
),
72+
},
73+
{
74+
label: '承認ステップ2',
75+
status: 'closed',
76+
children: (
77+
<Stack>
78+
<DefinitionList items={[{ term: '承認条件', description: '営業部に所属する1名が承認' }]} />
79+
<Base overflow="hidden">
80+
<BaseColumn>
81+
<p>
82+
<ResponseMessage type="info">承認済みのアカウントはありません。</ResponseMessage>
83+
</p>
84+
</BaseColumn>
85+
</Base>
86+
</Stack>
87+
),
88+
},
89+
{
90+
label: '完了',
91+
children: (
92+
<DefinitionList
93+
items={[
94+
{
95+
term: '承認設定',
96+
description: '承認設定なし。申請者がフォームを送信した時点で情報が反映されます。',
97+
},
98+
]}
99+
/>
100+
),
101+
},
102+
]
103+
104+
export const _Default: StoryFn = () => {
105+
const [activeIndex, setActiveIndex] = React.useState(0)
106+
return (
107+
<Stack gap={1.25}>
108+
<FormControl title="現在地(0始まり)">
109+
<Input
110+
type="number"
111+
name="activeIndex"
112+
value={activeIndex}
113+
onChange={({ target: { value } }) => setActiveIndex(Number(value))}
114+
/>
115+
</FormControl>
116+
<Stack gap={0.5} align="flex-start" as="section">
117+
<Heading type="blockTitle">横型</Heading>
118+
<Center>
119+
<Stepper type="horizontal" activeIndex={activeIndex} steps={hSteps} />
120+
</Center>
121+
</Stack>
122+
<Stack gap={0.5} as="section">
123+
<Heading type="blockTitle">縦型</Heading>
124+
<Stepper type="vertical" activeIndex={activeIndex} steps={vSteps} />
125+
</Stack>
126+
</Stack>
127+
)
128+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { useMemo } from 'react'
2+
import { tv } from 'tailwind-variants'
3+
4+
import { HorizontalStepItem } from './HorizontalStepItem'
5+
import { VerticalStepItem } from './VerticalStepItem'
6+
7+
import type {
8+
HorizontalStepper as HStepperProps,
9+
Step,
10+
VerticalStepper as VStepperProps,
11+
} from './types'
12+
import type { FC } from 'react'
13+
14+
type Props = HStepperProps | VStepperProps
15+
16+
const stepper = tv({
17+
base: ['smarthr-ui-Stepper', 'shr-list-none shr-my-0 shr-ps-0'],
18+
variants: {
19+
type: {
20+
// ステップ見出しの左右パディングをネガティブマージンで消し込んでいてる
21+
horizontal: 'shr-flex -shr-mx-0.75',
22+
vertical: '',
23+
},
24+
},
25+
})
26+
27+
const isStepCompleted = (step: Step | undefined) => {
28+
if (!step) return false
29+
30+
const { status } = step
31+
const statusType = typeof status === 'object' ? status.type : status
32+
return statusType === 'completed'
33+
}
34+
35+
export const Stepper: FC<Props> = ({ type, steps, activeIndex, className, ...rest }) => {
36+
const ActualStepItem = useMemo(() => {
37+
switch (type) {
38+
case 'horizontal':
39+
return HorizontalStepItem
40+
case 'vertical':
41+
return VerticalStepItem
42+
}
43+
}, [type])
44+
const style = stepper({ type, className })
45+
46+
return (
47+
<ol {...rest} className={style}>
48+
{steps.map((step, id) => {
49+
const stepItemProps = {
50+
...step,
51+
stepNumber: id + 1,
52+
current: id === activeIndex,
53+
...(type === 'horizontal'
54+
? // 装飾上、前のステップが完了しているかどうかが必要
55+
{ isPrevStepCompleted: isStepCompleted(steps[id - 1]) }
56+
: {}),
57+
}
58+
return <ActualStepItem {...stepItemProps} key={id} />
59+
})}
60+
</ol>
61+
)
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { StoryFn } from '@storybook/react'
2+
import React from 'react'
3+
4+
import { InformationPanel } from '../InformationPanel'
5+
import { Stack } from '../Layout'
6+
7+
import { _Default } from './Stepper.stories'
8+
9+
import { Stepper } from '.'
10+
11+
export default {
12+
title: 'Data Display(データ表示)/Stepper',
13+
component: Stepper,
14+
}
15+
16+
export const _VRTStepperForcedColors: StoryFn = () => (
17+
<Stack gap={1.5}>
18+
<InformationPanel title="VRT 用の Story です">
19+
Chromatic 上では強制カラーモードで表示されます
20+
</InformationPanel>
21+
<_Default />
22+
</Stack>
23+
)
24+
_VRTStepperForcedColors.parameters = {
25+
chromatic: { forcedColors: 'active' },
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React, { type FC } from 'react'
2+
import { tv } from 'tailwind-variants'
3+
4+
import { Heading } from '../Heading'
5+
import { SectioningFragment } from '../SectioningContent/SectioningContent'
6+
7+
import { StepCounter } from './StepCounter'
8+
9+
import type { VerticalStep } from './types'
10+
11+
const verticalStepItem = tv({
12+
slots: {
13+
wrapper: 'shr-group/stepItem',
14+
headingWrapper: 'shr-flex shr-items-center shr-gap-1',
15+
heading: 'shr-inline-block',
16+
body: [
17+
// body > (:before + inner) という構造
18+
'shr-flex',
19+
// body の before 疑似要素がステップを繋ぐ線
20+
'before:shr-block before:shr-content-[""] before:shr-relative before:shr-mx-1 before:shr-bg-border before:shr-w-[theme(borderWidth.2)]',
21+
// 最後のステップの線を消す
22+
'group-last/stepItem:before:shr-bg-transparent',
23+
'forced-colors:before:shr-bg-[ButtonBorder]',
24+
],
25+
inner: 'shr-grow shr-ms-1 shr-pt-0.5 shr-pb-1.5',
26+
},
27+
variants: {
28+
status: {
29+
completed: {
30+
body: ['before:shr-bg-main', 'forced-colors:before:shr-bg-[Highlight]'],
31+
},
32+
closed: {},
33+
},
34+
current: {
35+
true: {
36+
heading: 'shr-font-bold',
37+
},
38+
false: {},
39+
},
40+
},
41+
compoundVariants: [
42+
{
43+
status: ['completed', 'closed'],
44+
current: false,
45+
className: {
46+
heading: 'shr-text-grey',
47+
},
48+
},
49+
],
50+
})
51+
52+
type Props = VerticalStep & {
53+
/** ステップ数 */
54+
stepNumber?: number
55+
/** 現在地かどうか */
56+
current: boolean
57+
}
58+
59+
export const VerticalStepItem: FC<Props> = ({ stepNumber, label, status, children, current }) => {
60+
const statusType = typeof status === 'object' ? status.type : status
61+
const { wrapper, headingWrapper, heading, body, inner } = verticalStepItem({
62+
status: statusType,
63+
current,
64+
})
65+
66+
return (
67+
<li aria-current={current} className={wrapper()}>
68+
<SectioningFragment>
69+
<div className={headingWrapper()}>
70+
<StepCounter status={status} current={current} stepNumber={stepNumber} />
71+
<Heading type="sectionTitle" className={heading()}>
72+
{label}
73+
</Heading>
74+
</div>
75+
<div className={body()}>
76+
<div className={inner()}>{children}</div>
77+
</div>
78+
</SectioningFragment>
79+
</li>
80+
)
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Stepper } from './Stepper'
2+
export { HorizontalStep, VerticalStep } from './types'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ComponentPropsWithoutRef, PropsWithChildren, ReactNode } from 'react'
2+
3+
type PropsWithBase<P> = P & {
4+
/** 現在地。0始まり。 */
5+
activeIndex?: number
6+
} & Omit<ComponentPropsWithoutRef<'ol'>, keyof P>
7+
8+
export type Step = {
9+
/** ステップラベル */
10+
label: ReactNode
11+
/** 状態 */
12+
status?:
13+
| 'completed'
14+
| 'closed'
15+
| {
16+
type: 'completed' | 'closed'
17+
text: string
18+
}
19+
}
20+
21+
export type VerticalStep = PropsWithChildren<Step>
22+
23+
export type HorizontalStep = Step
24+
25+
export type VerticalStepper = PropsWithBase<{
26+
type: 'vertical'
27+
/** type=vertical では子要素を持てる */
28+
steps: VerticalStep[]
29+
}>
30+
31+
export type HorizontalStepper = PropsWithBase<{
32+
type: 'horizontal'
33+
steps: HorizontalStep[]
34+
}>

‎packages/smarthr-ui/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export * from './components/SpreadsheetTable'
107107
export * from './components/ResponseMessage'
108108
export * from './components/Badge'
109109
export * from './components/Switch'
110+
export * from './components/Stepper'
110111

111112
// layout components
112113
export { Center, Cluster, Reel, Stack, Sidebar } from './components/Layout'

0 commit comments

Comments
 (0)
Please sign in to comment.