Skip to content

feat(design-system): make DsPopover and DsTooltip controllable and composable [AR-75956] - #744

Merged
iromanchuk-dn merged 11 commits into
drivenets:mainfrom
iromanchuk-dn:feat/AR-75956-popover-tooltip-composition
Oct 1, 2026
Merged

iromanchuk-dn merged 11 commits into
drivenets:mainfrom
iromanchuk-dn:feat/AR-75956-popover-tooltip-composition

Conversation

@iromanchuk-dn

@iromanchuk-dn iromanchuk-dn commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes AR-75956

Summary

AR-75956 was filed against 0.13.0. DsPopover's controlled open, hover opening, delays and Header/Content/Footer slots have shipped since. This PR covers what was still missing, plus the hover → click-pin behavior from AR-39567.

  • DsTooltip
    • Adds controlled open / defaultOpen / onOpenChange(open).
    • Forwards ref and asChild-injected props to the trigger, including when content is undefined.
    • Adopts the trigger id when nested in another trigger.
    • Zag's instant-open path (another tooltip already visible) ignores a controlled open, so a controlled open now also drives presence.
  • Shared trigger: DsPopover.Trigger and .Anchor forward ref and injected props, so a tooltip and a popover can share one trigger element.
    • Works in either nesting order; DsPopover.Trigger > DsTooltip > element is recommended.
    • The trigger keeps the popover's data-scope / data-part / data-state, so zag still finds it, and the Anchor is positioned from its registered ref.
  • Close button: new DsPopover.CloseTrigger, an icon-only tertiary DsButtonV3 with locale={{ close }} defaulting to 'Close' and no aria-pressed. It goes in the new DsPopover.Header actions slot, which sits outside the title and so isn't part of the dialog's accessible name.
  • openOn="hover" pins on click
    • Clicking a panel that hover opened keeps it open.
    • A panel opened by click survives the pointer leaving.
    • Either way it closes on Escape, an outside click, the close button, or another trigger click.
    • Every close cancels a pending hover open. Previously, two quick clicks could leave the panel open.
    • Behavior change: openOn="hover" shipped in 0.20.0, where a click on a hover-opened panel closed it and a click-opened panel closed on mouse-out. Both now pin instead.
  • Shared type: DsTreeRowTriggerProps is hoisted into a shared DsAsChildTriggerProps in src/utils. It stays exported as an alias, so the public API is unchanged.
  • Stories: a close button in WithContentItemsAndCTA, a new popover WithTooltip recipe (preview on hover, full panel on click), and a new tooltip Controlled story. Both docs snapshots are updated.

Why forward props and ref

What they want: one element on screen, such as a workflow name, that shows a small tooltip on hover and opens a detailed popover on click.

How DS wrappers work: DsTooltip and DsPopover.Trigger don't render an element of their own. They pass their behavior onto their child: hover and click handlers, aria-* attributes, an id, and a ref so they can find and position against the element.

Why it breaks when you combine them:

  • Nesting. Take <DsTooltip><DsPopover.Trigger><button/></DsPopover.Trigger></DsTooltip>. The tooltip hands its handlers to its child, but that child is DsPopover.Trigger, which only read children and className and dropped everything else. The tooltip never reaches the button, so hovering does nothing. Swap the order and the popover is the one that never opens. That's what "nesting drops the inner wrapper's handlers" means.
  • Siblings. The workaround is to give each wrapper its own element, for example a <span> for the tooltip with the button inside it for the popover. Now there are two elements instead of one. That means two separate hover areas, the tooltip and panel positioned against different boxes, and extra markup. The bulk-actions menu does exactly this. That's "sibling composition creates two DOM anchors".

The fix they asked for: make each wrapper pass through whatever it receives, including the ref (in React 19, ref is an ordinary prop, so "forwardRef" just means passing it through). Then both behaviors land on the same element.

One snag beyond the ask: both wrappers also want to put their own id on that element, and an element can only have one. Whichever wrapper loses can no longer find the element, so its panel isn't positioned and its outside-click check misfires. That's why this PR also makes DsTooltip adopt the id it's given, and makes the popover keep its own markers so it can still find the trigger.

Test plan

  • pnpm eslint on the changed folders
  • pnpm --filter @drivenets/design-system typecheck
  • Browser tests for ds-popover, ds-tooltip and ds-tree, plus tests/exports.unit.test.ts: 182/182 passing
    • Hover pinning: click keeps a hover-opened panel open; a click-opened panel survives mouse-out; a pin doesn't carry over to the next session; a quick click pair stays closed.
    • Tooltip + popover in both nesting orders: positioning, a second click closes, defaultOpen, Escape returns focus.
    • Controlled tooltip.
    • ref forwarding on DsTooltip, DsPopover.Trigger and .Anchor.
    • CloseTrigger and actions.
  • Docs snippet tests (pnpm test:storybook-docs): popover and tooltip goldens updated, diffs reviewed.
  • figma:lint passes.

Notes

  • Timing flakiness: in local runs under load, the new timer-based hover tests sometimes failed at random, on a different test each time. The last full-block runs were 8/8 clean. Please flag it if CI shows flakes.
  • Figma: the Code Connect mapping for the popover header is unchanged. The close button's spacing still needs checking against the Figma header node (37891-11066).
  • Found during research, not in this PR:
    • A Panel id combined with open-on-mount can break Escape and outside-click dismissal in the first session.
    • DsTree rows wrapped in DsPopover.Trigger lose the tree node id.
    • DsDropdownMenu.Item wrapped in DsTooltip loses its menu item id.
    • The workarounds in ds-bulk-actions (extra span) and DsSavedFilters (hand-built close button) can be removed.

…mposable [AR-75956]

- DsTooltip: controlled open / defaultOpen / onOpenChange; forward ref and asChild-injected props; share the trigger id with an outer trigger.
- DsPopover.Trigger / .Anchor: forward ref and injected props so a tooltip and a popover can share one trigger in either nesting order.
- Add DsPopover.CloseTrigger and a DsPopover.Header actions slot.
- openOn="hover": a click pins the panel until Escape, outside click, close button or another trigger click; any close cancels a pending hover open.
- Hoist DsTreeRowTriggerProps into a shared DsAsChildTriggerProps type (aliased, public API unchanged).
@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for drivenets-design-system ready!

Name Link
🔨 Latest commit 94c0b34
🔍 Latest deploy log https://app.netlify.com/projects/drivenets-design-system/deploys/6abcfe132ebf220008329d08
😎 Deploy Preview https://deploy-preview-744--drivenets-design-system.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

…ver-tooltip-composition

# Conflicts:
#	packages/design-system/src/components/ds-popover/ds-popover.hover-intent.ts
#	packages/design-system/src/components/ds-popover/ds-popover.tsx
Comment thread .changeset/popover-tooltip-composition.md Outdated
…isabled [AR-75956]

Zag's disabled prop also drops pointer leave, so a tooltip disabled under the pointer kept its opened-by-pointer flag and ignored the next hover. Treat disabled as a controlled close so the machine keeps tracking the pointer.
… zag [AR-75956]

zag 1.41.2 (allowed by the ^1.42 / ark ^5.37.2 ranges) matches data-ownedby exactly and does not merge it, so the fallback lookup cannot find a trigger whose id an outer DsTooltip replaced. Resolve ids.trigger lazily from an id the trigger registers in a layout effect, which runs before the Root machine's effects.
…mposition' into feat/AR-75956-popover-tooltip-composition
Comment thread packages/design-system/src/components/ds-tooltip/ds-tooltip.tsx Outdated
…sed pointer leave [AR-75956]

Forcing a controlled open={false} while disabled could leave the machine stuck in closing after an instant open, holding the shared visible-tooltip id. Pass zag's disabled through again and, once re-enabled, replay a pointer leave that happened while disabled so the next hover still opens.
…mposition' into feat/AR-75956-popover-tooltip-composition
@iromanchuk-dn
iromanchuk-dn merged commit 328dee3 into drivenets:main Oct 1, 2026
27 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants