Skip to content

feat: support multiple triggers#2929

Open
segunadebayo wants to merge 23 commits intomainfrom
feat/multi-trigger
Open

feat: support multiple triggers#2929
segunadebayo wants to merge 23 commits intomainfrom
feat/multi-trigger

Conversation

@segunadebayo
Copy link
Member

Add support for rendering multiple triggers

📝 Description

Add a brief description

⛳️ Current behavior (updates)

Please describe the current behavior that you are modifying

🚀 New behavior

Please describe the behavior or changes this PR adds

💣 Is this a breaking change (Yes/No):

📝 Additional Information

@changeset-bot
Copy link

changeset-bot bot commented Jan 24, 2026

🦋 Changeset detected

Latest commit: 7557998

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 83 packages
Name Type
@zag-js/dialog Minor
@zag-js/hover-card Minor
@zag-js/menu Minor
@zag-js/popover Minor
@zag-js/tooltip Minor
@zag-js/anatomy-icons Minor
@zag-js/anatomy Minor
@zag-js/core Minor
@zag-js/docs Minor
@zag-js/preact Minor
@zag-js/react Minor
@zag-js/solid Minor
@zag-js/svelte Minor
@zag-js/vanilla Minor
@zag-js/vue Minor
@zag-js/accordion Minor
@zag-js/angle-slider Minor
@zag-js/async-list Minor
@zag-js/avatar Minor
@zag-js/bottom-sheet Minor
@zag-js/carousel Minor
@zag-js/checkbox Minor
@zag-js/clipboard Minor
@zag-js/collapsible Minor
@zag-js/color-picker Minor
@zag-js/combobox Minor
@zag-js/date-picker Minor
@zag-js/editable Minor
@zag-js/file-upload Minor
@zag-js/floating-panel Minor
@zag-js/image-cropper Minor
@zag-js/listbox Minor
@zag-js/marquee Minor
@zag-js/navigation-menu Minor
@zag-js/number-input Minor
@zag-js/pagination Minor
@zag-js/password-input Minor
@zag-js/pin-input Minor
@zag-js/presence Minor
@zag-js/progress Minor
@zag-js/qr-code Minor
@zag-js/radio-group Minor
@zag-js/rating-group Minor
@zag-js/scroll-area Minor
@zag-js/select Minor
@zag-js/signature-pad Minor
@zag-js/slider Minor
@zag-js/splitter Minor
@zag-js/steps Minor
@zag-js/switch Minor
@zag-js/tabs Minor
@zag-js/tags-input Minor
@zag-js/timer Minor
@zag-js/toast Minor
@zag-js/toggle-group Minor
@zag-js/toggle Minor
@zag-js/tour Minor
@zag-js/tree-view Minor
@zag-js/store Minor
@zag-js/types Minor
@zag-js/aria-hidden Minor
@zag-js/auto-resize Minor
@zag-js/collection Minor
@zag-js/color-utils Minor
@zag-js/utils Minor
@zag-js/date-utils Minor
@zag-js/dismissable Minor
@zag-js/dom-query Minor
@zag-js/file-utils Minor
@zag-js/focus-trap Minor
@zag-js/focus-visible Minor
@zag-js/highlight-word Minor
@zag-js/hotkeys Minor
@zag-js/i18n-utils Minor
@zag-js/interact-outside Minor
@zag-js/json-tree-utils Minor
@zag-js/live-region Minor
@zag-js/popper Minor
@zag-js/rect-utils Minor
@zag-js/remove-scroll Minor
@zag-js/scroll-snap Minor
@zag-js/stringify-state Minor
svelte-kit-starter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link

vercel bot commented Jan 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
zag-nextjs Ready Ready Preview Feb 9, 2026 8:24pm
zag-solid Ready Ready Preview Feb 9, 2026 8:24pm
zag-svelte Ready Ready Preview Feb 9, 2026 8:24pm
zag-vue Ready Ready Preview Feb 9, 2026 8:24pm
zag-website Ready Ready Preview Feb 9, 2026 8:24pm

Request Review

@segunadebayo segunadebayo marked this pull request as ready for review January 25, 2026 10:43
@claude
Copy link

claude bot commented Feb 6, 2026

Code Review

I've reviewed PR #2929 and found 7 issues that need to be addressed:


1. ❌ Missing Vue/Nuxt Examples

Severity: High - CLAUDE.md Violation

This PR adds multiple-trigger examples for React (7 examples), Solid (5 examples), and Svelte (5 examples), but completely omits Vue/Nuxt examples.

According to the Framework Integration Guide:

"When adding a new component or feature, you need to create examples in all framework directories."

Missing Vue/Nuxt examples needed:

  • examples/nuxt-ts/app/pages/dialog-multiple-trigger.vue
  • examples/nuxt-ts/app/pages/hover-card-multiple-trigger.vue
  • examples/nuxt-ts/app/pages/menu-multiple-trigger.vue
  • examples/nuxt-ts/app/pages/popover-multiple-trigger.vue
  • examples/nuxt-ts/app/pages/tooltip-multiple-trigger.vue
  • examples/nuxt-ts/app/pages/context-menu-multiple-trigger.vue

Zag.js is framework-agnostic and should maintain feature parity across all supported frameworks.


2. ❌ Inconsistent Event Naming: Dialog vs Other Components

Severity: Medium - CLAUDE.md Violation
File: packages/machines/dialog/src/dialog.machine.ts

