diff --git a/packages/smarthr-ui/src/components/Dialog/FormDialog/FormDialogContentInner.tsx b/packages/smarthr-ui/src/components/Dialog/FormDialog/FormDialogContentInner.tsx index ac2ba669b3..4e901692b3 100644 --- a/packages/smarthr-ui/src/components/Dialog/FormDialog/FormDialogContentInner.tsx +++ b/packages/smarthr-ui/src/components/Dialog/FormDialog/FormDialogContentInner.tsx @@ -5,6 +5,7 @@ import React, { type ReactNode, useCallback, } from 'react' +import { tv } from 'tailwind-variants' import { Button } from '../../Button' import { Cluster, Stack } from '../../Layout' @@ -46,6 +47,17 @@ export type FormDialogContentInnerProps = BaseProps & { const CLOSE_BUTTON_LABEL = 'キャンセル' +const formDialogContentInner = tv({ + extend: dialogContentInner, + slots: { + // 領域を狭くしたときにwrapperも縮むようにflexを使用 + wrapper: 'shr-flex shr-flex-col', + // 領域を狭くしたときにwrapperも縮むようにflexを使用 + form: 'shr-overflow-y-auto shr-flex-auto shr-flex shr-flex-col', + contentWrapper: 'shr-overflow-y-auto shr-flex-auto', + }, +}) + export const FormDialogContentInner: FC = ({ children, title, @@ -76,48 +88,49 @@ export const FormDialogContentInner: FC = ({ ) const isRequestProcessing = responseMessage && responseMessage.status === 'processing' - const { wrapper, actionArea, buttonArea, message } = dialogContentInner() + const { form, contentWrapper, wrapper, actionArea, buttonArea, message } = + formDialogContentInner() return ( // eslint-disable-next-line smarthr/a11y-heading-in-sectioning-content, smarthr/a11y-prohibit-sectioning-content-in-form -
+
-
-
+ +
{children} - - - {subActionArea} - - - - - - {(responseMessage?.status === 'success' || responseMessage?.status === 'error') && ( -
- - {responseMessage.text} - -
- )} -
+ + + {subActionArea} + + + + + + {(responseMessage?.status === 'success' || responseMessage?.status === 'error') && ( +
+ + {responseMessage.text} + +
+ )} +
) diff --git a/packages/smarthr-ui/src/components/Dialog/ModelessDialog.tsx b/packages/smarthr-ui/src/components/Dialog/ModelessDialog.tsx index 7dfb88de7a..accdb8c481 100644 --- a/packages/smarthr-ui/src/components/Dialog/ModelessDialog.tsx +++ b/packages/smarthr-ui/src/components/Dialog/ModelessDialog.tsx @@ -127,7 +127,6 @@ export const ModelessDialog: FC { const labelId = useId() + const lastFocusElementRef = useRef(null) const { createPortal } = useDialogPortal(portalParent, id) const { @@ -310,14 +310,39 @@ export const ModelessDialog: FC) => { + lastFocusElementRef.current?.focus() + props.onClickClose?.(e) + }, + [props], + ) + useHandleEscape( useCallback(() => { if (isOpen && onPressEscape) { + lastFocusElementRef.current?.focus() onPressEscape() } }, [isOpen, onPressEscape]), ) + useEffect(() => { + const focusHandler = (e: FocusEvent) => { + if (!(e.target instanceof HTMLElement)) return + + // e.target(現在フォーカスがあたっている要素)がModeless dialogの中の要素であれば、lastFocusElementRefに代入しない + if (wrapperRef?.current?.contains(e.target)) { + return + } + + lastFocusElementRef.current = e.target + } + + document.addEventListener('focus', focusHandler, true) + return () => document.removeEventListener('focus', focusHandler, true) + }, []) + return createPortal( + decorators?: DecoratorsType< + | 'beforeMaxLettersCount' + | 'afterMaxLettersCount' + | 'afterMaxLettersCountExceeded' + | 'beforeScreenReaderMaxLettersDescription' + | 'afterScreenReaderMaxLettersDescription' + > /** * @deprecated placeholder属性は非推奨です。別途ヒント用要素の設置を検討してください。 */ @@ -59,6 +66,10 @@ const getStringLength = (value: string | number | readonly string[]) => { const TEXT_BEFORE_MAXLETTERS_COUNT = 'あと' const TEXT_AFTER_MAXLETTERS_COUNT = '文字' +const TEXT_AFTER_MAXLETTERS_COUNT_EXCEEDED = 'オーバー' + +const SCREEN_READER_BEFORE_MAXLETTERS_DESCRIPTION = '最大' +const SCREEN_READER_AFTER_MAXLETTERS_DESCRIPTION = '文字入力できます' const textarea = tv({ slots: { @@ -72,16 +83,13 @@ const textarea = tv({ 'aria-[invalid]:shr-border-danger', ], counter: 'smarthr-ui-Textarea-counter shr-block shr-text-sm', - counterText: 'shr-font-bold', + counterText: 'shr-text-black', }, variants: { error: { true: { counterText: 'shr-text-danger', }, - false: { - counterText: 'shr-text-grey', - }, }, }, defaultVariants: { @@ -108,25 +116,61 @@ export const Textarea = forwardRef( ref, ) => { const maxLettersId = useId() + const maxLettersNoticeId = `${maxLettersId}-notice` const actualMaxLettersId = maxLetters ? maxLettersId : undefined const textareaRef = useRef(null) const currentValue = props.defaultValue || props.value const [interimRows, setInterimRows] = useState(rows) const [count, setCount] = useState(currentValue ? getStringLength(currentValue) : 0) - const beforeMaxLettersCount = useMemo( - () => - decorators?.beforeMaxLettersCount?.(TEXT_BEFORE_MAXLETTERS_COUNT) || - TEXT_BEFORE_MAXLETTERS_COUNT, + const [srCounterMessage, setSrCounterMessage] = useState('') + + const { + afterMaxLettersCount, + beforeMaxLettersCount, + maxLettersCountExceeded, + beforeScreenReaderMaxLettersDescription, + afterScreenReaderMaxLettersDescription, + } = useMemo( + () => ({ + beforeMaxLettersCount: + decorators?.beforeMaxLettersCount?.(TEXT_BEFORE_MAXLETTERS_COUNT) || + TEXT_BEFORE_MAXLETTERS_COUNT, + afterMaxLettersCount: + decorators?.afterMaxLettersCount?.(TEXT_AFTER_MAXLETTERS_COUNT) || + TEXT_AFTER_MAXLETTERS_COUNT, + maxLettersCountExceeded: + decorators?.afterMaxLettersCountExceeded?.(TEXT_AFTER_MAXLETTERS_COUNT_EXCEEDED) || + TEXT_AFTER_MAXLETTERS_COUNT_EXCEEDED, + beforeScreenReaderMaxLettersDescription: + decorators?.beforeScreenReaderMaxLettersDescription?.( + SCREEN_READER_BEFORE_MAXLETTERS_DESCRIPTION, + ) || SCREEN_READER_BEFORE_MAXLETTERS_DESCRIPTION, + afterScreenReaderMaxLettersDescription: + decorators?.afterScreenReaderMaxLettersDescription?.( + SCREEN_READER_AFTER_MAXLETTERS_DESCRIPTION, + ) || SCREEN_READER_AFTER_MAXLETTERS_DESCRIPTION, + }), [decorators], ) - const afterMaxLettersCount = useMemo( - () => - decorators?.afterMaxLettersCount?.(TEXT_AFTER_MAXLETTERS_COUNT) || - TEXT_AFTER_MAXLETTERS_COUNT, - [decorators], + + const getCounterMessage = useCallback( + (counterValue: number) => { + if (maxLetters === undefined) return + + if (counterValue > maxLetters) { + // {count}文字オーバー + return `${counterValue - maxLetters}${afterMaxLettersCount}${maxLettersCountExceeded}` + } + + // あと{count}文字 + return `${beforeMaxLettersCount}${maxLetters - counterValue}${afterMaxLettersCount}` + }, + [maxLetters, maxLettersCountExceeded, afterMaxLettersCount, beforeMaxLettersCount], ) + const counterVisualMessage = useMemo(() => getCounterMessage(count), [count, getCounterMessage]) + useImperativeHandle( ref, () => textareaRef.current, @@ -140,7 +184,7 @@ export const Textarea = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedUpdateCount = useCallback( - debounce((value) => { + debounce((value: string) => { startTransition(() => { setCount(getStringLength(value)) }) @@ -148,6 +192,21 @@ export const Textarea = forwardRef( [], ) + // countが連続で更新されると、スクリーンリーダーが古い値を読み上げてしまうため、メッセージの更新を遅延しています + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedUpdateSrCounterMessage = useCallback( + debounce((value: string) => { + startTransition(() => { + const counterText = getCounterMessage(getStringLength(value)) + + if (counterText) { + setSrCounterMessage(counterText) + } + }) + }, 1000), + [getCounterMessage], + ) + const handleChange = useCallback( (event: React.ChangeEvent) => { if (onChange) { @@ -155,10 +214,12 @@ export const Textarea = forwardRef( } if (maxLetters) { - debouncedUpdateCount(event.currentTarget.value) + const inputValue = event.currentTarget.value + debouncedUpdateCount(inputValue) + debouncedUpdateSrCounterMessage(inputValue) } }, - [debouncedUpdateCount, maxLetters, onChange], + [debouncedUpdateCount, maxLetters, onChange, debouncedUpdateSrCounterMessage], ) const handleInput = useCallback( @@ -198,19 +259,24 @@ export const Textarea = forwardRef( style: { width: typeof width === 'number' ? `${width}px` : width }, }, counterStyle: counter(), - counterTextStyle: counterText({ error: !!(maxLetters && maxLetters - count <= 0) }), + counterTextStyle: counterText({ error: !!(maxLetters && maxLetters - count < 0) }), } }, [className, count, maxLetters, width]) + const hasInputError = useMemo(() => { + const isCharLengthExceeded = maxLetters && count > maxLetters + return error || isCharLengthExceeded || undefined + }, [error, maxLetters, count]) + const body = (