From 3ef5639a834d23c68920554b62ff24b75bf76a82 Mon Sep 17 00:00:00 2001 From: schktjm Date: Thu, 7 May 2026 18:50:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(Tooltip):=20type=20prop=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97label=E3=81=A8description=E3=82=92?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=E5=8F=AF=E8=83=BD=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primer UIのTooltipのように、type propでツールチップの役割を選択できるようにする。 type="label"の場合はchildren要素にaria-labelledbyを付与しアクセシブルネームとして機能する。 type="description"(デフォルト)の場合は従来通りaria-describedbyを付与し補足説明として機能する。 Co-Authored-By: Claude Opus 4.6 --- .../src/components/Tooltip/Tooltip.test.tsx | 82 +++++++++++++++++++ .../src/components/Tooltip/Tooltip.tsx | 25 ++++-- .../Tooltip/stories/Tooltip.stories.tsx | 19 ++++- 3 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx 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..e06236f66c --- /dev/null +++ b/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react' + +import { Button } from '../Button' +import { FaPencilIcon } from '../Icon' + +import { Tooltip } from './Tooltip' + +describe('Tooltip', () => { + 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..f1809a90b9 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,6 +72,7 @@ const classNameGenerator = tv({ export const Tooltip: FC = ({ message, children, + type = 'description', triggerType, ellipsisOnly, tabIndex = 0, @@ -190,13 +194,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 ( @@ -205,7 +216,7 @@ export const Tooltip: FC = ({ {...rest} ref={ref} tabIndex={tabIndex} - aria-describedby={isInnerTarget ? undefined : messageId} + aria-describedby={actualAriaDescribedby} onPointerEnter={onDelegatePointerEnter} onTouchStart={onDelegateTouchStart} onFocus={onDelegateFocus} diff --git a/packages/smarthr-ui/src/components/Tooltip/stories/Tooltip.stories.tsx b/packages/smarthr-ui/src/components/Tooltip/stories/Tooltip.stories.tsx index d880583027..840faf155b 100644 --- a/packages/smarthr-ui/src/components/Tooltip/stories/Tooltip.stories.tsx +++ b/packages/smarthr-ui/src/components/Tooltip/stories/Tooltip.stories.tsx @@ -1,4 +1,5 @@ -import { FaCircleQuestionIcon } from '../../Icon' +import { Button } from '../../Button' +import { FaCircleQuestionIcon, FaPencilIcon } from '../../Icon' import { Tooltip } from '../Tooltip' import type { Meta, StoryObj } from '@storybook/react-webpack5' @@ -113,6 +114,22 @@ export const TabIndex: StoryObj = { }, } +export const Type: StoryObj = { + name: 'type', + render: () => ( +
+ + + + + + +
+ ), +} + export const AriaDescribedbyTarget: StoryObj = { name: 'ariaDescribedbyTarget', args: { From 6930e06ac0f4a5f5c028b883fb72b363c36ae4eb Mon Sep 17 00:00:00 2001 From: schktjm Date: Fri, 8 May 2026 14:01:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(Tooltip):=20children=E3=81=8C=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=BC=E3=82=AB=E3=82=B9=E5=8F=AF=E8=83=BD=E3=81=AA?= =?UTF-8?q?=E8=A6=81=E7=B4=A0=E3=81=AE=E5=A0=B4=E5=90=88=E3=81=ABwrapper?= =?UTF-8?q?=E3=81=AEtabIndex=E3=82=92=E8=87=AA=E5=8B=95=E5=88=B6=E5=BE=A1?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../src/components/Tooltip/Tooltip.test.tsx | 29 +++++++++++++++++++ .../src/components/Tooltip/Tooltip.tsx | 28 ++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx b/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx index e06236f66c..3557345073 100644 --- a/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx +++ b/packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx @@ -2,10 +2,39 @@ 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( diff --git a/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx b/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx index f1809a90b9..48cce07db8 100644 --- a/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx @@ -75,7 +75,7 @@ export const Tooltip: FC = ({ type = 'description', triggerType, ellipsisOnly, - tabIndex = 0, + tabIndex, ariaDescribedbyTarget = 'wrapper', className, onPointerEnter, @@ -97,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を表示しない @@ -215,7 +239,7 @@ export const Tooltip: FC = ({