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: {