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!: FormControl,Fieldsetのtitleにアイコンやボタンを含めずマークアップできるよう属性を追加 #5235

Merged
merged 9 commits into from
Jan 9, 2025
Merged
6 changes: 3 additions & 3 deletions packages/smarthr-ui/src/components/Fieldset/Fieldset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import React, { ComponentProps } from 'react'

import { ActualFormControl } from '../FormControl/FormControl'

export const Fieldset: React.FC<Omit<ComponentProps<typeof ActualFormControl>, 'as'>> = (props) => (
<ActualFormControl {...props} as="fieldset" />
)
export const Fieldset: React.FC<
Omit<ComponentProps<typeof ActualFormControl>, 'as' | 'htmlFor' | 'labelId'>
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
> = (props) => <ActualFormControl {...props} as="fieldset" />
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Input } from '../../Input'
import { Cluster, Stack } from '../../Layout'
import { RadioButton } from '../../RadioButton'
import { STYLE_TYPE_MAP } from '../../Text'
import { TextLink } from '../../TextLink'
import { Fieldset } from '../Fieldset'

import type { Meta, StoryObj } from '@storybook/react'
Expand Down Expand Up @@ -88,6 +89,17 @@ export const TitleType: StoryObj<typeof Fieldset> = {
),
}

export const SubActionArea: StoryObj<typeof Fieldset> = {
name: 'subActionArea',
args: {
subActionArea: (
<Cluster justify="end">
<TextLink href="https://example.com/">リンク</TextLink>
</Cluster>
),
},
}

export const DangerouslyTitleHidden: StoryObj<typeof Fieldset> = {
name: 'dangerouslyTitleHidden(非推奨)',
args: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'

import { Stack } from '../../Layout'
import { Cluster, Stack } from '../../Layout'
import { Fieldset } from '../Fieldset'

import { _childrenOptions } from './Fieldset.stories'
Expand Down Expand Up @@ -33,6 +33,12 @@ export default {
args: {
title: 'フィールドセットタイトル',
statusLabelProps: { type: 'grey', children: '任意' },
subActionArea: (
<Cluster justify="space-between">
<div>ABC</div>
<div>DEF</div>
</Cluster>
),
helpMessage: 'フィールドセットの補足となるヘルプメッセージを入れます。',
exampleMessage: '入力欄に入れる入力例',
errorMessages: ['入力されていません', '20文字以上入力してください。'],
Expand Down
180 changes: 107 additions & 73 deletions packages/smarthr-ui/src/components/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import React, {
useRef,
} from 'react'
import { useId } from 'react'
import innerText from 'react-innertext'
import { tv } from 'tailwind-variants'

import { FaCircleExclamationIcon } from '../Icon'
import { Cluster, Stack } from '../Layout'
import { StatusLabel } from '../StatusLabel'
import { Text, TextProps } from '../Text'
import { visuallyHiddenText } from '../VisuallyHiddenText/VisuallyHiddenText'
import { VisuallyHiddenText, visuallyHiddenText } from '../VisuallyHiddenText'

import type { Gap } from '../../types'

Expand All @@ -27,6 +28,8 @@ type Props = PropsWithChildren<{
title: ReactNode
/** タイトルの見出しのタイプ */
titleType?: TextProps['styleType']
/** タイトル右の領域 */
subActionArea?: ReactNode
/** タイトルの見出しを視覚的に隠すかどうか */
dangerouslyTitleHidden?: boolean
/** label 要素に適用する `htmlFor` 値 */
Expand Down Expand Up @@ -65,12 +68,7 @@ const formGroup = tv({
'[&:disabled_.smarthr-ui-FormControl-supplementaryMessage]:shr-text-color-inherit',
'[&:disabled_.smarthr-ui-Input]:shr-border-default/50 [&:disabled_.smarthr-ui-Input]:shr-bg-white-darken',
],
label: [
'smarthr-ui-FormControl-label',
// flex-item が stretch してクリッカブル領域が広がりすぎないようにする
'shr-self-start',
'shr-px-[unset]',
],
label: ['smarthr-ui-FormControl-label'],
errorList: ['shr-list-none'],
errorIcon: ['smarthr-ui-FormControl-errorMessage', 'shr-text-danger'],
},
Expand Down Expand Up @@ -121,14 +119,17 @@ const childrenWrapper = tv({
],
})

const SMARTHR_UI_INPUT_SELECTOR = '[data-smarthr-ui-input="true"]'

export const ActualFormControl: React.FC<Props & ElementProps> = ({
title,
titleType = 'blockTitle',
subActionArea,
dangerouslyTitleHidden = false,
htmlFor,
labelId,
innerMargin,
statusLabelProps = [],
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
statusLabelProps,
helpMessage,
exampleMessage,
errorMessages,
Expand All @@ -145,7 +146,6 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({
const managedLabelId = labelId || defaultLabelId
const inputWrapperRef = useRef<HTMLDivElement>(null)
const isRoleGroup = as === 'fieldset'
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
const statusLabelList = Array.isArray(statusLabelProps) ? statusLabelProps : [statusLabelProps]

const describedbyIds = useMemo(() => {
const temp = []
Expand All @@ -165,6 +165,13 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({

return temp.join(' ')
}, [helpMessage, exampleMessage, supplementaryMessage, errorMessages, managedHtmlFor])
const statusLabelList = useMemo(() => {
if (!statusLabelProps) {
return []
}

return Array.isArray(statusLabelProps) ? statusLabelProps : [statusLabelProps]
}, [statusLabelProps])
const actualErrorMessages = useMemo(() => {
if (!errorMessages) {
return []
Expand All @@ -176,6 +183,7 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({
const { wrapperStyle, labelStyle, errorListStyle, errorIconStyle, childrenWrapperStyle } =
useMemo(() => {
const { wrapper, label, errorList, errorIcon } = formGroup()

return {
wrapperStyle: wrapper({ className }),
labelStyle: label({ className: dangerouslyTitleHidden ? visuallyHiddenText() : '' }),
Expand All @@ -192,62 +200,73 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({

const inputWrapper = inputWrapperRef?.current

if (inputWrapper) {
// HINT: 対象idを持つ要素が既に存在する場合、何もしない
if (document.getElementById(managedHtmlFor)) {
return
}
if (!inputWrapper) {
return
}

const input = inputWrapper.querySelector('[data-smarthr-ui-input="true"]')
// HINT: 対象idを持つ要素が既に存在する場合、何もしない
if (document.getElementById(managedHtmlFor)) {
return
}

if (input) {
if (!input.getAttribute('id')) {
input.setAttribute('id', managedHtmlFor)
}
const input = inputWrapper.querySelector(SMARTHR_UI_INPUT_SELECTOR)

const isInputFile = input instanceof HTMLInputElement && input.type === 'file'
const inputLabelledByIds = input.getAttribute('aria-labelledby')
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
if (isInputFile && inputLabelledByIds) {
// InputFileの場合はlabel要素の可視ラベルをアクセシブルネームに含める
input.setAttribute('aria-labelledby', `${inputLabelledByIds} ${managedLabelId}`)
}
if (!input) {
return
}

if (!input.getAttribute('id')) {
input.setAttribute('id', managedHtmlFor)
}

if (input instanceof HTMLInputElement && input.type === 'file') {
const attrName = 'aria-labelledby'
const inputLabelledByIds = input.getAttribute(attrName)

if (inputLabelledByIds) {
// InputFileの場合はlabel要素の可視ラベルをアクセシブルネームに含める
input.setAttribute(attrName, `${inputLabelledByIds} ${managedLabelId}`)
}
}
}, [managedHtmlFor, isRoleGroup, managedLabelId])
useEffect(() => {
if (!describedbyIds) {
return
}

const attrName = 'aria-describedby'
const inputWrapper = inputWrapperRef?.current

if (inputWrapper) {
// HINT: 対象idを持つ要素が既に存在する場合、何もしない
if (!describedbyIds || inputWrapper.querySelector(`[aria-describedby="${describedbyIds}"]`)) {
return
}
if (!inputWrapper || inputWrapper.querySelector(`[${attrName}="${describedbyIds}"]`)) {
return
}

const input = inputWrapper.querySelector('[data-smarthr-ui-input="true"]')
const input = inputWrapper.querySelector(SMARTHR_UI_INPUT_SELECTOR)

if (input && !input.getAttribute('aria-describedby')) {
input.setAttribute('aria-describedby', describedbyIds)
}
if (input && !input.getAttribute(attrName)) {
input.setAttribute(attrName, describedbyIds)
}
}, [describedbyIds, isRoleGroup])
}, [describedbyIds])
useEffect(() => {
if (!autoBindErrorInput) {
return
}

const inputWrapper = inputWrapperRef?.current

if (inputWrapper) {
const input = inputWrapper.querySelector('[data-smarthr-ui-input="true"]')
if (!inputWrapper) {
return
}

if (!input) {
return
}
const input = inputWrapper.querySelector(SMARTHR_UI_INPUT_SELECTOR)

if (input) {
const attrName = 'aria-invalid'

if (actualErrorMessages.length > 0) {
input.setAttribute('aria-invalid', 'true')
input.setAttribute(attrName, 'true')
} else {
input.removeAttribute('aria-invalid')
input.removeAttribute(attrName)
}
}
}, [actualErrorMessages.length, autoBindErrorInput])
Expand All @@ -257,7 +276,6 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({
{...props}
as={as}
gap={innerMargin ?? 0.5}
aria-labelledby={isRoleGroup ? managedLabelId : undefined}
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
aria-describedby={isRoleGroup && describedbyIds ? describedbyIds : undefined}
className={wrapperStyle}
>
Expand All @@ -270,6 +288,7 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({
titleType={titleType}
title={title}
statusLabelList={statusLabelList}
subActionArea={subActionArea}
/>
<HelpMessageParagraph helpMessage={helpMessage} managedHtmlFor={managedHtmlFor} />
<ExampleMessageText exampleMessage={exampleMessage} managedHtmlFor={managedHtmlFor} />
Expand All @@ -291,7 +310,7 @@ export const ActualFormControl: React.FC<Props & ElementProps> = ({
}

const TitleCluster = React.memo<
Pick<Props, 'dangerouslyTitleHidden' | 'title'> & {
Pick<Props, 'dangerouslyTitleHidden' | 'title' | 'subActionArea'> & {
titleType: TextProps['styleType']
statusLabelList: StatusLabelProps[]
isRoleGroup: boolean
Expand All @@ -309,28 +328,47 @@ const TitleCluster = React.memo<
titleType,
title,
statusLabelList,
}) => (
<Cluster
align="center"
htmlFor={!isRoleGroup ? managedHtmlFor : undefined}
id={managedLabelId}
className={labelStyle}
as={isRoleGroup ? 'legend' : 'label'}
// Stack 対象にしないための hidden
hidden={dangerouslyTitleHidden || undefined}
>
<Text as="span" styleType={titleType}>
{title}
</Text>
{statusLabelList.length > 0 && (
<Cluster gap={0.25} as="span">
{statusLabelList.map((prop, index) => (
<StatusLabel {...prop} key={index} />
))}
subActionArea,
}) => {
const body = (
<>
<Text styleType={titleType}>{title}</Text>
{statusLabelList.length > 0 && (
<Cluster gap={0.25} as="span">
{statusLabelList.map((prop, index) => (
<StatusLabel {...prop} key={index} />
))}
</Cluster>
)}
</>
)
const labelAttrs = isRoleGroup
? { 'aria-hidden': 'true' }
: {
htmlFor: managedHtmlFor,
id: managedLabelId,
as: 'label' as React.ComponentProps<typeof Cluster>['as'],
}

return (
<>
{isRoleGroup && <VisuallyHiddenText as="legend">{innerText(body)}</VisuallyHiddenText>}
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
uknmr marked this conversation as resolved.
Show resolved Hide resolved
<Cluster
justify="space-between"
// HINT: legendが存在する場合、Stackの余白が狂ってしまう&常にこのClusterはUI上先頭になるため、margin-topを0固定する
className="[&&&]:shr-mt-0"
// HINT: dangerouslyTitleHiddenの場合、Stackの余白計算を正常にするためのhidden
hidden={dangerouslyTitleHidden || undefined}
>
{/* eslint-disable-next-line smarthr/best-practice-for-layouts */}
<Cluster {...labelAttrs} align="center" className={labelStyle}>
{body}
</Cluster>
{subActionArea && <div className="shr-grow">{subActionArea}</div>}
</Cluster>
)}
</Cluster>
),
</>
)
},
)

const HelpMessageParagraph = React.memo<Pick<Props, 'helpMessage'> & { managedHtmlFor: string }>(
Expand Down Expand Up @@ -362,21 +400,17 @@ const ErrorMessageList = React.memo<{
managedHtmlFor: string
errorListStyle: string
errorIconStyle: string
}>(({ errorMessages, managedHtmlFor, errorListStyle, errorIconStyle }) => {
if (errorMessages.length === 0) {
return null
}

return (
}>(({ errorMessages, managedHtmlFor, errorListStyle, errorIconStyle }) =>
errorMessages.length > 0 ? (
AtsushiM marked this conversation as resolved.
Show resolved Hide resolved
<div id={`${managedHtmlFor}_errorMessages`} className={errorListStyle} role="alert">
{errorMessages.map((message, index) => (
<p key={index}>
<FaCircleExclamationIcon text={message} className={errorIconStyle} />
</p>
))}
</div>
)
})
) : null,
)

const SupplementaryMessageText = React.memo<
Pick<Props, 'supplementaryMessage'> & { managedHtmlFor: string }
Expand Down
Loading
Loading