diff --git a/packages/smarthr-ui/src/components/Fieldset/Fieldset.tsx b/packages/smarthr-ui/src/components/Fieldset/Fieldset.tsx index 04b532e290..e78376f31b 100644 --- a/packages/smarthr-ui/src/components/Fieldset/Fieldset.tsx +++ b/packages/smarthr-ui/src/components/Fieldset/Fieldset.tsx @@ -2,6 +2,6 @@ import React, { ComponentProps } from 'react' import { ActualFormControl } from '../FormControl/FormControl' -export const Fieldset: React.FC, 'as'>> = (props) => ( - -) +export const Fieldset: React.FC< + Omit, 'as' | 'htmlFor' | 'labelId'> +> = (props) => diff --git a/packages/smarthr-ui/src/components/Fieldset/stories/Fieldset.stories.tsx b/packages/smarthr-ui/src/components/Fieldset/stories/Fieldset.stories.tsx index 3a9b9d7a4e..afcb71dc1e 100644 --- a/packages/smarthr-ui/src/components/Fieldset/stories/Fieldset.stories.tsx +++ b/packages/smarthr-ui/src/components/Fieldset/stories/Fieldset.stories.tsx @@ -88,6 +88,18 @@ export const TitleType: StoryObj = { ), } +export const SubActionArea: StoryObj = { + name: 'subActionArea', + args: { + subActionArea: ( + +
サブアクションエリア(start)
+
サブアクションエリア(end)
+
+ ), + }, +} + export const DangerouslyTitleHidden: StoryObj = { name: 'dangerouslyTitleHidden(非推奨)', args: { diff --git a/packages/smarthr-ui/src/components/Fieldset/stories/VRTFieldset.stories.tsx b/packages/smarthr-ui/src/components/Fieldset/stories/VRTFieldset.stories.tsx index 3cf8054706..8b18405192 100644 --- a/packages/smarthr-ui/src/components/Fieldset/stories/VRTFieldset.stories.tsx +++ b/packages/smarthr-ui/src/components/Fieldset/stories/VRTFieldset.stories.tsx @@ -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' @@ -33,6 +33,12 @@ export default { args: { title: 'フィールドセットタイトル', statusLabelProps: { type: 'grey', children: '任意' }, + subActionArea: ( + +
サブアクションエリア(start)
+
サブアクションエリア(end)
+
+ ), helpMessage: 'フィールドセットの補足となるヘルプメッセージを入れます。', exampleMessage: '入力欄に入れる入力例', errorMessages: ['入力されていません', '20文字以上入力してください。'], diff --git a/packages/smarthr-ui/src/components/FormControl/FormControl.tsx b/packages/smarthr-ui/src/components/FormControl/FormControl.tsx index ff869eae6f..4e7b934189 100644 --- a/packages/smarthr-ui/src/components/FormControl/FormControl.tsx +++ b/packages/smarthr-ui/src/components/FormControl/FormControl.tsx @@ -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' @@ -27,6 +28,8 @@ type Props = PropsWithChildren<{ title: ReactNode /** タイトルの見出しのタイプ */ titleType?: TextProps['styleType'] + /** タイトル右の領域 */ + subActionArea?: ReactNode /** タイトルの見出しを視覚的に隠すかどうか */ dangerouslyTitleHidden?: boolean /** label 要素に適用する `htmlFor` 値 */ @@ -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'], }, @@ -102,7 +100,7 @@ const childrenWrapper = tv({ XXL: '[&&&]:shr-mt-3.5', X3L: '[&&&]:shr-mt-4', } as { [key in Gap]: string }, - isRoleGroup: { + isFieldset: { true: '', false: '', }, @@ -110,25 +108,28 @@ const childrenWrapper = tv({ compoundVariants: [ { innerMargin: undefined, - isRoleGroup: true, + isFieldset: true, className: '[:not([hidden])_~_&&&]:shr-mt-1', }, { innerMargin: undefined, - isRoleGroup: false, + isFieldset: false, className: '[:not([hidden])_~_&&&]:shr-mt-0.5', }, ], }) +const SMARTHR_UI_INPUT_SELECTOR = '[data-smarthr-ui-input="true"]' + export const ActualFormControl: React.FC = ({ title, titleType = 'blockTitle', + subActionArea, dangerouslyTitleHidden = false, htmlFor, labelId, innerMargin, - statusLabelProps = [], + statusLabelProps, helpMessage, exampleMessage, errorMessages, @@ -144,8 +145,7 @@ export const ActualFormControl: React.FC = ({ const managedHtmlFor = htmlFor || defaultHtmlFor const managedLabelId = labelId || defaultLabelId const inputWrapperRef = useRef(null) - const isRoleGroup = as === 'fieldset' - const statusLabelList = Array.isArray(statusLabelProps) ? statusLabelProps : [statusLabelProps] + const isFieldset = as === 'fieldset' const describedbyIds = useMemo(() => { const temp = [] @@ -165,6 +165,13 @@ export const ActualFormControl: React.FC = ({ 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 [] @@ -176,60 +183,70 @@ export const ActualFormControl: React.FC = ({ const { wrapperStyle, labelStyle, errorListStyle, errorIconStyle, childrenWrapperStyle } = useMemo(() => { const { wrapper, label, errorList, errorIcon } = formGroup() + return { wrapperStyle: wrapper({ className }), labelStyle: label({ className: dangerouslyTitleHidden ? visuallyHiddenText() : '' }), errorListStyle: errorList(), errorIconStyle: errorIcon(), - childrenWrapperStyle: childrenWrapper({ innerMargin, isRoleGroup }), + childrenWrapperStyle: childrenWrapper({ innerMargin, isFieldset }), } - }, [className, dangerouslyTitleHidden, innerMargin, isRoleGroup]) + }, [className, dangerouslyTitleHidden, innerMargin, isFieldset]) useEffect(() => { - if (isRoleGroup) { + if (isFieldset) { return } 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') - 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]) + }, [managedHtmlFor, isFieldset, 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 @@ -237,17 +254,19 @@ export const ActualFormControl: React.FC = ({ 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]) @@ -257,12 +276,11 @@ export const ActualFormControl: React.FC = ({ {...props} as={as} gap={innerMargin ?? 0.5} - aria-labelledby={isRoleGroup ? managedLabelId : undefined} - aria-describedby={isRoleGroup && describedbyIds ? describedbyIds : undefined} + aria-describedby={isFieldset && describedbyIds ? describedbyIds : undefined} className={wrapperStyle} > = ({ titleType={titleType} title={title} statusLabelList={statusLabelList} + subActionArea={subActionArea} /> @@ -291,17 +310,17 @@ export const ActualFormControl: React.FC = ({ } const TitleCluster = React.memo< - Pick & { + Pick & { titleType: TextProps['styleType'] statusLabelList: StatusLabelProps[] - isRoleGroup: boolean + isFieldset: boolean managedHtmlFor: string managedLabelId: string labelStyle: string } >( ({ - isRoleGroup, + isFieldset, managedHtmlFor, managedLabelId, labelStyle, @@ -309,28 +328,47 @@ const TitleCluster = React.memo< titleType, title, statusLabelList, - }) => ( -