diff --git a/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx b/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx new file mode 100644 index 0000000000..3557345073 --- /dev/null +++ b/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx @@ -0,0 +1,111 @@ +import { render, screen } from '@testing-library/react' + +import { Button } from '../Button' +import { FaPencilIcon } from '../Icon' +import { Input } from '../Input' + +import { Tooltip } from './Tooltip' + +describe('Tooltip', () => { + describe('tabIndex', () => { + it('children がフォーカス可能な要素の場合、wrapper に tabIndex が設定されない', () => { + render( + + + , + ) + const wrapper = screen.getByRole('button', { name: 'ボタン' }).closest('.smarthr-ui-Tooltip')! + expect(wrapper).not.toHaveAttribute('tabindex') + }) + + it('children がフォーカス不可能な要素の場合、wrapper に tabIndex=0 が設定される', () => { + render(テキスト) + const wrapper = screen.getByText('テキスト').closest('.smarthr-ui-Tooltip')! + expect(wrapper).toHaveAttribute('tabindex', '0') + }) + + it('tabIndex を明示的に指定した場合、その値が使われる', () => { + render( + + + , + ) + const wrapper = screen.getByRole('button', { name: 'ボタン' }).closest('.smarthr-ui-Tooltip')! + expect(wrapper).toHaveAttribute('tabindex', '-1') + }) + }) + + describe('type="description"(デフォルト)', () => { + it('wrapper span に aria-describedby が付与される', () => { + render( + + + , + ) + const wrapper = screen.getByRole('button', { name: 'ボタン' }).closest('.smarthr-ui-Tooltip')! + expect(wrapper).toHaveAttribute('aria-describedby') + const describedbyId = wrapper.getAttribute('aria-describedby')! + expect(document.getElementById(describedbyId)).toHaveTextContent('説明テキスト') + }) + + it('ariaDescribedbyTarget="inner" で message が children のaccessible descriptionになる', () => { + render( + + + , + ) + expect( + screen.getByRole('button', { name: 'ボタン', description: '説明テキスト' }), + ).toBeInTheDocument() + }) + + it('children のアクセシブルネームが message にならない', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: 'ボタン' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '説明テキスト' })).not.toBeInTheDocument() + }) + }) + + describe('type="label"', () => { + it('message が children のアクセシブルネームになる', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument() + }) + + it('children のaccessible descriptionにならない', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: '保存' })).not.toHaveAttribute('aria-describedby') + }) + + it('ariaDescribedbyTarget を指定しても message が children のアクセシブルネームになる', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument() + + const wrapper = screen.getByRole('button', { name: '保存' }).closest('.smarthr-ui-Tooltip')! + expect(wrapper).not.toHaveAttribute('aria-describedby') + expect(wrapper).not.toHaveAttribute('aria-labelledby') + }) + }) +}) diff --git a/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx b/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx index 0c07b102ee..48cce07db8 100644 --- a/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx @@ -41,16 +41,19 @@ const getFullscreenElementOnSSR = () => null type AbstractProps = PropsWithChildren<{ /** ツールチップ内に表示するメッセージ */ message: ReactNode + /** ツールチップの種類。`label` の場合は children の要素に `aria-labelledby` を付与しアクセシブルネームとして機能する。`description`(デフォルト)の場合は `aria-describedby` を付与し補足説明として機能する */ + type?: 'label' | 'description' /** ツールチップを表示する対象のタイプ。アイコンの場合は `icon` を指定する */ triggerType?: 'icon' | 'text' /** `true` のとき、ツールチップを表示する対象が省略されている場合のみツールチップ表示を有効にする */ ellipsisOnly?: boolean /** ツールチップを表示する対象の tabIndex 値 */ tabIndex?: number - /** ツールチップを内包要素に紐付けるかどうか */ + /** `type` が `description` の場合に `aria-describedby` を付与する対象。`type` が `label` の場合は常に children に付与されるため無視される */ ariaDescribedbyTarget?: 'wrapper' | 'inner' }> -type Props = AbstractProps & Omit, keyof AbstractProps | 'aria-describedby'> +type Props = AbstractProps & + Omit, keyof AbstractProps | 'aria-describedby' | 'aria-labelledby'> const classNameGenerator = tv({ base: [ @@ -69,9 +72,10 @@ const classNameGenerator = tv({ export const Tooltip: FC = ({ message, children, + type = 'description', triggerType, ellipsisOnly, - tabIndex = 0, + tabIndex, ariaDescribedbyTarget = 'wrapper', className, onPointerEnter, @@ -93,10 +97,34 @@ export const Tooltip: FC = ({ getFullscreenElementOnSSR, ) + const [actualTabIndex, setActualTabIndex] = useState(tabIndex ?? 0) + useEnhancedEffect(() => { setPortalRoot(fullscreenElement ?? document.body) }, [fullscreenElement]) + useEnhancedEffect(() => { + if (tabIndex !== undefined) { + setActualTabIndex(tabIndex) + return + } + + const childElement = ref.current?.querySelector( + ':scope > :not(.smarthr-ui-VisuallyHiddenText)', + ) + + if (!childElement) { + setActualTabIndex(0) + return + } + + const isFocusable = + childElement.matches('a[href], button, input, select, textarea, [tabindex]') && + !childElement.matches('[tabindex="-1"]') + + setActualTabIndex(isFocusable ? undefined : 0) + }, [tabIndex]) + const toShowAction = useCallback( (e: BaseSyntheticEvent) => { // Tooltipのtriggerの他の要素(Dropwdown menu buttonで開いたmenu contentとか)に移動されたらtooltipを表示しない @@ -190,13 +218,20 @@ export const Tooltip: FC = ({ () => classNameGenerator({ isIcon, className }), [isIcon, className], ) + const isLabel = type === 'label' const isInnerTarget = ariaDescribedbyTarget === 'inner' const childrenWithProps = useMemo( () => - isInnerTarget - ? cloneElement(children as ReactElement, { 'aria-describedby': messageId }) - : children, - [children, isInnerTarget, messageId], + isLabel + ? cloneElement(children as ReactElement, { 'aria-labelledby': messageId }) + : isInnerTarget + ? cloneElement(children as ReactElement, { 'aria-describedby': messageId }) + : children, + [children, isLabel, isInnerTarget, messageId], + ) + const actualAriaDescribedby = useMemo( + () => (!isLabel && !isInnerTarget ? messageId : undefined), + [isLabel, isInnerTarget, messageId], ) return ( @@ -204,8 +239,8 @@ export const Tooltip: FC = ({ = { }, } +export const Type: StoryObj = { + name: 'type', + render: () => ( +
+ + + + + + +
+ ), +} + export const AriaDescribedbyTarget: StoryObj = { name: 'ariaDescribedbyTarget', args: {