+
+ {(props) => (
+
+
+
+ )}
+
+
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
+
+`}
+/>
diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx
new file mode 100644
index 000000000..1f5272319
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx
@@ -0,0 +1,492 @@
+import React, { useState } from 'react';
+
+import Information from '../Icon/Information';
+import { IconButton } from '../IconButton';
+import { Tooltip } from './Tooltip';
+
+export function DefaultOpen() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ これは補足情報です。hover または focus で表示されます。
+
+
+
+ );
+}
+
+export function InitialOpen() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ これは初期表示の Tooltip
+ です。閉じるボタンをクリックして閉じることができます。
+
+
+
+ );
+}
+
+export function WithOnClose() {
+ const [hasSeenTooltip, setHasSeenTooltip] = useState(false);
+
+ return (
+
+ {!hasSeenTooltip ? (
+ {
+ setHasSeenTooltip(true);
+ }}
+ >
+
+ {(props) => (
+
+
+
+ )}
+
+
+ これは一度閉じたら再表示されない Tooltip
+ です。チュートリアルや新機能案内などに使用します。
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+export function VariantInformation() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報を伝えるためのTooltipです。
+
+
+ );
+}
+
+export function VariantConfirmation() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ 訴求したい内容を伝えるためのTooltipです。
+
+
+
+ );
+}
+
+export function VariantError() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ エラーメッセージを表示するためのTooltipです。
+
+
+
+ );
+}
+
+export function DirectionTop() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ トリガーの下に表示され、ポインターが上を指します。
+
+
+
+ );
+}
+
+export function DirectionBottom() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ トリガーの上に表示され、ポインターが下を指します。
+
+
+
+ );
+}
+
+export function DirectionLeft() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ トリガーの右に表示され、ポインターが左を指します。
+
+
+
+ );
+}
+
+export function DirectionRight() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ トリガーの左に表示され、ポインターが右を指します。
+
+
+
+ );
+}
+
+export function PositionCenter() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ ポインターがトリガーの中央に配置されます。
+
+
+
+ );
+}
+
+export function PositionStart() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ ポインターがTooltipの開始位置寄りに配置されます。
+
+
+
+ );
+}
+
+export function PositionEnd() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ ポインターがTooltipの終了位置寄りに配置されます。
+
+
+
+ );
+}
+
+export function PositionEdgeStart() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ トリガーが画面端から 16-36px の範囲にある場合に使用します。
+
+
+
+ );
+}
+
+export function PositionEdgeEnd() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ トリガーが画面端から 16-36px の範囲にある場合に使用します。
+
+
+
+ );
+}
+
+export function LongText() {
+ return (
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+
+
+
+ );
+}
diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.stories.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.stories.tsx
new file mode 100644
index 000000000..63f425a2b
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/Tooltip.stories.tsx
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import type { Tooltip } from './Tooltip';
+import {
+ DefaultOpen as DefaultOpenComponent,
+ DirectionBottom as DirectionBottomComponent,
+ DirectionLeft as DirectionLeftComponent,
+ DirectionRight as DirectionRightComponent,
+ DirectionTop as DirectionTopComponent,
+ InitialOpen as InitialOpenComponent,
+ LongText as LongTextComponent,
+ PositionCenter as PositionCenterComponent,
+ PositionEdgeEnd as PositionEdgeEndComponent,
+ PositionEdgeStart as PositionEdgeStartComponent,
+ PositionEnd as PositionEndComponent,
+ PositionStart as PositionStartComponent,
+ VariantConfirmation as VariantConfirmationComponent,
+ VariantError as VariantErrorComponent,
+ VariantInformation as VariantInformationComponent,
+ WithOnClose as WithOnCloseComponent,
+} from './Tooltip.stories.example';
+
+const meta: Meta = {
+ title: 'Tooltip',
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Normal: Story = {
+ render: () => ,
+};
+
+export const InitialOpen: Story = {
+ render: () => ,
+};
+
+export const OnceOnly: Story = {
+ render: () => ,
+};
+
+export const VariantInformation: Story = {
+ render: () => ,
+};
+
+export const VariantConfirmation: Story = {
+ render: () => ,
+};
+
+export const VariantError: Story = {
+ render: () => ,
+};
+
+export const DirectionTop: Story = {
+ render: () => ,
+};
+
+export const DirectionBottom: Story = {
+ render: () => ,
+};
+
+export const DirectionLeft: Story = {
+ render: () => ,
+};
+
+export const DirectionRight: Story = {
+ render: () => ,
+};
+
+export const PositionCenter: Story = {
+ render: () => ,
+};
+
+export const PositionStart: Story = {
+ render: () => ,
+};
+
+export const PositionEnd: Story = {
+ render: () => ,
+};
+
+export const PositionEdgeStart: Story = {
+ render: () => ,
+};
+
+export const PositionEdgeEnd: Story = {
+ render: () => ,
+};
+
+export const LongText: Story = {
+ render: () => ,
+};
diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx
new file mode 100644
index 000000000..de3d68656
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx
@@ -0,0 +1,725 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import React from 'react';
+import { vi } from 'vitest';
+
+import Information from '../Icon/Information';
+import { IconButton } from '../IconButton';
+import { Tooltip } from './Tooltip';
+
+// JSDOMでpointerTypeを正しく扱うためのヘルパー
+const fireTouchPointerDown = (element: Element) => {
+ const event = new PointerEvent('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ pointerType: 'touch',
+ });
+ element.dispatchEvent(event);
+};
+
+const fireTouchPointerUp = (element: Element) => {
+ const event = new PointerEvent('pointerup', {
+ bubbles: true,
+ cancelable: true,
+ pointerType: 'touch',
+ });
+ element.dispatchEvent(event);
+};
+
+describe('', () => {
+ describe('Common', () => {
+ test('sets aria-describedby automatically', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ expect(trigger).toHaveAttribute('aria-describedby');
+ });
+ });
+
+ describe('when defaultOpen={false}', () => {
+ test('is not visible initially', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ test('shows on hover (pointer devices)', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+ });
+
+ test('shows on focus (pointer devices)', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.focus(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+ });
+
+ test('hides on blur (pointer devices)', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.focus(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+
+ fireEvent.blur(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+ });
+
+ test('hides on Escape key press', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+
+ fireEvent.keyDown(window, { key: 'Escape' });
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+ });
+
+ test('has role="tooltip"', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ const tooltip = screen.getByRole('tooltip');
+ expect(tooltip).toBeInTheDocument();
+ });
+ });
+
+ test('does not have aria-expanded', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ expect(trigger).not.toHaveAttribute('aria-expanded');
+ });
+
+ test('does not show close button', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.queryByRole('button', { name: '閉じる' }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('toggles visibility on touch (pointerdown + pointerup with pointerType=touch)', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+
+ // タップして開く
+ await act(async () => {
+ fireTouchPointerDown(trigger);
+ fireTouchPointerUp(trigger);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+
+ // もう一度タップして閉じる
+ await act(async () => {
+ fireTouchPointerDown(trigger);
+ fireTouchPointerUp(trigger);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+ });
+
+ test('cancels when touch starts on trigger but ends outside', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+
+ // トリガーでpointerdownしたが、外でpointerup
+ await act(async () => {
+ fireTouchPointerDown(trigger);
+ fireTouchPointerUp(document.body); // 外で離す
+ });
+
+ // Tooltipは開かない
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+ });
+
+ test('starts fade-out on outside touch', async () => {
+ render(
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+
+
+
,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+
+ // タップして開く(pointerdown + pointerup)
+ await act(async () => {
+ fireTouchPointerDown(trigger);
+ fireTouchPointerUp(trigger);
+ });
+
+ const tooltipText = await screen.findByText('補足情報');
+ expect(tooltipText).toBeInTheDocument();
+
+ const outsideButton = screen.getByRole('button', {
+ name: '外部のボタン',
+ });
+
+ // 外部をタップ(pointerdownのみでフェードアウト開始)
+ await act(async () => {
+ const event = new PointerEvent('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ pointerType: 'touch',
+ });
+ outsideButton.dispatchEvent(event);
+ });
+
+ const tooltipFrame = tooltipText.closest('.spui-Tooltip-frame');
+ await waitFor(() => {
+ expect(tooltipFrame).toHaveClass('is-fade-out');
+ });
+ });
+ });
+
+ describe('when defaultOpen={true} (initial display)', () => {
+ test('is visible initially', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+
+ test('has role="group"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group).toBeInTheDocument();
+ });
+
+ test('has aria-expanded="true"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ test('shows close button', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: '閉じる' }),
+ ).toBeInTheDocument();
+ });
+
+ test('closes when close button is clicked', async () => {
+ const onClose = vi.fn();
+
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const closeButton = screen.getByRole('button', { name: '閉じる' });
+ fireEvent.click(closeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ test('closes on Escape key when tooltip or its content is focused', async () => {
+ const onClose = vi.fn();
+
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const closeButton = screen.getByRole('button', { name: '閉じる' });
+ closeButton.focus();
+
+ fireEvent.keyDown(window, { key: 'Escape' });
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not close on outside click', async () => {
+ render(
+
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+
+
+
,
+ );
+
+ const outsideButton = screen.getByRole('button', {
+ name: '外部のボタン',
+ });
+ fireEvent.click(outsideButton);
+
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+ });
+
+ describe('when defaultOpen={true} and reopened after closing', () => {
+ test('reopens on hover/focus', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const closeButton = screen.getByRole('button', { name: '閉じる' });
+ fireEvent.click(closeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+ });
+
+ test('changes from role="group" to role="tooltip"', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ expect(screen.getByRole('group')).toBeInTheDocument();
+
+ const closeButton = screen.getByRole('button', { name: '閉じる' });
+ fireEvent.click(closeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ const tooltip = screen.getByRole('tooltip');
+ expect(tooltip).toBeInTheDocument();
+ });
+ });
+
+ test('does not have aria-expanded after reopen', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+
+ const closeButton = screen.getByRole('button', { name: '閉じる' });
+ fireEvent.click(closeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ expect(trigger).not.toHaveAttribute('aria-expanded');
+ });
+
+ test('does not show close button after reopen', async () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const closeButton = screen.getByRole('button', { name: '閉じる' });
+ fireEvent.click(closeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('補足情報')).not.toBeInTheDocument();
+ });
+
+ const trigger = screen.getByRole('button', { name: '詳細情報' });
+ fireEvent.mouseEnter(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText('補足情報')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.queryByRole('button', { name: '閉じる' }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('variants', () => {
+ test('applies class for variant="information"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group).toHaveClass('spui-Tooltip-frame--information');
+ });
+
+ test('applies class for variant="confirmation"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group).toHaveClass('spui-Tooltip-frame--confirmation');
+ });
+
+ test('applies class for variant="error"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group).toHaveClass('spui-Tooltip-frame--error');
+ });
+ });
+
+ describe('direction and position', () => {
+ test('applies classes for direction="top" and position="center"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group).toHaveClass('spui-Tooltip-frame--top');
+ expect(group).toHaveClass('spui-Tooltip-frame--center');
+ });
+
+ test('applies classes for direction="bottom" and position="start"', () => {
+ render(
+
+
+ {(props) => (
+
+
+
+ )}
+
+ 補足情報
+ ,
+ );
+
+ const group = screen.getByRole('group');
+ expect(group).toHaveClass('spui-Tooltip-frame--bottom');
+ expect(group).toHaveClass('spui-Tooltip-frame--start');
+ });
+ });
+});
diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.tsx
new file mode 100644
index 000000000..590dd56be
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/Tooltip.tsx
@@ -0,0 +1,482 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+import Cross from '../Icon/Cross';
+import { createGraceArea, isPointInPolygon, type Polygon } from './graceArea';
+
+type Direction = 'top' | 'right' | 'bottom' | 'left';
+type Position = 'edgeStart' | 'start' | 'center' | 'end' | 'edgeEnd';
+type Variant = 'information' | 'confirmation' | 'error';
+
+type FrameProps = {
+ children: React.ReactNode;
+ defaultOpen?: boolean;
+ onClose?: () => void;
+ variant?: Variant;
+ direction?: Direction;
+ position?: Position;
+};
+
+type TriggerProps = {
+ ref: React.RefCallback;
+ 'aria-describedby': string;
+ 'aria-expanded'?: boolean;
+ onMouseEnter: (e: React.MouseEvent) => void;
+ onFocus: () => void;
+ onBlur: () => void;
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerUp: (e: React.PointerEvent) => void;
+};
+
+type TriggerComponentProps = {
+ children: (props: TriggerProps) => React.ReactNode;
+};
+
+type ContentProps = {
+ children?: React.ReactNode;
+};
+
+type TooltipContextValue = {
+ tooltipId: string;
+ isOpen: boolean;
+ isInitialOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+ handleClose: () => void;
+ triggerRef: React.RefObject;
+ contentRef: React.RefObject;
+ triggerWidth: number;
+ triggerHeight: number;
+ variant: Variant;
+ direction: Direction;
+ position: Position;
+ handleMouseEnter: (e: React.MouseEvent) => void;
+ handleFocus: () => void;
+ handleBlur: () => void;
+ handlePointerDown: (e: React.PointerEvent) => void;
+ handleTriggerPointerUp: (e: React.PointerEvent) => void;
+ isPointerInTransitRef: React.RefObject;
+};
+
+const BLOCK_NAME = 'spui-Tooltip';
+const FADE_IN_ANIMATION = 'spui-Tooltip-fade-in';
+const CLOSE_KEY_LIST = ['ESCAPE', 'ESC'];
+
+const TooltipContext = createContext(null);
+
+const useTooltipContext = () => {
+ const context = useContext(TooltipContext);
+ if (!context) {
+ throw new Error('Tooltip components must be used within Tooltip.Frame');
+ }
+ return context;
+};
+
+const Frame = ({
+ children,
+ defaultOpen = false,
+ onClose,
+ variant = 'information',
+ direction = 'top',
+ position = 'center',
+}: FrameProps) => {
+ const tooltipId = useId();
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+ const [isInitialOpen, setIsInitialOpen] = useState(defaultOpen);
+ const triggerRef = useRef(null);
+ const contentRef = useRef(null);
+ const [triggerWidth, setTriggerWidth] = useState(0);
+ const [triggerHeight, setTriggerHeight] = useState(0);
+
+ const isPointerInTransitRef = useRef(false);
+ // pointerdown中はfocusイベントを無視
+ const isPointerDownRef = useRef(false);
+ // pointerdownした要素を記録(キャンセル判定用)
+ const pointerDownTargetRef = useRef(null);
+
+ const handlePointerUp = useCallback(() => {
+ isPointerDownRef.current = false;
+ pointerDownTargetRef.current = null;
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!triggerRef.current) return;
+ const { width, height } = triggerRef.current.getBoundingClientRect();
+ setTriggerWidth(width);
+ setTriggerHeight(height);
+ }, []);
+
+ useEffect(() => {
+ return () => document.removeEventListener('pointerup', handlePointerUp);
+ }, [handlePointerUp]);
+
+ const handleClose = useCallback(() => {
+ setIsOpen(false);
+ setIsInitialOpen(false);
+ onClose?.();
+ }, [onClose]);
+
+ const openTooltip = useCallback(() => {
+ if (!triggerRef.current) return;
+ const { width, height } = triggerRef.current.getBoundingClientRect();
+ setTriggerWidth(width);
+ setTriggerHeight(height);
+ setIsOpen(true);
+ }, []);
+
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ isPointerDownRef.current = true;
+ pointerDownTargetRef.current = e.currentTarget;
+ document.addEventListener('pointerup', handlePointerUp, { once: true });
+ },
+ [handlePointerUp],
+ );
+
+ const handleTriggerPointerUp = useCallback(
+ (e: React.PointerEvent) => {
+ // タッチデバイスでpointerdownと同じ要素でpointerupした場合のみトグル
+ if (
+ e.pointerType === 'touch' &&
+ !isInitialOpen &&
+ pointerDownTargetRef.current === e.currentTarget
+ ) {
+ if (!triggerRef.current) return;
+ const { width, height } = triggerRef.current.getBoundingClientRect();
+ setTriggerWidth(width);
+ setTriggerHeight(height);
+ setIsOpen((prev) => !prev);
+ }
+ },
+ [isInitialOpen],
+ );
+
+ // ポインティングデバイス: hover
+ const handleMouseEnter = useCallback(
+ (_e: React.MouseEvent) => {
+ if (isPointerDownRef.current) return;
+ if (isInitialOpen) return;
+ if (isPointerInTransitRef.current) return;
+ openTooltip();
+ },
+ [isInitialOpen, openTooltip],
+ );
+
+ // ポインティングデバイス: focus(pointerdown中は無視)
+ const handleFocus = useCallback(() => {
+ if (isPointerDownRef.current) return;
+ if (isInitialOpen) return;
+ openTooltip();
+ }, [isInitialOpen, openTooltip]);
+
+ // ポインティングデバイス: blur
+ const handleBlur = useCallback(() => {
+ if (isPointerDownRef.current) return;
+ if (isInitialOpen) return;
+ setIsOpen(false);
+ }, [isInitialOpen]);
+
+ const contextValue: TooltipContextValue = {
+ tooltipId,
+ isOpen,
+ isInitialOpen,
+ setIsOpen,
+ handleClose,
+ triggerRef,
+ contentRef,
+ triggerWidth,
+ triggerHeight,
+ variant,
+ direction,
+ position,
+ handleMouseEnter,
+ handleFocus,
+ handleBlur,
+ handlePointerDown,
+ handleTriggerPointerUp,
+ isPointerInTransitRef,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+const Trigger = ({ children }: TriggerComponentProps) => {
+ const {
+ tooltipId,
+ isOpen,
+ isInitialOpen,
+ triggerRef,
+ handleMouseEnter,
+ handleFocus,
+ handleBlur,
+ handlePointerDown,
+ handleTriggerPointerUp,
+ } = useTooltipContext();
+
+ const triggerProps: TriggerProps = {
+ ref: (node) => {
+ triggerRef.current = node;
+ },
+ 'aria-describedby': tooltipId,
+ ...(isInitialOpen ? { 'aria-expanded': isOpen } : {}),
+ onMouseEnter: handleMouseEnter,
+ onFocus: handleFocus,
+ onBlur: handleBlur,
+ onPointerDown: handlePointerDown,
+ onPointerUp: handleTriggerPointerUp,
+ };
+
+ return children(triggerProps);
+};
+
+const Content = ({ children }: ContentProps) => {
+ const {
+ tooltipId,
+ isOpen,
+ isInitialOpen,
+ handleClose,
+ triggerRef,
+ contentRef,
+ triggerWidth,
+ triggerHeight,
+ variant,
+ direction,
+ position,
+ setIsOpen,
+ isPointerInTransitRef,
+ } = useTooltipContext();
+
+ const localContentRef = useRef(null);
+ const [fadeOut, setFadeOut] = useState(false);
+ const [graceArea, setGraceArea] = useState(null);
+ const prevIsOpenRef = useRef(isOpen);
+
+ useEffect(() => {
+ contentRef.current = localContentRef.current;
+ });
+
+ // 再表示時にfadeOutをリセット
+ useEffect(() => {
+ if (isOpen && !prevIsOpenRef.current) {
+ setFadeOut(false);
+ }
+ prevIsOpenRef.current = isOpen;
+ }, [isOpen]);
+
+ // Grace area: triggerとtooltip間のポインター移動を許容
+ const handleRemoveGraceArea = useCallback(() => {
+ setGraceArea(null);
+ isPointerInTransitRef.current = false;
+ }, [isPointerInTransitRef]);
+
+ const handleCreateGraceArea = useCallback(
+ (event: PointerEvent, targetElement: HTMLElement) => {
+ const currentTarget = event.currentTarget as HTMLElement;
+ if (!currentTarget) return;
+
+ const exitPoint = { x: event.clientX, y: event.clientY };
+ const polygon = createGraceArea(exitPoint, currentTarget, targetElement);
+ setGraceArea(polygon);
+ isPointerInTransitRef.current = true;
+ },
+ [isPointerInTransitRef],
+ );
+
+ // pointerleaveでgrace areaを生成
+ useEffect(() => {
+ if (isInitialOpen || !isOpen) return;
+
+ const trigger = triggerRef.current;
+ const content = localContentRef.current;
+ if (!trigger || !content) return;
+
+ const handleTriggerLeave = (event: PointerEvent) => {
+ if (event.pointerType === 'touch') return;
+ handleCreateGraceArea(event, content);
+ };
+
+ const handleContentLeave = (event: PointerEvent) => {
+ if (event.pointerType === 'touch') return;
+ handleCreateGraceArea(event, trigger);
+ };
+
+ trigger.addEventListener('pointerleave', handleTriggerLeave);
+ content.addEventListener('pointerleave', handleContentLeave);
+
+ return () => {
+ trigger.removeEventListener('pointerleave', handleTriggerLeave);
+ content.removeEventListener('pointerleave', handleContentLeave);
+ };
+ }, [triggerRef, isOpen, isInitialOpen, handleCreateGraceArea]);
+
+ // grace area内のポインター移動を追跡
+ useEffect(() => {
+ if (!graceArea || isInitialOpen) return;
+
+ const trigger = triggerRef.current;
+ const content = localContentRef.current;
+
+ const handlePointerMove = (event: PointerEvent) => {
+ if (event.pointerType === 'touch') return;
+
+ const target = event.target as HTMLElement;
+ const pointerPosition = { x: event.clientX, y: event.clientY };
+ const hasEnteredTarget =
+ trigger?.contains(target) || content?.contains(target);
+ const isPointerOutsideGraceArea = !isPointInPolygon(
+ pointerPosition,
+ graceArea,
+ );
+
+ if (hasEnteredTarget) {
+ handleRemoveGraceArea();
+ } else if (isPointerOutsideGraceArea) {
+ handleRemoveGraceArea();
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('pointermove', handlePointerMove);
+ return () => document.removeEventListener('pointermove', handlePointerMove);
+ }, [graceArea, triggerRef, isInitialOpen, setIsOpen, handleRemoveGraceArea]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ handleRemoveGraceArea();
+ }
+ }, [isOpen, handleRemoveGraceArea]);
+
+ // タッチデバイス: 外部タップで閉じる
+ useEffect(() => {
+ if (!isOpen || isInitialOpen) return;
+
+ const handlePointerDownOutside = (e: PointerEvent) => {
+ if (e.pointerType !== 'touch') return;
+
+ const content = localContentRef.current;
+ const trigger = triggerRef.current;
+ const target = e.target as Node;
+
+ if (
+ content &&
+ !content.contains(target) &&
+ trigger &&
+ !trigger.contains(target)
+ ) {
+ setFadeOut(true);
+ }
+ };
+
+ window.addEventListener('pointerdown', handlePointerDownOutside);
+ return () =>
+ window.removeEventListener('pointerdown', handlePointerDownOutside);
+ }, [isOpen, isInitialOpen, triggerRef]);
+
+ // Escapeキーで閉じる
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!CLOSE_KEY_LIST.includes(e.key.toUpperCase())) return;
+
+ if (isInitialOpen) {
+ const content = localContentRef.current;
+ if (
+ content &&
+ (content.contains(document.activeElement) ||
+ document.activeElement === content)
+ ) {
+ e.preventDefault();
+ handleClose();
+ }
+ } else {
+ e.preventDefault();
+ setIsOpen(false);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen, isInitialOpen, handleClose, setIsOpen]);
+
+ const handleAnimationEnd = useCallback(
+ (event: AnimationEvent) => {
+ if (event.animationName === FADE_IN_ANIMATION) return;
+ handleClose();
+ setFadeOut(false);
+ },
+ [handleClose],
+ );
+
+ useEffect(() => {
+ const content = localContentRef.current;
+ if (!content) return;
+
+ content.addEventListener('animationend', handleAnimationEnd);
+ return () =>
+ content.removeEventListener('animationend', handleAnimationEnd);
+ }, [handleAnimationEnd]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const role = isInitialOpen ? 'group' : 'tooltip';
+ const showCloseButton = isInitialOpen;
+
+ return (
+
+ );
+};
+
+export const Tooltip = {
+ Frame,
+ Trigger,
+ Content,
+};
diff --git a/packages/spindle-ui/src/Tooltip/design-doc.md b/packages/spindle-ui/src/Tooltip/design-doc.md
index 248b842b1..783f6690f 100644
--- a/packages/spindle-ui/src/Tooltip/design-doc.md
+++ b/packages/spindle-ui/src/Tooltip/design-doc.md
@@ -61,27 +61,34 @@ FigmaではTooltip(モードレス形式)とToggletip(モード形式)
- 情報が多くなる場合やインタラクションを多数含めたい場合にはモーダルの使用や別ページへの遷移を検討しましょう
- **テキストやリンクに付けない**
- ユーザーのメンタルモデルに反した挙動となり、混乱を招く可能性があります
+- **ポインティングデバイスでは、機能を持つボタンにTooltipを付けない**
+ - BボタンやIボタンなど、トリガー要素自体に補足の説明以外の機能がある場合、タッチデバイスではクリックでTooltipが表示されてしまい本来の機能が動作しません。このケースはモバイル端末でアクセシブルにできないため、使用すべきではありません
+ - 代替案: 初期表示(`defaultOpen={true}`)で表示するか、機能を持つ要素の隣にインフォメーションアイコンを配置してください
## 要素
### Design Tokens
#### Variant: Information
+
- Surface Accent Neutral High Emphasis (背景色)
- Text High Emphasis Inverse (テキスト色)
- Object High Emphasis Inverse (アイコン色)
#### Variant: Confirmation
+
- Surface Accent Primary (背景色)
- Text High Emphasis Inverse (テキスト色)
- Object High Emphasis Inverse (アイコン色)
#### Variant: Error
+
- Surface Caution (背景色)
- Text High Emphasis Inverse (テキスト色)
- Object High Emphasis Inverse (アイコン色)
#### その他
+
- Box Shadow Lv6 Strong
- Animation Appear In Duration (表示時のアニメーション時間)
- Animation Appear In Easing (表示時のイージング)
@@ -175,10 +182,12 @@ Tooltipには三角形のポインターが表示され、トリガー要素を
Tooltipの閉じる動作は表示状態に応じて自動的に最適化されます:
**`defaultOpen={false}`または`defaultOpen={true}`で一度閉じた後の再表示の場合:**
+
- ポインティングデバイス: マウスを離す/フォーカスを外すと自動で閉じる
- タッチデバイス: 再度クリック/タップ、または領域外クリックで閉じる
**`defaultOpen={true}`で初期表示されている場合:**
+
- 閉じるボタンで閉じる
- Tooltipまたはその内部要素にフォーカスが当たっている時、Escapeキーで閉じる
- 領域外クリックでは閉じない(閉じるボタンがあるため)
@@ -209,15 +218,54 @@ Tooltipは、表示状態に応じて適切なARIA属性を自動的に付与し
`Tooltip.Trigger`から渡されるprops(`ref`、`aria-describedby`、`aria-expanded`、`onMouseEnter`、`onFocus`、`onBlur`、`onPointerDown`、`onPointerUp`)を適切に受け取れるようにしてください。
+#### トリガー要素の hover スタイル
+
+ポインティングデバイスにおいて、トリガー要素にhoverした際は`background-color: var(--color-surface-secondary)`を適用します。これはトリガー要素がボタンのような押下可能な要素でない場合(例:「?」アイコン)でも同様です。
+
+**設計根拠:**
+
+hoverスタイルは「押せるアフォーダンス」であると同時に「インタラクティブな要素にフォーカスしたフィードバック」でもあります。Tooltipトリガーのような押せない要素であっても、ユーザーに「なぜ吹き出しが出てきたのか」を伝えるフィードバックとして有用です。
+
+hoverスタイルの必要条件:
+
+- 変化したことが色以外でもわかること
+- 今より見えにくくならないこと(opacityを下げるアプローチは、hoverしたものが見えなくなるため不適切)
+- 押せそう感ができるだけ少ないこと
+
+これらの条件を踏まえ、落とし所として薄く円をつける(`background-color: var(--color-surface-secondary)` + `border-radius: 50%`)スタイルを採用しています。`cursor: default`を指定し、クリック可能な要素と区別します。
+
+**注意:** Tooltipコンポーネント自体はトリガー要素のスタイルを制御しません。hoverスタイルは利用側で適用してください。
+
+```css
+.trigger {
+ border-radius: 50%;
+}
+
+.trigger:hover {
+ background-color: var(--color-surface-secondary);
+ cursor: default;
+}
+```
+
## 実装例
React実装の一例です。
```tsx
-
+
- {props => (
-
+ {(props) => (
+
)}
@@ -245,11 +293,11 @@ React実装の一例です。
style="--Tooltip-trigger-width: 48px; --Tooltip-trigger-height: 48px;"
>
-
- 補足情報
-
+
補足情報
@@ -265,28 +313,35 @@ const [hasSeenTooltip, setHasSeenTooltip] = useState(() => {
return localStorage.getItem('tutorial-tooltip-seen') === 'true';
});
-{!hasSeenTooltip ? (
- {
- localStorage.setItem('tutorial-tooltip-seen', 'true');
- setHasSeenTooltip(true);
- }}
- >
-
- {props => (
-
-
-
- )}
-
- 新機能: 機能が追加されました
-
-) : (
-
-
-
-)}
+{
+ !hasSeenTooltip ? (
+ {
+ localStorage.setItem('tutorial-tooltip-seen', 'true');
+ setHasSeenTooltip(true);
+ }}
+ >
+
+ {(props) => (
+
+
+
+ )}
+
+ 新機能: 機能が追加されました
+
+ ) : (
+
+
+
+ );
+}
```
## アクセシビリティ
diff --git a/packages/spindle-ui/src/Tooltip/graceArea.test.ts b/packages/spindle-ui/src/Tooltip/graceArea.test.ts
new file mode 100644
index 000000000..0214e7c41
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/graceArea.test.ts
@@ -0,0 +1,153 @@
+import {
+ getConvexHull,
+ getExitSideFromRect,
+ getPaddedExitPoints,
+ getPointsFromRect,
+ isPointInPolygon,
+} from './graceArea';
+
+describe('graceArea utilities', () => {
+ describe('getExitSideFromRect', () => {
+ const rect = {
+ top: 100,
+ right: 200,
+ bottom: 150,
+ left: 100,
+ } as DOMRect;
+
+ test('returns "top" when point is closest to top edge', () => {
+ expect(getExitSideFromRect({ x: 150, y: 102 }, rect)).toBe('top');
+ });
+
+ test('returns "bottom" when point is closest to bottom edge', () => {
+ expect(getExitSideFromRect({ x: 150, y: 148 }, rect)).toBe('bottom');
+ });
+
+ test('returns "left" when point is closest to left edge', () => {
+ expect(getExitSideFromRect({ x: 102, y: 125 }, rect)).toBe('left');
+ });
+
+ test('returns "right" when point is closest to right edge', () => {
+ expect(getExitSideFromRect({ x: 198, y: 125 }, rect)).toBe('right');
+ });
+ });
+
+ describe('getPaddedExitPoints', () => {
+ const exitPoint = { x: 100, y: 100 };
+
+ test('creates padded points for top exit', () => {
+ const points = getPaddedExitPoints(exitPoint, 'top', 5);
+ expect(points).toEqual([
+ { x: 95, y: 105 },
+ { x: 105, y: 105 },
+ ]);
+ });
+
+ test('creates padded points for bottom exit', () => {
+ const points = getPaddedExitPoints(exitPoint, 'bottom', 5);
+ expect(points).toEqual([
+ { x: 95, y: 95 },
+ { x: 105, y: 95 },
+ ]);
+ });
+
+ test('creates padded points for left exit', () => {
+ const points = getPaddedExitPoints(exitPoint, 'left', 5);
+ expect(points).toEqual([
+ { x: 105, y: 95 },
+ { x: 105, y: 105 },
+ ]);
+ });
+
+ test('creates padded points for right exit', () => {
+ const points = getPaddedExitPoints(exitPoint, 'right', 5);
+ expect(points).toEqual([
+ { x: 95, y: 95 },
+ { x: 95, y: 105 },
+ ]);
+ });
+ });
+
+ describe('getPointsFromRect', () => {
+ test('returns four corner points of rectangle', () => {
+ const rect = {
+ top: 10,
+ right: 50,
+ bottom: 30,
+ left: 20,
+ } as DOMRect;
+
+ expect(getPointsFromRect(rect)).toEqual([
+ { x: 20, y: 10 },
+ { x: 50, y: 10 },
+ { x: 50, y: 30 },
+ { x: 20, y: 30 },
+ ]);
+ });
+ });
+
+ describe('isPointInPolygon', () => {
+ // Square polygon: (0,0), (10,0), (10,10), (0,10)
+ const square = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ { x: 10, y: 10 },
+ { x: 0, y: 10 },
+ ];
+
+ test('returns true for point inside polygon', () => {
+ expect(isPointInPolygon({ x: 5, y: 5 }, square)).toBe(true);
+ });
+
+ test('returns false for point outside polygon', () => {
+ expect(isPointInPolygon({ x: 15, y: 5 }, square)).toBe(false);
+ expect(isPointInPolygon({ x: -5, y: 5 }, square)).toBe(false);
+ expect(isPointInPolygon({ x: 5, y: -5 }, square)).toBe(false);
+ expect(isPointInPolygon({ x: 5, y: 15 }, square)).toBe(false);
+ });
+
+ test('works with triangle', () => {
+ const triangle = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ { x: 5, y: 10 },
+ ];
+ expect(isPointInPolygon({ x: 5, y: 3 }, triangle)).toBe(true);
+ expect(isPointInPolygon({ x: 1, y: 9 }, triangle)).toBe(false);
+ });
+ });
+
+ describe('getConvexHull', () => {
+ test('returns same points for triangle', () => {
+ const triangle = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ { x: 5, y: 10 },
+ ];
+ const hull = getConvexHull(triangle);
+ expect(hull).toHaveLength(3);
+ });
+
+ test('removes interior points', () => {
+ // Square with a point inside
+ const points = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ { x: 10, y: 10 },
+ { x: 0, y: 10 },
+ { x: 5, y: 5 }, // interior point
+ ];
+ const hull = getConvexHull(points);
+ expect(hull).toHaveLength(4);
+ expect(hull).not.toContainEqual({ x: 5, y: 5 });
+ });
+
+ test('handles single point', () => {
+ expect(getConvexHull([{ x: 5, y: 5 }])).toEqual([{ x: 5, y: 5 }]);
+ });
+
+ test('handles empty array', () => {
+ expect(getConvexHull([])).toEqual([]);
+ });
+ });
+});
diff --git a/packages/spindle-ui/src/Tooltip/graceArea.ts b/packages/spindle-ui/src/Tooltip/graceArea.ts
new file mode 100644
index 000000000..dbe347750
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/graceArea.ts
@@ -0,0 +1,167 @@
+/**
+ * triggerとtooltip間のポインター移動を許容するためのgrace area(猶予領域)
+ * ポインターがtriggerからtooltipへ移動する際、tooltipが閉じないようにする
+ */
+
+export type Point = { x: number; y: number };
+export type Polygon = Point[];
+export type Side = 'top' | 'right' | 'bottom' | 'left';
+
+/**
+ * ポインターがrectのどの辺から出たかを判定
+ */
+export function getExitSideFromRect(point: Point, rect: DOMRect): Side {
+ const top = Math.abs(rect.top - point.y);
+ const bottom = Math.abs(rect.bottom - point.y);
+ const right = Math.abs(rect.right - point.x);
+ const left = Math.abs(rect.left - point.x);
+
+ const min = Math.min(top, bottom, right, left);
+ if (min === left) return 'left';
+ if (min === right) return 'right';
+ if (min === top) return 'top';
+ return 'bottom';
+}
+
+/**
+ * exit pointの周囲にpaddingを追加した2点を生成
+ */
+export function getPaddedExitPoints(
+ exitPoint: Point,
+ exitSide: Side,
+ padding = 5,
+): Point[] {
+ switch (exitSide) {
+ case 'top':
+ return [
+ { x: exitPoint.x - padding, y: exitPoint.y + padding },
+ { x: exitPoint.x + padding, y: exitPoint.y + padding },
+ ];
+ case 'bottom':
+ return [
+ { x: exitPoint.x - padding, y: exitPoint.y - padding },
+ { x: exitPoint.x + padding, y: exitPoint.y - padding },
+ ];
+ case 'left':
+ return [
+ { x: exitPoint.x + padding, y: exitPoint.y - padding },
+ { x: exitPoint.x + padding, y: exitPoint.y + padding },
+ ];
+ case 'right':
+ return [
+ { x: exitPoint.x - padding, y: exitPoint.y - padding },
+ { x: exitPoint.x - padding, y: exitPoint.y + padding },
+ ];
+ }
+}
+
+/**
+ * rectの4隅の座標を取得
+ */
+export function getPointsFromRect(rect: DOMRect): Point[] {
+ return [
+ { x: rect.left, y: rect.top },
+ { x: rect.right, y: rect.top },
+ { x: rect.right, y: rect.bottom },
+ { x: rect.left, y: rect.bottom },
+ ];
+}
+
+/**
+ * pointがpolygon内にあるかをray casting法で判定
+ * https://github.com/substack/point-in-polygon
+ */
+export function isPointInPolygon(point: Point, polygon: Polygon): boolean {
+ const { x, y } = point;
+ let inside = false;
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const pi = polygon[i];
+ const pj = polygon[j];
+ if (!pi || !pj) continue;
+
+ const xi = pi.x;
+ const yi = pi.y;
+ const xj = pj.x;
+ const yj = pj.y;
+
+ const intersect =
+ yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
+ if (intersect) inside = !inside;
+ }
+
+ return inside;
+}
+
+/**
+ * 点群の凸包を計算
+ * https://www.nayuki.io/page/convex-hull-algorithm
+ */
+export function getConvexHull(points: Point[]): Point[] {
+ const sorted = [...points].sort((a, b) => {
+ if (a.x !== b.x) return a.x - b.x;
+ return a.y - b.y;
+ });
+
+ if (sorted.length <= 1) return sorted;
+
+ const upperHull: Point[] = [];
+ for (const p of sorted) {
+ while (upperHull.length >= 2) {
+ const q = upperHull[upperHull.length - 1];
+ const r = upperHull[upperHull.length - 2];
+ if (q && r && (q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) {
+ upperHull.pop();
+ } else {
+ break;
+ }
+ }
+ upperHull.push(p);
+ }
+ upperHull.pop();
+
+ const lowerHull: Point[] = [];
+ for (let i = sorted.length - 1; i >= 0; i--) {
+ const p = sorted[i];
+ if (!p) continue;
+ while (lowerHull.length >= 2) {
+ const q = lowerHull[lowerHull.length - 1];
+ const r = lowerHull[lowerHull.length - 2];
+ if (q && r && (q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) {
+ lowerHull.pop();
+ } else {
+ break;
+ }
+ }
+ lowerHull.push(p);
+ }
+ lowerHull.pop();
+
+ if (
+ upperHull.length === 1 &&
+ lowerHull.length === 1 &&
+ upperHull[0]?.x === lowerHull[0]?.x &&
+ upperHull[0]?.y === lowerHull[0]?.y
+ ) {
+ return upperHull;
+ }
+
+ return [...upperHull, ...lowerHull];
+}
+
+/**
+ * 2要素間のgrace area polygonを生成
+ */
+export function createGraceArea(
+ exitPoint: Point,
+ exitElement: HTMLElement,
+ targetElement: HTMLElement,
+): Polygon {
+ const exitSide = getExitSideFromRect(
+ exitPoint,
+ exitElement.getBoundingClientRect(),
+ );
+ const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide);
+ const targetPoints = getPointsFromRect(targetElement.getBoundingClientRect());
+ return getConvexHull([...paddedExitPoints, ...targetPoints]);
+}
diff --git a/packages/spindle-ui/src/Tooltip/index.ts b/packages/spindle-ui/src/Tooltip/index.ts
new file mode 100644
index 000000000..b44d466fa
--- /dev/null
+++ b/packages/spindle-ui/src/Tooltip/index.ts
@@ -0,0 +1 @@
+export { Tooltip } from './Tooltip';
diff --git a/packages/spindle-ui/src/index.css b/packages/spindle-ui/src/index.css
index 6cc12f7f2..a810a7478 100644
--- a/packages/spindle-ui/src/index.css
+++ b/packages/spindle-ui/src/index.css
@@ -15,6 +15,7 @@
@import './TextButton/TextButton.css';
@import './TextLink/TextLink.css';
@import './Toast/Toast.css';
+@import './Tooltip/Tooltip.css';
@import './SnackBar/SnackBar.css';
@import './List/index.css';
@import './DropdownMenu/DropdownMenu.css';