From 9e54b0ff0b20284a1fccc543c4c3eea1e89d5e1f Mon Sep 17 00:00:00 2001 From: yu-3in Date: Tue, 9 Dec 2025 12:16:03 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(spindle-ui):=20tooltip=E3=82=B3?= =?UTF-8?q?=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/src/Tooltip/Tooltip.css | 336 +++++++++ packages/spindle-ui/src/Tooltip/Tooltip.mdx | 383 ++++++++++ .../src/Tooltip/Tooltip.stories.example.tsx | 400 +++++++++++ .../src/Tooltip/Tooltip.stories.tsx | 91 +++ .../spindle-ui/src/Tooltip/Tooltip.test.tsx | 665 ++++++++++++++++++ packages/spindle-ui/src/Tooltip/Tooltip.tsx | 378 ++++++++++ packages/spindle-ui/src/Tooltip/index.ts | 1 + packages/spindle-ui/src/index.css | 2 + 8 files changed, 2256 insertions(+) create mode 100644 packages/spindle-ui/src/Tooltip/Tooltip.css create mode 100644 packages/spindle-ui/src/Tooltip/Tooltip.mdx create mode 100644 packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx create mode 100644 packages/spindle-ui/src/Tooltip/Tooltip.stories.tsx create mode 100644 packages/spindle-ui/src/Tooltip/Tooltip.test.tsx create mode 100644 packages/spindle-ui/src/Tooltip/Tooltip.tsx create mode 100644 packages/spindle-ui/src/Tooltip/index.ts diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.css b/packages/spindle-ui/src/Tooltip/Tooltip.css new file mode 100644 index 000000000..c13457086 --- /dev/null +++ b/packages/spindle-ui/src/Tooltip/Tooltip.css @@ -0,0 +1,336 @@ +@import "../IconButton/IconButton.css"; + +:root { + --Tooltip-z-index: 1; + --Tooltip-arrow-offset: 4px; +} + +.spui-Tooltip { + display: inline-block; + position: relative; + width: fit-content; +} + +.spui-Tooltip-frame { + animation: var(--animation-appear-in-duration) var(--animation-appear-in-easing) spui-Tooltip-fade-in; + border-radius: 8px; + box-shadow: 0 11px 28px 0 rgba(8, 18, 26, 0.24); + box-sizing: border-box; + max-width: 242px; + min-width: 116px; + overflow: visible; + padding: 16px 10px 16px 16px; + position: absolute; + width: max-content; + z-index: var(--Tooltip-z-index); +} + +/* stylelint-disable-next-line plugin/selector-bem-pattern */ +.spui-Tooltip-frame.is-fade-out { + animation: var(--animation-disappear-duration) var(--animation-disappear-easing) spui-Tooltip-fade-out; + opacity: 0; +} + +.spui-Tooltip-frame::before { + content: ''; + height: 13px; + position: absolute; + width: 13px; +} + +/* Variants */ +.spui-Tooltip-frame--information { + background-color: var(--color-surface-accent-neutral-high-emphasis); + color: var(--color-text-high-emphasis-inverse); +} + +.spui-Tooltip-frame--information::before { + background-color: var(--color-surface-accent-neutral-high-emphasis); +} + +.spui-Tooltip-frame--confirmation { + background-color: var(--color-surface-accent-primary); + color: var(--color-text-high-emphasis-inverse); +} + +.spui-Tooltip-frame--confirmation::before { + background-color: var(--color-surface-accent-primary); +} + +.spui-Tooltip-frame--error { + background-color: var(--color-surface-caution); + color: var(--color-text-high-emphasis-inverse); +} + +.spui-Tooltip-frame--error::before { + background-color: var(--color-surface-caution); +} + +/* Direction: top */ +.spui-Tooltip-frame--top { + top: calc(var(--Tooltip-trigger-height) + 6px + var(--Tooltip-arrow-offset)); +} + +.spui-Tooltip-frame--top::before { + border-radius: 5px 0 0; + top: -6px; + transform: rotate(45deg); +} + +/* Direction: bottom */ +.spui-Tooltip-frame--bottom { + bottom: calc(var(--Tooltip-trigger-height) + 6px + var(--Tooltip-arrow-offset)); +} + +.spui-Tooltip-frame--bottom::before { + border-radius: 0 0 5px; + bottom: -6px; + transform: rotate(45deg); +} + +/* Direction: left */ +.spui-Tooltip-frame--left { + left: calc(var(--Tooltip-trigger-width) + 6px + var(--Tooltip-arrow-offset)); +} + +.spui-Tooltip-frame--left::before { + border-radius: 0 0 0 5px; + left: -6px; + transform: rotate(45deg); +} + +/* Direction: right */ +.spui-Tooltip-frame--right { + right: calc(var(--Tooltip-trigger-width) + 6px + var(--Tooltip-arrow-offset)); +} + +.spui-Tooltip-frame--right::before { + border-radius: 0 5px 0 0; + right: -6px; + transform: rotate(45deg); +} + +/* Position: center (for top/bottom) */ +.spui-Tooltip-frame--top.spui-Tooltip-frame--center, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--center { + animation: var(--animation-appear-in-duration) var(--animation-appear-in-easing) spui-Tooltip-fade-in; + left: 50%; + transform: translateX(-50%); +} + +.spui-Tooltip-frame--top.spui-Tooltip-frame--center::before, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--center::before { + left: 50%; + transform: translateX(-50%) rotate(45deg); +} + +/* Position: center (for left/right) */ +.spui-Tooltip-frame--left.spui-Tooltip-frame--center, +.spui-Tooltip-frame--right.spui-Tooltip-frame--center { + animation: var(--animation-appear-in-duration) var(--animation-appear-in-easing) spui-Tooltip-fade-in; + top: 50%; + transform: translateY(-50%); +} + +.spui-Tooltip-frame--left.spui-Tooltip-frame--center::before, +.spui-Tooltip-frame--right.spui-Tooltip-frame--center::before { + top: 50%; + transform: translateY(-50%) rotate(45deg); +} + +/* Position: start (for top/bottom) */ +.spui-Tooltip-frame--top.spui-Tooltip-frame--start, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--start { + left: 0; +} + +.spui-Tooltip-frame--top.spui-Tooltip-frame--start::before, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--start::before { + left: calc(var(--Tooltip-trigger-width) / 2 - 6.5px); +} + +/* Position: start (for left/right) */ +.spui-Tooltip-frame--left.spui-Tooltip-frame--start, +.spui-Tooltip-frame--right.spui-Tooltip-frame--start { + top: 0; +} + +.spui-Tooltip-frame--left.spui-Tooltip-frame--start::before, +.spui-Tooltip-frame--right.spui-Tooltip-frame--start::before { + top: calc(var(--Tooltip-trigger-height) / 2 - 6.5px); +} + +/* Position: end (for top/bottom) */ +.spui-Tooltip-frame--top.spui-Tooltip-frame--end, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--end { + right: 0; +} + +.spui-Tooltip-frame--top.spui-Tooltip-frame--end::before, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--end::before { + right: calc(var(--Tooltip-trigger-width) / 2 - 6.5px); +} + +/* Position: end (for left/right) */ +.spui-Tooltip-frame--left.spui-Tooltip-frame--end, +.spui-Tooltip-frame--right.spui-Tooltip-frame--end { + bottom: 0; +} + +.spui-Tooltip-frame--left.spui-Tooltip-frame--end::before, +.spui-Tooltip-frame--right.spui-Tooltip-frame--end::before { + bottom: calc(var(--Tooltip-trigger-height) / 2 - 6.5px); +} + +/* Position: edgeStart (for top/bottom) */ +.spui-Tooltip-frame--top.spui-Tooltip-frame--edgeStart, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeStart { + left: calc(var(--Tooltip-trigger-width) / 2); +} + +.spui-Tooltip-frame--top.spui-Tooltip-frame--edgeStart::before { + border-radius: 0 3px 0 0; + clip-path: polygon(0 100%, 0 0, 100% 0); + height: 11px; + left: 8px; + transform: none; + width: 13px; +} + +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeStart::before { + border-radius: 0 0 3px; + clip-path: polygon(0 0, 0 100%, 100% 100%); + height: 11px; + left: 8px; + transform: none; + width: 13px; +} + +/* Position: edgeStart (for left/right) */ +.spui-Tooltip-frame--left.spui-Tooltip-frame--edgeStart, +.spui-Tooltip-frame--right.spui-Tooltip-frame--edgeStart { + top: calc(var(--Tooltip-trigger-height) / 2); +} + +.spui-Tooltip-frame--left.spui-Tooltip-frame--edgeStart::before { + border-radius: 0 0 0 3px; + clip-path: polygon(0 0, 0 100%, 100% 0); + height: 13px; + top: 8px; + transform: none; + width: 11px; +} + +.spui-Tooltip-frame--right.spui-Tooltip-frame--edgeStart::before { + border-radius: 0 0 3px; + clip-path: polygon(0 0, 100% 0, 100% 100%); + height: 13px; + top: 8px; + transform: none; + width: 11px; +} + +/* Position: edgeEnd (for top/bottom) */ +.spui-Tooltip-frame--top.spui-Tooltip-frame--edgeEnd, +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeEnd { + right: calc(var(--Tooltip-trigger-width) / 2); +} + +.spui-Tooltip-frame--top.spui-Tooltip-frame--edgeEnd::before { + border-radius: 3px 0 0; + clip-path: polygon(100% 100%, 0 0, 100% 0); + height: 11px; + right: 8px; + transform: none; + width: 13px; +} + +.spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeEnd::before { + border-radius: 0 0 0 3px; + clip-path: polygon(0 100%, 100% 0, 100% 100%); + height: 11px; + right: 8px; + transform: none; + width: 13px; +} + +/* Position: edgeEnd (for left/right) */ +.spui-Tooltip-frame--left.spui-Tooltip-frame--edgeEnd, +.spui-Tooltip-frame--right.spui-Tooltip-frame--edgeEnd { + bottom: calc(var(--Tooltip-trigger-height) / 2); +} + +.spui-Tooltip-frame--left.spui-Tooltip-frame--edgeEnd::before { + border-radius: 0 3px 0 0; + bottom: 8px; + clip-path: polygon(0 100%, 100% 0, 100% 100%); + height: 13px; + transform: none; + width: 11px; +} + +.spui-Tooltip-frame--right.spui-Tooltip-frame--edgeEnd::before { + border-radius: 3px 0 0; + bottom: 8px; + clip-path: polygon(0 0, 0 100%, 100% 100%); + height: 13px; + transform: none; + width: 11px; +} + +.spui-Tooltip-content { + align-items: flex-start; + display: flex; + gap: 16px; +} + +.spui-Tooltip-text { + align-self: center; + flex: 1; + font-size: 0.875em; + font-weight: bold; + line-height: 1.4; + overflow-wrap: break-word; + word-break: break-all; +} + +.spui-Tooltip-closeButton { + /* stylelint-disable plugin/selector-bem-pattern */ + --IconButton--neutral-backgroundColor: transparent; + --IconButton--neutral-onActive-backgroundColor: var(--white-20-alpha); + --IconButton--neutral-onHover-backgroundColor: var(--white-20-alpha); + --IconButton--neutral-color: var(--color-object-high-emphasis-inverse); + /* stylelint-enable plugin/selector-bem-pattern */ + + align-items: center; + display: flex; + flex-shrink: 0; + justify-content: center; +} + +/* Animations */ +@keyframes spui-Tooltip-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes spui-Tooltip-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@media (min-width: 768px) { + .spui-Tooltip-frame { + max-width: 732px; + } +} diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.mdx b/packages/spindle-ui/src/Tooltip/Tooltip.mdx new file mode 100644 index 000000000..444e7b600 --- /dev/null +++ b/packages/spindle-ui/src/Tooltip/Tooltip.mdx @@ -0,0 +1,383 @@ +import { Meta, Story, Source } from '@storybook/addon-docs/blocks'; +import * as TooltipStories from './Tooltip.stories'; + + + +# Tooltip + +Tooltipは、トリガー要素に対して**補足情報や訴求内容を一時的に表示する**ためのコンポーネントです。UIの見た目をすっきり保ちながら、必要な時にのみ情報を提供します。アイコンボタンなどラベルがない要素の説明や、新機能の案内などに使用します。 + + + + + +`} +/> + +## このコンポーネントでやっていること + +- デバイスに応じて自動的に表示トリガーを切り替えます(ポインティングデバイスではhover/focus、タッチデバイスではclick/tap) +- 初期表示オプション(`defaultOpen={true}`)でチュートリアルや新機能案内として最初から表示できます +- 表示状態に応じて`role="tooltip"`または`role="group"`を自動的に切り替えます +- `defaultOpen={true}`の初期表示時のみ閉じるボタンを表示し、再表示時は自動的に非表示にします +- トリガー要素のサイズを取得し、ポインターが適切な位置に配置されるように自動計算します + +## 利用時に注意してほしいこと + +- **操作に必須でない補足的な説明や情報を提供する**: 操作に必要な情報は予め表示しておき、Tooltipの使用はできる限り控えめにすることが理想です +- **トリガーにはインフォメーションアイコンやはてなアイコンを使用する**: 原則として、機能を持つ要素(ボタンなど)に直接付けず、その隣にアイコンを配置します + - 例外: 初期表示ありかつ再表示しないケース(チュートリアルや新機能案内など)では、機能を持つ要素に直接付けることも許容されます +- **多くの情報やインタラクションを含めない**: 情報が多くなる場合やインタラクションを多数含めたい場合にはモーダルの使用や別ページへの遷移を検討しましょう +- **テキストやリンクに付けない**: ユーザーのメンタルモデルに反した挙動となり、混乱を招く可能性があります +- `Tooltip.Trigger`のchildrenとして渡す関数は、受け取ったprops(`ref`、`aria-describedby`、`aria-expanded`、`onMouseEnter`、`onMouseLeave`、`onFocus`、`onBlur`、`onClick`)をトリガー要素に必ず適用してください +- `z-index`はdefaultでは`1`になっていますが、必要に応じて`--Tooltip-z-index`を設定してください + +## 基本的な使い方 + +デフォルトでは、トリガー要素にhoverまたはfocusすることでTooltipが表示されます。トリガー要素から離れると自動的に非表示になります。 + + + + + + {(props) => ( + + + )} + + + これは補足情報です。hover または focus で表示されます。 + +`} +/> + +### トリガー動作の違い + +- **ポインティングデバイス**: hoverで表示、トリガーから離れると非表示 +- **タッチデバイス**: click/tapで表示、再度click/tapで非表示 + +## 初期表示 + +`defaultOpen={true}`を指定すると、初期状態でTooltipが表示されます。閉じるボタンをクリックすることで非表示にできます。 + +チュートリアルや新機能案内などで使用します。 + + + + + + {(props) => ( + + + )} + + + これは初期表示のTooltipです。閉じるボタンをクリックして閉じることができます。 + +`} +/> + +### 閉じるボタンの表示 + +閉じるボタンは**初期表示時のみ**表示されます: + +- `defaultOpen={true}`の場合、初期表示時に閉じるボタンが表示される +- 一度閉じた後、hover/focusまたはclick/tapで再表示された時は閉じるボタンは表示されない +- `defaultOpen={false}`または未指定の場合、閉じるボタンは表示されない + +### 閉じる動作 + +Tooltipの閉じる動作は表示状態に応じて自動的に最適化されます。 + +**通常のTooltip(`defaultOpen={false}`または再表示時)** + +- **ポインティングデバイス**: マウスを離す/フォーカスを外すと自動で閉じる +- **タッチデバイス**: 再度click/tap、または領域外clickで閉じる + +**初期表示のTooltip(`defaultOpen={true}`の初回表示)** + +- 閉じるボタンで閉じる +- Tooltipまたはその内部要素にフォーカスが当たっている時、Escapeキーで閉じる +- 領域外clickでは閉じない(閉じるボタンがあるため) + +### 一度だけ表示する実装 + +`onClose`コールバックを使用して、Tooltipを閉じた際の処理を実装できます。 + + + + { + setHasSeenTooltip(true); + }} + > + + {(props) => ( + + + )} + + + これは一度閉じたら再表示されないTooltipです。チュートリアルや新機能案内などに使用します。 + + +) : ( + + +)} +`} /> + +**注意**: `defaultOpen`プロパティは初期表示の制御のみを行うため、状態が変わっても再評価されません。一度閉じたら表示しないようにするには、上記のようにTooltipコンポーネント自体を条件付きでレンダリングする必要があります。 + +## Variants + +### Information + + + + + + {(props) => ( + + + )} + + 補足情報を伝えるためのTooltipです。 +`} +/> + +### Confirmation + + + + + + {(props) => ( + + + )} + + 訴求したい内容を伝えるためのTooltipです。 +`} +/> + +### Error + + + + + + {(props) => ( + + + )} + + エラーメッセージを表示するためのTooltipです。 +`} +/> + +## Direction + +### Top + + + + + + {(props) => ( + + + )} + + トリガーの下に表示され、ポインターが上を指します。 +`} +/> + +### Bottom + + + + + + {(props) => ( + + + )} + + トリガーの上に表示され、ポインターが下を指します。 +`} +/> + +### Left + + + + + + {(props) => ( + + + )} + + トリガーの右に表示され、ポインターが左を指します。 +`} +/> + +### Right + + + + + + {(props) => ( + + + )} + + トリガーの左に表示され、ポインターが右を指します。 +`} +/> + +## Position + +### Center + + + + + + {(props) => ( + + + )} + + ポインターがトリガーの中央に配置されます。 +`} +/> + +### Start + + + + + + {(props) => ( + + + )} + + ポインターがTooltipの開始位置寄りに配置されます。 +`} +/> + +### End + + + + + + {(props) => ( + + + )} + + ポインターがTooltipの終了位置寄りに配置されます。 +`} +/> + +### EdgeStart + + + + + + {(props) => ( + + + )} + + トリガーが画面端から 16-36px の範囲にある場合に使用します。 +`} +/> + +### EdgeEnd + + + + + + {(props) => ( + + + )} + + トリガーが画面端から 16-36px の範囲にある場合に使用します。 +`} +/> + +## テキストの折り返し + +### LongText + + + + + + {(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..d13ade9c3 --- /dev/null +++ b/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx @@ -0,0 +1,400 @@ +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..39e0cabfb --- /dev/null +++ b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx @@ -0,0 +1,665 @@ +import { jest } from '@jest/globals'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import Information from '../Icon/Information'; +import { IconButton } from '../IconButton'; +import { Tooltip } from './Tooltip'; + +describe('', () => { + beforeEach(() => { + Object.defineProperty(window, 'ontouchstart', { + writable: true, + value: undefined, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + writable: true, + value: 0, + }); + }); + + describe('共通', () => { + test('aria-describedby が自動的に設定される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + expect(trigger).toHaveAttribute('aria-describedby'); + }); + }); + + describe('defaultOpen={false}の場合', () => { + test('初期状態では表示されない', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); + }); + + test('hover時に表示される', async () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + fireEvent.mouseEnter(trigger); + + await waitFor(() => { + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + }); + + test('マウスを離すと非表示になる', async () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + fireEvent.mouseEnter(trigger); + + await waitFor(() => { + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + + fireEvent.mouseLeave(trigger); + + await waitFor(() => { + expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); + }); + }); + + test('focus時に表示される', async () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + fireEvent.focus(trigger); + + await waitFor(() => { + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + }); + + test('フォーカスを外すと非表示になる', 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('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('aria-expandedが設定されない', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); + + test('閉じるボタンが表示されない', 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('タッチデバイスでclick時に表示/非表示を切り替える', async () => { + Object.defineProperty(window, 'ontouchstart', { + writable: true, + value: {}, + }); + + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); + }); + }); + + test('タッチデバイスで領域外クリックで閉じる', async () => { + Object.defineProperty(window, 'ontouchstart', { + writable: true, + value: {}, + }); + + render( +
+ + + {(props) => ( + + + )} + + 補足情報 + + +
, + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + + const outsideButton = screen.getByRole('button', { name: '外部のボタン' }); + fireEvent.click(outsideButton); + + await waitFor(() => { + expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); + }); + }); + }); + + describe('defaultOpen={true}の初期表示時', () => { + test('初期状態で表示される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + + test('role="group"が設定される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const group = screen.getByRole('group'); + expect(group).toBeInTheDocument(); + }); + + test('aria-expanded="true"が設定される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const trigger = screen.getByRole('button', { name: '詳細情報' }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + test('閉じるボタンが表示される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + expect(screen.getByRole('button', { name: '閉じる' })).toBeInTheDocument(); + }); + + test('閉じるボタンクリック時に閉じる', async () => { + const onClose = jest.fn(); + + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const closeButton = screen.getByRole('button', { name: '閉じる' }); + fireEvent.click(closeButton); + + await waitFor(() => { + expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('Tooltipまたはその内部要素にフォーカスが当たっている時、Escapeキー押下で閉じる', async () => { + const onClose = jest.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('領域外クリックでは閉じない', async () => { + render( +
+ + + {(props) => ( + + + )} + + 補足情報 + + +
, + ); + + const outsideButton = screen.getByRole('button', { name: '外部のボタン' }); + fireEvent.click(outsideButton); + + expect(screen.getByText('補足情報')).toBeInTheDocument(); + }); + }); + + describe('defaultOpen={true}で一度閉じた後の再表示時', () => { + test('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('role="group"から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('aria-expandedが設定されない', 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('閉じるボタンが表示されない', 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('variant="information"でクラスが適用される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const group = screen.getByRole('group'); + expect(group).toHaveClass('spui-Tooltip-frame--information'); + }); + + test('variant="confirmation"でクラスが適用される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const group = screen.getByRole('group'); + expect(group).toHaveClass('spui-Tooltip-frame--confirmation'); + }); + + test('variant="error"でクラスが適用される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const group = screen.getByRole('group'); + expect(group).toHaveClass('spui-Tooltip-frame--error'); + }); + }); + + describe('direction and position', () => { + test('direction="top"とposition="center"でクラスが適用される', () => { + render( + + + {(props) => ( + + + )} + + 補足情報 + , + ); + + const group = screen.getByRole('group'); + expect(group).toHaveClass('spui-Tooltip-frame--top'); + expect(group).toHaveClass('spui-Tooltip-frame--center'); + }); + + test('direction="bottom"と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..81f14019b --- /dev/null +++ b/packages/spindle-ui/src/Tooltip/Tooltip.tsx @@ -0,0 +1,378 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import Cross from '../Icon/Cross'; +import { IconButton } from '../IconButton'; + +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?: () => void; + onMouseLeave?: () => void; + onFocus?: () => void; + onBlur?: () => void; + onClick?: () => void; +}; + +type TriggerComponentProps = { + children: (props: TriggerProps) => React.ReactNode; +}; + +type ContentProps = { + children?: React.ReactNode; +}; + +type TooltipContextValue = { + tooltipId: string; + isOpen: boolean; + isInitialOpen: boolean; + isTouchDevice: boolean; + setIsOpen: (open: boolean) => void; + handleClose: () => void; + cancelClose: () => void; + triggerRef: React.RefObject; + triggerWidth: number; + triggerHeight: number; + variant: Variant; + direction: Direction; + position: Position; + handleMouseEnter: () => void; + handleMouseLeave: () => void; + handleFocus: () => void; + handleBlur: () => void; + handleClick: () => void; +}; + +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 [triggerWidth, setTriggerWidth] = useState(0); + const [triggerHeight, setTriggerHeight] = useState(0); + const [isTouchDevice, setIsTouchDevice] = useState(false); + const closeTimeoutRef = useRef | null>(null); + + useEffect(() => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); + }, []); + + useLayoutEffect(() => { + if (!triggerRef.current) return; + const { width, height } = triggerRef.current.getBoundingClientRect(); + setTriggerWidth(width); + setTriggerHeight(height); + }, []); + + const handleClose = useCallback(() => { + setIsOpen(false); + setIsInitialOpen(false); + onClose?.(); + }, [onClose]); + + const cancelClose = useCallback(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + if (isTouchDevice || isInitialOpen) return; + closeTimeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, 100); + }, [isTouchDevice, isInitialOpen]); + + const handleMouseEnter = useCallback(() => { + if (isTouchDevice || isInitialOpen) return; + cancelClose(); + if (!triggerRef.current) return; + const { width, height } = triggerRef.current.getBoundingClientRect(); + setTriggerWidth(width); + setTriggerHeight(height); + setIsOpen(true); + }, [isTouchDevice, isInitialOpen, cancelClose]); + + const handleMouseLeave = useCallback(() => { + scheduleClose(); + }, [scheduleClose]); + + const handleFocus = useCallback(() => { + if (isTouchDevice || isInitialOpen) return; + cancelClose(); + if (!triggerRef.current) return; + const { width, height } = triggerRef.current.getBoundingClientRect(); + setTriggerWidth(width); + setTriggerHeight(height); + setIsOpen(true); + }, [isTouchDevice, isInitialOpen, cancelClose]); + + const handleBlur = useCallback(() => { + scheduleClose(); + }, [scheduleClose]); + + const handleClick = useCallback(() => { + if (!isTouchDevice || isInitialOpen) return; + if (!triggerRef.current) return; + const { width, height } = triggerRef.current.getBoundingClientRect(); + setTriggerWidth(width); + setTriggerHeight(height); + setIsOpen((prev) => !prev); + }, [isTouchDevice, isInitialOpen]); + + + const contextValue: TooltipContextValue = { + tooltipId, + isOpen, + isInitialOpen, + isTouchDevice, + setIsOpen, + handleClose, + cancelClose, + triggerRef, + triggerWidth, + triggerHeight, + variant, + direction, + position, + handleMouseEnter, + handleMouseLeave, + handleFocus, + handleBlur, + handleClick, + }; + + return ( + +
{children}
+
+ ); +}; + +const Trigger = ({ children }: TriggerComponentProps) => { + const { + tooltipId, + isOpen, + isInitialOpen, + triggerRef, + handleMouseEnter, + handleMouseLeave, + handleFocus, + handleBlur, + handleClick, + } = useTooltipContext(); + + const triggerProps: TriggerProps = { + ref: (node) => { + triggerRef.current = node; + }, + 'aria-describedby': tooltipId, + ...(isInitialOpen ? { 'aria-expanded': isOpen } : {}), + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onFocus: handleFocus, + onBlur: handleBlur, + onClick: handleClick, + }; + + return children(triggerProps); +}; + +const Content = ({ children }: ContentProps) => { + const { + tooltipId, + isOpen, + isInitialOpen, + isTouchDevice, + handleClose, + cancelClose, + handleMouseLeave, + triggerRef, + triggerWidth, + triggerHeight, + variant, + direction, + position, + } = useTooltipContext(); + + const contentRef = useRef(null); + const [fadeOut, setFadeOut] = useState(false); + + const handleClickOutsideRef = useRef<((e: MouseEvent) => void) | null>(null); + const handleKeyDownRef = useRef<((e: KeyboardEvent) => void) | null>(null); + + useEffect(() => { + handleClickOutsideRef.current = (e: MouseEvent) => { + if (!isOpen || !isTouchDevice || isInitialOpen) return; + + const content = contentRef.current; + const trigger = triggerRef.current; + const target = e.target as Node; + + if ( + content && + !content.contains(target) && + trigger && + !trigger.contains(target) + ) { + setFadeOut(true); + } + }; + }, [isOpen, isTouchDevice, isInitialOpen, triggerRef]); + + useEffect(() => { + handleKeyDownRef.current = (e: KeyboardEvent) => { + if (!CLOSE_KEY_LIST.includes(e.key.toUpperCase())) return; + + if (isInitialOpen && isOpen) { + const content = contentRef.current; + if ( + content && + (content.contains(document.activeElement) || + document.activeElement === content) + ) { + e.preventDefault(); + handleClose(); + } + } + }; + }, [isInitialOpen, isOpen, handleClose]); + + const handleAnimationEnd = useCallback( + (event: AnimationEvent) => { + if (event.animationName === FADE_IN_ANIMATION) return; + + handleClose(); + setFadeOut(false); + }, + [handleClose], + ); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + handleClickOutsideRef.current?.(e); + }; + + const handleKey = (e: KeyboardEvent) => { + handleKeyDownRef.current?.(e); + }; + + if (isOpen) { + window.addEventListener('click', handleClick); + window.addEventListener('keydown', handleKey); + } + + return () => { + window.removeEventListener('click', handleClick); + window.removeEventListener('keydown', handleKey); + }; + }, [isOpen]); + + useEffect(() => { + const content = contentRef.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 ( + + // biome-ignore lint/a11y/noStaticElementInteractions: non-interactive wrapper needs mouse handlers for timeout control +
+
+
{children}
+ {showCloseButton && ( +
+ + +
+ )} +
+
+ ); +}; + +export const Tooltip = { + Frame, + Trigger, + Content, +}; 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..613c55c16 100644 --- a/packages/spindle-ui/src/index.css +++ b/packages/spindle-ui/src/index.css @@ -1,5 +1,6 @@ /* stylelint-disable plugin/selector-bem-pattern */ @import 'ameba-color-palette.css'; +@import '../../spindle-tokens/dist/css/spindle-tokens.css'; @import './Breadcrumb/Breadcrumb.css'; @import './BottomButton/BottomButton.css'; @import './Button/Button.css'; @@ -15,6 +16,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'; From f1bb5c7d5d98ba6ea5eef7a685034bae1b1ef749 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Tue, 9 Dec 2025 12:17:09 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat(spindle-ui):=20graceArea=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=97=E3=80=81trigger=E3=81=8B=E3=82=89to?= =?UTF-8?q?oltip=E9=96=93=E3=81=AE=E3=83=9D=E3=82=A4=E3=83=B3=E3=82=BF?= =?UTF-8?q?=E3=83=BC=E7=A7=BB=E5=8B=95=E3=81=8C=E3=81=A7=E3=81=8D=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/setup-tests.ts | 19 ++ .../spindle-ui/src/Tooltip/Tooltip.test.tsx | 146 +++++---- packages/spindle-ui/src/Tooltip/Tooltip.tsx | 296 +++++++++++------- .../spindle-ui/src/Tooltip/graceArea.test.ts | 153 +++++++++ packages/spindle-ui/src/Tooltip/graceArea.ts | 167 ++++++++++ 5 files changed, 608 insertions(+), 173 deletions(-) create mode 100644 packages/spindle-ui/src/Tooltip/graceArea.test.ts create mode 100644 packages/spindle-ui/src/Tooltip/graceArea.ts diff --git a/packages/spindle-ui/setup-tests.ts b/packages/spindle-ui/setup-tests.ts index 7b0828bfa..0b4f81dfd 100644 --- a/packages/spindle-ui/setup-tests.ts +++ b/packages/spindle-ui/setup-tests.ts @@ -1 +1,20 @@ import '@testing-library/jest-dom'; + +// JSDOMにPointerEventがないのでPolyfill +if (typeof PointerEvent === 'undefined') { + class PointerEventPolyfill extends MouseEvent { + readonly pointerType: string; + readonly pointerId: number; + readonly pressure: number; + + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerType = params.pointerType ?? ''; + this.pointerId = params.pointerId ?? 0; + this.pressure = params.pressure ?? 0; + } + } + + // @ts-expect-error Polyfill for JSDOM + global.PointerEvent = PointerEventPolyfill; +} diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx index 39e0cabfb..18ff81eb8 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx @@ -1,25 +1,30 @@ import { jest } from '@jest/globals'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import React from 'react'; import Information from '../Icon/Information'; import { IconButton } from '../IconButton'; import { Tooltip } from './Tooltip'; -describe('', () => { - beforeEach(() => { - Object.defineProperty(window, 'ontouchstart', { - writable: true, - value: undefined, - }); - Object.defineProperty(navigator, 'maxTouchPoints', { - writable: true, - value: 0, - }); +// JSDOMでpointerTypeを正しく扱うためのヘルパー +const fireTouchPointerDown = (element: Element) => { + const event = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + pointerType: 'touch', }); + element.dispatchEvent(event); +}; - describe('共通', () => { - test('aria-describedby が自動的に設定される', () => { +describe('', () => { + describe('Common', () => { + test('sets aria-describedby automatically', () => { render( @@ -38,8 +43,8 @@ describe('', () => { }); }); - describe('defaultOpen={false}の場合', () => { - test('初期状態では表示されない', () => { + describe('when defaultOpen={false}', () => { + test('is not visible initially', () => { render( @@ -56,7 +61,7 @@ describe('', () => { expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); }); - test('hover時に表示される', async () => { + test('shows on hover (pointer devices)', async () => { render( @@ -78,7 +83,7 @@ describe('', () => { }); }); - test('マウスを離すと非表示になる', async () => { + test('shows on focus (pointer devices)', async () => { render( @@ -93,20 +98,14 @@ describe('', () => { ); const trigger = screen.getByRole('button', { name: '詳細情報' }); - fireEvent.mouseEnter(trigger); + fireEvent.focus(trigger); await waitFor(() => { expect(screen.getByText('補足情報')).toBeInTheDocument(); }); - - fireEvent.mouseLeave(trigger); - - await waitFor(() => { - expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); - }); }); - test('focus時に表示される', async () => { + test('hides on blur (pointer devices)', async () => { render( @@ -126,9 +125,15 @@ describe('', () => { await waitFor(() => { expect(screen.getByText('補足情報')).toBeInTheDocument(); }); + + fireEvent.blur(trigger); + + await waitFor(() => { + expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); + }); }); - test('フォーカスを外すと非表示になる', async () => { + test('hides on Escape key press', async () => { render( @@ -143,20 +148,20 @@ describe('', () => { ); const trigger = screen.getByRole('button', { name: '詳細情報' }); - fireEvent.focus(trigger); + fireEvent.mouseEnter(trigger); await waitFor(() => { expect(screen.getByText('補足情報')).toBeInTheDocument(); }); - fireEvent.blur(trigger); + fireEvent.keyDown(window, { key: 'Escape' }); await waitFor(() => { expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); }); }); - test('role="tooltip"が設定される', async () => { + test('has role="tooltip"', async () => { render( @@ -179,7 +184,7 @@ describe('', () => { }); }); - test('aria-expandedが設定されない', () => { + test('does not have aria-expanded', () => { render( @@ -197,7 +202,7 @@ describe('', () => { expect(trigger).not.toHaveAttribute('aria-expanded'); }); - test('閉じるボタンが表示されない', async () => { + test('does not show close button', async () => { render( @@ -223,12 +228,7 @@ describe('', () => { ).not.toBeInTheDocument(); }); - test('タッチデバイスでclick時に表示/非表示を切り替える', async () => { - Object.defineProperty(window, 'ontouchstart', { - writable: true, - value: {}, - }); - + test('toggles visibility on touch (pointerdown with pointerType=touch)', async () => { render( @@ -244,25 +244,24 @@ describe('', () => { const trigger = screen.getByRole('button', { name: '詳細情報' }); - fireEvent.click(trigger); + await act(async () => { + fireTouchPointerDown(trigger); + }); await waitFor(() => { expect(screen.getByText('補足情報')).toBeInTheDocument(); }); - fireEvent.click(trigger); + await act(async () => { + fireTouchPointerDown(trigger); + }); await waitFor(() => { expect(screen.queryByText('補足情報')).not.toBeInTheDocument(); }); }); - test('タッチデバイスで領域外クリックで閉じる', async () => { - Object.defineProperty(window, 'ontouchstart', { - writable: true, - value: {}, - }); - + test('starts fade-out on outside touch', async () => { render(
@@ -280,23 +279,34 @@ describe('', () => { ); const trigger = screen.getByRole('button', { name: '詳細情報' }); - fireEvent.click(trigger); - await waitFor(() => { - expect(screen.getByText('補足情報')).toBeInTheDocument(); + await act(async () => { + fireTouchPointerDown(trigger); }); + const tooltipText = await screen.findByText('補足情報'); + expect(tooltipText).toBeInTheDocument(); + const outsideButton = screen.getByRole('button', { name: '外部のボタン' }); - fireEvent.click(outsideButton); + 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(screen.queryByText('補足情報')).not.toBeInTheDocument(); + expect(tooltipFrame).toHaveClass('is-fade-out'); }); }); }); - describe('defaultOpen={true}の初期表示時', () => { - test('初期状態で表示される', () => { + describe('when defaultOpen={true} (initial display)', () => { + test('is visible initially', () => { render( @@ -313,7 +323,7 @@ describe('', () => { expect(screen.getByText('補足情報')).toBeInTheDocument(); }); - test('role="group"が設定される', () => { + test('has role="group"', () => { render( @@ -331,7 +341,7 @@ describe('', () => { expect(group).toBeInTheDocument(); }); - test('aria-expanded="true"が設定される', () => { + test('has aria-expanded="true"', () => { render( @@ -349,7 +359,7 @@ describe('', () => { expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); - test('閉じるボタンが表示される', () => { + test('shows close button', () => { render( @@ -366,7 +376,7 @@ describe('', () => { expect(screen.getByRole('button', { name: '閉じる' })).toBeInTheDocument(); }); - test('閉じるボタンクリック時に閉じる', async () => { + test('closes when close button is clicked', async () => { const onClose = jest.fn(); render( @@ -392,7 +402,7 @@ describe('', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - test('Tooltipまたはその内部要素にフォーカスが当たっている時、Escapeキー押下で閉じる', async () => { + test('closes on Escape key when tooltip or its content is focused', async () => { const onClose = jest.fn(); render( @@ -420,7 +430,7 @@ describe('', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - test('領域外クリックでは閉じない', async () => { + test('does not close on outside click', async () => { render(
@@ -444,8 +454,8 @@ describe('', () => { }); }); - describe('defaultOpen={true}で一度閉じた後の再表示時', () => { - test('hover/focusで再表示される', async () => { + describe('when defaultOpen={true} and reopened after closing', () => { + test('reopens on hover/focus', async () => { render( @@ -474,7 +484,7 @@ describe('', () => { }); }); - test('role="group"からrole="tooltip"に切り替わる', async () => { + test('changes from role="group" to role="tooltip"', async () => { render( @@ -506,7 +516,7 @@ describe('', () => { }); }); - test('aria-expandedが設定されない', async () => { + test('does not have aria-expanded after reopen', async () => { render( @@ -533,7 +543,7 @@ describe('', () => { expect(trigger).not.toHaveAttribute('aria-expanded'); }); - test('閉じるボタンが表示されない', async () => { + test('does not show close button after reopen', async () => { render( @@ -568,7 +578,7 @@ describe('', () => { }); describe('variants', () => { - test('variant="information"でクラスが適用される', () => { + test('applies class for variant="information"', () => { render( @@ -586,7 +596,7 @@ describe('', () => { expect(group).toHaveClass('spui-Tooltip-frame--information'); }); - test('variant="confirmation"でクラスが適用される', () => { + test('applies class for variant="confirmation"', () => { render( @@ -604,7 +614,7 @@ describe('', () => { expect(group).toHaveClass('spui-Tooltip-frame--confirmation'); }); - test('variant="error"でクラスが適用される', () => { + test('applies class for variant="error"', () => { render( @@ -624,7 +634,7 @@ describe('', () => { }); describe('direction and position', () => { - test('direction="top"とposition="center"でクラスが適用される', () => { + test('applies classes for direction="top" and position="center"', () => { render( @@ -643,7 +653,7 @@ describe('', () => { expect(group).toHaveClass('spui-Tooltip-frame--center'); }); - test('direction="bottom"とposition="start"でクラスが適用される', () => { + test('applies classes for direction="bottom" and position="start"', () => { render( diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.tsx index 81f14019b..24abb4594 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.tsx @@ -10,6 +10,7 @@ import React, { } from 'react'; import Cross from '../Icon/Cross'; import { IconButton } from '../IconButton'; +import { createGraceArea, isPointInPolygon, type Polygon } from './graceArea'; type Direction = 'top' | 'right' | 'bottom' | 'left'; type Position = 'edgeStart' | 'start' | 'center' | 'end' | 'edgeEnd'; @@ -28,11 +29,10 @@ type TriggerProps = { ref: React.RefCallback; 'aria-describedby': string; 'aria-expanded'?: boolean; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onFocus?: () => void; - onBlur?: () => void; - onClick?: () => void; + onMouseEnter: (e: React.MouseEvent) => void; + onFocus: () => void; + onBlur: () => void; + onPointerDown: (e: React.PointerEvent) => void; }; type TriggerComponentProps = { @@ -47,21 +47,20 @@ type TooltipContextValue = { tooltipId: string; isOpen: boolean; isInitialOpen: boolean; - isTouchDevice: boolean; setIsOpen: (open: boolean) => void; handleClose: () => void; - cancelClose: () => void; triggerRef: React.RefObject; + contentRef: React.RefObject; triggerWidth: number; triggerHeight: number; variant: Variant; direction: Direction; position: Position; - handleMouseEnter: () => void; - handleMouseLeave: () => void; + handleMouseEnter: (e: React.MouseEvent) => void; handleFocus: () => void; handleBlur: () => void; - handleClick: () => void; + handlePointerDown: (e: React.PointerEvent) => void; + isPointerInTransitRef: React.RefObject; }; const BLOCK_NAME = 'spui-Tooltip'; @@ -90,13 +89,16 @@ const Frame = ({ 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 [isTouchDevice, setIsTouchDevice] = useState(false); - const closeTimeoutRef = useRef | null>(null); - useEffect(() => { - setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); + const isPointerInTransitRef = useRef(false); + // pointerdown中はfocusイベントを無視 + const isPointerDownRef = useRef(false); + + const handlePointerUp = useCallback(() => { + isPointerDownRef.current = false; }, []); useLayoutEffect(() => { @@ -106,83 +108,84 @@ const Frame = ({ setTriggerHeight(height); }, []); + useEffect(() => { + return () => document.removeEventListener('pointerup', handlePointerUp); + }, [handlePointerUp]); + const handleClose = useCallback(() => { setIsOpen(false); setIsInitialOpen(false); onClose?.(); }, [onClose]); - const cancelClose = useCallback(() => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - }, []); - - const scheduleClose = useCallback(() => { - if (isTouchDevice || isInitialOpen) return; - closeTimeoutRef.current = setTimeout(() => { - setIsOpen(false); - }, 100); - }, [isTouchDevice, isInitialOpen]); - - const handleMouseEnter = useCallback(() => { - if (isTouchDevice || isInitialOpen) return; - cancelClose(); + const openTooltip = useCallback(() => { if (!triggerRef.current) return; const { width, height } = triggerRef.current.getBoundingClientRect(); setTriggerWidth(width); setTriggerHeight(height); setIsOpen(true); - }, [isTouchDevice, isInitialOpen, cancelClose]); + }, []); - const handleMouseLeave = useCallback(() => { - scheduleClose(); - }, [scheduleClose]); + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + isPointerDownRef.current = true; + document.addEventListener('pointerup', handlePointerUp, { once: true }); + + // タッチデバイス: トグル + if (e.pointerType === 'touch' && !isInitialOpen) { + if (!triggerRef.current) return; + const { width, height } = triggerRef.current.getBoundingClientRect(); + setTriggerWidth(width); + setTriggerHeight(height); + setIsOpen((prev) => !prev); + } + }, + [isInitialOpen, handlePointerUp], + ); + + // ポインターデバイス: 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 (isTouchDevice || isInitialOpen) return; - cancelClose(); - if (!triggerRef.current) return; - const { width, height } = triggerRef.current.getBoundingClientRect(); - setTriggerWidth(width); - setTriggerHeight(height); - setIsOpen(true); - }, [isTouchDevice, isInitialOpen, cancelClose]); + if (isPointerDownRef.current) return; + if (isInitialOpen) return; + openTooltip(); + }, [isInitialOpen, openTooltip]); + // ポインターデバイス: blur const handleBlur = useCallback(() => { - scheduleClose(); - }, [scheduleClose]); - - const handleClick = useCallback(() => { - if (!isTouchDevice || isInitialOpen) return; - if (!triggerRef.current) return; - const { width, height } = triggerRef.current.getBoundingClientRect(); - setTriggerWidth(width); - setTriggerHeight(height); - setIsOpen((prev) => !prev); - }, [isTouchDevice, isInitialOpen]); - + if (isPointerDownRef.current) return; + if (isInitialOpen) return; + setIsOpen(false); + }, [isInitialOpen]); const contextValue: TooltipContextValue = { tooltipId, isOpen, isInitialOpen, - isTouchDevice, setIsOpen, handleClose, - cancelClose, triggerRef, + contentRef, triggerWidth, triggerHeight, variant, direction, position, handleMouseEnter, - handleMouseLeave, handleFocus, handleBlur, - handleClick, + handlePointerDown, + isPointerInTransitRef, }; return ( @@ -199,10 +202,9 @@ const Trigger = ({ children }: TriggerComponentProps) => { isInitialOpen, triggerRef, handleMouseEnter, - handleMouseLeave, handleFocus, handleBlur, - handleClick, + handlePointerDown, } = useTooltipContext(); const triggerProps: TriggerProps = { @@ -212,10 +214,9 @@ const Trigger = ({ children }: TriggerComponentProps) => { 'aria-describedby': tooltipId, ...(isInitialOpen ? { 'aria-expanded': isOpen } : {}), onMouseEnter: handleMouseEnter, - onMouseLeave: handleMouseLeave, onFocus: handleFocus, onBlur: handleBlur, - onClick: handleClick, + onPointerDown: handlePointerDown, }; return children(triggerProps); @@ -226,29 +227,126 @@ const Content = ({ children }: ContentProps) => { tooltipId, isOpen, isInitialOpen, - isTouchDevice, handleClose, - cancelClose, - handleMouseLeave, triggerRef, + contentRef, triggerWidth, triggerHeight, variant, direction, position, + setIsOpen, + isPointerInTransitRef, } = useTooltipContext(); - const contentRef = useRef(null); + 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); + }; - const handleClickOutsideRef = useRef<((e: MouseEvent) => void) | null>(null); - const handleKeyDownRef = useRef<((e: KeyboardEvent) => void) | null>(null); + 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(() => { - handleClickOutsideRef.current = (e: MouseEvent) => { - if (!isOpen || !isTouchDevice || isInitialOpen) return; + if (!isOpen || isInitialOpen) return; - const content = contentRef.current; + const handlePointerDownOutside = (e: PointerEvent) => { + if (e.pointerType !== 'touch') return; + + const content = localContentRef.current; const trigger = triggerRef.current; const target = e.target as Node; @@ -261,14 +359,21 @@ const Content = ({ children }: ContentProps) => { setFadeOut(true); } }; - }, [isOpen, isTouchDevice, isInitialOpen, triggerRef]); + window.addEventListener('pointerdown', handlePointerDownOutside); + return () => + window.removeEventListener('pointerdown', handlePointerDownOutside); + }, [isOpen, isInitialOpen, triggerRef]); + + // Escapeキーで閉じる useEffect(() => { - handleKeyDownRef.current = (e: KeyboardEvent) => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { if (!CLOSE_KEY_LIST.includes(e.key.toUpperCase())) return; - if (isInitialOpen && isOpen) { - const content = contentRef.current; + if (isInitialOpen) { + const content = localContentRef.current; if ( content && (content.contains(document.activeElement) || @@ -277,14 +382,19 @@ const Content = ({ children }: ContentProps) => { e.preventDefault(); handleClose(); } + } else { + e.preventDefault(); + setIsOpen(false); } }; - }, [isInitialOpen, isOpen, handleClose]); + + 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); }, @@ -292,27 +402,7 @@ const Content = ({ children }: ContentProps) => { ); useEffect(() => { - const handleClick = (e: MouseEvent) => { - handleClickOutsideRef.current?.(e); - }; - - const handleKey = (e: KeyboardEvent) => { - handleKeyDownRef.current?.(e); - }; - - if (isOpen) { - window.addEventListener('click', handleClick); - window.addEventListener('keydown', handleKey); - } - - return () => { - window.removeEventListener('click', handleClick); - window.removeEventListener('keydown', handleKey); - }; - }, [isOpen]); - - useEffect(() => { - const content = contentRef.current; + const content = localContentRef.current; if (!content) return; content.addEventListener('animationend', handleAnimationEnd); @@ -328,11 +418,9 @@ const Content = ({ children }: ContentProps) => { const showCloseButton = isInitialOpen; return ( - - // biome-ignore lint/a11y/noStaticElementInteractions: non-interactive wrapper needs mouse handlers for timeout control -
{ .filter(Boolean) .join(' ')} role={role} - onMouseEnter={isInitialOpen ? undefined : cancelClose} - onMouseLeave={isInitialOpen ? undefined : handleMouseLeave} style={ { '--Tooltip-trigger-width': `${triggerWidth}px`, 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]); +} From 73a99f4fb1940ff0a78e7c66fc5077ceb6f5291a Mon Sep 17 00:00:00 2001 From: yu-3in Date: Tue, 9 Dec 2025 12:18:03 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix(spindle-ui):=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=A8=E3=83=A9=E3=83=BC=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/src/Tooltip/Tooltip.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx index 18ff81eb8..cec4d1576 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx @@ -287,7 +287,9 @@ describe('', () => { const tooltipText = await screen.findByText('補足情報'); expect(tooltipText).toBeInTheDocument(); - const outsideButton = screen.getByRole('button', { name: '外部のボタン' }); + const outsideButton = screen.getByRole('button', { + name: '外部のボタン', + }); await act(async () => { const event = new PointerEvent('pointerdown', { @@ -373,7 +375,9 @@ describe('', () => { , ); - expect(screen.getByRole('button', { name: '閉じる' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '閉じる' }), + ).toBeInTheDocument(); }); test('closes when close button is clicked', async () => { @@ -447,7 +451,9 @@ describe('', () => {
, ); - const outsideButton = screen.getByRole('button', { name: '外部のボタン' }); + const outsideButton = screen.getByRole('button', { + name: '外部のボタン', + }); fireEvent.click(outsideButton); expect(screen.getByText('補足情報')).toBeInTheDocument(); From 3ca6aa34500a2bfe5eeff4f5f1e2af5b340456aa Mon Sep 17 00:00:00 2001 From: yu-3in Date: Tue, 9 Dec 2025 15:06:29 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(spindle-ui):=20=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E9=96=A2=E9=80=A3=E3=81=AE=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/src/Tooltip/Tooltip.css | 323 +++++++++++++----- packages/spindle-ui/src/Tooltip/Tooltip.mdx | 34 +- .../src/Tooltip/Tooltip.stories.example.tsx | 148 ++++++-- packages/spindle-ui/src/Tooltip/Tooltip.tsx | 22 +- 4 files changed, 383 insertions(+), 144 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.css b/packages/spindle-ui/src/Tooltip/Tooltip.css index c13457086..04b56f7cd 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.css +++ b/packages/spindle-ui/src/Tooltip/Tooltip.css @@ -1,5 +1,3 @@ -@import "../IconButton/IconButton.css"; - :root { --Tooltip-z-index: 1; --Tooltip-arrow-offset: 4px; @@ -12,7 +10,12 @@ } .spui-Tooltip-frame { - animation: var(--animation-appear-in-duration) var(--animation-appear-in-easing) spui-Tooltip-fade-in; + --Tooltip-arrow-depth: 6px; + --Tooltip-arrow-size: 11px; + --Tooltip-position-offset: 24px; + + animation: var(--animation-appear-in-duration) + var(--animation-appear-in-easing) spui-Tooltip-fade-in; border-radius: 8px; box-shadow: 0 11px 28px 0 rgba(8, 18, 26, 0.24); box-sizing: border-box; @@ -27,15 +30,16 @@ /* stylelint-disable-next-line plugin/selector-bem-pattern */ .spui-Tooltip-frame.is-fade-out { - animation: var(--animation-disappear-duration) var(--animation-disappear-easing) spui-Tooltip-fade-out; + animation: var(--animation-disappear-duration) + var(--animation-disappear-easing) spui-Tooltip-fade-out; opacity: 0; } .spui-Tooltip-frame::before { - content: ''; - height: 13px; + content: ""; + height: var(--Tooltip-arrow-size); position: absolute; - width: 13px; + width: var(--Tooltip-arrow-size); } /* Variants */ @@ -68,52 +72,69 @@ /* Direction: top */ .spui-Tooltip-frame--top { - top: calc(var(--Tooltip-trigger-height) + 6px + var(--Tooltip-arrow-offset)); + top: calc( + var(--Tooltip-trigger-height) + + var(--Tooltip-arrow-depth) + + var(--Tooltip-arrow-offset) + ); } .spui-Tooltip-frame--top::before { border-radius: 5px 0 0; - top: -6px; + top: calc(-1 * var(--Tooltip-arrow-depth)); transform: rotate(45deg); } /* Direction: bottom */ .spui-Tooltip-frame--bottom { - bottom: calc(var(--Tooltip-trigger-height) + 6px + var(--Tooltip-arrow-offset)); + bottom: calc( + var(--Tooltip-trigger-height) + + var(--Tooltip-arrow-depth) + + var(--Tooltip-arrow-offset) + ); } .spui-Tooltip-frame--bottom::before { border-radius: 0 0 5px; - bottom: -6px; + bottom: calc(-1 * var(--Tooltip-arrow-depth)); transform: rotate(45deg); } /* Direction: left */ .spui-Tooltip-frame--left { - left: calc(var(--Tooltip-trigger-width) + 6px + var(--Tooltip-arrow-offset)); + left: calc( + var(--Tooltip-trigger-width) + + var(--Tooltip-arrow-depth) + + var(--Tooltip-arrow-offset) + ); } .spui-Tooltip-frame--left::before { border-radius: 0 0 0 5px; - left: -6px; + left: calc(-1 * var(--Tooltip-arrow-depth)); transform: rotate(45deg); } /* Direction: right */ .spui-Tooltip-frame--right { - right: calc(var(--Tooltip-trigger-width) + 6px + var(--Tooltip-arrow-offset)); + right: calc( + var(--Tooltip-trigger-width) + + var(--Tooltip-arrow-depth) + + var(--Tooltip-arrow-offset) + ); } .spui-Tooltip-frame--right::before { border-radius: 0 5px 0 0; - right: -6px; + right: calc(-1 * var(--Tooltip-arrow-depth)); transform: rotate(45deg); } /* Position: center (for top/bottom) */ .spui-Tooltip-frame--top.spui-Tooltip-frame--center, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--center { - animation: var(--animation-appear-in-duration) var(--animation-appear-in-easing) spui-Tooltip-fade-in; + animation: var(--animation-appear-in-duration) + var(--animation-appear-in-easing) spui-Tooltip-fade-in; left: 50%; transform: translateX(-50%); } @@ -127,7 +148,8 @@ /* Position: center (for left/right) */ .spui-Tooltip-frame--left.spui-Tooltip-frame--center, .spui-Tooltip-frame--right.spui-Tooltip-frame--center { - animation: var(--animation-appear-in-duration) var(--animation-appear-in-easing) spui-Tooltip-fade-in; + animation: var(--animation-appear-in-duration) + var(--animation-appear-in-easing) spui-Tooltip-fade-in; top: 50%; transform: translateY(-50%); } @@ -141,141 +163,159 @@ /* Position: start (for top/bottom) */ .spui-Tooltip-frame--top.spui-Tooltip-frame--start, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--start { - left: 0; + left: calc(var(--Tooltip-trigger-width) / 2 - var(--Tooltip-position-offset)); } .spui-Tooltip-frame--top.spui-Tooltip-frame--start::before, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--start::before { - left: calc(var(--Tooltip-trigger-width) / 2 - 6.5px); + left: calc(var(--Tooltip-position-offset) - var(--Tooltip-arrow-size) / 2); } /* Position: start (for left/right) */ .spui-Tooltip-frame--left.spui-Tooltip-frame--start, .spui-Tooltip-frame--right.spui-Tooltip-frame--start { - top: 0; + top: calc(var(--Tooltip-trigger-height) / 2 - var(--Tooltip-position-offset)); } .spui-Tooltip-frame--left.spui-Tooltip-frame--start::before, .spui-Tooltip-frame--right.spui-Tooltip-frame--start::before { - top: calc(var(--Tooltip-trigger-height) / 2 - 6.5px); + top: calc(var(--Tooltip-position-offset) - var(--Tooltip-arrow-size) / 2); } /* Position: end (for top/bottom) */ .spui-Tooltip-frame--top.spui-Tooltip-frame--end, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--end { - right: 0; + right: calc( + var(--Tooltip-trigger-width) / + 2 - + var(--Tooltip-position-offset) + ); } .spui-Tooltip-frame--top.spui-Tooltip-frame--end::before, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--end::before { - right: calc(var(--Tooltip-trigger-width) / 2 - 6.5px); + right: calc(var(--Tooltip-position-offset) - var(--Tooltip-arrow-size) / 2); } /* Position: end (for left/right) */ .spui-Tooltip-frame--left.spui-Tooltip-frame--end, .spui-Tooltip-frame--right.spui-Tooltip-frame--end { - bottom: 0; + bottom: calc( + var(--Tooltip-trigger-height) / + 2 - + var(--Tooltip-position-offset) + ); } .spui-Tooltip-frame--left.spui-Tooltip-frame--end::before, .spui-Tooltip-frame--right.spui-Tooltip-frame--end::before { - bottom: calc(var(--Tooltip-trigger-height) / 2 - 6.5px); + bottom: calc(var(--Tooltip-position-offset) - var(--Tooltip-arrow-size) / 2); +} + +.spui-Tooltip-frame--edgeStart, +.spui-Tooltip-frame--edgeEnd { + --Tooltip-arrow-depth: 9px; + --Tooltip-edge-arrow-depth: 10px; + --Tooltip-edge-arrow-breadth: 13px; + --Tooltip-position-offset: 8px; +} + +.spui-Tooltip-frame--edgeStart::before, +.spui-Tooltip-frame--edgeEnd::before { + mask-size: 100% 100%; + transform: none; } -/* Position: edgeStart (for top/bottom) */ .spui-Tooltip-frame--top.spui-Tooltip-frame--edgeStart, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeStart { - left: calc(var(--Tooltip-trigger-width) / 2); + left: calc(var(--Tooltip-trigger-width) / 2 - var(--Tooltip-position-offset)); } .spui-Tooltip-frame--top.spui-Tooltip-frame--edgeStart::before { - border-radius: 0 3px 0 0; - clip-path: polygon(0 100%, 0 0, 100% 0); - height: 11px; - left: 8px; - transform: none; - width: 13px; + height: var(--Tooltip-edge-arrow-depth); + inset: calc(-1 * var(--Tooltip-arrow-depth)) auto auto + var(--Tooltip-position-offset); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 10'%3E%3Cpath d='M0 9.8457L13 9.8457L1.64595 0.238426C0.996028 -0.311504 0 0.150452 0 1.00181L0 9.8457Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-breadth); } .spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeStart::before { - border-radius: 0 0 3px; - clip-path: polygon(0 0, 0 100%, 100% 100%); - height: 11px; - left: 8px; - transform: none; - width: 13px; + height: var(--Tooltip-edge-arrow-depth); + inset: auto auto calc(-1 * var(--Tooltip-arrow-depth)) + var(--Tooltip-position-offset); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 10'%3E%3Cpath d='M0 0L13 0L1.64595 9.60728C0.996028 10.1572 0 9.69525 0 8.84389L0 0Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-breadth); } -/* Position: edgeStart (for left/right) */ .spui-Tooltip-frame--left.spui-Tooltip-frame--edgeStart, .spui-Tooltip-frame--right.spui-Tooltip-frame--edgeStart { - top: calc(var(--Tooltip-trigger-height) / 2); + top: calc(var(--Tooltip-trigger-height) / 2 - var(--Tooltip-position-offset)); } .spui-Tooltip-frame--left.spui-Tooltip-frame--edgeStart::before { - border-radius: 0 0 0 3px; - clip-path: polygon(0 0, 0 100%, 100% 0); - height: 13px; - top: 8px; - transform: none; - width: 11px; + height: var(--Tooltip-edge-arrow-breadth); + inset: var(--Tooltip-position-offset) auto auto + calc(-1 * var(--Tooltip-arrow-depth)); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 13'%3E%3Cpath d='M9.84583 0V13L0.238548 1.64595C-0.311382 0.996028 0.150574 0 1.00194 0L9.84583 0Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-depth); } .spui-Tooltip-frame--right.spui-Tooltip-frame--edgeStart::before { - border-radius: 0 0 3px; - clip-path: polygon(0 0, 100% 0, 100% 100%); - height: 13px; - top: 8px; - transform: none; - width: 11px; + height: var(--Tooltip-edge-arrow-breadth); + inset: var(--Tooltip-position-offset) calc(-1 * var(--Tooltip-arrow-depth)) + auto auto; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 13'%3E%3Cpath d='M0 0L0 13L9.60728 1.64595C10.1572 0.996028 9.69525 0 8.84389 0L0 0Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-depth); } -/* Position: edgeEnd (for top/bottom) */ .spui-Tooltip-frame--top.spui-Tooltip-frame--edgeEnd, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeEnd { - right: calc(var(--Tooltip-trigger-width) / 2); + right: calc( + var(--Tooltip-trigger-width) / + 2 - + var(--Tooltip-position-offset) + ); } .spui-Tooltip-frame--top.spui-Tooltip-frame--edgeEnd::before { - border-radius: 3px 0 0; - clip-path: polygon(100% 100%, 0 0, 100% 0); - height: 11px; - right: 8px; - transform: none; - width: 13px; + height: var(--Tooltip-edge-arrow-depth); + inset: calc(-1 * var(--Tooltip-arrow-depth)) var(--Tooltip-position-offset) + auto auto; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 10'%3E%3Cpath d='M13 9.8457L0 9.8457L11.3541 0.238426C12.004 -0.311504 13 0.150452 13 1.00181L13 9.8457Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-breadth); } .spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeEnd::before { - border-radius: 0 0 0 3px; - clip-path: polygon(0 100%, 100% 0, 100% 100%); - height: 11px; - right: 8px; - transform: none; - width: 13px; + height: var(--Tooltip-edge-arrow-depth); + inset: auto var(--Tooltip-position-offset) + calc(-1 * var(--Tooltip-arrow-depth)) auto; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 10'%3E%3Cpath d='M13 0L0 0L11.3541 9.60728C12.004 10.1572 13 9.69525 13 8.84389L13 0Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-breadth); } -/* Position: edgeEnd (for left/right) */ .spui-Tooltip-frame--left.spui-Tooltip-frame--edgeEnd, .spui-Tooltip-frame--right.spui-Tooltip-frame--edgeEnd { - bottom: calc(var(--Tooltip-trigger-height) / 2); + bottom: calc( + var(--Tooltip-trigger-height) / + 2 - + var(--Tooltip-position-offset) + ); } .spui-Tooltip-frame--left.spui-Tooltip-frame--edgeEnd::before { - border-radius: 0 3px 0 0; - bottom: 8px; - clip-path: polygon(0 100%, 100% 0, 100% 100%); - height: 13px; - transform: none; - width: 11px; + height: var(--Tooltip-edge-arrow-breadth); + inset: auto auto var(--Tooltip-position-offset) + calc(-1 * var(--Tooltip-arrow-depth)); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 13'%3E%3Cpath d='M9.84583 13V0L0.238548 11.3541C-0.311382 12.004 0.150574 13 1.00194 13L9.84583 13Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-depth); } .spui-Tooltip-frame--right.spui-Tooltip-frame--edgeEnd::before { - border-radius: 3px 0 0; - bottom: 8px; - clip-path: polygon(0 0, 0 100%, 100% 100%); - height: 13px; - transform: none; - width: 11px; + height: var(--Tooltip-edge-arrow-breadth); + inset: auto calc(-1 * var(--Tooltip-arrow-depth)) + var(--Tooltip-position-offset) auto; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 13'%3E%3Cpath d='M0 13L0 0L9.60728 11.3541C10.1572 12.004 9.69525 13 8.84389 13L0 13Z'/%3E%3C/svg%3E"); + width: var(--Tooltip-edge-arrow-depth); } .spui-Tooltip-content { @@ -290,22 +330,48 @@ font-size: 0.875em; font-weight: bold; line-height: 1.4; + max-width: 180px; + min-width: 48px; overflow-wrap: break-word; word-break: break-all; } .spui-Tooltip-closeButton { - /* stylelint-disable plugin/selector-bem-pattern */ - --IconButton--neutral-backgroundColor: transparent; - --IconButton--neutral-onActive-backgroundColor: var(--white-20-alpha); - --IconButton--neutral-onHover-backgroundColor: var(--white-20-alpha); - --IconButton--neutral-color: var(--color-object-high-emphasis-inverse); - /* stylelint-enable plugin/selector-bem-pattern */ - align-items: center; + background-color: transparent; + border: none; + border-radius: 100%; + color: var(--color-object-high-emphasis-inverse); display: flex; flex-shrink: 0; + height: 20px; justify-content: center; + margin: 0; + padding: 0; + transition: background-color 0.3s; + width: 20px; +} + +.spui-Tooltip-closeButton:hover { + background-color: var(--white-20-alpha); +} + +.spui-Tooltip-closeButton:active { + background-color: var(--white-20-alpha); +} + +.spui-Tooltip-closeButton:focus { + outline: 2px solid var(--color-focus-clarity); + outline-offset: 1px; +} + +.spui-Tooltip-closeButton:focus:not(:focus-visible) { + outline: none; +} + +.spui-Tooltip-closeButtonIcon { + height: 12px; + width: 12px; } /* Animations */ @@ -331,6 +397,87 @@ @media (min-width: 768px) { .spui-Tooltip-frame { + --Tooltip-arrow-size: 14px; + max-width: 732px; + padding: 16px; + } + + .spui-Tooltip-text { + max-width: 640px; + min-width: 200px; + } + + .spui-Tooltip-frame--edgeStart, + .spui-Tooltip-frame--edgeEnd { + --Tooltip-arrow-depth: 14px; + --Tooltip-edge-arrow-depth: 15px; + --Tooltip-edge-arrow-breadth: 19px; + } + + .spui-Tooltip-frame--top.spui-Tooltip-frame--edgeStart::before { + height: var(--Tooltip-edge-arrow-depth); + inset: calc(-1 * var(--Tooltip-arrow-depth)) auto auto + var(--Tooltip-position-offset); + width: var(--Tooltip-edge-arrow-breadth); + } + + .spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeStart::before { + height: var(--Tooltip-edge-arrow-depth); + inset: auto auto calc(-1 * var(--Tooltip-arrow-depth)) + var(--Tooltip-position-offset); + width: var(--Tooltip-edge-arrow-breadth); + } + + .spui-Tooltip-frame--left.spui-Tooltip-frame--edgeStart::before { + height: var(--Tooltip-edge-arrow-breadth); + inset: var(--Tooltip-position-offset) auto auto + calc(-1 * var(--Tooltip-arrow-depth)); + width: var(--Tooltip-edge-arrow-depth); + } + + .spui-Tooltip-frame--right.spui-Tooltip-frame--edgeStart::before { + height: var(--Tooltip-edge-arrow-breadth); + inset: var(--Tooltip-position-offset) calc(-1 * var(--Tooltip-arrow-depth)) + auto auto; + width: var(--Tooltip-edge-arrow-depth); + } + + .spui-Tooltip-frame--top.spui-Tooltip-frame--edgeEnd::before { + height: var(--Tooltip-edge-arrow-depth); + inset: calc(-1 * var(--Tooltip-arrow-depth)) var(--Tooltip-position-offset) + auto auto; + width: var(--Tooltip-edge-arrow-breadth); + } + + .spui-Tooltip-frame--bottom.spui-Tooltip-frame--edgeEnd::before { + height: var(--Tooltip-edge-arrow-depth); + inset: auto var(--Tooltip-position-offset) + calc(-1 * var(--Tooltip-arrow-depth)) auto; + width: var(--Tooltip-edge-arrow-breadth); + } + + .spui-Tooltip-frame--left.spui-Tooltip-frame--edgeEnd::before { + height: var(--Tooltip-edge-arrow-breadth); + inset: auto auto var(--Tooltip-position-offset) + calc(-1 * var(--Tooltip-arrow-depth)); + width: var(--Tooltip-edge-arrow-depth); + } + + .spui-Tooltip-frame--right.spui-Tooltip-frame--edgeEnd::before { + height: var(--Tooltip-edge-arrow-breadth); + inset: auto calc(-1 * var(--Tooltip-arrow-depth)) + var(--Tooltip-position-offset) auto; + width: var(--Tooltip-edge-arrow-depth); + } + + .spui-Tooltip-closeButton { + height: 24px; + width: 24px; + } + + .spui-Tooltip-closeButtonIcon { + height: 14px; + width: 14px; } } diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.mdx b/packages/spindle-ui/src/Tooltip/Tooltip.mdx index 444e7b600..b5b63abfc 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.mdx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.mdx @@ -53,7 +53,7 @@ import '@openameba/spindle-ui/Tooltip.css'; code={` {(props) => ( - + )} @@ -81,7 +81,7 @@ import '@openameba/spindle-ui/Tooltip.css'; code={` {(props) => ( - + )} @@ -133,7 +133,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され > {(props) => ( - + )} @@ -143,7 +143,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され ) : ( - + )} @@ -161,7 +161,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -178,7 +178,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -195,7 +195,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -214,7 +214,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -231,7 +231,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -248,7 +248,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -265,7 +265,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -284,7 +284,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -301,7 +301,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -318,7 +318,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -335,7 +335,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -352,7 +352,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} @@ -371,7 +371,7 @@ Tooltipの閉じる動作は表示状態に応じて自動的に最適化され code={` {(props) => ( - + )} diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx index d13ade9c3..1f5272319 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.stories.example.tsx @@ -16,7 +16,12 @@ export function DefaultOpen() { {(props) => ( - + )} @@ -41,7 +46,12 @@ export function InitialOpen() { {(props) => ( - + )} @@ -75,7 +85,12 @@ export function WithOnClose() { > {(props) => ( - + )} @@ -86,7 +101,7 @@ export function WithOnClose() { ) : ( - + )} @@ -106,12 +121,17 @@ export function VariantInformation() { {(props) => ( - + )} - 補足情報を伝えるための Tooltip です。 + 補足情報を伝えるためのTooltipです。
); @@ -129,12 +149,19 @@ export function VariantConfirmation() { {(props) => ( - + )} - 訴求したい内容を伝えるための Tooltip です。 + + 訴求したい内容を伝えるためのTooltipです。 +
); @@ -152,13 +179,18 @@ export function VariantError() { {(props) => ( - + )} - エラーメッセージを表示するための Tooltip です。 + エラーメッセージを表示するためのTooltipです。 @@ -177,12 +209,19 @@ export function DirectionTop() { {(props) => ( - + )} - トリガーの下に表示され、ポインターが上を指します。 + + トリガーの下に表示され、ポインターが上を指します。 + ); @@ -200,12 +239,19 @@ export function DirectionBottom() { {(props) => ( - + )} - トリガーの上に表示され、ポインターが下を指します。 + + トリガーの上に表示され、ポインターが下を指します。 + ); @@ -223,12 +269,19 @@ export function DirectionLeft() { {(props) => ( - + )} - トリガーの右に表示され、ポインターが左を指します。 + + トリガーの右に表示され、ポインターが左を指します。 + ); @@ -246,12 +299,19 @@ export function DirectionRight() { {(props) => ( - + )} - トリガーの左に表示され、ポインターが右を指します。 + + トリガーの左に表示され、ポインターが右を指します。 + ); @@ -269,12 +329,19 @@ export function PositionCenter() { {(props) => ( - + )} - ポインターがトリガーの中央に配置されます。 + + ポインターがトリガーの中央に配置されます。 + ); @@ -286,19 +353,24 @@ export function PositionStart() { style={{ padding: '100px', display: 'flex', - justifyContent: 'center', + justifyContent: 'flex-start', }} > {(props) => ( - + )} - ポインターが Tooltip の開始位置寄りに配置されます。 + ポインターがTooltipの開始位置寄りに配置されます。 @@ -317,13 +389,18 @@ export function PositionEnd() { {(props) => ( - + )} - ポインターが Tooltip の終了位置寄りに配置されます。 + ポインターがTooltipの終了位置寄りに配置されます。 @@ -336,7 +413,12 @@ export function PositionEdgeStart() { {(props) => ( - + )} @@ -361,7 +443,12 @@ export function PositionEdgeEnd() { {(props) => ( - + )} @@ -386,7 +473,12 @@ export function LongText() { {(props) => ( - + )} diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.tsx index 24abb4594..9d9dd6936 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.tsx @@ -9,7 +9,6 @@ import React, { useState, } from 'react'; import Cross from '../Icon/Cross'; -import { IconButton } from '../IconButton'; import { createGraceArea, isPointInPolygon, type Polygon } from './graceArea'; type Direction = 'top' | 'right' | 'bottom' | 'left'; @@ -441,16 +440,17 @@ const Content = ({ children }: ContentProps) => {
{children}
{showCloseButton && ( -
- - -
+ )}
From 913e23a5d1a6e11b35824432ed4a95170aa4cc61 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Tue, 9 Dec 2025 15:08:39 +0900 Subject: [PATCH 05/12] =?UTF-8?q?fix(spindle-ui):=20=E3=83=9D=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=BF=E3=83=BC=E3=83=87=E3=83=90=E3=82=A4=E3=82=B9?= =?UTF-8?q?=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E3=83=9D=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=86=E3=82=A3=E3=83=B3=E3=82=B0=E3=83=87=E3=83=90=E3=82=A4?= =?UTF-8?q?=E3=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/src/Tooltip/Tooltip.css | 1 + packages/spindle-ui/src/Tooltip/Tooltip.tsx | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.css b/packages/spindle-ui/src/Tooltip/Tooltip.css index 04b56f7cd..1c037404f 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.css +++ b/packages/spindle-ui/src/Tooltip/Tooltip.css @@ -222,6 +222,7 @@ .spui-Tooltip-frame--edgeStart::before, .spui-Tooltip-frame--edgeEnd::before { + border-radius: 0; mask-size: 100% 100%; transform: none; } diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.tsx index 9d9dd6936..caaba4524 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.tsx @@ -142,7 +142,7 @@ const Frame = ({ [isInitialOpen, handlePointerUp], ); - // ポインターデバイス: hover + // ポインティングデバイス: hover const handleMouseEnter = useCallback( (_e: React.MouseEvent) => { if (isPointerDownRef.current) return; @@ -153,14 +153,14 @@ const Frame = ({ [isInitialOpen, openTooltip], ); - // ポインターデバイス: focus(pointerdown中は無視) + // ポインティングデバイス: focus(pointerdown中は無視) const handleFocus = useCallback(() => { if (isPointerDownRef.current) return; if (isInitialOpen) return; openTooltip(); }, [isInitialOpen, openTooltip]); - // ポインターデバイス: blur + // ポインティングデバイス: blur const handleBlur = useCallback(() => { if (isPointerDownRef.current) return; if (isInitialOpen) return; From 6f0fb21ab946f973f2be5fc75c128c50fd205197 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Tue, 9 Dec 2025 15:21:00 +0900 Subject: [PATCH 06/12] =?UTF-8?q?chore(spindle-ui):=20bundlewatch=E3=82=92?= =?UTF-8?q?=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/.bundlewatch.config.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/spindle-ui/.bundlewatch.config.js b/packages/spindle-ui/.bundlewatch.config.js index 87d68295f..621affb1f 100644 --- a/packages/spindle-ui/.bundlewatch.config.js +++ b/packages/spindle-ui/.bundlewatch.config.js @@ -11,10 +11,15 @@ const bundlewatchConfig = { compression: 'gzip', }, { - path: './dist/!(Icon|Toast|DropdownMenu|Pagination|Modal|SnackBar|StackNotificationManager|SegmentedControl)/*.mjs', + path: './dist/!(Icon|Toast|DropdownMenu|Pagination|Modal|SnackBar|StackNotificationManager|SegmentedControl|Tooltip)/*.mjs', maxSize: '1.1 kB', compression: 'gzip', }, + { + path: './dist/Tooltip/*.mjs', + maxSize: '2.7 kB', + compression: 'gzip', + }, { path: './dist/Modal/*.mjs', maxSize: '1.5 kB', @@ -56,10 +61,15 @@ const bundlewatchConfig = { compression: 'gzip', }, { - path: './dist/!(InlineNotification|Modal|SnackBar)/!(index).css', + path: './dist/!(InlineNotification|Modal|SnackBar|Tooltip)/!(index).css', maxSize: '1.5 kB', compression: 'gzip', }, + { + path: './dist/Tooltip/!(index).css', + maxSize: '1.9 kB', + compression: 'gzip', + }, { path: './dist/Modal/!(index).css', maxSize: '2.0 kB', From efb83e245bd27bafe845aafbcbb9135cee2c2e0a Mon Sep 17 00:00:00 2001 From: yu-3in Date: Thu, 11 Dec 2025 12:28:59 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat(spindle-ui):=20=E3=83=9D=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=BF=E6=93=8D=E4=BD=9C=E3=81=AE=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=B3=E3=82=BB=E3=83=AB=E3=81=8C=E3=81=A7=E3=81=8D=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spindle-ui/src/Tooltip/Tooltip.test.tsx | 46 ++++++++++++++++++- packages/spindle-ui/src/Tooltip/Tooltip.tsx | 24 ++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx index cec4d1576..c090bc6b6 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx @@ -22,6 +22,15 @@ const fireTouchPointerDown = (element: Element) => { 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', () => { @@ -228,7 +237,7 @@ describe('', () => { ).not.toBeInTheDocument(); }); - test('toggles visibility on touch (pointerdown with pointerType=touch)', async () => { + test('toggles visibility on touch (pointerdown + pointerup with pointerType=touch)', async () => { render( @@ -244,18 +253,50 @@ describe('', () => { 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(); }); @@ -280,8 +321,10 @@ describe('', () => { const trigger = screen.getByRole('button', { name: '詳細情報' }); + // タップして開く(pointerdown + pointerup) await act(async () => { fireTouchPointerDown(trigger); + fireTouchPointerUp(trigger); }); const tooltipText = await screen.findByText('補足情報'); @@ -291,6 +334,7 @@ describe('', () => { name: '外部のボタン', }); + // 外部をタップ(pointerdownのみでフェードアウト開始) await act(async () => { const event = new PointerEvent('pointerdown', { bubbles: true, diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.tsx index caaba4524..590dd56be 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.tsx @@ -32,6 +32,7 @@ type TriggerProps = { onFocus: () => void; onBlur: () => void; onPointerDown: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; }; type TriggerComponentProps = { @@ -59,6 +60,7 @@ type TooltipContextValue = { handleFocus: () => void; handleBlur: () => void; handlePointerDown: (e: React.PointerEvent) => void; + handleTriggerPointerUp: (e: React.PointerEvent) => void; isPointerInTransitRef: React.RefObject; }; @@ -95,9 +97,12 @@ const Frame = ({ 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(() => { @@ -128,10 +133,20 @@ const Frame = ({ const handlePointerDown = useCallback( (e: React.PointerEvent) => { isPointerDownRef.current = true; + pointerDownTargetRef.current = e.currentTarget; document.addEventListener('pointerup', handlePointerUp, { once: true }); + }, + [handlePointerUp], + ); - // タッチデバイス: トグル - if (e.pointerType === 'touch' && !isInitialOpen) { + 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); @@ -139,7 +154,7 @@ const Frame = ({ setIsOpen((prev) => !prev); } }, - [isInitialOpen, handlePointerUp], + [isInitialOpen], ); // ポインティングデバイス: hover @@ -184,6 +199,7 @@ const Frame = ({ handleFocus, handleBlur, handlePointerDown, + handleTriggerPointerUp, isPointerInTransitRef, }; @@ -204,6 +220,7 @@ const Trigger = ({ children }: TriggerComponentProps) => { handleFocus, handleBlur, handlePointerDown, + handleTriggerPointerUp, } = useTooltipContext(); const triggerProps: TriggerProps = { @@ -216,6 +233,7 @@ const Trigger = ({ children }: TriggerComponentProps) => { onFocus: handleFocus, onBlur: handleBlur, onPointerDown: handlePointerDown, + onPointerUp: handleTriggerPointerUp, }; return children(triggerProps); From 69f8b19297bc3774f51d5c88a03bf0091e268c6c Mon Sep 17 00:00:00 2001 From: yu-3in Date: Wed, 15 Apr 2026 14:14:47 +0900 Subject: [PATCH 08/12] =?UTF-8?q?docs(spindle-ui):=20=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=82=AC=E3=83=BC=E8=A6=81=E7=B4=A0=E3=81=AEhover=E3=82=B9?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=AB=E4=BB=95=E6=A7=98=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slackでの議論に基づき、Tooltipトリガー要素のhoverスタイルに関する 仕様と設計根拠をdesign-doc.mdとTooltip.mdxに追記。 推奨スタイル: background-color: var(--color-surface-secondary) + cursor: default --- packages/spindle-ui/src/Tooltip/Tooltip.mdx | 15 +++ packages/spindle-ui/src/Tooltip/design-doc.md | 110 +++++++++++++----- 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.mdx b/packages/spindle-ui/src/Tooltip/Tooltip.mdx index b5b63abfc..cdcc817b5 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.mdx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.mdx @@ -69,6 +69,21 @@ import '@openameba/spindle-ui/Tooltip.css'; - **ポインティングデバイス**: hoverで表示、トリガーから離れると非表示 - **タッチデバイス**: click/tapで表示、再度click/tapで非表示 +### トリガー要素の hover スタイル + +ポインティングデバイスでトリガー要素にhoverした際は、インタラクティブな要素にフォーカスしたフィードバックとして`background-color: var(--color-surface-secondary)`を適用することを推奨します。押せない要素(「?」アイコンなど)であっても、ユーザーに「なぜ吹き出しが出てきたのか」を伝えるために有用です。押せそうに見えることを避けるため、`cursor: default`も併せて指定してください。 + + + ## 初期表示 `defaultOpen={true}`を指定すると、初期状態でTooltipが表示されます。閉じるボタンをクリックすることで非表示にできます。 diff --git a/packages/spindle-ui/src/Tooltip/design-doc.md b/packages/spindle-ui/src/Tooltip/design-doc.md index 248b842b1..f142e6b0a 100644 --- a/packages/spindle-ui/src/Tooltip/design-doc.md +++ b/packages/spindle-ui/src/Tooltip/design-doc.md @@ -67,21 +67,25 @@ FigmaではTooltip(モードレス形式)とToggletip(モード形式) ### 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 +179,12 @@ Tooltipには三角形のポインターが表示され、トリガー要素を Tooltipの閉じる動作は表示状態に応じて自動的に最適化されます: **`defaultOpen={false}`または`defaultOpen={true}`で一度閉じた後の再表示の場合:** + - ポインティングデバイス: マウスを離す/フォーカスを外すと自動で閉じる - タッチデバイス: 再度クリック/タップ、または領域外クリックで閉じる **`defaultOpen={true}`で初期表示されている場合:** + - 閉じるボタンで閉じる - Tooltipまたはその内部要素にフォーカスが当たっている時、Escapeキーで閉じる - 領域外クリックでは閉じない(閉じるボタンがあるため) @@ -209,15 +215,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 +290,11 @@ React実装の一例です。 style="--Tooltip-trigger-width: 48px; --Tooltip-trigger-height: 48px;" >
-
- 補足情報 -
+
補足情報
@@ -265,28 +310,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) => ( + + + )} + + 新機能: 機能が追加されました + + ) : ( + + + ); +} ``` ## アクセシビリティ From 0bc75407c0b4d5dbdb7905694c4891ca82e039f7 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Wed, 15 Apr 2026 14:21:55 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix(spindle-ui):=20animation=20token?= =?UTF-8?q?=E3=82=92=E3=83=8F=E3=83=BC=E3=83=89=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 他コンポーネントの慣習に合わせ、animation tokenのCSS変数参照を 値の直接記述に変更。index.cssからspindle-tokensのimportを除外。 - appear-in: 0.35s cubic-bezier(0.5, 0, 0.5, 1) - disappear: 0.15s cubic-bezier(0.5, 0, 0.5, 1) --- packages/spindle-ui/src/Tooltip/Tooltip.css | 12 ++++-------- packages/spindle-ui/src/index.css | 1 - 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.css b/packages/spindle-ui/src/Tooltip/Tooltip.css index 1c037404f..12b3c5a62 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.css +++ b/packages/spindle-ui/src/Tooltip/Tooltip.css @@ -14,8 +14,7 @@ --Tooltip-arrow-size: 11px; --Tooltip-position-offset: 24px; - animation: var(--animation-appear-in-duration) - var(--animation-appear-in-easing) spui-Tooltip-fade-in; + animation: 0.35s cubic-bezier(0.5, 0, 0.5, 1) spui-Tooltip-fade-in; border-radius: 8px; box-shadow: 0 11px 28px 0 rgba(8, 18, 26, 0.24); box-sizing: border-box; @@ -30,8 +29,7 @@ /* stylelint-disable-next-line plugin/selector-bem-pattern */ .spui-Tooltip-frame.is-fade-out { - animation: var(--animation-disappear-duration) - var(--animation-disappear-easing) spui-Tooltip-fade-out; + animation: 0.15s cubic-bezier(0.5, 0, 0.5, 1) spui-Tooltip-fade-out; opacity: 0; } @@ -133,8 +131,7 @@ /* Position: center (for top/bottom) */ .spui-Tooltip-frame--top.spui-Tooltip-frame--center, .spui-Tooltip-frame--bottom.spui-Tooltip-frame--center { - animation: var(--animation-appear-in-duration) - var(--animation-appear-in-easing) spui-Tooltip-fade-in; + animation: 0.35s cubic-bezier(0.5, 0, 0.5, 1) spui-Tooltip-fade-in; left: 50%; transform: translateX(-50%); } @@ -148,8 +145,7 @@ /* Position: center (for left/right) */ .spui-Tooltip-frame--left.spui-Tooltip-frame--center, .spui-Tooltip-frame--right.spui-Tooltip-frame--center { - animation: var(--animation-appear-in-duration) - var(--animation-appear-in-easing) spui-Tooltip-fade-in; + animation: 0.35s cubic-bezier(0.5, 0, 0.5, 1) spui-Tooltip-fade-in; top: 50%; transform: translateY(-50%); } diff --git a/packages/spindle-ui/src/index.css b/packages/spindle-ui/src/index.css index 613c55c16..a810a7478 100644 --- a/packages/spindle-ui/src/index.css +++ b/packages/spindle-ui/src/index.css @@ -1,6 +1,5 @@ /* stylelint-disable plugin/selector-bem-pattern */ @import 'ameba-color-palette.css'; -@import '../../spindle-tokens/dist/css/spindle-tokens.css'; @import './Breadcrumb/Breadcrumb.css'; @import './BottomButton/BottomButton.css'; @import './Button/Button.css'; From 874d7605d0265b25d31229da15281d6dbe16b549 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Wed, 15 Apr 2026 14:31:02 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix(spindle-ui):=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92vitest=E5=AF=BE=E5=BF=9C=E3=80=81=E7=9F=A2?= =?UTF-8?q?=E5=8D=B0=E3=81=AE=E6=A8=AA=E5=B9=85=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @jest/globalsをvitestのviに置き換え - モバイルの矢印サイズをFigmaに合わせて調整 (arrow-size: 11→14px, arrow-depth: 6→8px, arrow-offset: 4→2px) --- packages/spindle-ui/src/Tooltip/Tooltip.css | 6 +++--- packages/spindle-ui/src/Tooltip/Tooltip.test.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.css b/packages/spindle-ui/src/Tooltip/Tooltip.css index 12b3c5a62..18e42ccc9 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.css +++ b/packages/spindle-ui/src/Tooltip/Tooltip.css @@ -1,6 +1,6 @@ :root { --Tooltip-z-index: 1; - --Tooltip-arrow-offset: 4px; + --Tooltip-arrow-offset: 2px; } .spui-Tooltip { @@ -10,8 +10,8 @@ } .spui-Tooltip-frame { - --Tooltip-arrow-depth: 6px; - --Tooltip-arrow-size: 11px; + --Tooltip-arrow-depth: 8px; + --Tooltip-arrow-size: 14px; --Tooltip-position-offset: 24px; animation: 0.35s cubic-bezier(0.5, 0, 0.5, 1) spui-Tooltip-fade-in; diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx index c090bc6b6..de3d68656 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx +++ b/packages/spindle-ui/src/Tooltip/Tooltip.test.tsx @@ -1,4 +1,3 @@ -import { jest } from '@jest/globals'; import { act, fireEvent, @@ -7,6 +6,7 @@ import { waitFor, } from '@testing-library/react'; import React from 'react'; +import { vi } from 'vitest'; import Information from '../Icon/Information'; import { IconButton } from '../IconButton'; @@ -425,7 +425,7 @@ describe('', () => { }); test('closes when close button is clicked', async () => { - const onClose = jest.fn(); + const onClose = vi.fn(); render( @@ -451,7 +451,7 @@ describe('', () => { }); test('closes on Escape key when tooltip or its content is focused', async () => { - const onClose = jest.fn(); + const onClose = vi.fn(); render( From 45ee87cbfdbafb4f70d50e82a2e822422365d657 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Wed, 15 Apr 2026 14:31:45 +0900 Subject: [PATCH 11/12] =?UTF-8?q?docs(spindle-ui):=20=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E6=8C=81=E3=81=A4=E3=83=9C=E3=82=BF=E3=83=B3=E3=81=B8?= =?UTF-8?q?=E3=81=AETooltip=E4=BD=BF=E7=94=A8=E5=88=B6=E9=99=90=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タッチデバイスでは機能を持つボタンをトリガーにすると 本来の機能が動作しない問題をDO NOTセクションに記載。 --- packages/spindle-ui/src/Tooltip/design-doc.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/spindle-ui/src/Tooltip/design-doc.md b/packages/spindle-ui/src/Tooltip/design-doc.md index f142e6b0a..783f6690f 100644 --- a/packages/spindle-ui/src/Tooltip/design-doc.md +++ b/packages/spindle-ui/src/Tooltip/design-doc.md @@ -61,6 +61,9 @@ FigmaではTooltip(モードレス形式)とToggletip(モード形式) - 情報が多くなる場合やインタラクションを多数含めたい場合にはモーダルの使用や別ページへの遷移を検討しましょう - **テキストやリンクに付けない** - ユーザーのメンタルモデルに反した挙動となり、混乱を招く可能性があります +- **ポインティングデバイスでは、機能を持つボタンにTooltipを付けない** + - BボタンやIボタンなど、トリガー要素自体に補足の説明以外の機能がある場合、タッチデバイスではクリックでTooltipが表示されてしまい本来の機能が動作しません。このケースはモバイル端末でアクセシブルにできないため、使用すべきではありません + - 代替案: 初期表示(`defaultOpen={true}`)で表示するか、機能を持つ要素の隣にインフォメーションアイコンを配置してください ## 要素 From f9286f867a8df1292d933b2a5e833fbbe50ff686 Mon Sep 17 00:00:00 2001 From: yu-3in Date: Wed, 15 Apr 2026 14:32:52 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix(spindle-ui):=20=E7=9F=A2=E5=8D=B0?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=82=BA=E3=81=AE=E5=A4=89=E6=9B=B4=E3=82=92?= =?UTF-8?q?=E5=85=83=E3=81=AB=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spindle-ui/src/Tooltip/Tooltip.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spindle-ui/src/Tooltip/Tooltip.css b/packages/spindle-ui/src/Tooltip/Tooltip.css index 18e42ccc9..12b3c5a62 100644 --- a/packages/spindle-ui/src/Tooltip/Tooltip.css +++ b/packages/spindle-ui/src/Tooltip/Tooltip.css @@ -1,6 +1,6 @@ :root { --Tooltip-z-index: 1; - --Tooltip-arrow-offset: 2px; + --Tooltip-arrow-offset: 4px; } .spui-Tooltip { @@ -10,8 +10,8 @@ } .spui-Tooltip-frame { - --Tooltip-arrow-depth: 8px; - --Tooltip-arrow-size: 14px; + --Tooltip-arrow-depth: 6px; + --Tooltip-arrow-size: 11px; --Tooltip-position-offset: 24px; animation: 0.35s cubic-bezier(0.5, 0, 0.5, 1) spui-Tooltip-fade-in;