diff --git a/change/@fluentui-react-headless-components-preview-b16870e3-e158-4967-bf40-1c8256986797.json b/change/@fluentui-react-headless-components-preview-b16870e3-e158-4967-bf40-1c8256986797.json new file mode 100644 index 00000000000000..914d6835e16616 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-b16870e3-e158-4967-bf40-1c8256986797.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "usePositioning: share anchor-name across positioned popovers so a Tooltip and Menu can attach to one trigger", + "packageName": "@fluentui/react-headless-components-preview", + "email": "mgodbolt+microsoft@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index e048c4c6421b0a..cadfd8afc62d67 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx @@ -34,6 +34,47 @@ describe('usePositioning', () => { expect(node.style.getPropertyValue('anchor-name')).toMatch(/^--popover-anchor-/); }); + it('appends to anchor-name so multiple instances can share one trigger', () => { + // Two popovers (e.g. a Tooltip and a Menu) attached to the same trigger. + const first = mountHook(); + const second = mountHook(); + const node = document.createElement('div'); + + act(() => { + first.current.targetRef(node); + second.current.targetRef(node); + }); + + const names = node.style + .getPropertyValue('anchor-name') + .split(',') + .map(name => name.trim()) + .filter(Boolean); + + // Both instances contribute their own anchor name; neither clobbers the other. + expect(names).toHaveLength(2); + expect(names[0]).not.toBe(names[1]); + names.forEach(name => expect(name).toMatch(/^--popover-anchor-/)); + }); + + it('preserves a pre-existing author-set anchor-name', () => { + const result = mountHook(); + const node = document.createElement('div'); + node.style.setProperty('anchor-name', '--app-anchor'); + + act(() => { + result.current.targetRef(node); + }); + + const names = node.style + .getPropertyValue('anchor-name') + .split(',') + .map(name => name.trim()); + + expect(names).toContain('--app-anchor'); + expect(names.some(name => /^--popover-anchor-/.test(name))).toBe(true); + }); + it('containerRef writes position-anchor and position-area matching the props', () => { const result = mountHook({ position: 'below', align: 'start' }); const node = document.createElement('div'); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts index 53de720c0bbb58..5b029e2f3473c7 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts @@ -72,9 +72,31 @@ export function usePositioning(options: PositioningProps): PositioningReturn { if (!effectiveTarget) { return; } - effectiveTarget.style.setProperty('anchor-name', anchorName); + + // `anchor-name` is a comma-separated list. Append this instance's name + // instead of overwriting so that multiple positioned popovers can share a + // single trigger (e.g. a Tooltip on hover and a Menu on click attached to + // the same button) without clobbering each other's anchor. On cleanup we + // remove only our own name, preserving any others still in use. + const readAnchorNames = () => + effectiveTarget.style + .getPropertyValue('anchor-name') + .split(',') + .map(name => name.trim()) + .filter(Boolean); + + const names = readAnchorNames(); + if (!names.includes(anchorName)) { + effectiveTarget.style.setProperty('anchor-name', [...names, anchorName].join(', ')); + } + return () => { - effectiveTarget.style.removeProperty('anchor-name'); + const remaining = readAnchorNames().filter(name => name !== anchorName); + if (remaining.length > 0) { + effectiveTarget.style.setProperty('anchor-name', remaining.join(', ')); + } else { + effectiveTarget.style.removeProperty('anchor-name'); + } }; }, [effectiveTarget, anchorName]);