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