From f59b5fd3c3ca5700547f8f0c54519105e34fffab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Louren=C3=A7o?= Date: Sun, 6 Sep 2026 17:45:01 -0300 Subject: [PATCH] feat(tooltip): rework component on top of @base-ui/react --- .claude/skills/component-audit/SKILL.md | 4 +- .../skills/component-docs-stories/SKILL.md | 3 + .github/pull_request_template.md | 4 +- COMPONENT_AUDIT_RUBRIC.md | 4 +- COMPONENT_GUIDELINES.md | 60 +- apps/docs/index.css | 8 + apps/docs/stories/combobox-simple.stories.tsx | 3 +- apps/docs/stories/tabs-root.mdx | 2 +- .../tooltip-content.stories.module.css | 3 - apps/docs/stories/tooltip-content.stories.tsx | 126 ---- .../docs/stories/tooltip-provider.stories.tsx | 57 +- apps/docs/stories/tooltip-simple.mdx | 137 ---- .../stories/tooltip-simple.stories.module.css | 13 - apps/docs/stories/tooltip-simple.stories.tsx | 205 ------ .../tooltip-trigger.stories.module.css | 3 - apps/docs/stories/tooltip-trigger.stories.tsx | 58 -- apps/docs/stories/tooltip.mdx | 205 ++++-- apps/docs/stories/tooltip.stories.module.css | 101 ++- apps/docs/stories/tooltip.stories.tsx | 593 +++++++++++++----- packages/ui/package.json | 1 - .../combobox-simple/combobox-simple.tsx | 7 +- .../combobox-simple/components/pills.tsx | 6 +- .../ui/src/dialog/dialog.tooltip.test.tsx | 51 ++ packages/ui/src/pin-list/pin-list.tsx | 11 +- packages/ui/src/slider/slider.tsx | 16 +- packages/ui/src/tabs/tabs.tsx | 11 +- .../__tests__/tooltip.accessibility.test.tsx | 127 ++++ .../__tests__/tooltip.controlled.test.tsx | 129 ++++ .../__tests__/tooltip.forward-ref.test.tsx | 126 ++++ .../__tests__/tooltip.interaction.test.tsx | 151 +++++ .../tooltip/__tests__/tooltip.portal.test.tsx | 159 +++++ .../__tests__/tooltip.positioning.test.tsx | 51 ++ .../__tests__/tooltip.rendering.test.tsx | 166 +++++ .../tooltip/__tests__/tooltip.stack.test.tsx | 78 +++ .../__tests__/tooltip.stacking.test.tsx | 374 +++++++++++ .../tooltip/__tests__/tooltip.title.test.tsx | 146 +++++ .../tooltip.trigger-context.test.tsx | 72 +++ .../__tests__/tooltip.types.test-d.tsx | 189 ++++++ packages/ui/src/tooltip/index.ts | 37 +- .../ui/src/tooltip/presets/tooltip-simple.tsx | 150 ----- packages/ui/src/tooltip/presets/tooltip.tsx | 160 +++++ .../tooltip/subcomponents/tooltip-content.tsx | 168 ++--- .../tooltip/subcomponents/tooltip-popup.tsx | 43 ++ .../tooltip/subcomponents/tooltip-portal.tsx | 22 + .../subcomponents/tooltip-positioner.tsx | 35 ++ .../subcomponents/tooltip-provider.tsx | 89 +-- .../tooltip/subcomponents/tooltip-root.tsx | 78 +-- .../tooltip/subcomponents/tooltip-stack.tsx | 45 ++ .../tooltip/subcomponents/tooltip-trigger.tsx | 108 +++- .../ui/src/tooltip/tooltip-config-context.tsx | 51 ++ .../tooltip/tooltip-content-id-context.tsx | 23 + .../tooltip/tooltip-content-stack-context.tsx | 165 +++++ packages/ui/src/tooltip/tooltip-handle.ts | 14 + .../ui/src/tooltip/tooltip-stacked-props.ts | 31 + .../src/tooltip/tooltip-trigger-context.tsx | 24 + .../src/tooltip/tooltip.forward-ref.test.tsx | 51 -- packages/ui/src/tooltip/tooltip.module.scss | 80 +-- packages/ui/src/tooltip/types.ts | 85 +++ pnpm-lock.yaml | 37 -- 59 files changed, 3575 insertions(+), 1381 deletions(-) delete mode 100644 apps/docs/stories/tooltip-content.stories.module.css delete mode 100644 apps/docs/stories/tooltip-content.stories.tsx delete mode 100644 apps/docs/stories/tooltip-simple.mdx delete mode 100644 apps/docs/stories/tooltip-simple.stories.module.css delete mode 100644 apps/docs/stories/tooltip-simple.stories.tsx delete mode 100644 apps/docs/stories/tooltip-trigger.stories.module.css delete mode 100644 apps/docs/stories/tooltip-trigger.stories.tsx create mode 100644 packages/ui/src/dialog/dialog.tooltip.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.accessibility.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.controlled.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.forward-ref.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.interaction.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.portal.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.positioning.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.rendering.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.stack.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.stacking.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.title.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.trigger-context.test.tsx create mode 100644 packages/ui/src/tooltip/__tests__/tooltip.types.test-d.tsx delete mode 100644 packages/ui/src/tooltip/presets/tooltip-simple.tsx create mode 100644 packages/ui/src/tooltip/presets/tooltip.tsx create mode 100644 packages/ui/src/tooltip/subcomponents/tooltip-popup.tsx create mode 100644 packages/ui/src/tooltip/subcomponents/tooltip-portal.tsx create mode 100644 packages/ui/src/tooltip/subcomponents/tooltip-positioner.tsx create mode 100644 packages/ui/src/tooltip/subcomponents/tooltip-stack.tsx create mode 100644 packages/ui/src/tooltip/tooltip-config-context.tsx create mode 100644 packages/ui/src/tooltip/tooltip-content-id-context.tsx create mode 100644 packages/ui/src/tooltip/tooltip-content-stack-context.tsx create mode 100644 packages/ui/src/tooltip/tooltip-handle.ts create mode 100644 packages/ui/src/tooltip/tooltip-stacked-props.ts create mode 100644 packages/ui/src/tooltip/tooltip-trigger-context.tsx delete mode 100644 packages/ui/src/tooltip/tooltip.forward-ref.test.tsx create mode 100644 packages/ui/src/tooltip/types.ts diff --git a/.claude/skills/component-audit/SKILL.md b/.claude/skills/component-audit/SKILL.md index a5320062..83d41a7a 100644 --- a/.claude/skills/component-audit/SKILL.md +++ b/.claude/skills/component-audit/SKILL.md @@ -116,8 +116,8 @@ Check: JSDoc comments on props ### 5. Stories & MDX - 0: No story, wrong title group, or one story with no argTypes -- 1: Default story + some argTypes but no per-subcomponent stories or no MDX -- 2: Story per exported component, full argTypes, MDX with usage + Controls +- 1: Default story + some argTypes but subcomponents with no story at all or no MDX +- 2: One file per root component and per preset with subcomponent stories in the parent file, full argTypes, MDX with usage + Controls Check: apps/docs/src/stories/ for *.stories.tsx and *.mdx files diff --git a/.claude/skills/component-docs-stories/SKILL.md b/.claude/skills/component-docs-stories/SKILL.md index c8d36a80..62aaa061 100644 --- a/.claude/skills/component-docs-stories/SKILL.md +++ b/.claude/skills/component-docs-stories/SKILL.md @@ -114,6 +114,9 @@ Rules: Use shared classes from `apps/docs/index.css`: - `story-container`, `story-section`, `story-grid`, `story-row`, `story-panel`, `icon-md` +- `story-freeze-animations` on the story root of a snapshotted story: pauses every animation at + frame 0 and drops every transition, document-wide (portals included). Skip it when the + animation is what the story shows. No Tailwind classes. No ad-hoc inline `style` where shared class exists. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a07a75a1..b4951a6f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -74,13 +74,13 @@ that apply to this PR and delete the rest. A docs-only or CI-only PR needs none - [ ] `{Component}Props` exported; only the props the component actually needs are exposed - [ ] Upstream-owned prop types borrowed by indexed access (`OriginalProps['x']`), never restated by hand - [ ] Every type named by a public prop is exported from `index.ts` -- [ ] `forwardRef` + `displayName`; `asChild`, `testId` supported +- [ ] `forwardRef` with a named render function (or `displayName`); `testId` supported - [ ] Defaults in the destructuring; controlled/uncontrolled naming follows Radix **Docs** ([guidelines](https://github.com/SigNoz/components/blob/main/COMPONENT_GUIDELINES.md#4-how-to-document-props)) - [ ] JSDoc on **every** public prop, with `@default` where applicable -- [ ] Story file per exported component, correct `title` group +- [ ] Story file per root component and per preset, subcomponent stories in the parent's file (`@access private` ones exempt), correct `title` group - [ ] `argTypes` complete with `category`, `type.summary`, `defaultValue.summary` - [ ] Stories for every meaningful state, not just the happy path - [ ] `{component}.mdx` with a usage snippet and a `` per exported piece diff --git a/COMPONENT_AUDIT_RUBRIC.md b/COMPONENT_AUDIT_RUBRIC.md index ad725237..ae2ceeac 100644 --- a/COMPONENT_AUDIT_RUBRIC.md +++ b/COMPONENT_AUDIT_RUBRIC.md @@ -20,9 +20,9 @@ is for auditing what already shipped. | --- | --- | --- | --- | --- | | 1 | **Structure & exports** | Logic in `index.ts`, or a flat file dump, or missing from one of the four lists | Correct layout, but naming drift (`components/` instead of `subcomponents/`) or shared helpers inlined | Kebab-case dir, clean `index.ts`, `subcomponents/` + `presets/` split, all lists in sync | | 2 | **CSS & tokens** | Hardcoded values, primitive (`--bg-*` / `--text-*`) colours, Tailwind remnants, global selectors, or a `--x: var(--x)` bug | Tokenized, but still carries literal fallbacks on design tokens, or some literals / class-based variants left | Every value a `--{component}-*` var resolving to a **semantic** design token with no literal fallback, `data-*` variants, `-internal-` used for non-overridable values, token region current, all interaction states styled | -| 3 | **Props & types** | Upstream signatures restated by hand, unexported types a prop needs, or no `forwardRef` | Correct `forwardRef`/`testId`, but some upstream-owned prop types still hand-written instead of borrowed | Every prop declared explicitly with JSDoc, upstream-owned types borrowed via `OriginalProps['x']`, `Pick` reserved for undocumented pass-throughs, all referenced types exported, `asChild` where sensible, Radix-style controlled/uncontrolled naming | +| 3 | **Props & types** | Upstream signatures restated by hand, unexported types a prop needs, or no `forwardRef` | Correct `forwardRef`/`testId`, but some upstream-owned prop types still hand-written instead of borrowed | Every prop declared explicitly with JSDoc, upstream-owned types borrowed via `OriginalProps['x']`, `Pick` reserved for undocumented pass-throughs, all referenced types exported, Radix-style controlled/uncontrolled naming | | 4 | **Prop documentation** | No JSDoc | Partial JSDoc, or `@default` tags that disagree with the implementation | Every public prop documented with its constraints and interactions; `@default` matches implementation *and* `argTypes` | -| 5 | **Stories & MDX** | No story, or wrong title group, or one story with no `argTypes` | `Default` story + some `argTypes`; no per-subcomponent stories or no MDX | Story per exported component, fully categorized `argTypes`, a story per meaningful state, MDX with usage snippet + correct per-piece `` | +| 5 | **Stories & MDX** | No story, or wrong title group, or one story with no `argTypes` | `Default` story + some `argTypes`; subcomponents with no story at all or no MDX | One file per root component and per preset, subcomponent stories in the parent's file (`@access private` ones exempt), fully categorized `argTypes`, a story per meaningful state, MDX with usage snippet + correct per-piece `` | | 6 | **Tests** | None | Render smoke test only | Behaviour test per interactive prop, `forward-ref` test, interaction story exercised by `test-storybook` | ## Bands diff --git a/COMPONENT_GUIDELINES.md b/COMPONENT_GUIDELINES.md index 830ec54c..ab26f8c8 100644 --- a/COMPONENT_GUIDELINES.md +++ b/COMPONENT_GUIDELINES.md @@ -46,7 +46,8 @@ Five principles behind every component. Read when building; refer back when maki 4. Props are **picked deliberately**, exposing only what the component actually needs, and **every prop carries JSDoc**, so a human or an agent reading the type declaration understands it without opening the implementation. -5. **One story file per exported component**, subcomponents included. +5. **One story file per exported component**, subcomponents included. Symbols tagged + `@access private` are exempt. Reference implementations to copy from: @@ -93,11 +94,14 @@ packages/ui/src/dialog/ Rules: -- **`subcomponents/`** holds the composable primitives. One file per exported component, named - after it in kebab-case (`dialog-close-button.tsx` for `DialogCloseButton`). `select/` uses - `components/` for this. That is drift, not an alternative. Use `subcomponents/`. -- **`presets/`** holds the batteries-included versions. A preset must be buildable from the - exported primitives. If it needs something the primitives don't expose, expose it. +- **`subcomponents/`** holds the parts a component is composed from. One file per component, + named after it in kebab-case (`dialog-close-button.tsx` for `DialogCloseButton`). `select/` + uses `components/` for this. That is drift, not an alternative. Use `subcomponents/`. +- **`presets/`** holds the batteries-included versions, built from the subcomponents. +- A composed component exports only the composed surface. Its subcomponents stay out of + `index.ts` and carry `@access private` (`tooltip/` exports `Tooltip`, not `TooltipTrigger` or + `TooltipContent`). Another component that needs one imports it by relative path, and a change + to that subcomponent has to keep those imports working. - Shared non-component logic goes in `utils.ts` (see `pagination/utils.ts`) or a `lib/` subfolder (`table/lib/`). Cross-component helpers go in `src/lib/`. - One style file per component directory is the norm; add `{subcomponent}.module.scss` only @@ -129,6 +133,33 @@ export { Badge } from './badge.js'; - Every type referenced by a public prop must be exported here. A prop typed with something a consumer can't import is a bug: they cannot declare their own handler or hold the value in a typed variable. +- `@access private` in a symbol's JSDoc marks it as not public API: the subcomponents a + composed component is built from, its contexts and hooks. Nothing outside the package may + rely on it, it needs no story or MDX section, and it can change without a major version. A + symbol can carry the tag and still be exported when another component in this repo needs it + (`TooltipProviderIfMissing`). +- Applying it is mechanical: **every `export` in the component directory that `index.ts` does + not re-export carries the tag**, props types as much as components (`TooltipTriggerProps` + next to `TooltipTrigger`), contexts, providers and hooks included. A symbol that is never + exported from its own file needs nothing, it is already unreachable. +- A symbol listed in `index.ts` must **not** carry the tag. Public and private at once is a + bug: it exempts a real part of the surface from its story, MDX section and prop docs. +- The tag goes last in the JSDoc block, after the prose and the other tags, separated by a + blank ` *` line. A symbol with no prose gets a block holding only the tag: + +```tsx +/** + * Where the tooltip content is portalled to. + * + * @access private + */ +export type TooltipPortalProps = ...; + +/** + * @access private + */ +export type TooltipRootProps = ...; +``` ### Import hygiene @@ -345,7 +376,6 @@ export interface BadgeProps extends Pick< testId?: string; variant?: BadgeVariant; color?: BadgeColor; - asChild?: boolean; } ``` @@ -458,8 +488,8 @@ the lowercase string that lands in `data-*`, and never a TS `enum`. | Convention | Rule | | --- | --- | -| `forwardRef` | Every component forwards its ref to the real DOM node, and sets `Component.displayName = 'Component'` | -| `asChild` | Support it (via `@radix-ui/react-slot`) wherever a consumer might want to swap the element. Document what it disables: `Badge` ignores `closable` under `asChild`; `Button` doesn't support `loading`/`prefix`/`suffix` | +| `forwardRef` | Every component forwards its ref to the real DOM node. Name the render function, `forwardRef(function Badge(props, ref) { ... })`, so DevTools and stack traces show the name. An explicit `Component.displayName` does the same and stays valid where it already exists | +| Providers | A component that needs a provider wraps itself in `XProviderIfMissing` (see `TooltipProviderIfMissing`), never in `XProvider`. It adds the provider when none is above and reuses the existing one otherwise, so the component never fails for want of a provider and never shadows what the app configured. Apps place `XProvider` once near the root | | `testId` | Always present; forwarded as `data-testid`. Don't make consumers use `className` for test hooks | | Defaults | Set in the destructuring (`variant = 'default'`), and mirrored in an `@default` JSDoc tag | | Controlled/uncontrolled | Follow Radix naming: `value`/`defaultValue`/`onChange`, `open`/`defaultOpen`/`onOpenChange` | @@ -542,7 +572,8 @@ Top-level groups are fixed by `storySort.order` in `apps/docs/.storybook/preview - Don't invent a new top-level group. **One story file per exported component**, subcomponents included. That is what makes -per-component Controls tables possible. Bind `Meta` to the actual symbol: +per-component Controls tables possible. Symbols tagged `@access private` are exempt: they are +not public API, so they get no story and no Controls table. Bind `Meta` to the actual symbol: ```tsx const meta: Meta = { @@ -617,9 +648,14 @@ Layout: use the shared classes in `apps/docs/index.css` (`story-container`, `sto `story-grid`, `story-row`, `story-panel`, `icon-md`) or a `{name}.stories.module.css`. No Tailwind classes, and no ad-hoc inline `style` where a shared class exists. +Snapshotted stories add `story-freeze-animations` to the story root: it pauses every animation +at its first frame and drops every transition, document-wide, so a Chromatic capture cannot +land mid-spinner or mid-marching-border. Leave it off the stories whose animation is the point. + ### The MDX page -One `{component}.mdx` per component, wiring Controls per exported piece: +One `{component}.mdx` per component, wiring Controls per exported piece (`@access private` +ones excluded): ```mdx import { Meta, Controls, Primary } from '@storybook/addon-docs/blocks'; @@ -669,7 +705,7 @@ Visual changes need the `run-visual-testing` label on the PR to get Chromatic sn | File | Covers | | --- | --- | -| `{name}.test.tsx` | Behaviour: each variant renders, callbacks fire, controlled + uncontrolled, keyboard interaction, `asChild` composition | +| `{name}.test.tsx` | Behaviour: each variant renders, callbacks fire, controlled + uncontrolled, keyboard interaction | | `{name}.forward-ref.test.tsx` | `ref.current` is the expected element instance and carries `data-slot` | | `{name}.test-utils.tsx` | Shared render helpers, when several test files need them | | `apps/docs/stories/*.stories.tsx` | Render + interaction in a real browser via `@storybook/addon-vitest` | diff --git a/apps/docs/index.css b/apps/docs/index.css index 99f4ff8b..ff8840fb 100644 --- a/apps/docs/index.css +++ b/apps/docs/index.css @@ -120,6 +120,14 @@ button, background-color: var(--muted); } +body:has(.story-freeze-animations), +body:has(.story-freeze-animations) *, +body:has(.story-freeze-animations) *::before, +body:has(.story-freeze-animations) *::after { + animation-play-state: paused !important; + transition: none !important; +} + .story-resizable { width: 100%; height: 400px; diff --git a/apps/docs/stories/combobox-simple.stories.tsx b/apps/docs/stories/combobox-simple.stories.tsx index 38e4cea3..77f6a064 100644 --- a/apps/docs/stories/combobox-simple.stories.tsx +++ b/apps/docs/stories/combobox-simple.stories.tsx @@ -103,7 +103,8 @@ const meta: Meta = { }, disableTooltipProvider: { control: 'boolean', - description: 'Disable internal TooltipProvider when already inside one.', + description: + 'Deprecated. An outer TooltipProvider is detected on its own, so this no longer has to be set.', table: { category: 'Behavior', type: { summary: 'boolean' }, diff --git a/apps/docs/stories/tabs-root.mdx b/apps/docs/stories/tabs-root.mdx index cbf50238..ce335d15 100644 --- a/apps/docs/stories/tabs-root.mdx +++ b/apps/docs/stories/tabs-root.mdx @@ -35,7 +35,7 @@ export default function MyComponent() { } ``` -> Note: Disabled tab tooltips and automatic lock-icon replacement for disabled states require the `Tabs` composed component. Primitive composition does not wrap `TabsTrigger` with `TooltipSimple`, so `disabledReason` has no effect. +> Note: Disabled tab tooltips and automatic lock-icon replacement for disabled states require the `Tabs` composed component. Primitive composition does not wrap `TabsTrigger` with `Tooltip`, so `disabledReason` has no effect. ## TabsRoot Props diff --git a/apps/docs/stories/tooltip-content.stories.module.css b/apps/docs/stories/tooltip-content.stories.module.css deleted file mode 100644 index 894ccc94..00000000 --- a/apps/docs/stories/tooltip-content.stories.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.demoArea { - padding: 80px; -} diff --git a/apps/docs/stories/tooltip-content.stories.tsx b/apps/docs/stories/tooltip-content.stories.tsx deleted file mode 100644 index 9850d25b..00000000 --- a/apps/docs/stories/tooltip-content.stories.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import type { TooltipContentProps } from '@signozhq/ui'; -import { - Button, - ButtonColor, - ButtonVariant, - TooltipContent, - TooltipProvider, - TooltipRoot, - TooltipTrigger, -} from '@signozhq/ui'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import styles from './tooltip-content.stories.module.css'; - -const meta: Meta = { - title: 'Primitive Components/Tooltip/TooltipContent', - component: TooltipContent, - argTypes: { - side: { - control: 'select', - options: ['top', 'right', 'bottom', 'left'], - description: - 'The preferred side of the trigger to render against when open. Will be reversed when collisions occur and avoidCollisions is enabled.', - table: { category: 'Position', type: { summary: "'top' | 'right' | 'bottom' | 'left'" } }, - }, - sideOffset: { - control: 'number', - description: 'The distance in pixels from the trigger.', - table: { category: 'Position', type: { summary: 'number' }, defaultValue: { summary: '4' } }, - }, - align: { - control: 'select', - options: ['start', 'center', 'end'], - description: 'The preferred alignment against the trigger. May change when collisions occur.', - table: { category: 'Position', type: { summary: "'start' | 'center' | 'end'" } }, - }, - alignOffset: { - control: 'number', - description: 'An offset in pixels from the "start" or "end" alignment options.', - table: { category: 'Position', type: { summary: 'number' } }, - }, - arrowPadding: { - control: 'number', - description: - 'The padding between the arrow and the edges of the content. If your content has border-radius, this will prevent it from overflowing the corners.', - table: { category: 'Position', type: { summary: 'number' } }, - }, - avoidCollisions: { - control: 'boolean', - description: - 'When true, overrides the side and align preferences to prevent collisions with boundary edges.', - table: { category: 'Position', type: { summary: 'boolean' } }, - }, - arrow: { - control: 'boolean', - description: 'Whether to show the arrow.', - table: { - category: 'Appearance', - type: { summary: 'boolean' }, - defaultValue: { summary: 'false' }, - }, - }, - forceMount: { - control: 'boolean', - description: - 'Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries.', - table: { category: 'Behavior', type: { summary: 'true' } }, - }, - withPortal: { - control: 'boolean', - description: 'Whether to render in a portal. Set to false when inside modals/dialogs.', - table: { - category: 'Behavior', - type: { summary: 'boolean' }, - defaultValue: { summary: 'true' }, - }, - }, - testId: { - control: 'text', - description: 'The test id of the tooltip content.', - table: { category: 'Testing', type: { summary: 'string' } }, - }, - onEscapeKeyDown: { - control: false, - description: 'Event handler called when the escape key is down. Can be prevented.', - table: { category: 'Events' }, - }, - onPointerDownOutside: { - control: false, - description: - 'Event handler called when a pointerdown event happens outside of the Tooltip. Can be prevented.', - table: { category: 'Events' }, - }, - }, - parameters: { - layout: 'fullscreen', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - side: 'bottom', - sideOffset: 4, - align: 'center', - arrow: true, - }, - render: (args: Partial) => ( - -
- - - - - - Rich tooltip content with positioning controls - - -
-
- ), -}; diff --git a/apps/docs/stories/tooltip-provider.stories.tsx b/apps/docs/stories/tooltip-provider.stories.tsx index 8b758994..5bc5f806 100644 --- a/apps/docs/stories/tooltip-provider.stories.tsx +++ b/apps/docs/stories/tooltip-provider.stories.tsx @@ -1,45 +1,47 @@ -import { Button, ButtonColor, ButtonVariant, TooltipProvider, TooltipSimple } from '@signozhq/ui'; +import { Button, ButtonColor, ButtonVariant, Tooltip, TooltipProvider } from '@signozhq/ui'; import type { Meta, StoryObj } from '@storybook/react-vite'; import styles from './tooltip-provider.stories.module.css'; const meta: Meta = { - title: 'Primitive Components/Tooltip/TooltipProvider', + title: 'Composed Components/Tooltip/TooltipProvider', component: TooltipProvider, argTypes: { - delayDuration: { + delay: { control: 'number', description: - 'The duration from when the pointer enters the trigger until the tooltip gets opened.', + 'How long to wait before opening a tooltip on hover, in milliseconds. Focus opens it at once.', table: { category: 'Behavior', type: { summary: 'number' }, - defaultValue: { summary: '700' }, + defaultValue: { summary: '300' }, }, }, - skipDelayDuration: { + closeDelay: { control: 'number', description: - 'How much time a user has to enter another trigger without incurring a delay again.', + 'How long to wait before closing a tooltip once the pointer leaves, in milliseconds.', + table: { category: 'Behavior', type: { summary: 'number' }, defaultValue: { summary: '0' } }, + }, + timeout: { + control: 'number', + description: + 'Another tooltip opens at once, skipping `delay`, when the previous one closed within this many milliseconds.', table: { category: 'Behavior', type: { summary: 'number' }, - defaultValue: { summary: '300' }, + defaultValue: { summary: '400' }, }, }, - disableHoverableContent: { - control: 'boolean', + container: { + control: false, description: - 'When true, trying to hover the content will result in the tooltip closing as the pointer leaves the trigger.', - table: { - category: 'Behavior', - type: { summary: 'boolean' }, - defaultValue: { summary: 'false' }, - }, + 'The element every tooltip below is portalled into. Defaults to `document.body`.', + table: { category: 'Behavior', type: { summary: 'HTMLElement | ShadowRoot | RefObject' } }, }, - testId: { - control: 'text', - description: 'The test id of the tooltip provider.', - table: { category: 'Testing', type: { summary: 'string' } }, + children: { + control: false, + description: 'The children of the tooltip provider.', + table: { category: 'Content', type: { summary: 'ReactNode' } }, }, }, parameters: { @@ -52,22 +54,19 @@ export default meta; type Story = StoryObj; export const Default: Story = { - args: { - delayDuration: 0, - }, render: (args) => (
- - - - - - +
), diff --git a/apps/docs/stories/tooltip-simple.mdx b/apps/docs/stories/tooltip-simple.mdx deleted file mode 100644 index d64920ce..00000000 --- a/apps/docs/stories/tooltip-simple.mdx +++ /dev/null @@ -1,137 +0,0 @@ -import { Meta, Controls, Primary } from '@storybook/addon-docs/blocks'; -import * as TooltipSimpleStories from './tooltip-simple.stories'; - - - -# TooltipSimple - -Preset that wraps `TooltipRoot`, `TooltipTrigger`, and `TooltipContent` in a single component. Pass a `title` and a trigger element instead of composing subcomponents manually. - -For full primitive control, see [Tooltip](?path=/docs/primitive-components-tooltip--docs). - -## How to use - -`TooltipSimple` must be rendered inside a `TooltipProvider`. Place the provider once near the root of your app or around a group of tooltips: - -```tsx -import { TooltipSimple, TooltipProvider, Button } from '@signozhq/ui'; - -export default function MyComponent() { - return ( - - - - - - ); -} -``` - - - -## Arrow - -Set `arrow` to render a directional indicator pointing toward the trigger: - -```tsx - - - - - -``` - -## Positioning - -`side` sets which side of the trigger the tooltip opens against (`top`, `right`, `bottom`, `left`). `align` controls alignment along that side (`start`, `center`, `end`). Collision detection repositions the tooltip automatically when it would overflow the viewport: - -```tsx - - - - - -``` - -Use `sideOffset` and `alignOffset` for pixel-level adjustments: - -```tsx - - - - - -``` - -## Delay duration - -Control how long the pointer must hover before the tooltip opens. Pass `delayDuration` on the `TooltipProvider` to apply it globally, or on the individual tooltip to override it: - -```tsx - - - - - -``` - -Per-instance override: - -```tsx - - - -``` - -## Controlled mode - -Pass `open` and `onOpenChange` to drive the open state externally: - -```tsx -import { useState } from 'react'; -import { TooltipSimple, TooltipProvider, Button } from '@signozhq/ui'; - -function MyComponent() { - const [open, setOpen] = useState(false); - - return ( - - - - - - ); -} -``` - -## Inside a modal - -When placing a tooltip inside a modal or dialog, set `withPortal={false}` so the tooltip content stays within the modal DOM instead of portaling to `document.body`. This avoids z-index and stacking context issues: - -```tsx -import { Dialog, DialogContent, TooltipSimple, Button } from '@signozhq/ui'; - - - - - - - -; -``` - -## Extra content props - -Use `tooltipContentProps` to pass additional props to the underlying `TooltipContent` without losing the preset's composition: - -```tsx - - - -``` - -## Props - - diff --git a/apps/docs/stories/tooltip-simple.stories.module.css b/apps/docs/stories/tooltip-simple.stories.module.css deleted file mode 100644 index 27786318..00000000 --- a/apps/docs/stories/tooltip-simple.stories.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.demoArea { - padding: 80px; -} - -.positionsArea { - padding: 80px; - flex-wrap: wrap; - gap: 32px; -} - -.alignButton { - width: 120px; -} diff --git a/apps/docs/stories/tooltip-simple.stories.tsx b/apps/docs/stories/tooltip-simple.stories.tsx deleted file mode 100644 index 52eb8ad4..00000000 --- a/apps/docs/stories/tooltip-simple.stories.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import type { TooltipSimpleProps } from '@signozhq/ui'; -import { Button, ButtonColor, ButtonVariant, TooltipProvider, TooltipSimple } from '@signozhq/ui'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import styles from './tooltip-simple.stories.module.css'; - -const meta: Meta = { - title: 'Composed Components/TooltipSimple', - component: TooltipSimple, - argTypes: { - title: { - control: 'text', - description: 'The content of the tooltip.', - table: { category: 'Content', type: { summary: 'React.ReactNode' } }, - }, - arrow: { - control: 'boolean', - description: 'Whether to show the arrow.', - table: { - category: 'Appearance', - type: { summary: 'boolean' }, - defaultValue: { summary: 'false' }, - }, - }, - side: { - control: 'select', - options: ['top', 'right', 'bottom', 'left'], - description: 'The preferred side of the trigger to render against when open.', - table: { - category: 'Position', - type: { summary: "'top' | 'right' | 'bottom' | 'left'" }, - defaultValue: { summary: "'top'" }, - }, - }, - align: { - control: 'select', - options: ['start', 'center', 'end'], - description: 'The preferred alignment against the trigger.', - table: { - category: 'Position', - type: { summary: "'start' | 'center' | 'end'" }, - defaultValue: { summary: "'center'" }, - }, - }, - sideOffset: { - control: 'number', - description: 'The distance in pixels from the trigger.', - table: { - category: 'Position', - type: { summary: 'number' }, - defaultValue: { summary: '4' }, - }, - }, - alignOffset: { - control: 'number', - description: 'An offset in pixels from the "start" or "end" alignment options.', - table: { category: 'Position', type: { summary: 'number' } }, - }, - avoidCollisions: { - control: 'boolean', - description: 'When true, overrides the side and align preferences to prevent collisions.', - table: { - category: 'Position', - type: { summary: 'boolean' }, - defaultValue: { summary: 'true' }, - }, - }, - withPortal: { - control: 'boolean', - description: 'Whether to render in a portal. Set to false when inside modals/dialogs.', - table: { - category: 'Behavior', - type: { summary: 'boolean' }, - defaultValue: { summary: 'true' }, - }, - }, - delayDuration: { - control: 'number', - description: 'The duration from when the pointer enters the trigger until the tooltip opens.', - table: { - category: 'Behavior', - type: { summary: 'number' }, - defaultValue: { summary: '700' }, - }, - }, - open: { - control: 'boolean', - description: 'The controlled open state of the tooltip.', - table: { category: 'State', type: { summary: 'boolean' } }, - }, - defaultOpen: { - control: 'boolean', - description: 'The open state of the tooltip when initially rendered.', - table: { category: 'State', type: { summary: 'boolean' } }, - }, - onOpenChange: { - control: false, - description: 'Event handler called when the open state changes.', - table: { category: 'Events', type: { summary: '(open: boolean) => void' } }, - }, - testId: { - control: 'text', - description: 'Test ID for the tooltip.', - table: { category: 'Testing', type: { summary: 'string' } }, - }, - tooltipContentProps: { - control: false, - description: - 'Additional props passed to the underlying TooltipContent. Use to set className, arrowPadding, or other TooltipContent props without breaking the preset composition.', - table: { - category: 'Advanced', - type: { summary: 'TooltipContentProps (positioning/arrow props excluded)' }, - }, - }, - }, - parameters: { - layout: 'fullscreen', - design: { - type: 'figma', - url: 'https://www.figma.com/design/egMidgk6VJDXTumxcCYUl1/Periscope---Primitives?node-id=12-746&m=dev', - }, - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - title: "I'm a basic tooltip", - arrow: false, - side: 'top', - align: 'center', - }, - render: (args: Partial) => ( - -
- - - -
-
- ), -}; - -export const WithArrow: Story = { - args: { - title: 'Tooltip with arrow', - arrow: true, - side: 'top', - }, - render: (args: Partial) => ( - -
- - - -
-
- ), -}; - -export const Positions: Story = { - render: () => ( - -
- {(['top', 'right', 'bottom', 'left'] as const).map((side) => ( - - - - ))} -
-
- ), -}; - -export const Alignments: Story = { - render: () => ( - -
- {(['start', 'center', 'end'] as const).map((align) => ( - - - - ))} -
-
- ), -}; diff --git a/apps/docs/stories/tooltip-trigger.stories.module.css b/apps/docs/stories/tooltip-trigger.stories.module.css deleted file mode 100644 index 894ccc94..00000000 --- a/apps/docs/stories/tooltip-trigger.stories.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.demoArea { - padding: 80px; -} diff --git a/apps/docs/stories/tooltip-trigger.stories.tsx b/apps/docs/stories/tooltip-trigger.stories.tsx deleted file mode 100644 index 46f25f02..00000000 --- a/apps/docs/stories/tooltip-trigger.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - Button, - ButtonColor, - ButtonVariant, - TooltipContent, - TooltipProvider, - TooltipRoot, - TooltipTrigger, -} from '@signozhq/ui'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import styles from './tooltip-trigger.stories.module.css'; - -const meta: Meta = { - title: 'Primitive Components/Tooltip/TooltipTrigger', - component: TooltipTrigger, - argTypes: { - asChild: { - control: 'boolean', - description: - 'When true, merges props onto the child element instead of rendering a wrapper. Use to delegate to a child (e.g. a Button).', - table: { category: 'Behavior', type: { summary: 'boolean' } }, - }, - testId: { - control: 'text', - description: 'The test id of the tooltip trigger.', - table: { category: 'Testing', type: { summary: 'string' } }, - }, - }, - parameters: { - layout: 'fullscreen', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - asChild: true, - }, - render: (args: { asChild?: boolean }) => ( - -
- - - - - - Rich tooltip content - - -
-
- ), -}; diff --git a/apps/docs/stories/tooltip.mdx b/apps/docs/stories/tooltip.mdx index 54e80dde..8f92fe08 100644 --- a/apps/docs/stories/tooltip.mdx +++ b/apps/docs/stories/tooltip.mdx @@ -1,79 +1,184 @@ -import { Meta, Controls } from '@storybook/addon-docs/blocks'; +import { Meta, Controls, Primary } from '@storybook/addon-docs/blocks'; import * as TooltipStories from './tooltip.stories'; import * as TooltipProviderStories from './tooltip-provider.stories'; -import * as TooltipTriggerStories from './tooltip-trigger.stories'; -import * as TooltipContentStories from './tooltip-content.stories'; # Tooltip -A customizable tooltip component with smooth animations and flexible positioning. +A tooltip that wraps a trigger element and shows its `title` on hover, with flexible positioning. -For the quick-start preset, see [TooltipSimple](?path=/docs/composed-components-tooltipsimple--docs). +## How to use -## TooltipProvider Props +Place one `TooltipProvider` at the root of the app. It sets the open and close delays and the +portal container for every tooltip below it, so it belongs next to the other app-wide providers +rather than around each tooltip: - +```tsx +import { TooltipProvider } from '@signozhq/ui'; -## Basic usage (composition) +createRoot(document.getElementById('root')).render( + + + , +); +``` -For custom content and positioning, use `TooltipRoot`, `TooltipTrigger`, and `TooltipContent`: +From there a tooltip is a wrapper around whatever triggers it: ```tsx -import { TooltipRoot, TooltipTrigger, TooltipContent, TooltipProvider } from '@signozhq/ui'; -import { Button } from '@signozhq/ui'; +import { Tooltip, Button } from '@signozhq/ui'; export default function MyComponent() { return ( - - - - - - - Custom tooltip -
- With multiple lines -
-
-
+ + + ); } ``` -**Inside a modal (no portal):** +A tooltip with no provider above it adds one on the spot, so it still works. That is a fallback, +not the pattern to copy: a second provider halfway down the tree shadows the container and the +delays the app configured for everything under it. + + + +## Positioning + +`side` sets which side of the trigger the tooltip opens against (`top`, `right`, `bottom`, `left`). `align` controls alignment along that side (`start`, `center`, `end`): + +```tsx + + + +``` + +Use `sideOffset` and `alignOffset` for pixel-level adjustments: + +```tsx + + + +``` + +Collision handling is not exposed: a tooltip flips to the opposite side and shifts along it on its +own when it would otherwise run off the viewport. + +## Empty title + +A tooltip with an empty `title` (`undefined`, `null`, `false` or `''`) renders nothing, so a +condition belongs in the `title` rather than around the tooltip: + +```tsx + + {label} + +``` + +The trigger stays the same DOM element either way. It is never unmounted and remounted as the +title appears and disappears, so focus, text selection and running transitions survive the +change. + +## Holding one open in a story or a test + +`open` puts the popup on screen and keeps it there: hover and focus stop driving it, and the +tooltip shows exactly what it is told to. + +```tsx + + + +``` + +It is there for stories and tests, which is the case hover cannot cover: one pointer sits on one +trigger, so a snapshot of several popups at once, or an assertion on the content of one, has no +way to open them. Every popup in the showcase above is opened that way. + +Leave it out in app code. A tooltip belongs to the pointer, and one held open from the outside +covers the page it is anchored over without anyone having asked for it. There is no +`onOpenChange` to pair `open` with either, so a tooltip driven this way cannot close itself. + +## Rich content + +`title` takes any node, so a tooltip is not limited to a single line of text: + +```tsx + + + Custom tooltip + +
+ + With multiple lines + + + } +> + +
+``` + +## Inside a modal + +Tooltips are portalled to `document.body`. Pass `container` to keep one inside a dialog instead, which sidesteps stacking context issues: ```tsx -import { - Dialog, - DialogContent, - TooltipRoot, - TooltipTrigger, - TooltipContent, - TooltipProvider, -} from '@signozhq/ui'; - - - - - - +import { useRef } from 'react'; +import { Dialog, DialogContent, Tooltip, Button } from '@signozhq/ui'; + +function MyComponent() { + const dialogRef = useRef(null); + + return ( + + + - - - Tooltip inside modal - - - - -; + + + + ); +} +``` + +Set it once on the provider to cover every tooltip in that subtree: + +```tsx +{children} +``` + +## Nested tooltips + +Two tooltips anchored to the same element would open on the same hover and render on top of each other, so a tooltip inside the trigger of another one stacks into it: one popup, the title of the outer tooltip first, then the content of everything below it, with a divider between the pieces. + +```tsx + + + ``` -## TooltipTrigger Props +That popup reads: - +``` +Removes every rule, cannot be undone +──────────────────────────────────── +You need write access to edit alerts +``` + +Stacking exists so a tooltip is never hidden behind another one, not as a pattern to reach for. Aim for one tooltip per element and avoid nesting them in the first place. + +## Props -## TooltipContent Props + - +## TooltipProvider Props + + diff --git a/apps/docs/stories/tooltip.stories.module.css b/apps/docs/stories/tooltip.stories.module.css index a1d90f68..6ef88420 100644 --- a/apps/docs/stories/tooltip.stories.module.css +++ b/apps/docs/stories/tooltip.stories.module.css @@ -1,30 +1,101 @@ -.demoArea { - padding: 80px; +.playground { + padding: 5rem; } -.showcaseContainer { - min-height: 600px; +.showcase { + display: flex; + flex-direction: column; + gap: 5rem; +} + +/* + * One column per side, one row per align. Placement is only true to the props while the trigger + * is inside the viewport, so this section and the offsets below it stay in the first screen: a + * tooltip anchored past the fold is pushed back against its trigger by collision handling. + */ +.placementGrid { + display: grid; + grid-template-columns: repeat(4, max-content); + gap: 4rem 6rem; + padding: 4rem 6rem; + justify-content: start; +} + +.offsetRow { + display: flex; + flex-wrap: wrap; + gap: 6rem; + padding-block: 3rem 4rem; } -.showcaseContent { +/* Every section below opens on the default side, so each row needs the room above it. */ +.contentColumn { display: flex; flex-direction: column; - gap: 64px; + align-items: flex-start; + gap: 9rem; + padding-block-start: 9rem; } -.positionsContainer { +.stackColumn { display: flex; - flex-wrap: wrap; - gap: 32px; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 8rem; + padding-block-start: 6rem; } -.alignContainer { +.triggerColumn { display: flex; - flex-wrap: wrap; - gap: 32px; + flex-direction: column; + align-items: flex-start; + gap: 4rem; + padding-block-start: 4rem; +} + +.textTrigger { + text-decoration: underline dotted; + text-underline-offset: 3px; + cursor: help; +} + +.containerPanel { + display: flex; + align-items: flex-end; + position: relative; + overflow: hidden; + width: 26rem; + min-height: 9rem; +} + +/* + * A card is taller than the line of text every other section opens, so each row needs the height + * of a whole popup under it. + */ +.elementColumn { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 20rem; + padding-block: 2rem 16rem; +} + +.metricCard { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 16rem; +} + +.metricRow { + display: flex; + justify-content: space-between; + gap: 2rem; + border-block-start: 1px solid var(--border); + padding-block-start: 0.25rem; } -.multilineOpacity { - opacity: 0.9; +/* Wider than the 26.25rem the popup caps itself at, which is the point of the example. */ +.wideCard { + width: 40rem; } diff --git a/apps/docs/stories/tooltip.stories.tsx b/apps/docs/stories/tooltip.stories.tsx index e8601fc2..6c00730d 100644 --- a/apps/docs/stories/tooltip.stories.tsx +++ b/apps/docs/stories/tooltip.stories.tsx @@ -1,62 +1,106 @@ +import { Info } from '@signozhq/icons'; +import type { TooltipProps } from '@signozhq/ui'; import { Button, ButtonColor, + ButtonSize, ButtonVariant, - TooltipContent, + Tooltip, TooltipProvider, - TooltipRoot, - TooltipSimple, - TooltipTrigger, Typography, } from '@signozhq/ui'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { type CSSProperties, type ReactElement, type ReactNode, useState } from 'react'; import styles from './tooltip.stories.module.css'; const SIDES = ['top', 'right', 'bottom', 'left'] as const; const ALIGNS = ['start', 'center', 'end'] as const; -const meta: Meta = { - title: 'Primitive Components/Tooltip', - component: TooltipRoot, +const meta: Meta = { + title: 'Composed Components/Tooltip', + component: Tooltip, argTypes: { - open: { - control: 'boolean', - description: 'The controlled open state of the tooltip.', - table: { category: 'State', type: { summary: 'boolean' } }, - }, - defaultOpen: { - control: 'boolean', - description: 'The open state of the tooltip when it is initially rendered.', - table: { category: 'State', type: { summary: 'boolean' } }, + title: { + control: 'text', + description: + 'The content of the tooltip. No tooltip is rendered while it is empty, and the trigger stays the element it already was.', + table: { category: 'Content', type: { summary: 'React.ReactNode' } }, }, - onOpenChange: { + children: { control: false, - description: 'Event handler called when the open state of the tooltip changes.', - table: { category: 'Events', type: { summary: '(open: boolean) => void' } }, + description: 'The trigger element.', + table: { category: 'Content', type: { summary: 'React.ReactNode' } }, }, - delayDuration: { - control: 'number', + side: { + control: 'select', + options: SIDES, description: - 'The duration from when the pointer enters the trigger until the tooltip gets opened. This will override the prop with the same name passed to Provider.', + 'Which side of the trigger the tooltip opens against. May change on its own to avoid the edges of the viewport.', + table: { + category: 'Appearance', + type: { summary: "'top' | 'right' | 'bottom' | 'left'" }, + defaultValue: { summary: "'top'" }, + }, + }, + align: { + control: 'select', + options: ALIGNS, + description: 'How the tooltip is aligned along the side it opens against.', + table: { + category: 'Appearance', + type: { summary: "'start' | 'center' | 'end'" }, + defaultValue: { summary: "'center'" }, + }, + }, + sideOffset: { + control: 'number', + description: 'The distance in pixels between the tooltip and the trigger.', table: { - category: 'Behavior', + category: 'Appearance', type: { summary: 'number' }, - defaultValue: { summary: '700' }, + defaultValue: { summary: '4' }, }, }, - disableHoverableContent: { - control: 'boolean', - description: - 'When true, trying to hover the content will result in the tooltip closing as the pointer leaves the trigger.', + alignOffset: { + control: 'number', + description: 'An offset in pixels from the `start` or `end` alignment.', table: { - category: 'Behavior', - type: { summary: 'boolean' }, - defaultValue: { summary: 'false' }, + category: 'Appearance', + type: { summary: 'number' }, + defaultValue: { summary: '0' }, }, }, + open: { + control: 'boolean', + description: + 'Whether the tooltip is open, for a story or a test that needs a popup on screen rather than for app code. Set, neither hover nor focus changes it.', + table: { category: 'Behavior', type: { summary: 'boolean' } }, + }, + container: { + control: false, + description: + 'The element this tooltip is portalled into. Defaults to the one the surrounding TooltipProvider set, and to `document.body` without one.', + table: { category: 'Behavior', type: { summary: 'HTMLElement | ShadowRoot | RefObject' } }, + }, + className: { + control: 'text', + description: + 'Class name of the tooltip content. Merges with the styles of the component instead of replacing them.', + table: { category: 'Styling', type: { summary: 'string' } }, + }, + style: { + control: false, + description: 'Inline styles of the tooltip content.', + table: { category: 'Styling', type: { summary: 'React.CSSProperties' } }, + }, + id: { + control: 'text', + description: 'Id of the tooltip content. One is generated when it is left out.', + table: { category: 'Accessibility', type: { summary: 'string' } }, + }, testId: { control: 'text', - description: 'The test id of the tooltip root.', + description: 'Alias for `data-testid`, set on the tooltip content.', table: { category: 'Testing', type: { summary: 'string' } }, }, }, @@ -64,165 +108,392 @@ const meta: Meta = { layout: 'fullscreen', design: { type: 'figma', - url: 'https://www.figma.com/design/egMidgk6VJDXTumxcCYUl1/Periscope---Primitives?node-id=12-746&m=dev', + url: 'https://www.figma.com/design/eyORbfrXMWCz9w0xEFdgWe/Periscope-%E2%80%93-Primitives-v2?node-id=12-746&p=f&m=dev', }, }, + decorators: [ + // One provider per app is the pattern; in Storybook that is one per story tree. + (Story): ReactElement => ( + + + + ), + ], tags: ['autodocs'], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { - render: () => ( - -
- - - -
-
+ args: { + title: "I'm a basic tooltip", + side: 'top', + align: 'center', + }, + parameters: { + // Playground: every placement, content shape and stack it can be driven into is covered + // by `TooltipShowcase`. + chromatic: { disableSnapshot: true }, + }, + render: (args: Partial) => ( +
+ + + +
), }; +const TRIGGER_WIDTH = '8rem'; + +const PLACEMENTS = ALIGNS.flatMap((align) => SIDES.map((side) => ({ side, align }))); + +const OFFSETS: { label: string; props: Partial }[] = [ + { label: 'defaults', props: {} }, + { label: 'sideOffset 24', props: { sideOffset: 24 } }, + { label: 'alignOffset 40', props: { align: 'start', alignOffset: 40 } }, +]; + +const WRAPPING_TITLE = + 'A title long enough to run into the 26.25rem the tooltip caps its width at, so it wraps onto a second line instead of stretching across the page.'; + +const CLAMPED_TITLE = + 'Past six lines the content belongs in a popover, because a tooltip has nowhere to defer the rest of the string to. So the seventh line and everything under it is clipped, and this title is here to prove it: line one, line two, line three, line four, line five, line six, and the tail nobody gets to read no matter how long it goes on for. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam id velit ut justo convallis interdum. Integer egestas elit sagittis erat tempus, non convallis elit ultrices. Aliquam suscipit est rutrum est tempus, nec rhoncus nisl vehicula. Pellentesque a felis non nisl aliquet dictum. Pellentesque vulputate elit diam, ac gravida est auctor id. Pellentesque gravida erat sed porttitor efficitur. Proin et elit vitae lorem efficitur dictum eu ac mauris. Phasellus dignissim pretium elit. Phasellus felis urna, auctor ut risus et, gravida dignissim enim.'; + +const CONTENT: { label: string; title: ReactNode }[] = [ + { label: 'Plain text', title: 'Helpful information' }, + { + label: 'Rich content', + title: ( + <> + + Custom tooltip + +
+ + With multiple lines + + + ), + }, + { label: 'Wraps at the max width', title: WRAPPING_TITLE }, + { label: 'Clamped past six lines', title: CLAMPED_TITLE }, + { label: 'Empty title, no popup', title: undefined }, +]; + +const METRICS: [label: string, value: string][] = [ + ['p99 latency', '1.42 s'], + ['p50 latency', '184 ms'], + ['error rate', '2.3 %'], + ['throughput', '4.1k rpm'], + ['apdex', '0.82'], + ['saturation', '61 %'], + ['last deploy', '12 min ago'], +]; + +/** + * Element content rather than a string: eight rows, so it is past the six lines the popup clamps + * text at, and the `wide` variant is past the width it caps itself at. + */ +function MetricCard({ wide = false }: { wide?: boolean }): ReactElement { + return ( +
+ + checkout-api + + {METRICS.map(([label, value]) => ( +
+ + {label} + + + {value} + +
+ ))} +
+ ); +} + +// The popup sizes itself off its text, so element content wider than the cap is cut instead of +// scrolled. Both variables have to move: `--tooltip-max-width` lifts the cap, `--tooltip-width` +// makes the popup measure the card instead of shrinking to the width of a text line. +const LIFTED_CAPS = { + '--tooltip-width': 'max-content', + '--tooltip-max-width': 'none', +} as CSSProperties; + +/** + * The tooltip is portalled into the panel instead of `document.body`, which is what keeps one + * inside a dialog or a drawer. The element only exists after the first render, so it is held in + * state rather than a ref. + */ +function ContainerDemo(): ReactElement { + const [panel, setPanel] = useState(null); + + return ( +
+ + + +
+ ); +} + +/** + * Every placement, offset, content shape, stack and trigger in one snapshot, with each tooltip + * held open by `open`. Hover could never do this: Base UI groups the + * tooltips under one provider and closes the open one as the next opens, and one pointer can + * only sit on one trigger anyway. + */ export const TooltipShowcase: Story = { parameters: { - docs: { story: { autoplay: true } }, + chromatic: { disableSnapshot: false, disableAnimations: true }, }, render: () => ( - -
-
-
- - Positions - -
- {SIDES.map((side) => ( - - - - - - Tooltip on {side} - - - ))} -
+
+
+
+ + Placement + + + side picks which side of the trigger the tooltip opens against,{' '} + align where it sits along that side. Both are preferences: a tooltip flips + to the opposite side and shifts along it on its own rather than running off the + viewport. + +
+ {PLACEMENTS.map(({ side, align }) => ( + + + + ))}
+
-
- - Align variations - -
- {ALIGNS.map((align) => ( - - - - - - Align {align} - - - ))} -
+
+ + Offsets + + + sideOffset is the gap between the tooltip and the trigger, 4px by default.{' '} + alignOffset slides the tooltip along the side it opens against, and only + does anything for align="start" or{' '} + align="end". + +
+ {OFFSETS.map(({ label, props }) => ( + + + + ))}
+
-
- - With / without arrow - -
- - - - - - -
+ + ))}
+
-
- - Delay variations - -
- - - - - - - - + + } side="bottom" align="start"> + + + } + side="bottom" + align="start" + style={LIFTED_CAPS} + > + + +
+
+ +
+ + Stacking + + + Two tooltips anchored to the same element would open on the same hover and render on top + of each other, so a tooltip inside the trigger of another one adds its title to that + popup instead: the outer title first, a divider, then everything below it. An empty + title adds nothing, and only the outer tooltip carries the placement props. + +
+ + + - - - + + + + + + - -
+ +
+
-
- - Default open - - - - + + + Inline text + + + + + + A bare string child +
+
-
- - Custom content (composition) - - - - - - - - Custom tooltip - -
- - With multiple lines - -
-
-
+
+ + Container + + + container is the element the popup is portalled into,{' '} + document.body by default. Pass the dialog or drawer element to keep the + tooltip inside it and out of a stacking context it would otherwise sit behind. + +
- +
), }; diff --git a/packages/ui/package.json b/packages/ui/package.json index 5f79df21..2390a70a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -440,7 +440,6 @@ "@radix-ui/react-tabs": "^1.1.19", "@radix-ui/react-toggle": "^1.1.16", "@radix-ui/react-toggle-group": "^1.1.17", - "@radix-ui/react-tooltip": "^1.2.14", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.8", "@types/lodash-es": "^4.17.12", diff --git a/packages/ui/src/combobox/presets/combobox-simple/combobox-simple.tsx b/packages/ui/src/combobox/presets/combobox-simple/combobox-simple.tsx index f3487e81..8b5b6c27 100644 --- a/packages/ui/src/combobox/presets/combobox-simple/combobox-simple.tsx +++ b/packages/ui/src/combobox/presets/combobox-simple/combobox-simple.tsx @@ -1,7 +1,7 @@ import { ChevronDown, LoaderCircle, X } from '@signozhq/icons'; import * as React from 'react'; import { cn } from '../../../lib/utils.js'; -import { TooltipProvider } from '../../../tooltip/index.js'; +import { TooltipProviderIfMissing } from '../../../tooltip/subcomponents/tooltip-provider'; import styles from '../../combobox.module.scss'; import { ComboboxCommand } from '../../subcomponents/combobox-command.js'; import { ComboboxContent } from '../../subcomponents/combobox-content.js'; @@ -121,7 +121,8 @@ export type ComboboxSimpleProps = { maxDisplayedPills?: number; /** * Disable the internal TooltipProvider wrapper. - * Set to true when ComboboxSimple is already inside a TooltipProvider. + * @deprecated An outer `TooltipProvider` is detected on its own now, so this + * no longer has to be set. * @default false */ disableTooltipProvider?: boolean; @@ -344,7 +345,7 @@ const ComboboxSimpleInner = React.forwardRef< [isVirtualized, renderTree], ); - const Wrapper = disableTooltipProvider ? React.Fragment : TooltipProvider; + const Wrapper = disableTooltipProvider ? React.Fragment : TooltipProviderIfMissing; const dropdownContent = ( diff --git a/packages/ui/src/combobox/presets/combobox-simple/components/pills.tsx b/packages/ui/src/combobox/presets/combobox-simple/components/pills.tsx index 4dd9e708..fd18fe4a 100644 --- a/packages/ui/src/combobox/presets/combobox-simple/components/pills.tsx +++ b/packages/ui/src/combobox/presets/combobox-simple/components/pills.tsx @@ -1,5 +1,5 @@ import type * as React from 'react'; -import { TooltipSimple } from '../../../../tooltip/index.js'; +import { Tooltip } from '../../../../tooltip/index.js'; import { ComboboxPill } from '../../../subcomponents/combobox-pill.js'; import styles from '../../../combobox.module.scss'; @@ -30,11 +30,11 @@ export function ComboboxPills({ ))} {overflowCount > 0 && ( - resolveLabel(v)).join(', ')}> + resolveLabel(v)).join(', ')}> +{overflowCount} - + )} ); diff --git a/packages/ui/src/dialog/dialog.tooltip.test.tsx b/packages/ui/src/dialog/dialog.tooltip.test.tsx new file mode 100644 index 00000000..1f687a81 --- /dev/null +++ b/packages/ui/src/dialog/dialog.tooltip.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../tooltip/presets/tooltip.js'; +import { Dialog, DialogContent, DialogTitle } from './index.js'; + +// A tooltip portalled into `document.body` lands next to the dialog portal, outside the +// dialog's stacking context, and the dialog covers it whenever its z-index wins. Inside the +// dialog element it stacks with the dialog and stays visible. +describe('Dialog with a tooltip inside', () => { + // TODO: fix in the dialog rework. `DialogContent` should hand nested tooltips its own + // element as the portal container (a `TooltipProvider container={...}` around `children`), + // so a consumer never has to pass `container` by hand. + it.fails('portals a nested tooltip into the dialog content', () => { + render( + + + Title + + + + + , + ); + + expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('tooltip')); + }); + + it('portals a nested tooltip into the dialog content when handed it as container', () => { + function Content() { + const [panel, setPanel] = useState(null); + + return ( + + Title + + + + + ); + } + + render( + + + , + ); + + expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('tooltip')); + }); +}); diff --git a/packages/ui/src/pin-list/pin-list.tsx b/packages/ui/src/pin-list/pin-list.tsx index ed1614d7..354c9cbd 100644 --- a/packages/ui/src/pin-list/pin-list.tsx +++ b/packages/ui/src/pin-list/pin-list.tsx @@ -8,8 +8,9 @@ import { } from 'motion/react'; import * as React from 'react'; import { cn } from '../lib/utils.js'; -import { TooltipProvider, TooltipSimple } from '../tooltip/index.js'; +import { Tooltip } from '../tooltip/index.js'; import styles from './pin-list.module.scss'; +import { TooltipProviderIfMissing } from '../tooltip/subcomponents/tooltip-provider.js'; /** * Represents a single item in the PinList component. @@ -277,7 +278,7 @@ const PinList = React.forwardRef( ); return ( - + (
-
+ ); }, ); @@ -456,7 +457,7 @@ const PinListItemComponent = React.memo(function PinListItemComponent({ {item.label} - +
)}
-
+ ); }); diff --git a/packages/ui/src/slider/slider.tsx b/packages/ui/src/slider/slider.tsx index bef82335..b2f59319 100644 --- a/packages/ui/src/slider/slider.tsx +++ b/packages/ui/src/slider/slider.tsx @@ -2,8 +2,8 @@ import * as SliderPrimitive from '@radix-ui/react-slider'; import React, { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { cn } from '../lib/utils.js'; -import { TooltipProvider, TooltipSimple } from '../tooltip/index.js'; import styles from './slider.module.scss'; +import { Tooltip } from '../tooltip/presets/tooltip.js'; export interface SliderProps extends Omit< React.ComponentPropsWithoutRef, @@ -374,14 +374,12 @@ function SliderThumb({ value, className, style, tooltip }: SliderThumbProps) { if (!tooltip) return thumb; return ( - - - {thumb} - - + + {thumb} + ); } diff --git a/packages/ui/src/tabs/tabs.tsx b/packages/ui/src/tabs/tabs.tsx index b878277d..f12a4160 100644 --- a/packages/ui/src/tabs/tabs.tsx +++ b/packages/ui/src/tabs/tabs.tsx @@ -2,8 +2,9 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'; import { Lock } from '@signozhq/icons'; import * as React from 'react'; import { cn } from '../lib/utils.js'; -import { TooltipProvider, TooltipSimple } from '../tooltip/index.js'; +import { Tooltip } from '../tooltip/index.js'; import styles from './tabs.module.scss'; +import { TooltipProviderIfMissing } from '../tooltip/subcomponents/tooltip-provider.js'; export type TabVariants = 'primary' | 'secondary'; @@ -167,7 +168,7 @@ export const Tabs = React.forwardRef testId={testId} {...props} > - + ); return item.disabled ? ( - + {triggerContent} - + ) : ( triggerContent ); @@ -208,7 +209,7 @@ export const Tabs = React.forwardRef {item.children} ))} - + ); }, diff --git a/packages/ui/src/tooltip/__tests__/tooltip.accessibility.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.accessibility.test.tsx new file mode 100644 index 00000000..ff58800c --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.accessibility.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipContent } from '../subcomponents/tooltip-content.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; + +const TITLE = 'Helpful information'; + +describe('Tooltip accessibility', () => { + it('exposes the content with role="tooltip"', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveTextContent(TITLE); + }); + + it('describes the trigger with the content, even before it opens', async () => { + const user = userEvent.setup(); + render( + + + , + ); + const trigger = screen.getByRole('button'); + const describedBy = trigger.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + + await user.hover(trigger); + + expect(await screen.findByRole('tooltip')).toHaveAttribute('id', describedBy); + }); + + it('uses the id the caller set for both the content and aria-describedby', async () => { + const user = userEvent.setup(); + render( + + + , + ); + const trigger = screen.getByRole('button'); + expect(trigger).toHaveAttribute('aria-describedby', 'own-id'); + + await user.hover(trigger); + + expect(await screen.findByRole('tooltip')).toHaveAttribute('id', 'own-id'); + }); + + it('gives two tooltips two different ids', () => { + render( + <> + + + + + + + , + ); + + const [one, two] = screen.getAllByRole('button'); + expect(one).toHaveAttribute('aria-describedby'); + expect(one?.getAttribute('aria-describedby')).not.toBe(two?.getAttribute('aria-describedby')); + }); + + it('describes the trigger through the root when the content sits inside it', () => { + render( + + Hover + {TITLE} + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip.id).toBeTruthy(); + expect(screen.getByRole('button')).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('keeps an id the caller set on the content inside a root', () => { + render( + + Hover + {TITLE} + , + ); + + expect(screen.getByRole('tooltip')).toHaveAttribute('id', 'own-id'); + }); + + it('lets a trigger point at nothing with contentId={null}', () => { + render( + + Hover + {TITLE} + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-describedby'); + }); + + it('lets a trigger point at a content id of its own', () => { + render( + + Hover + {TITLE} + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('aria-describedby', 'elsewhere'); + }); + + it('keeps the accessible name of the trigger', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.controlled.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.controlled.test.tsx new file mode 100644 index 00000000..17dd0c5f --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.controlled.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipProvider } from '../subcomponents/tooltip-provider.js'; + +const TITLE = 'Helpful information'; + +describe('Tooltip open', () => { + it('is open right away when open is true, without any hover', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveTextContent(TITLE); + }); + + it('stays closed on hover while open is false', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('stays closed on focus while open is false', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.tab(); + + expect(screen.getByRole('button')).toHaveFocus(); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('stays open when the pointer leaves', async () => { + const user = userEvent.setup(); + render( + + + , + ); + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + await user.unhover(trigger); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('follows the open prop', () => { + const { rerender } = render( + + + , + ); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('describes the trigger with the content', () => { + render( + + + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(screen.getByRole('button')).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('keeps the same trigger element as open flips', () => { + const { rerender } = render( + + + , + ); + const trigger = screen.getByRole('button'); + + rerender( + + + , + ); + + expect(screen.getByRole('button')).toBe(trigger); + }); + + // What a snapshot of every placement at once needs: Base UI closes the open tooltip of a + // group as the next one opens, and one pointer can only sit on one trigger anyway. + it('holds every tooltip of one provider open at the same time', () => { + render( + + + + + + + + , + ); + + expect(screen.getAllByRole('tooltip')).toHaveLength(2); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.forward-ref.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.forward-ref.test.tsx new file mode 100644 index 00000000..9d54a045 --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.forward-ref.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipContent } from '../subcomponents/tooltip-content.js'; +import { TooltipPopup } from '../subcomponents/tooltip-popup.js'; +import { TooltipPortal } from '../subcomponents/tooltip-portal.js'; +import { TooltipPositioner } from '../subcomponents/tooltip-positioner.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; + +describe('Tooltip forwardRef', () => { + it('forwards the ref to the trigger element', () => { + const ref = createRef(); + render( + + + , + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('forwards the ref while there is no title, so no trigger is mounted by Base UI', () => { + const ref = createRef(); + render( + + + , + ); + + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('forwards the ref to the cloned element of a stacked tooltip', () => { + const ref = createRef(); + render( + + + + + , + ); + + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('keeps the ref of the cloned element next to the forwarded one', () => { + const forwarded = createRef(); + const own = createRef(); + render( + + + + + , + ); + + expect(forwarded.current).toBe(screen.getByRole('button')); + expect(own.current).toBe(screen.getByRole('button')); + }); + + it('calls a callback ref with the trigger and with null on unmount', () => { + const seen: Array = []; + const { unmount } = render( + { + seen.push(node); + }} + > + + , + ); + const trigger = screen.getByRole('button'); + + unmount(); + + expect(seen[0]).toBe(trigger); + expect(seen.at(-1)).toBeNull(); + }); + + it('TooltipTrigger forwards the ref', () => { + const ref = createRef(); + render( + + Trigger + , + ); + + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('TooltipContent forwards the ref to the popup', () => { + const ref = createRef(); + render( + + Trigger + Content + , + ); + + expect(ref.current).toBe(screen.getByRole('tooltip')); + }); + + it('TooltipPositioner and TooltipPopup forward their refs', () => { + const positionerRef = createRef(); + const popupRef = createRef(); + render( + + Trigger + + + Content + + + , + ); + + expect(positionerRef.current).toHaveAttribute('data-slot', 'tooltip-positioner'); + expect(popupRef.current).toBe(screen.getByRole('tooltip')); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.interaction.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.interaction.test.tsx new file mode 100644 index 00000000..f1272caa --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.interaction.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipProvider } from '../subcomponents/tooltip-provider.js'; + +const TITLE = 'Helpful information'; + +describe('Tooltip pointer', () => { + it('opens on hover', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(TITLE); + }); + + it('closes again when the pointer leaves', async () => { + const user = userEvent.setup(); + render( + + + , + ); + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + await user.unhover(trigger); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('puts nothing in the DOM before the first hover', () => { + render( + + + , + ); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); +}); + +describe('Tooltip keyboard', () => { + it('opens on focus', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.tab(); + + expect(screen.getByRole('button')).toHaveFocus(); + expect(await screen.findByRole('tooltip')).toHaveTextContent(TITLE); + }); + + it('closes again on blur', async () => { + const user = userEvent.setup(); + render( + <> + + + + + , + ); + + await user.tab(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + await user.tab(); + + expect(screen.getByTestId('input')).toHaveFocus(); + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('does not steal focus from the trigger when it opens', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.tab(); + await screen.findByRole('tooltip'); + + expect(screen.getByRole('button')).toHaveFocus(); + }); +}); + +describe('Tooltip delay', () => { + // Real timers: Base UI schedules the open through its own timeout helpers, and + // faking the clock leaves `user.hover` waiting forever. A slow machine can only + // make the timer fire later, never earlier, so the early check cannot flake. + it('waits 300ms on hover by default, so a passing pointer never opens it', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + }); + + it('opens at once with delay={0}', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.portal.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.portal.test.tsx new file mode 100644 index 00000000..681c67a9 --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.portal.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipPopup } from '../subcomponents/tooltip-popup.js'; +import { TooltipPortal } from '../subcomponents/tooltip-portal.js'; +import { TooltipPositioner } from '../subcomponents/tooltip-positioner.js'; +import { TooltipProvider, TooltipProviderIfMissing } from '../subcomponents/tooltip-provider.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; + +const containers: HTMLElement[] = []; + +function makeContainer(): HTMLDivElement { + const container = document.createElement('div'); + document.body.append(container); + containers.push(container); + + return container; +} + +afterEach(() => { + for (const container of containers.splice(0)) { + container.remove(); + } +}); + +describe('Tooltip portal container', () => { + it('portals into document.body by default', () => { + const { container: root } = render( + + + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(root).not.toContainElement(tooltip); + expect(document.body).toContainElement(tooltip); + }); + + it('portals into the container element it was given', () => { + const container = makeContainer(); + render( + + + , + ); + + expect(container).toContainElement(screen.getByRole('tooltip')); + }); + + it('portals into the element a ref points at', () => { + const ref = createRef(); + render( + <> +
+ + + + , + ); + + expect(screen.getByTestId('container')).toContainElement(screen.getByRole('tooltip')); + }); + + it('inherits the container from the provider', () => { + const container = makeContainer(); + render( + + + + + , + ); + + expect(container).toContainElement(screen.getByRole('tooltip')); + }); + + it('prefers its own container over the one of the provider', () => { + const fromProvider = makeContainer(); + const own = makeContainer(); + render( + + + + + , + ); + + expect(own).toContainElement(screen.getByRole('tooltip')); + expect(fromProvider).toBeEmptyDOMElement(); + }); + + it('takes the container of the closest provider', () => { + const outer = makeContainer(); + const inner = makeContainer(); + render( + + + + + + + , + ); + + expect(inner).toContainElement(screen.getByRole('tooltip')); + expect(outer).toBeEmptyDOMElement(); + }); + + it('keeps the provider container when a component adds a provider of its own', () => { + const container = makeContainer(); + render( + + + + + + + , + ); + + expect(container).toContainElement(screen.getByRole('tooltip')); + }); + + it('portals the primitives into the container the portal was given', () => { + const container = makeContainer(); + render( + + Hover + + + Helpful information + + + , + ); + + expect(container).toContainElement(screen.getByRole('tooltip')); + }); + + it('lets the portal inherit the provider container as well', () => { + const container = makeContainer(); + render( + + + Hover + + + Helpful information + + + + , + ); + + expect(container).toContainElement(screen.getByRole('tooltip')); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.positioning.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.positioning.test.tsx new file mode 100644 index 00000000..805a6cc6 --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.positioning.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; + +function positioner(): Element | null { + return screen.getByRole('tooltip').closest('[data-slot="tooltip-positioner"]'); +} + +describe('Tooltip positioning', () => { + it('opens on top, centered, by default', () => { + render( + + + , + ); + + expect(positioner()).toHaveAttribute('data-side', 'top'); + expect(positioner()).toHaveAttribute('data-align', 'center'); + }); + + it.each(['top', 'right', 'bottom', 'left'] as const)('opens against side="%s"', (side) => { + render( + + + , + ); + + expect(positioner()).toHaveAttribute('data-side', side); + }); + + it.each(['start', 'center', 'end'] as const)('aligns to align="%s"', (align) => { + render( + + + , + ); + + expect(positioner()).toHaveAttribute('data-align', align); + }); + + it('marks the positioner and the content as open', () => { + render( + + + , + ); + + expect(positioner()).toHaveAttribute('data-open'); + expect(screen.getByRole('tooltip')).toHaveAttribute('data-open'); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.rendering.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.rendering.test.tsx new file mode 100644 index 00000000..46b96b88 --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.rendering.test.tsx @@ -0,0 +1,166 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; + +describe('Tooltip trigger rendering', () => { + it('renders as the element it is given, instead of wrapping it', () => { + render( + + + , + ); + + expect(screen.getAllByRole('button')).toHaveLength(1); + expect(screen.getByTestId('child')).toHaveAttribute('data-slot', 'tooltip-trigger'); + expect(document.querySelectorAll('[data-slot="tooltip-trigger"]')).toHaveLength(1); + }); + + it('keeps the props the child element set for itself', () => { + render( + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Own label' }); + expect(trigger).toHaveAttribute('type', 'submit'); + expect(trigger).toHaveClass('own-class'); + expect(trigger).toHaveAttribute('id', 'own-id'); + }); + + it('falls back to a button for children that are not an element', () => { + render( + + Hover + , + ); + + expect(screen.getByRole('button')).toHaveTextContent('Hover'); + }); + + it('renders a non-button element as the trigger too', () => { + render( + + Hover + , + ); + + expect(screen.getByTestId('child').tagName).toBe('SPAN'); + expect(screen.getByTestId('child')).toHaveAttribute('data-slot', 'tooltip-trigger'); + }); + + it('leaves data-testid off the trigger when no testId is given', () => { + render( + + + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-testid'); + }); +}); + +describe('Tooltip content rendering', () => { + it('marks the trigger, positioner and content with their slot', () => { + render( + + + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-slot', 'tooltip-trigger'); + expect(document.querySelector('[data-slot="tooltip-positioner"]')).toBeInTheDocument(); + expect(screen.getByRole('tooltip')).toHaveAttribute('data-slot', 'tooltip-content'); + }); + + it('exposes testId as data-testid on the content, not the trigger', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveAttribute('data-testid', 'tooltip'); + expect(screen.getByRole('button')).not.toHaveAttribute('data-testid'); + }); + + it('leaves data-testid off the content when no testId is given', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).not.toHaveAttribute('data-testid'); + }); + + it('forwards data-* to the content, not to the trigger', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveAttribute('data-state-of', 'the world'); + expect(screen.getByRole('button')).not.toHaveAttribute('data-state-of'); + }); + + it('keeps its own slot when the call site sends a data-slot', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveAttribute('data-slot', 'tooltip-content'); + }); + + it('keeps the component class next to a custom className', () => { + render( + + + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveClass('custom-class'); + expect(tooltip.className.split(' ').length).toBeGreaterThan(1); + }); + + it('forwards style to the content', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + }); + + it('renders a rich title, not only text', () => { + render( + Ask an admin}> + + , + ); + + expect(screen.getByRole('tooltip')).toContainElement(screen.getByTestId('rich')); + }); + + it('renders a lone title bare, without the stack wrapper', () => { + render( + + + , + ); + + expect(document.querySelector('[data-slot="tooltip-stack"]')).toBeNull(); + expect(document.querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.stack.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.stack.test.tsx new file mode 100644 index 00000000..63f858b4 --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.stack.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { TooltipStack } from '../subcomponents/tooltip-stack.js'; + +describe('TooltipStack', () => { + it('renders nothing without content', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a lone item bare, without wrapper or divider', () => { + const { container } = render(); + + expect(container).toHaveTextContent('Only'); + expect(container.querySelector('[data-slot="tooltip-stack"]')).toBeNull(); + expect(container.querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + }); + + it('renders several items in order, with a divider between each pair', () => { + render( + A }, + { id: 'b', content: B }, + { id: 'c', content: C }, + ]} + />, + ); + + const stack = document.querySelector('[data-slot="tooltip-stack"]'); + expect(stack).toHaveTextContent('ABC'); + expect(stack?.querySelectorAll('[data-slot="tooltip-divider"]')).toHaveLength(2); + expect(stack?.children[0]).toBe(screen.getByTestId('a')); + expect(stack?.children[1]).toHaveAttribute('data-slot', 'tooltip-divider'); + expect(stack?.children[2]).toBe(screen.getByTestId('b')); + }); + + it('skips empty items, so no divider is left dangling', () => { + render( + , + ); + + const stack = document.querySelector('[data-slot="tooltip-stack"]'); + expect(stack).toHaveTextContent('AD'); + expect(stack?.querySelectorAll('[data-slot="tooltip-divider"]')).toHaveLength(1); + }); + + it('collapses to the bare item when every other one is empty', () => { + const { container } = render( + , + ); + + expect(container).toHaveTextContent('Only'); + expect(container.querySelector('[data-slot="tooltip-stack"]')).toBeNull(); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.stacking.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.stacking.test.tsx new file mode 100644 index 00000000..f6938850 --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.stacking.test.tsx @@ -0,0 +1,374 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipContent } from '../subcomponents/tooltip-content.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; + +describe('Tooltip inside another tooltip', () => { + it('adds its title to the wrapping popup instead of opening a second one', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltips = await screen.findAllByRole('tooltip'); + expect(tooltips).toHaveLength(1); + expect(tooltips[0]).toHaveTextContent('Outer titleInner title'); + }); + + it('separates the two titles with a divider, in a stack', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip.querySelector('[data-slot="tooltip-stack"]')).toBeInTheDocument(); + expect(tooltip.querySelectorAll('[data-slot="tooltip-divider"]')).toHaveLength(1); + }); + + it('leaves a single trigger on the element', () => { + render( + + + + + , + ); + + expect(document.querySelectorAll('[data-slot="tooltip-trigger"]')).toHaveLength(1); + expect(screen.getAllByRole('button')).toHaveLength(1); + }); + + it('describes the trigger with the one popup', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + + const tooltip = await screen.findByRole('tooltip'); + expect(trigger).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('stacks three levels into the one popup', async () => { + const user = userEvent.setup(); + render( + + + + + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltips = await screen.findAllByRole('tooltip'); + expect(tooltips).toHaveLength(1); + expect(tooltips[0]?.textContent?.startsWith('Outer')).toBe(true); + expect(tooltips[0]).toHaveTextContent('Middle'); + expect(tooltips[0]).toHaveTextContent('Inner'); + expect(tooltips[0]?.querySelectorAll('[data-slot="tooltip-divider"]')).toHaveLength(2); + }); + + it('adds nothing when the nested title is empty', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Outer title'); + expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + }); + + it('shows only the nested title when the wrapping one is empty', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Inner title'); + expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + expect(trigger).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('shows nothing when both titles are empty', async () => { + const user = userEvent.setup(); + render( + + + + + , + ); + const trigger = screen.getByRole('button'); + expect(trigger).not.toHaveAttribute('aria-describedby'); + + await user.hover(trigger); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('keeps its own tooltip when it is not inside another trigger', () => { + render( + <> + + + + + + + , + ); + + expect(screen.getAllByRole('tooltip')).toHaveLength(2); + }); +}); + +describe('Tooltip stacked content lifecycle', () => { + it('drops the stacked content once the nested tooltip is gone', () => { + const { rerender } = render( + + + + + + + Outer title + , + ); + expect(screen.getByRole('tooltip')).toHaveTextContent('Inner title'); + + rerender( + + + + + Outer title + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Outer title'); + expect(tooltip).not.toHaveTextContent('Inner title'); + expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + }); + + it('drops the stacked content once the nested title turns empty', () => { + const { rerender } = render( + + + + + + + Outer title + , + ); + expect(screen.getByRole('tooltip')).toHaveTextContent('Inner title'); + + rerender( + + + + + + + Outer title + , + ); + + expect(screen.getByRole('tooltip')).not.toHaveTextContent('Inner title'); + expect(screen.getByRole('tooltip').querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + }); + + it('replaces the stacked content when the nested title changes', () => { + const { rerender } = render( + + + + + + + Outer title + , + ); + + rerender( + + + + + + + Outer title + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Outer titleAfter'); + expect(tooltip).not.toHaveTextContent('Before'); + }); + + it('keeps the same trigger element as the nested title comes and goes', () => { + const { rerender } = render( + + + + + , + ); + const trigger = screen.getByRole('button'); + + rerender( + + + + + , + ); + + expect(screen.getByRole('button')).toBe(trigger); + }); +}); + +describe('Tooltip stacked props', () => { + it('puts className, style, id, testId and data-* on the trigger element it clones', () => { + render( + + + + + , + ); + + const trigger = screen.getByRole('button'); + expect(trigger).toHaveClass('own-class', 'inner-class'); + expect(trigger).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + expect(trigger).toHaveAttribute('id', 'inner-id'); + expect(trigger).toHaveAttribute('data-testid', 'inner'); + expect(trigger).toHaveAttribute('data-inner', 'yes'); + }); + + it('drops the positioning and container of a stacked tooltip, the popup above owns them', async () => { + const user = userEvent.setup(); + const container = document.createElement('div'); + document.body.append(container); + render( + + + + + , + ); + const trigger = screen.getByRole('button'); + expect(trigger).not.toHaveAttribute('side'); + expect(trigger).not.toHaveAttribute('align'); + + await user.hover(trigger); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip.closest('[data-slot="tooltip-positioner"]')).toHaveAttribute( + 'data-side', + 'bottom', + ); + expect(container).toBeEmptyDOMElement(); + container.remove(); + }); + + it('keeps the props of the wrapping tooltip on the popup', () => { + render( + + + + + , + ); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveAttribute('data-testid', 'outer'); + expect(tooltip).toHaveClass('outer-class'); + expect(screen.getByRole('button')).toHaveAttribute('data-testid', 'inner'); + }); + + it('mounts no root, portal or content of its own', () => { + render( + + + + + , + ); + + expect(document.querySelectorAll('[data-slot="tooltip-content"]')).toHaveLength(1); + expect(document.querySelectorAll('[data-slot="tooltip-positioner"]')).toHaveLength(1); + }); +}); + +describe('TooltipRoot inside a trigger', () => { + it('renders its children in place, without a root of its own', () => { + render( + + + + + Hover + Inner title + + + + Outer title + , + ); + + expect(screen.getByTestId('inner')).toBeInTheDocument(); + expect(screen.getAllByRole('tooltip')).toHaveLength(1); + expect(screen.getByRole('tooltip')).toHaveTextContent('Outer titleInner title'); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.title.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.title.test.tsx new file mode 100644 index 00000000..0f3f661f --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.title.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; + +describe('Tooltip empty title', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['false', false], + ['an empty string', ''], + ] as const)('renders no tooltip for %s', async (_, title) => { + const user = userEvent.setup(); + render( + + + , + ); + const trigger = screen.getByRole('button'); + expect(trigger).not.toHaveAttribute('aria-describedby'); + + await user.hover(trigger); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull(); + }); + + it('renders no tooltip for an empty controlled title even while open', () => { + render( + + + , + ); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('still marks the element as a trigger while the title is empty', () => { + render( + + + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-slot', 'tooltip-trigger'); + }); +}); + +describe('Tooltip non-empty title', () => { + it('treats 0 as content', () => { + render( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveTextContent('0'); + }); + + it('updates the content when the title changes while open', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByRole('tooltip')).toHaveTextContent('Before'); + + rerender( + + + , + ); + + expect(screen.getByRole('tooltip')).toHaveTextContent('After'); + }); +}); + +describe('Tooltip title element identity', () => { + it('keeps the same trigger element when the title comes and goes', () => { + const { rerender } = render( + + + , + ); + const trigger = screen.getByRole('button'); + + rerender( + + + , + ); + expect(screen.getByRole('button')).toBe(trigger); + expect(trigger).toHaveAttribute('aria-describedby'); + + rerender( + + + , + ); + expect(screen.getByRole('button')).toBe(trigger); + expect(trigger).not.toHaveAttribute('aria-describedby'); + }); + + it('keeps focus on the trigger when the title comes and goes', () => { + const { rerender } = render( + + + , + ); + screen.getByRole('button').focus(); + + rerender( + + + , + ); + expect(screen.getByRole('button')).toHaveFocus(); + + rerender( + + + , + ); + expect(screen.getByRole('button')).toHaveFocus(); + }); + + it('closes an open tooltip once the title is gone', async () => { + const user = userEvent.setup(); + const { rerender } = render( + + + , + ); + + await user.hover(screen.getByRole('button')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.trigger-context.test.tsx b/packages/ui/src/tooltip/__tests__/tooltip.trigger-context.test.tsx new file mode 100644 index 00000000..76a93ced --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.trigger-context.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Tooltip } from '../presets/tooltip.js'; +import { TooltipContent } from '../subcomponents/tooltip-content.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; +import { useIsInsideTooltipTrigger } from '../tooltip-trigger-context.js'; + +function Probe() { + return {String(useIsInsideTooltipTrigger())}; +} + +describe('useIsInsideTooltipTrigger', () => { + it('is false with no tooltip around', () => { + render(); + + expect(screen.getByTestId('probe')).toHaveTextContent('false'); + }); + + it('is true inside a trigger', () => { + render( + + + + + Tip + , + ); + + expect(screen.getByTestId('probe')).toHaveTextContent('true'); + }); + + it('is true inside the trigger of a Tooltip, even with no title', () => { + render( + + + , + ); + + expect(screen.getByTestId('probe')).toHaveTextContent('true'); + }); + + it('is false inside the content, which is not the trigger', () => { + render( + + Hover + + + + , + ); + + expect(screen.getByTestId('probe')).toHaveTextContent('false'); + }); + + it('is false next to a tooltip', () => { + render( + <> + + + + + , + ); + + expect(screen.getByTestId('probe')).toHaveTextContent('false'); + }); +}); diff --git a/packages/ui/src/tooltip/__tests__/tooltip.types.test-d.tsx b/packages/ui/src/tooltip/__tests__/tooltip.types.test-d.tsx new file mode 100644 index 00000000..539e845e --- /dev/null +++ b/packages/ui/src/tooltip/__tests__/tooltip.types.test-d.tsx @@ -0,0 +1,189 @@ +/** + * Type-level tests for the props of {@link Tooltip}. See `button.types.test-d.tsx` for how these + * run, and why every `@ts-expect-error` sits inside `assertType(...)` right above the opening tag + * with every prop kept on that line. + */ +import { createRef } from 'react'; +import { assertType, describe, test } from 'vitest'; +import { Tooltip, TooltipProvider } from '../index.js'; + +const trigger = ; +const buttonRef = createRef(); +const divRef = createRef(); + +describe('title and children', () => { + test('accepts any node as title, including nothing', () => { + assertType({trigger}); + assertType(Rich}>{trigger}); + assertType({trigger}); + assertType({trigger}); + assertType({trigger}); + }); + + test('title is required', () => { + assertType( + // @ts-expect-error - a tooltip has to be told what to say, pass `undefined` to say nothing + {trigger}, + ); + }); + + test('children are required', () => { + assertType( + // @ts-expect-error - a tooltip needs a trigger to anchor to + , + ); + }); +}); + +describe('positioning', () => { + test('accepts every side and align', () => { + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + }); + + test('rejects a side outside the set', () => { + assertType( + // @ts-expect-error - `center` is not a side + + {trigger} + , + ); + }); + + test('rejects an align outside the set', () => { + assertType( + // @ts-expect-error - `middle` is not an align + + {trigger} + , + ); + }); +}); + +describe('container', () => { + test('accepts an element, a ref or null', () => { + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + assertType({trigger}); + }); + + test('rejects a selector string', () => { + assertType( + // @ts-expect-error - the container is an element, not a selector + + {trigger} + , + ); + }); +}); + +describe('remaining props', () => { + test('accepts the presentational and test ones', () => { + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + assertType( + + {trigger} + , + ); + }); + + test('rejects a ref for anything but the trigger element', () => { + assertType( + // @ts-expect-error - the ref lands on the trigger, an HTMLButtonElement + + {trigger} + , + ); + }); + + test('accepts open', () => { + assertType( + + {trigger} + , + ); + }); + + test('rejects an open that is not a boolean', () => { + assertType( + // @ts-expect-error - `open` is a boolean + + {trigger} + , + ); + }); + + test('accepts any data-* attribute', () => { + assertType( + + {trigger} + , + ); + }); + + test('rejects unknown props', () => { + assertType( + // @ts-expect-error - `onOpenChange` is not exposed + {}}> + {trigger} + , + ); + }); +}); + +describe('TooltipProvider', () => { + test('accepts the delays in milliseconds', () => { + assertType( + + {trigger} + , + ); + }); + + test('rejects a delay that is not a number', () => { + assertType( + // @ts-expect-error - delays are milliseconds, not strings + {trigger}, + ); + }); +}); diff --git a/packages/ui/src/tooltip/index.ts b/packages/ui/src/tooltip/index.ts index 916e75fd..0627332f 100644 --- a/packages/ui/src/tooltip/index.ts +++ b/packages/ui/src/tooltip/index.ts @@ -5,29 +5,36 @@ * * | Token | Default | * |-------|---------| - * | `--tooltip-arrow-border-radius` | `2px` | - * | `--tooltip-arrow-height` | `10px` | - * | `--tooltip-arrow-width` | `10px` | - * | `--tooltip-background` | `var(--l2-background)` | - * | `--tooltip-border-color` | `var(--l2-border)` | - * | `--tooltip-border-radius` | `calc(var(--radius-sm) - 4px)` | + * | `--tooltip-backdrop-filter` | `blur(30px)` | + * | `--tooltip-background` | `var(--surface-3)` | + * | `--tooltip-border-color` | `var(--l3-border)` | + * | `--tooltip-border-radius` | `var(--radius-1)` | * | `--tooltip-border-style` | `solid` | * | `--tooltip-border-width` | `1px` | - * | `--tooltip-box-shadow` | `0 6px 12px 0 rgba(0, 0, 0, 0.2)` | - * | `--tooltip-font-size` | `var(--periscope-font-size-small)` | + * | `--tooltip-box-shadow` | `var(--shadow-tooltip)` | + * | `--tooltip-divider-background` | `var(--l2-border)` | + * | `--tooltip-divider-block-size` | `1px` | + * | `--tooltip-divider-margin-inline` | `calc(-1 * var(--spacing-4))` | + * | `--tooltip-font-size` | `var(--periscope-font-size-base)` | + * | `--tooltip-font-weight` | `var(--periscope-font-weight-regular)` | * | `--tooltip-foreground` | `var(--l1-foreground)` | - * | `--tooltip-letter-spacing` | `-0.06px` | - * | `--tooltip-line-height` | `18px` | + * | `--tooltip-line-height` | `var(--periscope-line-height-base)` | + * | `--tooltip-max-height` | `8.5rem` | + * | `--tooltip-max-lines` | `6` | + * | `--tooltip-max-width` | `26.25rem` | + * | `--tooltip-overflow` | `hidden` | * | `--tooltip-padding` | `var(--spacing-2) var(--spacing-4)` | + * | `--tooltip-stack-display` | `flex` | + * | `--tooltip-stack-flex-direction` | `column` | + * | `--tooltip-stack-gap` | `var(--spacing-2)` | + * | `--tooltip-text-align` | `start` | * | `--tooltip-text-wrap` | `balance` | - * | `--tooltip-transform-origin` | `var(--radix-tooltip-content-transform-origin)` | * | `--tooltip-width` | `fit-content` | * | `--tooltip-z-index` | `50` | */ // #endregion css-tokens -export { TooltipSimple, type TooltipSimpleProps } from './presets/tooltip-simple.js'; -export { TooltipContent, type TooltipContentProps } from './subcomponents/tooltip-content.js'; +export { Tooltip } from './presets/tooltip.js'; +export { type TooltipProps } from './types.js'; export { TooltipProvider, type TooltipProviderProps } from './subcomponents/tooltip-provider.js'; -export { TooltipRoot, type TooltipRootProps } from './subcomponents/tooltip-root.js'; -export { TooltipTrigger, type TooltipTriggerProps } from './subcomponents/tooltip-trigger.js'; +export { type TooltipContainer } from './tooltip-config-context.js'; diff --git a/packages/ui/src/tooltip/presets/tooltip-simple.tsx b/packages/ui/src/tooltip/presets/tooltip-simple.tsx deleted file mode 100644 index 5b445129..00000000 --- a/packages/ui/src/tooltip/presets/tooltip-simple.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import * as React from 'react'; -import { TooltipContent, type TooltipContentProps } from '../subcomponents/tooltip-content.js'; -import { TooltipRoot, type TooltipRootProps } from '../subcomponents/tooltip-root.js'; -import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; - -export type TooltipSimpleProps = { - /** - * The content of the tooltip. - */ - title: React.ReactNode; - /** - * Whether to show the arrow. - * @default false - */ - arrow?: boolean; - /** - * The preferred side of the trigger to render against when open. - * @default 'top' - */ - side?: TooltipContentProps['side']; - /** - * The preferred alignment against the trigger. - * @default 'center' - */ - align?: TooltipContentProps['align']; - /** - * The distance in pixels from the trigger. - * @default 4 - */ - sideOffset?: number; - /** - * An offset in pixels from the "start" or "end" alignment options. - */ - alignOffset?: number; - /** - * When true, overrides the side and align preferences to prevent collisions with boundary edges. - * @default true - */ - avoidCollisions?: boolean; - /** - * Whether to render in a portal. Set to false when inside modals/dialogs. - * @default true - */ - withPortal?: boolean; - /** - * Additional props to pass to TooltipContent. - */ - tooltipContentProps?: Omit< - TooltipContentProps, - | 'side' - | 'align' - | 'sideOffset' - | 'alignOffset' - | 'avoidCollisions' - | 'arrow' - | 'withPortal' - | 'children' - >; - /** - * The test id of the tooltip. - */ - testId?: string; - /** - * The trigger element. - */ - children: React.ReactNode; -} & Omit; - -/** - * Simple tooltip preset. Wraps a trigger element and shows a tooltip on hover. - * - * @example Basic usage - * ```tsx - * - * - * - * - * - * ``` - * - * @example With positioning - * ```tsx - * - * - * - * - * - * ``` - * - * @example Inside a modal (no portal) - * ```tsx - * - * - * - * - * - * - * - * ``` - * - * @example With extra content props - * ```tsx - * - * - * - * ``` - */ -export const TooltipSimple = React.forwardRef( - ( - { - title, - arrow = false, - side, - align, - sideOffset, - alignOffset, - avoidCollisions, - withPortal = true, - tooltipContentProps, - testId, - children, - ...rootProps - }, - ref, - ) => { - return ( - - - {children} - - - {title} - - - ); - }, -); -TooltipSimple.displayName = 'TooltipSimple'; diff --git a/packages/ui/src/tooltip/presets/tooltip.tsx b/packages/ui/src/tooltip/presets/tooltip.tsx new file mode 100644 index 00000000..eb089e23 --- /dev/null +++ b/packages/ui/src/tooltip/presets/tooltip.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { TooltipContent } from '../subcomponents/tooltip-content.js'; +import { TooltipProviderIfMissing } from '../subcomponents/tooltip-provider.js'; +import { TooltipRoot } from '../subcomponents/tooltip-root.js'; +import { TooltipTrigger } from '../subcomponents/tooltip-trigger.js'; +import { + hasTooltipContent, + TooltipContentStackProvider, + useTooltipContentStackEntries, +} from '../tooltip-content-stack-context.js'; +import { useTooltipHandle } from '../tooltip-handle.js'; +import { useStackedTooltipProps } from '../tooltip-stacked-props.js'; +import { useIsInsideTooltipTrigger } from '../tooltip-trigger-context.js'; +import type { TooltipProps } from '../types.js'; + +/** + * Shows `title` in a popup while its child is hovered or focused (Base UI `Tooltip`). + * + * The child is the trigger. No wrapper element is added. + * + * Visual values are `--tooltip-*` custom properties, defaults in the `css-tokens` region of + * [../index.ts](../index.ts). + * + * ### The provider + * + * Optional. A tooltip without one adds its own with the defaults. + * + * Place one above a subtree to set `delay`, `closeDelay`, `timeout` and `container` for every + * tooltip under it. That is the only way to change them for more than one tooltip. + * + * ### The child + * + * - One element child. It receives the trigger props and the `ref`. + * - Text, a fragment or several nodes end up inside a ` + * + * ``` + * + * @example + * ```tsx + * // Nothing to say, nothing to show + * + * {label} + * + * ``` + * + * @example + * ```tsx + * // A custom child works only if it forwards its ref + * const Chip = forwardRef(function Chip(props, ref) { + * return ; + * }); + * ``` + */ +export const Tooltip = React.forwardRef( + function Tooltip(props, ref) { + const stacked = useIsInsideTooltipTrigger(); + const tooltip = ; + + // The trigger and the root are siblings, so their stack has to sit above both. + return ( + + {stacked ? tooltip : {tooltip}} + + ); + }, +); + +const TooltipParts = React.forwardRef(function TooltipParts( + { title, children, open, ...props }, + ref, +) { + const { stacked, triggerProps, contentProps } = useStackedTooltipProps(props); + const handle = useTooltipHandle(); + const generatedId = React.useId(); + const contentId = contentProps.id ?? generatedId; + const stackedEntries = useTooltipContentStackEntries(); + const hasContent = hasTooltipContent(title) || (!stacked && stackedEntries.length > 0); + + return ( + <> + + {children} + + {hasContent && ( + + + {title} + + + )} + + ); +}); diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-content.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-content.tsx index e9f43596..18164a8e 100644 --- a/packages/ui/src/tooltip/subcomponents/tooltip-content.tsx +++ b/packages/ui/src/tooltip/subcomponents/tooltip-content.tsx @@ -1,134 +1,48 @@ -import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import * as React from 'react'; -import { cn } from '../../lib/utils.js'; -import styles from '../tooltip.module.scss'; +import { + useRegisterTooltipContent, + useTooltipContentStackEntries, +} from '../tooltip-content-stack-context.js'; +import { TooltipPopup, type TooltipPopupProps } from './tooltip-popup.js'; +import { TooltipPortal, type TooltipPortalProps } from './tooltip-portal.js'; +import { TooltipPositioner, type TooltipPositionerProps } from './tooltip-positioner.js'; +import { TooltipStack } from './tooltip-stack.js'; -type OriginalTooltipContentProps = React.ComponentProps; - -export type TooltipContentProps = { - /** - * The preferred side of the trigger to render against when open. Will be reversed when collisions occur and avoidCollisions is enabled. - */ - side?: OriginalTooltipContentProps['side']; - /** - * The distance in pixels from the trigger. - */ - sideOffset?: number; - /** - * The preferred alignment against the trigger. May change when collisions occur. - */ - align?: OriginalTooltipContentProps['align']; - /** - * An offset in pixels from the "start" or "end" alignment options. - */ - alignOffset?: number; - /** - * The padding between the arrow and the edges of the content. If your content has border-radius, this will prevent it from overflowing the corners. - */ - arrowPadding?: number; - /** - * When true, overrides the side and align preferences to prevent collisions with boundary edges. - */ - avoidCollisions?: boolean; - /** - * The element used as the collision boundary. By default this is the viewport, though you can provide additional element(s) to be included in this check. - */ - collisionBoundary?: OriginalTooltipContentProps['collisionBoundary']; - /** - * The distance in pixels from the boundary edges where collision detection should occur. Accepts a number (same for all sides), or a partial padding object, for example: { top: 20, left: 20 }. - */ - collisionPadding?: OriginalTooltipContentProps['collisionPadding']; - /** - * The sticky behavior on the align axis. "partial" will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst "always" will keep the content in the boundary regardless. - */ - sticky?: 'partial' | 'always'; - /** - * Whether to hide the content when the trigger becomes fully occluded. - */ - hideWhenDetached?: boolean; - /** - * The strategy used to update the position of the content. "optimized" will use ResizeObserver to - * only update when necessary; "always" will update on every frame. - * @defaultValue 'optimized' - */ - updatePositionStrategy?: 'optimized' | 'always'; - /** - * Used to force mounting when more control is needed. Useful when - * controlling animation with React animation libraries. - */ - forceMount?: true; - - /** - * A more descriptive label for accessibility purpose - */ - 'aria-label'?: string; - /** - * Event handler called when the escape key is down. - * Can be prevented. - */ - onEscapeKeyDown?: OriginalTooltipContentProps['onEscapeKeyDown']; - /** - * Event handler called when the a `pointerdown` event happens outside of the `Tooltip`. - * Can be prevented. - */ - onPointerDownOutside?: OriginalTooltipContentProps['onPointerDownOutside']; - /** - * Whether to show the arrow. - */ - arrow?: boolean; - /** - * Whether to render in a portal. Set to false when inside modals/dialogs. - * @default true - */ - withPortal?: boolean; - /** - * The test id of the tooltip content. - */ - testId?: string; -} & Pick, 'id' | 'className' | 'style' | 'children'>; - -const TooltipContentInner = React.forwardRef< - React.ElementRef, - Omit ->(({ className, sideOffset = 4, testId, children, arrow = false, ...props }, ref) => ( - - {children} - {arrow && ( - - - - - - )} - -)); -TooltipContentInner.displayName = 'TooltipContentInner'; +/** + * @access private + */ +export type TooltipContentProps = TooltipPopupProps & { + container?: TooltipPortalProps['container']; +} & Pick; /** - * The content of the tooltip. Supports positioning via `side`, `align`, - * and collision detection. Use with `TooltipTrigger` when composing a custom tooltip. - * - * Set `withPortal={false}` when inside modals/dialogs to avoid z-index issues. + * @access private */ -export const TooltipContent = React.forwardRef< - React.ElementRef, - TooltipContentProps ->(({ withPortal = true, ...props }, ref) => { - if (withPortal) { +export const TooltipContent = React.forwardRef( + function TooltipContent( + { side, align, sideOffset, alignOffset, container, children, ...popupProps }, + ref, + ) { + const entries = useTooltipContentStackEntries(); + const stacked = useRegisterTooltipContent(children); + + if (stacked) { + return null; + } + return ( - - - + + + + + + + ); - } - - return ; -}); -TooltipContent.displayName = 'TooltipContent'; + }, +); diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-popup.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-popup.tsx new file mode 100644 index 00000000..8eba8e8c --- /dev/null +++ b/packages/ui/src/tooltip/subcomponents/tooltip-popup.tsx @@ -0,0 +1,43 @@ +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; +import * as React from 'react'; +import { cn } from '../../lib/utils.js'; +import { useTooltipContentId } from '../tooltip-content-id-context.js'; +import styles from '../tooltip.module.scss'; + +/** + * @access private + */ +export type TooltipPopupProps = Omit< + React.ComponentPropsWithoutRef, + 'className' +> & { + className?: string; + testId?: string; +}; + +/** + * @access private + */ +export const TooltipPopup = React.forwardRef( + function TooltipPopup({ className, id, testId, children, ...props }, ref) { + const contentId = useTooltipContentId(); + + return ( + + {children} + + ); + }, +); diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-portal.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-portal.tsx new file mode 100644 index 00000000..f0d1d2b9 --- /dev/null +++ b/packages/ui/src/tooltip/subcomponents/tooltip-portal.tsx @@ -0,0 +1,22 @@ +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; +import type * as React from 'react'; +import { useTooltipConfig } from '../tooltip-config-context.js'; + +/** + * @access private + */ +export type TooltipPortalProps = React.ComponentPropsWithoutRef; + +/** + * @access private + */ +export function TooltipPortal({ container, ...props }: TooltipPortalProps): React.ReactNode { + const config = useTooltipConfig(); + + return ( + + ); +} diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-positioner.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-positioner.tsx new file mode 100644 index 00000000..158533ca --- /dev/null +++ b/packages/ui/src/tooltip/subcomponents/tooltip-positioner.tsx @@ -0,0 +1,35 @@ +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; +import * as React from 'react'; +import { cn } from '../../lib/utils.js'; +import styles from '../tooltip.module.scss'; + +const SIDE_OFFSET = 4; + +/** + * @access private + */ +export type TooltipPositionerProps = Omit< + React.ComponentPropsWithoutRef, + 'className' +> & { + className?: string; +}; + +/** + * @access private + */ +export const TooltipPositioner = React.forwardRef( + function TooltipPositioner({ className, sideOffset = SIDE_OFFSET, children, ...props }, ref) { + return ( + + {children} + + ); + }, +); diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-provider.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-provider.tsx index 257b5c7a..aba8eadf 100644 --- a/packages/ui/src/tooltip/subcomponents/tooltip-provider.tsx +++ b/packages/ui/src/tooltip/subcomponents/tooltip-provider.tsx @@ -1,52 +1,69 @@ -import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; import type * as React from 'react'; +import { + type TooltipContainer, + TooltipConfigProvider, + useTooltipConfig, +} from '../tooltip-config-context.js'; -export type TooltipProviderProps = { +type OriginalProviderProps = React.ComponentProps; + +export interface TooltipProviderProps { /** - * The children of the tooltip provider. + * The subtree the provider applies to. */ - children: React.ReactNode; + children?: OriginalProviderProps['children']; /** - * The duration from when the pointer enters the trigger until the tooltip gets opened. - * @defaultValue 700 + * How long to wait before opening a tooltip on hover, in milliseconds. Long enough + * that a pointer passing over a trigger does not open it. Focus opens it at once. + * + * @default 300 */ - delayDuration?: number; + delay?: OriginalProviderProps['delay']; /** - * How much time a user has to enter another trigger without incurring a delay again. - * @defaultValue 300 + * How long to wait before closing a tooltip once the pointer leaves, in milliseconds. + * + * @default 0 */ - skipDelayDuration?: number; + closeDelay?: OriginalProviderProps['closeDelay']; /** - * When `true`, trying to hover the content will result in the tooltip closing as the pointer leaves the trigger. - * @defaultValue false + * Another tooltip opens at once, skipping `delay`, when the previous one closed + * within this many milliseconds. + * + * @default 400 */ - disableHoverableContent?: boolean; + timeout?: OriginalProviderProps['timeout']; /** - * The test id of the tooltip provider. + * The element every tooltip below is portalled into unless it sets its own + * `container`. Defaults to `document.body`. */ - testId?: string; -}; + container?: TooltipContainer; +} -/** - * Wraps your app (or a section of it) to provide shared configuration for all tooltips. - * Use delayDuration to control the hover delay; set to 0 in Storybook for instant feedback. - * - * @example - * ```tsx - * - * - * - * - * - * ``` - */ -export function TooltipProvider({ delayDuration = 0, testId, ...props }: TooltipProviderProps) { +export function TooltipProvider({ + container, + children, + delay = 300, + ...props +}: TooltipProviderProps): React.ReactNode { return ( - + + + {children} + + ); } + +/** + * @access private + */ +export function TooltipProviderIfMissing(props: TooltipProviderProps): React.ReactNode { + const { hasProvider } = useTooltipConfig(); + + if (hasProvider) { + return props.children; + } + + return ; +} diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-root.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-root.tsx index 2f812b3c..2ec4eed7 100644 --- a/packages/ui/src/tooltip/subcomponents/tooltip-root.tsx +++ b/packages/ui/src/tooltip/subcomponents/tooltip-root.tsx @@ -1,58 +1,36 @@ -import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; import type * as React from 'react'; +import { TooltipContentIdProvider } from '../tooltip-content-id-context.js'; +import { TooltipContentStackProviderIfMissing } from '../tooltip-content-stack-context.js'; +import { useIsInsideTooltipTrigger } from '../tooltip-trigger-context.js'; -export type TooltipRootProps = { - /** - * The tooltip trigger and content elements. - */ +/** + * @access private + */ +export type TooltipRootProps = Omit< + React.ComponentProps, + 'children' +> & { children?: React.ReactNode; - /** - * The controlled open state of the tooltip. - */ - open?: boolean; - /** - * The open state of the tooltip when it is initially rendered. - */ - defaultOpen?: boolean; - /** - * Event handler called when the open state of the tooltip changes. - */ - onOpenChange?: (open: boolean) => void; - /** - * The duration from when the pointer enters the trigger until the tooltip gets opened. This will - * override the prop with the same name passed to Provider. - * @defaultValue 700 - */ - delayDuration?: number; - /** - * When `true`, trying to hover the content will result in the tooltip closing as the pointer leaves the trigger. - * @defaultValue false - */ - disableHoverableContent?: boolean; - /** - * The test id of the tooltip root. - */ - testId?: string; }; /** - * Root component that manages the open state and accessibility wiring for a tooltip. - * Compose with `TooltipTrigger` and `TooltipContent` for custom content and positioning. - * - * @example - * ```tsx - * - * - * - * - * - * - * Rich tooltip content - * - * - * - * ``` + * @access private */ -export function TooltipRoot({ testId, ...props }: TooltipRootProps) { - return ; +export function TooltipRoot({ children, ...props }: TooltipRootProps): React.ReactNode { + const insideTrigger = useIsInsideTooltipTrigger(); + + // A second tooltip on the same element would open on the same hover, so the parts + // below stack into the tooltip above instead. + if (insideTrigger) { + return children; + } + + return ( + + + {children} + + + ); } diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-stack.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-stack.tsx new file mode 100644 index 00000000..105b81ee --- /dev/null +++ b/packages/ui/src/tooltip/subcomponents/tooltip-stack.tsx @@ -0,0 +1,45 @@ +import { Fragment, type ReactNode } from 'react'; +import { + hasTooltipContent, + type TooltipContentStackEntry, +} from '../tooltip-content-stack-context.js'; +import styles from '../tooltip.module.scss'; + +/** + * @access private + */ +export type TooltipStackProps = { + items: TooltipContentStackEntry[]; +}; + +/** + * @access private + */ +export function TooltipStack({ items }: TooltipStackProps): ReactNode { + const filled = items.filter((item) => hasTooltipContent(item.content)); + + if (filled.length === 0) { + return null; + } + + if (filled.length === 1) { + return filled[0]?.content; + } + + return ( +
+ {filled.map((item, index) => ( + + {index > 0 && ( + + ); +} diff --git a/packages/ui/src/tooltip/subcomponents/tooltip-trigger.tsx b/packages/ui/src/tooltip/subcomponents/tooltip-trigger.tsx index 6a333443..b55af00e 100644 --- a/packages/ui/src/tooltip/subcomponents/tooltip-trigger.tsx +++ b/packages/ui/src/tooltip/subcomponents/tooltip-trigger.tsx @@ -1,29 +1,95 @@ -import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; import * as React from 'react'; +import { cn } from '../../lib/utils.js'; +import { useTooltipContentId } from '../tooltip-content-id-context.js'; +import { TooltipTriggerProvider, useIsInsideTooltipTrigger } from '../tooltip-trigger-context.js'; -export type TooltipTriggerProps = { - /** - * The children of the tooltip trigger. - */ - children?: React.ReactNode; - /** - * When true, merges props onto the child element instead of rendering a wrapper. - */ - asChild?: boolean; +/** + * @access private + */ +export type TooltipTriggerProps = Omit< + React.ComponentPropsWithoutRef, + 'className' +> & { + className?: string; + testId?: string; /** - * The test id of the tooltip trigger. + * Id of the popup this trigger describes. Only needed when the content is not + * rendered inside a `TooltipRoot` above the trigger, which is where the id + * otherwise comes from. `null` says there is no popup to point at, which the id + * from above cannot say. */ - testId?: string; + contentId?: string | null; }; /** - * The element that triggers the tooltip to open on hover. Use with `asChild` to delegate - * to a child element (e.g. a Button). + * @access private */ -export const TooltipTrigger = React.forwardRef< - React.ElementRef, - TooltipTriggerProps ->(({ testId, ...props }, ref) => ( - -)); -TooltipTrigger.displayName = 'TooltipTrigger'; +export const TooltipTrigger = React.forwardRef( + function TooltipTrigger({ testId, handle, contentId, children, ...props }, ref) { + const inheritedContentId = useTooltipContentId(); + const insideTrigger = useIsInsideTooltipTrigger(); + const childRef = React.isValidElement(children) ? getElementRef(children) : undefined; + // `cloneElement` below replaces the ref of the child, so the two are merged first. + const mergedRef = React.useMemo( + () => + childRef == null || ref == null + ? (childRef ?? ref) + : (node: HTMLButtonElement | null) => { + setRef(childRef, node); + setRef(ref, node); + }, + [childRef, ref], + ); + // Only spread when set: `cloneElement` below would otherwise replace the + // `data-testid` the child brought along with `undefined`. + const testIdProps = testId === undefined ? {} : { 'data-testid': testId }; + + // The element is already the trigger of the tooltip above, which stacks the + // content below it. + if (insideTrigger) { + if (!React.isValidElement<{ className?: string }>(children)) { + return children; + } + + // `handle` and `contentId` are dropped: the tooltip above owns the popup. + return React.cloneElement(children, { + ...testIdProps, + ...props, + ...(props.className === undefined + ? {} + : { className: cn(children.props.className, props.className) }), + ...(mergedRef == null ? {} : { ref: mergedRef }), + } as React.Attributes); + } + + return ( + + + + ); + }, +); + +type ElementRef = React.Ref | undefined; + +// React 18 keeps the ref of an element next to its props, React 19 inside them. +function getElementRef(element: React.ReactElement): ElementRef { + return (element.props as { ref?: ElementRef }).ref ?? (element as { ref?: ElementRef }).ref; +} + +function setRef(ref: React.Ref, node: HTMLButtonElement | null): void { + if (typeof ref === 'function') { + ref(node); + } else if (ref != null) { + (ref as React.MutableRefObject).current = node; + } +} diff --git a/packages/ui/src/tooltip/tooltip-config-context.tsx b/packages/ui/src/tooltip/tooltip-config-context.tsx new file mode 100644 index 00000000..af155b57 --- /dev/null +++ b/packages/ui/src/tooltip/tooltip-config-context.tsx @@ -0,0 +1,51 @@ +import type { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; +import { type ComponentProps, createContext, type ReactNode, useContext, useMemo } from 'react'; + +type OriginalPortalProps = ComponentProps; + +/** + * Where the tooltip content is portalled to. Defaults to `document.body`. + */ +export type TooltipContainer = OriginalPortalProps['container']; + +/** + * @access private + */ +export type TooltipConfig = { + /** + * True once a `TooltipProvider` is above in the tree. Components that render + * tooltips of their own read it to avoid nesting a second provider, which + * would shadow the container the app configured. + */ + hasProvider: boolean; + /** + * The portal container every tooltip below uses unless it sets its own. + */ + container?: TooltipContainer; +}; + +const TooltipConfigContext = createContext({ hasProvider: false }); + +/** + * @access private + */ +export function TooltipConfigProvider({ + container, + children, +}: { + container?: TooltipContainer; + children: ReactNode; +}): ReactNode { + const value = useMemo(() => ({ hasProvider: true, container }), [container]); + + return {children}; +} + +/** + * The tooltip configuration in effect at this point of the tree. + * + * @access private + */ +export function useTooltipConfig(): TooltipConfig { + return useContext(TooltipConfigContext); +} diff --git a/packages/ui/src/tooltip/tooltip-content-id-context.tsx b/packages/ui/src/tooltip/tooltip-content-id-context.tsx new file mode 100644 index 00000000..c154a3c2 --- /dev/null +++ b/packages/ui/src/tooltip/tooltip-content-id-context.tsx @@ -0,0 +1,23 @@ +import { createContext, type ReactNode, useContext, useId } from 'react'; + +const TooltipContentIdContext = createContext(undefined); + +/** + * Hands the trigger and the content of one tooltip a shared id. Base UI leaves + * the two unrelated, so without it the trigger has nothing to point + * `aria-describedby` at and the tooltip goes unannounced. + * + * @access private + */ +export function TooltipContentIdProvider({ children }: { children: ReactNode }): ReactNode { + const id = useId(); + + return {children}; +} + +/** + * @access private + */ +export function useTooltipContentId(): string | undefined { + return useContext(TooltipContentIdContext); +} diff --git a/packages/ui/src/tooltip/tooltip-content-stack-context.tsx b/packages/ui/src/tooltip/tooltip-content-stack-context.tsx new file mode 100644 index 00000000..57134fa7 --- /dev/null +++ b/packages/ui/src/tooltip/tooltip-content-stack-context.tsx @@ -0,0 +1,165 @@ +import { + createContext, + type ReactNode, + useContext, + useEffect, + useId, + useMemo, + useState, +} from 'react'; +import { useIsInsideTooltipTrigger } from './tooltip-trigger-context.js'; + +/** + * One piece of content inside a tooltip popup. A tooltip renders its own content + * first and every registered entry after it, with a divider in between. + * + * @access private + */ +export type TooltipContentStackEntry = { + id: string; + content: ReactNode; +}; + +type TooltipContentStackApi = { + /** + * True once a tooltip above collects content. Components that would open a + * tooltip of their own add to that one instead of anchoring a second tooltip + * to the same element. + */ + hasStack: boolean; + register: (id: string, content: ReactNode) => void; + unregister: (id: string) => void; +}; + +const NO_ENTRIES: TooltipContentStackEntry[] = []; + +/** + * Whether a node is worth opening a tooltip for. `null`, `undefined`, `false` and + * an empty string all render nothing, so they are treated as no content at all. + * + * @access private + */ +export function hasTooltipContent(content: ReactNode): boolean { + return content != null && content !== false && content !== ''; +} + +const noop = (): void => {}; + +// The API is split from the entries so that the components adding content do not +// re-render when the content of the stack changes: only the tooltip rendering the +// popup reads the entries. +const TooltipContentStackApiContext = createContext({ + hasStack: false, + register: noop, + unregister: noop, +}); + +const TooltipContentStackEntriesContext = createContext(NO_ENTRIES); + +/** + * True when a `TooltipContentStackProvider` is already above in the tree. + * + * @access private + */ +export function useHasTooltipContentStack(): boolean { + return useContext(TooltipContentStackApiContext).hasStack; +} + +/** + * @access private + */ +export function TooltipContentStackProvider({ children }: { children: ReactNode }): ReactNode { + const [entries, setEntries] = useState(NO_ENTRIES); + + const api = useMemo( + () => ({ + hasStack: true, + register: (id, content) => + setEntries((current) => { + const index = current.findIndex((entry) => entry.id === id); + + if (index === -1) { + return [...current, { id, content }]; + } + + if (current[index]?.content === content) { + return current; + } + + const next = current.slice(); + next[index] = { id, content }; + + return next; + }), + unregister: (id) => + setEntries((current) => + current.some((entry) => entry.id === id) + ? current.filter((entry) => entry.id !== id) + : current, + ), + }), + [], + ); + + return ( + + + {children} + + + ); +} + +/** + * @access private + */ +export function TooltipContentStackProviderIfMissing({ + children, +}: { + children: ReactNode; +}): ReactNode { + const hasStack = useHasTooltipContentStack(); + + if (hasStack) { + return children; + } + + return {children}; +} + +/** + * The content other components stacked onto this tooltip, in mount order. + * + * @access private + */ +export function useTooltipContentStackEntries(): TooltipContentStackEntry[] { + return useContext(TooltipContentStackEntriesContext); +} + +/** + * Adds `content` to the tooltip above when the caller sits inside its trigger, + * and reports whether it did. A caller that is stacked renders nothing itself: + * its content shows up in the popup of the tooltip it is inside. + * + * @access private + */ +export function useRegisterTooltipContent(content: ReactNode): boolean { + const { hasStack, register, unregister } = useContext(TooltipContentStackApiContext); + const insideTrigger = useIsInsideTooltipTrigger(); + const id = useId(); + const stacked = hasStack && insideTrigger; + + useEffect(() => { + if (!stacked || !hasTooltipContent(content)) { + unregister(id); + + return; + } + + register(id, content); + }, [stacked, content, id, register, unregister]); + + useEffect(() => () => unregister(id), [id, unregister]); + + return stacked; +} diff --git a/packages/ui/src/tooltip/tooltip-handle.ts b/packages/ui/src/tooltip/tooltip-handle.ts new file mode 100644 index 00000000..d72d8385 --- /dev/null +++ b/packages/ui/src/tooltip/tooltip-handle.ts @@ -0,0 +1,14 @@ +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'; +import { useState } from 'react'; + +/** + * A handle that ties a `TooltipTrigger` to a `TooltipRoot` rendered next to it + * instead of around it. + * + * @access private + */ +export function useTooltipHandle(): ReturnType { + const [handle] = useState(TooltipPrimitive.createHandle); + + return handle; +} diff --git a/packages/ui/src/tooltip/tooltip-stacked-props.ts b/packages/ui/src/tooltip/tooltip-stacked-props.ts new file mode 100644 index 00000000..6a5906f4 --- /dev/null +++ b/packages/ui/src/tooltip/tooltip-stacked-props.ts @@ -0,0 +1,31 @@ +import type { TooltipTriggerProps } from './subcomponents/tooltip-trigger.js'; +import { useIsInsideTooltipTrigger } from './tooltip-trigger-context.js'; +import type { TooltipProps } from './types.js'; + +type TooltipOwnProps = Omit; + +/** + * @access private + */ +export function useStackedTooltipProps({ + side, + sideOffset, + align, + alignOffset, + container, + ...rest +}: TooltipOwnProps): { + stacked: boolean; + triggerProps: TooltipTriggerProps; + contentProps: TooltipOwnProps; +} { + const stacked = useIsInsideTooltipTrigger(); + + return stacked + ? { stacked, triggerProps: rest, contentProps: {} } + : { + stacked, + triggerProps: {}, + contentProps: { side, sideOffset, align, alignOffset, container, ...rest }, + }; +} diff --git a/packages/ui/src/tooltip/tooltip-trigger-context.tsx b/packages/ui/src/tooltip/tooltip-trigger-context.tsx new file mode 100644 index 00000000..034d204a --- /dev/null +++ b/packages/ui/src/tooltip/tooltip-trigger-context.tsx @@ -0,0 +1,24 @@ +import { createContext, type ReactNode, useContext } from 'react'; + +const TooltipTriggerContext = createContext(false); + +/** + * Marks everything below it as living inside a tooltip trigger. Sits around the + * trigger rather than inside it: the trigger hands its single child to Base UI as + * `render`, so a provider element there would become the rendered element and + * swallow the props meant for the trigger. + * + * @access private + */ +export function TooltipTriggerProvider({ children }: { children: ReactNode }): ReactNode { + return {children}; +} + +/** + * True when the caller is rendered inside a `TooltipTrigger`. + * + * @access private + */ +export function useIsInsideTooltipTrigger(): boolean { + return useContext(TooltipTriggerContext); +} diff --git a/packages/ui/src/tooltip/tooltip.forward-ref.test.tsx b/packages/ui/src/tooltip/tooltip.forward-ref.test.tsx deleted file mode 100644 index ddd51555..00000000 --- a/packages/ui/src/tooltip/tooltip.forward-ref.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { render } from '@testing-library/react'; -import { createRef } from 'react'; -import { describe, expect, it } from 'vitest'; - -import { - TooltipContent, - TooltipProvider, - TooltipRoot, - TooltipSimple, - TooltipTrigger, -} from './index.js'; - -describe('Tooltip forwardRef', () => { - it('TooltipTrigger forwards ref', () => { - const ref = createRef(); - render( - - - Trigger - - , - ); - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - }); - - it('TooltipSimple forwards ref', () => { - const ref = createRef(); - render( - - - - - , - ); - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - expect(ref.current).toHaveTextContent('Trigger'); - }); - - it('TooltipContent forwards ref', () => { - const ref = createRef(); - render( - - - Trigger - Content - - , - ); - expect(ref.current).toBeInstanceOf(HTMLDivElement); - }); -}); diff --git a/packages/ui/src/tooltip/tooltip.module.scss b/packages/ui/src/tooltip/tooltip.module.scss index 1646ff82..eed778ff 100644 --- a/packages/ui/src/tooltip/tooltip.module.scss +++ b/packages/ui/src/tooltip/tooltip.module.scss @@ -1,62 +1,46 @@ -@keyframes tooltip-in { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes tooltip-out { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.95); - } +.tooltip__positioner { + // TODO: After migrating the tooltips/dialogs/everything, need to revisit these values to make sure they are needed/not conflict with Antd + z-index: var(--tooltip-z-index, 50); } .tooltip__content { - background-color: var(--tooltip-background, var(--l2-background)); + // TODO: spec wants `surface-3` in dark and `surface-1` in light; needs a tooltip surface token. + background-color: var(--tooltip-background, var(--surface-3)); color: var(--tooltip-foreground, var(--l1-foreground)); - z-index: var(--tooltip-z-index, 50); width: var(--tooltip-width, fit-content); - transform-origin: var(--tooltip-transform-origin, var(--radix-tooltip-content-transform-origin)); - border-radius: var(--tooltip-border-radius, calc(var(--radius-sm) - 4px)); + max-width: var(--tooltip-max-width, 26.25rem); + // for now, I won't enable this because this has potential to break many places on signoz/signoz + // so I will keep it disabled until we figure out a good way to migrate all places usint it + //max-height: var(--tooltip-max-height, 8.5rem); + border-radius: var(--tooltip-border-radius, var(--radius-1)); padding: var(--tooltip-padding, var(--spacing-2) var(--spacing-4)); - font-size: var(--tooltip-font-size, var(--periscope-font-size-small)); + font-size: var(--tooltip-font-size, var(--periscope-font-size-base)); + font-weight: var(--tooltip-font-weight, var(--periscope-font-weight-regular)); text-wrap: var(--tooltip-text-wrap, balance); - line-height: var(--tooltip-line-height, 18px); - letter-spacing: var(--tooltip-letter-spacing, -0.06px); + text-align: var(--tooltip-text-align, start); + line-height: var(--tooltip-line-height, var(--periscope-line-height-base)); border-width: var(--tooltip-border-width, 1px); border-style: var(--tooltip-border-style, solid); - border-color: var(--tooltip-border-color, var(--l2-border)); - box-shadow: var(--tooltip-box-shadow, 0 6px 12px 0 rgba(0, 0, 0, 0.2)); + // TODO: spec wants `L2/border` in dark and `L3/border` in light; light is one step off + // until a tooltip border token exists. + border-color: var(--tooltip-border-color, var(--l3-border)); + box-shadow: var(--tooltip-box-shadow, var(--shadow-tooltip)); + backdrop-filter: var(--tooltip-backdrop-filter, blur(30px)); + // Backstop: past six lines the content belongs in a popover. + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--tooltip-max-lines, 6); + overflow: var(--tooltip-overflow, hidden); } -.tooltip__content[data-state="open"] { - animation: tooltip-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - -.tooltip__content[data-state="closed"] { - animation: tooltip-out 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards; -} - - -.tooltip__arrow { - z-index: calc(var(--tooltip-z-index, 50) + 1); - width: var(--tooltip-arrow-width, 10px); - height: var(--tooltip-arrow-height, 10px); - border-radius: var(--tooltip-arrow-border-radius, 2px); - transform: translateY(-1.5px); +.tooltip__stack { + display: var(--tooltip-stack-display, flex); + flex-direction: var(--tooltip-stack-flex-direction, column); + gap: var(--tooltip-stack-gap, var(--spacing-2)); } -.tooltip__arrowPath { - fill: var(--tooltip-background, var(--l2-background)); - stroke: var(--tooltip-border-color, var(--l2-border)); - stroke-width: var(--tooltip-border-width, 2px); +.tooltip__divider { + block-size: var(--tooltip-divider-block-size, 1px); + background-color: var(--tooltip-divider-background, var(--l2-border)); + margin-inline: var(--tooltip-divider-margin-inline, calc(-1 * var(--spacing-4))); } diff --git a/packages/ui/src/tooltip/types.ts b/packages/ui/src/tooltip/types.ts new file mode 100644 index 00000000..5758b016 --- /dev/null +++ b/packages/ui/src/tooltip/types.ts @@ -0,0 +1,85 @@ +import type * as React from 'react'; +import type { TooltipPositionerProps } from './subcomponents/tooltip-positioner.js'; +import type { TooltipRootProps } from './subcomponents/tooltip-root.js'; +import type { TooltipContainer } from './tooltip-config-context.js'; + +type OriginalPositionerProps = TooltipPositionerProps; + +export interface TooltipProps { + /** + * The content of the tooltip. No tooltip is rendered while it is empty, and the + * trigger stays the element it already was. + */ + title: React.ReactNode; + /** + * The trigger element. + */ + children: React.ReactNode; + /** + * Which side of the trigger the tooltip opens against. May change on its own to + * avoid the edges of the viewport. + * + * @default 'top' + */ + side?: OriginalPositionerProps['side']; + /** + * The distance in pixels between the tooltip and the trigger. + * + * @default 4 + */ + sideOffset?: OriginalPositionerProps['sideOffset']; + /** + * How the tooltip is aligned along the side it opens against. + * + * @default 'center' + */ + align?: OriginalPositionerProps['align']; + /** + * An offset in pixels from the `start` or `end` alignment. + * + * @default 0 + */ + alignOffset?: OriginalPositionerProps['alignOffset']; + /** + * Whether the tooltip is open. Set, the tooltip shows exactly what it is told to and + * neither hover nor focus changes that. + * + * @note For a story or a test that needs a popup on screen, not for app code: a + * tooltip belongs to the pointer, and holding one open from the outside takes that + * away. Leave it out and let hover and focus drive it. + */ + open?: TooltipRootProps['open']; + /** + * The element the tooltip is portalled into. Defaults to the one the surrounding + * `TooltipProvider` was given, and to `document.body` without one. Pass the + * dialog or drawer element to keep the tooltip inside it. + */ + container?: TooltipContainer; + /** + * Class name of the tooltip content. Merges with the styles of the component + * instead of replacing them. + * + * @note Reach for it only when nothing else works: padding and colours belong in + * the component, not at the call site. + */ + className?: string; + /** + * Inline styles of the tooltip content, for the exceptional case a class name + * cannot cover. + */ + style?: React.CSSProperties; + /** + * Id of the tooltip content. One is generated when it is left out. + */ + id?: string; + /** + * Alias for `data-testid`, set on the tooltip content. + */ + testId?: string; + /** + * Any `data-*` attribute is forwarded to the tooltip content, the only element this + * component renders of its own. A stacked tooltip has no content, so its `data-*` land + * on the trigger it clones, next to its `className` and `style`. + */ + [dataAttribute: `data-${string}`]: unknown; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c0e7273..7e8d2d9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,9 +299,6 @@ importers: '@radix-ui/react-toggle-group': specifier: ^1.1.17 version: 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': - specifier: ^1.2.14 - version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@signozhq/icons': specifier: 0.3.0 version: 0.3.0 @@ -2130,19 +2127,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.14': - resolution: {integrity: sha512-C/JxCKJJac+wtHPW1yFBSN8Ssuaufy2jYQMgyiJYyW4Fw0WJfDWXQDAly1qsbd1YDznH+sYitaljhf8igPCvmA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.3': resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==} peerDependencies: @@ -6891,27 +6875,6 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-tooltip@1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.2.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.3.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.3.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-use-callback-ref@1.1.3(@types/react@18.3.28)(react@18.3.1)': dependencies: react: 18.3.1