Dialog uses ACTIVE_TRIGGER.SET while Menu, Hover Card, and Popover all use TRIGGER_VALUE.SET for the same conceptual event.

See:

"ACTIVE_TRIGGER.SET": {
actions: ["setActiveTrigger"],
},
},

The State Machine Guide establishes that event names should match their context property names. Since all components use triggerValue as the context property, they should all use TRIGGER_VALUE.SET.

Recommendation: Change Dialog's event name from ACTIVE_TRIGGER.SET to TRIGGER_VALUE.SET for consistency.


3. ❌ Incorrect Event Naming in Tooltip

Severity: Medium - CLAUDE.md Violation
File: packages/machines/tooltip/src/tooltip.machine.ts

Tooltip uses "triggerValue.set" (camelCase) instead of "TRIGGER_VALUE.SET" (SCREAMING_SNAKE_CASE).

See:

actions: ["reposition"],
},
"triggerValue.set": {
// Transition to closing (which cleans up trackPositioning) then immediately back to open
// This re-creates the positioning effect with the new trigger

The State Machine Guide requires SCREAMING_SNAKE_CASE for event names:

Use SCREAMING_SNAKE_CASE for event names:

  • OPEN, CLOSE, TOGGLE
  • POINTER_DOWN, POINTER_MOVE, POINTER_UP
  • ACTIVE_SNAP_POINT.SET

Recommendation: Change "triggerValue.set" to "TRIGGER_VALUE.SET" throughout the tooltip machine.


4. ❌ Inconsistent Action Naming: Dialog vs Other Components

Severity: Low - CLAUDE.md Violation
File: packages/machines/dialog/src/dialog.machine.ts

Dialog uses setActiveTrigger while Menu, Hover Card, Popover, and Tooltip all use setTriggerValue.

Both actions have identical implementations:

// Dialog
setActiveTrigger({ context, event }) {
  context.set("triggerValue", event.value ?? null)
}

// Other components
setTriggerValue({ context, event }) {
  context.set("triggerValue", event.value ?? null)
}

Recommendation: Rename Dialog's action from setActiveTrigger to setTriggerValue for consistency across components.


5. 🐛 Dialog Single-Trigger Toggle Broken

Severity: Critical - Breaking Bug
File: packages/machines/dialog/src/dialog.connect.ts

When a dialog is used with a single trigger (no value prop), clicking the trigger while the dialog is open does not close it.

See:

if (event.defaultPrevented) return
const shouldSwitch = open && !current
send({ type: shouldSwitch ? "ACTIVE_TRIGGER.SET" : "TOGGLE", value })
},

The Problem:

const current = value == null ? false : triggerValue === value
const shouldSwitch = open && !current  // ❌ Missing value != null guard
send({ type: shouldSwitch ? "ACTIVE_TRIGGER.SET" : "TOGGLE", value })

In single-trigger mode, value is undefined, so current = false. When the dialog is open, shouldSwitch = true && !false = true, causing ACTIVE_TRIGGER.SET to be sent instead of TOGGLE, so the dialog stays open.

The Fix:
Popover and Menu correctly include a value != null guard:

const shouldSwitch = open && value != null && !current  // ✅ Correct

Recommendation: Add value != null to the shouldSwitch condition in dialog.connect.ts.


6. 🐛 Tooltip Single-Trigger Interactions Cause Close-Reopen Flicker

Severity: Critical - Breaking Bug
File: packages/machines/tooltip/src/tooltip.connect.ts

When a tooltip is used with a single trigger, interactions (click/focus/hover) cause a close-then-reopen flicker instead of normal behavior.

Affected handlers:

The Problem:

const shouldSwitch = open && !current  // ❌ Missing value != null guard

In single-trigger mode, current is always false, so when the tooltip is open:

  • onClick sends triggerValue.set instead of close
  • onFocus sends triggerValue.set instead of open
  • onPointerMove sends triggerValue.set instead of pointer.move

In the open state, triggerValue.set transitions to closing then immediately queues a reopen event, causing a flicker.

Recommendation: Add value != null to the shouldSwitch condition in all three handlers.


7. 🐛 Tooltip Controlled triggerValue Won't Reposition

Severity: Medium - Bug
File: packages/machines/tooltip/src/tooltip.machine.ts

When using controlled triggerValue prop, changing the trigger value externally will not reposition the tooltip.

The watch handler (line 66-68):

watch(prop("triggerValue"), "setTriggerValue", ["reposition"])

The reposition action (line 308-309):

reposition({ event }) {
  if (event.type !== "positioning.set") return  // ❌ Early return when called from watch
  // ... repositioning logic never runs
}

When invoked from a watch handler, event.type is never "positioning.set", so the reposition logic is unreachable.

The PR already has a repositionImmediate action (line 322) purpose-built for trigger switching without this guard.

Recommendation: Change the watch handler to use repositionImmediate instead of reposition:

watch(prop("triggerValue"), "setTriggerValue", ["repositionImmediate"])

Summary

  • 1 CLAUDE.md violation (missing Vue examples) - must be fixed
  • 3 CLAUDE.md violations (naming inconsistencies) - should be fixed for consistency
  • 3 critical bugs (single-trigger mode broken, controlled mode broken) - must be fixed

The bugs (#5, #6, #7) are particularly critical as they break backward compatibility for existing single-trigger usage and controlled component patterns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant