Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/smarthr-ui/src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Tooltip message="説明">
<Button>ボタン</Button>
</Tooltip>,
)
const wrapper = screen.getByRole('button', { name: 'ボタン' }).closest('.smarthr-ui-Tooltip')!
expect(wrapper).not.toHaveAttribute('tabindex')
})

it('children がフォーカス不可能な要素の場合、wrapper に tabIndex=0 が設定される', () => {
render(<Tooltip message="説明">テキスト</Tooltip>)
const wrapper = screen.getByText('テキスト').closest('.smarthr-ui-Tooltip')!
expect(wrapper).toHaveAttribute('tabindex', '0')
})

it('tabIndex を明示的に指定した場合、その値が使われる', () => {
render(
<Tooltip message="説明" tabIndex={-1}>
<Button>ボタン</Button>
</Tooltip>,
)
const wrapper = screen.getByRole('button', { name: 'ボタン' }).closest('.smarthr-ui-Tooltip')!
expect(wrapper).toHaveAttribute('tabindex', '-1')
})
})

describe('type="description"(デフォルト)', () => {
it('wrapper span に aria-describedby が付与される', () => {
render(
<Tooltip message="説明テキスト">
<Button>ボタン</Button>
</Tooltip>,
)
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(
<Tooltip message="説明テキスト" ariaDescribedbyTarget="inner">
<Button>ボタン</Button>
</Tooltip>,
)
expect(
screen.getByRole('button', { name: 'ボタン', description: '説明テキスト' }),
).toBeInTheDocument()
})

it('children のアクセシブルネームが message にならない', () => {
render(
<Tooltip message="説明テキスト">
<Button>ボタン</Button>
</Tooltip>,
)
expect(screen.getByRole('button', { name: 'ボタン' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '説明テキスト' })).not.toBeInTheDocument()
})
})

describe('type="label"', () => {
it('message が children のアクセシブルネームになる', () => {
render(
<Tooltip type="label" message="保存">
<Button>
<FaPencilIcon />
</Button>
</Tooltip>,
)
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
})

it('children のaccessible descriptionにならない', () => {
render(
<Tooltip type="label" message="保存">
<Button>
<FaPencilIcon />
</Button>
</Tooltip>,
)
expect(screen.getByRole('button', { name: '保存' })).not.toHaveAttribute('aria-describedby')
})

it('ariaDescribedbyTarget を指定しても message が children のアクセシブルネームになる', () => {
render(
<Tooltip type="label" message="保存" ariaDescribedbyTarget="wrapper">
<Button>
<FaPencilIcon />
</Button>
</Tooltip>,
)
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')
})
})
})
53 changes: 44 additions & 9 deletions packages/smarthr-ui/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentProps<'span'>, keyof AbstractProps | 'aria-describedby'>
type Props = AbstractProps &
Omit<ComponentProps<'span'>, keyof AbstractProps | 'aria-describedby' | 'aria-labelledby'>

const classNameGenerator = tv({
base: [
Expand All @@ -69,9 +72,10 @@ const classNameGenerator = tv({
export const Tooltip: FC<Props> = ({
message,
children,
type = 'description',
triggerType,
ellipsisOnly,
tabIndex = 0,
tabIndex,
ariaDescribedbyTarget = 'wrapper',
className,
onPointerEnter,
Expand All @@ -93,10 +97,34 @@ export const Tooltip: FC<Props> = ({
getFullscreenElementOnSSR,
)

const [actualTabIndex, setActualTabIndex] = useState<number | undefined>(tabIndex ?? 0)

useEnhancedEffect(() => {
setPortalRoot(fullscreenElement ?? document.body)
}, [fullscreenElement])

useEnhancedEffect(() => {
if (tabIndex !== undefined) {
setActualTabIndex(tabIndex)
return
}

const childElement = ref.current?.querySelector<HTMLElement>(
':scope > :not(.smarthr-ui-VisuallyHiddenText)',
)

if (!childElement) {
setActualTabIndex(0)
return
}

const isFocusable =
childElement.matches('a[href], button, input, select, textarea, [tabindex]') &&
Copy link
Copy Markdown
Contributor Author

@schktjm schktjm May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

children にフォームコントロール要素がいるかどうかの判定を、FormControl のように [data-smarthr-ui-input="true"]' などの smarthr-ui 内で定義しているものを使うか迷いましたが、 野良の button や a の見落としが発生しそうな気がして要素を指定しています

!childElement.matches('[tabindex="-1"]')

setActualTabIndex(isFocusable ? undefined : 0)
}, [tabIndex])

const toShowAction = useCallback(
(e: BaseSyntheticEvent) => {
// Tooltipのtriggerの他の要素(Dropwdown menu buttonで開いたmenu contentとか)に移動されたらtooltipを表示しない
Expand Down Expand Up @@ -190,22 +218,29 @@ export const Tooltip: FC<Props> = ({
() => 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 (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
{...rest}
ref={ref}
tabIndex={tabIndex}
aria-describedby={isInnerTarget ? undefined : messageId}
tabIndex={actualTabIndex}
aria-describedby={actualAriaDescribedby}
onPointerEnter={onDelegatePointerEnter}
onTouchStart={onDelegateTouchStart}
onFocus={onDelegateFocus}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -113,6 +114,22 @@ export const TabIndex: StoryObj<typeof Tooltip> = {
},
}

export const Type: StoryObj<typeof Tooltip> = {
name: 'type',
render: () => (
<div className="shr-flex shr-gap-1">
<Tooltip message="description" type="description" ariaDescribedbyTarget="inner">
<Button>ボタン</Button>
</Tooltip>
<Tooltip message="label" type="label" triggerType="icon">
<Button>
<FaPencilIcon />
</Button>
</Tooltip>
</div>
),
}

export const AriaDescribedbyTarget: StoryObj<typeof Tooltip> = {
name: 'ariaDescribedbyTarget',
args: {
Expand Down
Loading