From 2372e2d5c9e9449abab77ea267e329e3d1650354 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Tue, 20 Oct 2026 12:03:37 +0200 Subject: [PATCH 1/6] fix(react-headless-components-preview): synchronize InfoLabel ownership Track ownership from controlled open state or the current optional button lifetime. Ignore stale lifetime callbacks, consume defaultOpen once, and delegate dismissal to the shared Popover engine. Integrated focus dismissal validation remains blocked on PR #36929 reaching the base. Custom render-function slot key forwarding remains unverified. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d006ca2-c036-45bf-bac0-93275c5dfe08 --- ...-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json | 6 + .../InfoLabel/InfoButton/useInfoButton.tsx | 17 +- .../src/components/InfoLabel/InfoLabel.cy.tsx | 25 ++ .../InfoLabel/InfoLabelOwnership.test.tsx | 298 ++++++++++++++++++ .../src/components/InfoLabel/useInfoLabel.ts | 30 +- .../src/InfoLabel/InfoLabelDescription.md | 2 + 6 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx diff --git a/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json b/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json new file mode 100644 index 00000000000000..00ef92fb7a8236 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: synchronize InfoLabel ownership with the mounted InfoButton open state and delegate blur dismissal to Popover", + "packageName": "@fluentui/react-headless-components-preview", + "email": "paulmardling@microsoft.com" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx index d235405604a402..a65b44d1a9d25f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx @@ -7,8 +7,6 @@ import { useControllableState, slot, useMergedRefs, - elementContains, - useEventCallback, } from '@fluentui/react-utilities'; import type { InfoButtonProps, InfoButtonState } from './InfoButton.types'; @@ -67,6 +65,7 @@ export const useInfoButton = (props: InfoButtonProps, ref: React.Ref setPopoverOpen(data.open)); @@ -82,19 +81,5 @@ export const useInfoButton = (props: InfoButtonProps, ref: React.Ref = e => { - const nextFocused = e.relatedTarget; - - if (!nextFocused || (rootRef.current !== nextFocused && !elementContains(infoRef.current, nextFocused))) { - setPopoverOpen(false); - } - }; - - // eslint-disable-next-line react-hooks/refs - state.root.onBlur = useEventCallback(mergeCallbacks(state.root.onBlur, onBlurButtonOrInfo)); - const onInfoBlurCapture = state.info.onBlurCapture as React.FocusEventHandler | undefined; - // eslint-disable-next-line react-hooks/refs - state.info.onBlurCapture = useEventCallback(mergeCallbacks(onInfoBlurCapture, onBlurButtonOrInfo)); return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx index 064f7935c7c202..fee090b80d9091 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx @@ -14,6 +14,31 @@ describe('InfoLabel - close on tab-out', () => { return cy.get('button.info-button').focus().realPress('{enter}'); }; + it('preserves destination focus and clears ownership through exactly one close notification', () => { + mount( + <> + Inside} + infoButton={{ + className: 'info-button', + popover: { onOpenChange: cy.spy().as('change') }, + }} + /> + + , + ); + openInfoButton().get(surfaceSelector).should('exist'); + cy.get('#ownership').should('have.attr', 'aria-owns'); + cy.get('@change').invoke('resetHistory'); + cy.get('[data-testid=inside]').focus().realPress('Tab'); + cy.get(surfaceSelector).should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); + cy.get('@change').should('have.been.calledOnce'); + cy.get('#ownership').should('not.have.attr', 'aria-owns'); + }); + it('no focusable elements', () => { mount( <> diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx new file mode 100644 index 00000000000000..f50a732c02f00a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx @@ -0,0 +1,298 @@ +import * as React from 'react'; +import { act, fireEvent, render, renderHook, waitFor } from '@testing-library/react'; +import type { RefAttributes } from '@fluentui/react-utilities'; +import { InfoLabel } from './InfoLabel'; +import { useInfoLabel } from './useInfoLabel'; +import type { InfoLabelProps } from './InfoLabel.types'; +import type { InfoButtonProps } from './InfoButton/InfoButton.types'; +import type { PopoverProps } from '../Popover/Popover.types'; + +type ExampleProps = Omit & { + automaticButton?: boolean; + infoButton?: + | (Omit, 'popover'> & + RefAttributes & { popover?: Partial }) + | null; +}; + +const Example = ({ infoButton, automaticButton = false, ...props }: ExampleProps) => ( + <> + + Label + + + +); + +const strict = (element: React.ReactElement) => {element}; +const ownership = (container: HTMLElement) => container.querySelector('#ownership')!; +const trigger = (container: HTMLElement) => container.querySelector('#info-trigger')!; + +describe('InfoLabel ownership lifecycle', () => { + it('retains initial focus, consumer refs and blur handlers without closing on an unknown destination', () => { + const buttonRef = React.createRef(); + const surfaceRef = React.createRef(); + const buttonBlur = jest.fn(); + const surfaceBlur = jest.fn(); + const change = jest.fn(); + const { container, getByRole } = render( + strict( + <> + + + Other label + + , + ), + ); + expect(buttonRef.current).toBe(trigger(container)); + expect(surfaceRef.current).toBe(getByRole('note', { hidden: true })); + expect(surfaceRef.current).toHaveFocus(); + fireEvent.blur(surfaceRef.current!, { relatedTarget: null }); + fireEvent.blur(buttonRef.current!, { relatedTarget: null }); + expect(buttonBlur).toHaveBeenCalledTimes(1); + expect(surfaceBlur).toHaveBeenCalledTimes(1); + expect(change).not.toHaveBeenCalled(); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(container.querySelector('#second')).not.toHaveAttribute('aria-owns'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + }); + + it('does not emit callbacks for controlled parent changes or rejected opening requests', () => { + const change = jest.fn(); + const element = (open: boolean) => strict(); + const { container, rerender, queryByRole } = render(element(false)); + fireEvent.click(trigger(container)); + expect(change).toHaveBeenCalledTimes(1); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + change.mockClear(); + rerender(element(true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + rerender(element(false)); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(change).not.toHaveBeenCalled(); + }); + + it('ignores a removed lifetime callback after reinserting the same key', () => { + const oldChange = jest.fn(); + const newChange = jest.fn(); + const initialProps: InfoLabelProps = { + info: 'Information', + infoButton: { key: 'same', popover: { onOpenChange: oldChange } }, + }; + const { result, rerender } = renderHook((props: InfoLabelProps) => useInfoLabel(props, null), { initialProps }); + const oldRequest = result.current.infoButton?.popover; + const event = new MouseEvent('click'); + if (!oldRequest || typeof oldRequest !== 'object' || !('onOpenChange' in oldRequest)) { + throw new Error('Expected a resolved popover slot'); + } + const oldCallback = oldRequest.onOpenChange; + rerender({ infoButton: null }); + rerender({ + info: 'Information', + infoButton: { key: 'same', popover: { defaultOpen: true, onOpenChange: newChange } }, + }); + expect(result.current.root['aria-owns']).toBeDefined(); + act(() => oldCallback?.(event, { type: 'click', event, open: false })); + expect(result.current.root['aria-owns']).toBeDefined(); + expect(oldChange).toHaveBeenCalledTimes(1); + expect(newChange).not.toHaveBeenCalled(); + }); + + it('closes through one merged callback after destination focus is established', async () => { + const change = jest.fn(); + const buttonBlur = jest.fn(); + const infoBlur = jest.fn(); + const { container, getByTestId, getByRole, queryByRole } = render( + strict( + , + ), + ); + for (let cycle = 0; cycle < 2; cycle++) { + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + buttonBlur.mockClear(); + infoBlur.mockClear(); + act(() => getByRole('note', { hidden: true }).focus()); + act(() => trigger(container).focus()); + change.mockImplementation((_event, data) => { + if (!data.open) { + expect(getByTestId('outside')).toHaveFocus(); + } + }); + act(() => getByTestId('outside').focus()); + await waitFor(() => expect(queryByRole('note', { hidden: true })).toBeNull()); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(change).toHaveBeenCalledTimes(1); + expect(infoBlur).toHaveBeenCalledTimes(1); + expect(buttonBlur).toHaveBeenCalledTimes(1); + } + }); + + it('keeps controlled state authoritative when opening and closing requests are rejected', async () => { + const change = jest.fn(); + const { container, getByTestId, getByRole, rerender } = render( + strict(), + ); + fireEvent.click(trigger(container)); + expect(change).toHaveBeenCalledTimes(1); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + change.mockClear(); + rerender(strict()); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(change).not.toHaveBeenCalled(); + act(() => getByRole('note', { hidden: true }).focus()); + act(() => getByTestId('outside').focus()); + await waitFor(() => expect(change).toHaveBeenCalledTimes(1)); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + rerender(strict()); + await waitFor(() => expect(ownership(container)).not.toHaveAttribute('aria-owns')); + expect(change).not.toHaveBeenCalled(); + }); + + it('updates controlled ownership only after the parent accepts a real request', async () => { + const change = jest.fn(); + const Controlled = () => { + const [open, setOpen] = React.useState(false); + return ( + { + change(event, data); + setOpen(data.open); + }, + }, + }} + /> + ); + }; + const { container, getByTestId, queryByRole } = render(strict()); + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + act(() => getByTestId('outside').focus()); + await waitFor(() => expect(queryByRole('note', { hidden: true })).toBeNull()); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(change).toHaveBeenCalledTimes(1); + }); + + (['explicit-null', 'automatic'] as const).forEach(removal => { + it(`resets ownership across two ${removal} removal/reinsertion cycles`, () => { + const change = jest.fn(); + const present = + removal === 'automatic' ? ( + + ) : ( + + ); + const absent = + removal === 'explicit-null' ? : ; + const { container, queryByRole, rerender } = render(strict(present)); + for (let cycle = 0; cycle < 2; cycle++) { + fireEvent.click(ownership(container).querySelector('button')!); + expect(ownership(container)).toHaveAttribute('aria-owns'); + change.mockClear(); + rerender(strict(absent)); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(ownership(container).querySelector('button')).toBeNull(); + expect(queryByRole('note', { hidden: true })).toBeNull(); + rerender(strict(present)); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + expect(change).not.toHaveBeenCalled(); + } + }); + }); + + ( + [ + { from: 1, to: '1', remount: false }, + { from: null, to: 'null', remount: false }, + { from: undefined, to: null, remount: true }, + { from: 'first', to: 'second', remount: true }, + ] as const + ).forEach(({ from, to, remount }) => { + it(`matches React key normalization for ${String(from)} to ${String(to)}`, () => { + const change = jest.fn(); + const snapshots: Array = []; + const rootRef = React.createRef(); + const element = (key: React.Key | null | undefined) => + strict( + snapshots.push(rootRef.current?.getAttribute('aria-owns') ?? null)} + > + + , + ); + const { container, queryByRole, rerender } = render(element(from)); + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + snapshots.length = 0; + rerender(element(to)); + if (remount) { + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + expect(snapshots.every(value => value === null)).toBe(true); + } else { + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(queryByRole('note', { hidden: true })).not.toBeNull(); + } + expect(change).not.toHaveBeenCalled(); + }); + }); + + it('initializes ownership from defaultOpen only for the actual slot lifetime', () => { + const change = jest.fn(); + const element = (key: string, defaultOpen: boolean) => + strict(); + const { container, getByRole, queryByRole, rerender } = render(element('first', true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + rerender(element('first', false)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + rerender(element('second', false)); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + rerender(element('third', true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + expect(change).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts index 9fc942b443cf06..390cbef26f201f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useId, slot, useEventCallback, mergeCallbacks } from '@fluentui/react-utilities'; +import { useId, slot, mergeCallbacks } from '@fluentui/react-utilities'; import { Label } from '../Label'; import { InfoButton } from './InfoButton/InfoButton'; @@ -27,7 +27,6 @@ export const useInfoLabel = (props: InfoLabelProps, ref: React.Ref { - setOpen(data.open); - }), - ); + const present = !!infoButton; + const normalizedKey = present && infoButton.key !== undefined ? String(infoButton.key) : undefined; + const defaultOpen = infoButtonPopover.defaultOpen ?? false; + const createLifecycle = () => ({ + present, + key: normalizedKey, + generation: {}, + open: present && defaultOpen, + }); + const [storedLifecycle, setLifecycle] = React.useState(createLifecycle); + const identityChanged = storedLifecycle.present !== present || storedLifecycle.key !== normalizedKey; + const lifecycle = identityChanged ? createLifecycle() : storedLifecycle; + if (identityChanged) { + // Ownership belongs to the mounted slot, not a removed or differently keyed button. + setLifecycle(lifecycle); + } + const controlledOpen = infoButtonPopover.open; + const open = controlledOpen !== undefined ? controlledOpen : lifecycle.open; + + infoButtonPopover.onOpenChange = mergeCallbacks(infoButtonPopover.onOpenChange, (_, data) => { + setLifecycle(current => (current.generation === lifecycle.generation ? { ...current, open: data.open } : current)); + }); if (infoButton) { const infoPopupId = baseId + '__info'; // used as a self-referencing aria-labelledby to name the popup diff --git a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md index 22c887f401b935..b4393b83bd593f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md @@ -1,3 +1,5 @@ An `InfoLabel` is a `Label` with an information button (`InfoButton`) next to it that opens a popover containing additional details about the labeled item. Use the `info` prop to provide the content shown in the popover. To customize the underlying button or popover, use the `infoButton` slot. + +Control the popover with `infoButton.popover.open`, or use `defaultOpen` to initialize an uncontrolled button. The wrapper's `aria-owns` follows the actual open state, including when a controlled parent rejects an open or close request. Changing `open` in the parent does not call `onOpenChange`. Removing the information button or changing its React key starts a new uncontrolled lifetime; `defaultOpen` is read again for that new button. From 38dd0d6ebfff32e5431970c2608db2333d9ece8d Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Tue, 20 Oct 2026 13:40:24 +0200 Subject: [PATCH 2/6] fix(react-headless-components-preview): honor InfoLabel slot key boundaries Forward root and InfoButton keys through the slot runtime so custom render functions cannot bypass the keyed boundary. Reset ownership for root-driven remounts and cover normalized keys, defaults, stale callbacks, and commit-time ownership. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d006ca2-c036-45bf-bac0-93275c5dfe08 --- .../InfoLabel/InfoLabelOwnership.test.tsx | 159 ++++++++++++++---- .../components/InfoLabel/renderInfoLabel.tsx | 4 +- .../src/components/InfoLabel/useInfoLabel.ts | 9 +- .../src/InfoLabel/InfoLabelDescription.md | 2 +- 4 files changed, 132 insertions(+), 42 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx index f50a732c02f00a..1941056e696895 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { act, fireEvent, render, renderHook, waitFor } from '@testing-library/react'; -import type { RefAttributes } from '@fluentui/react-utilities'; +import type { ExtractSlotProps, RefAttributes, SlotRenderFunction } from '@fluentui/react-utilities'; import { InfoLabel } from './InfoLabel'; import { useInfoLabel } from './useInfoLabel'; import type { InfoLabelProps } from './InfoLabel.types'; @@ -42,6 +42,14 @@ const Example = ({ infoButton, automaticButton = false, ...props }: ExampleProps const strict = (element: React.ReactElement) => {element}; const ownership = (container: HTMLElement) => container.querySelector('#ownership')!; const trigger = (container: HTMLElement) => container.querySelector('#info-trigger')!; +const renderRootWithoutKey: SlotRenderFunction> = ( + Component, + { key: _key, ...props }, +) => ; +const renderButtonWithoutKey: SlotRenderFunction> = ( + Component, + { key: _key, ...props }, +) => ; describe('InfoLabel ownership lifecycle', () => { it('retains initial focus, consumer refs and blur handlers without closing on an unknown destination', () => { @@ -122,6 +130,34 @@ describe('InfoLabel ownership lifecycle', () => { expect(newChange).not.toHaveBeenCalled(); }); + it('ignores a previous root lifetime callback after the root key is reused', () => { + const oldChange = jest.fn(); + const newChange = jest.fn(); + const initialProps: InfoLabelProps = { + root: { key: 'first' }, + info: 'Information', + infoButton: { popover: { onOpenChange: oldChange } }, + }; + const { result, rerender } = renderHook((props: InfoLabelProps) => useInfoLabel(props, null), { initialProps }); + const oldRequest = result.current.infoButton?.popover; + const event = new MouseEvent('click'); + if (!oldRequest || typeof oldRequest !== 'object' || !('onOpenChange' in oldRequest)) { + throw new Error('Expected a resolved popover slot'); + } + const oldCallback = oldRequest.onOpenChange; + rerender({ root: { key: 'second' }, info: 'Information' }); + rerender({ + root: { key: 'first' }, + info: 'Information', + infoButton: { popover: { defaultOpen: true, onOpenChange: newChange } }, + }); + expect(result.current.root['aria-owns']).toBeDefined(); + act(() => oldCallback?.(event, { type: 'click', event, open: false })); + expect(result.current.root['aria-owns']).toBeDefined(); + expect(oldChange).toHaveBeenCalledTimes(1); + expect(newChange).not.toHaveBeenCalled(); + }); + it('closes through one merged callback after destination focus is established', async () => { const change = jest.fn(); const buttonBlur = jest.fn(); @@ -238,45 +274,94 @@ describe('InfoLabel ownership lifecycle', () => { }); }); - ( - [ - { from: 1, to: '1', remount: false }, - { from: null, to: 'null', remount: false }, - { from: undefined, to: null, remount: true }, - { from: 'first', to: 'second', remount: true }, - ] as const - ).forEach(({ from, to, remount }) => { - it(`matches React key normalization for ${String(from)} to ${String(to)}`, () => { - const change = jest.fn(); - const snapshots: Array = []; - const rootRef = React.createRef(); - const element = (key: React.Key | null | undefined) => - strict( - snapshots.push(rootRef.current?.getAttribute('aria-owns') ?? null)} - > - - , - ); - const { container, queryByRole, rerender } = render(element(from)); - fireEvent.click(trigger(container)); - expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); - change.mockClear(); - snapshots.length = 0; - rerender(element(to)); - if (remount) { - expect(ownership(container)).not.toHaveAttribute('aria-owns'); - expect(queryByRole('note', { hidden: true })).toBeNull(); - expect(snapshots.every(value => value === null)).toBe(true); - } else { - expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); - expect(queryByRole('note', { hidden: true })).not.toBeNull(); - } - expect(change).not.toHaveBeenCalled(); + (['root', 'infoButton'] as const).forEach(target => { + (['default', 'render-function'] as const).forEach(renderer => { + ( + [ + { from: 1, to: '1', remount: false }, + { from: null, to: 'null', remount: false }, + { from: undefined, to: null, remount: true }, + { from: 'first', to: 'second', remount: true }, + ] as const + ).forEach(({ from, to, remount }) => { + it(`matches ${target} React key normalization for ${String(from)} to ${String( + to, + )} with ${renderer} rendering`, () => { + const change = jest.fn(); + const snapshots: Array = []; + const rootRef = React.createRef(); + const element = (key: React.Key | null | undefined) => + strict( + snapshots.push(rootRef.current?.getAttribute('aria-owns') ?? null)} + > + + , + ); + const { container, queryByRole, rerender } = render(element(from)); + const originalButton = trigger(container); + fireEvent.click(originalButton); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + snapshots.length = 0; + rerender(element(to)); + if (remount) { + expect(trigger(container)).not.toBe(originalButton); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + expect(snapshots.every(value => value === null)).toBe(true); + } else { + expect(trigger(container)).toBe(originalButton); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(queryByRole('note', { hidden: true })).not.toBeNull(); + } + expect(change).not.toHaveBeenCalled(); + }); + }); }); }); + it('initializes defaultOpen again after a custom root render function remounts the button', () => { + const change = jest.fn(); + const element = (key: string, defaultOpen: boolean) => + strict( + , + ); + const { container, getByRole, queryByRole, rerender } = render(element('first', true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + rerender(element('first', false)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + rerender(element('second', false)); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + rerender(element('third', true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + expect(change).not.toHaveBeenCalled(); + }); + it('initializes ownership from defaultOpen only for the actual slot lifetime', () => { const change = jest.fn(); const element = (key: string, defaultOpen: boolean) => diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx index 07fbc9ce36c36b..47aea8f556aebb 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx @@ -12,9 +12,9 @@ export const renderInfoLabel = (state: InfoLabelState): JSXElement => { assertSlots(state); return ( - + - {state.infoButton && } + {state.infoButton && } ); }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts index 390cbef26f201f..e1c0afa3a585e9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts @@ -59,19 +59,24 @@ export const useInfoLabel = (props: InfoLabelProps, ref: React.Ref ({ present, + rootKey: normalizedRootKey, key: normalizedKey, generation: {}, open: present && defaultOpen, }); const [storedLifecycle, setLifecycle] = React.useState(createLifecycle); - const identityChanged = storedLifecycle.present !== present || storedLifecycle.key !== normalizedKey; + const identityChanged = + storedLifecycle.present !== present || + storedLifecycle.rootKey !== normalizedRootKey || + storedLifecycle.key !== normalizedKey; const lifecycle = identityChanged ? createLifecycle() : storedLifecycle; if (identityChanged) { - // Ownership belongs to the mounted slot, not a removed or differently keyed button. + // Either keyed slot boundary can replace the mounted button. setLifecycle(lifecycle); } const controlledOpen = infoButtonPopover.open; diff --git a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md index b4393b83bd593f..51f771653ddb14 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md @@ -2,4 +2,4 @@ An `InfoLabel` is a `Label` with an information button (`InfoButton`) next to it Use the `info` prop to provide the content shown in the popover. To customize the underlying button or popover, use the `infoButton` slot. -Control the popover with `infoButton.popover.open`, or use `defaultOpen` to initialize an uncontrolled button. The wrapper's `aria-owns` follows the actual open state, including when a controlled parent rejects an open or close request. Changing `open` in the parent does not call `onOpenChange`. Removing the information button or changing its React key starts a new uncontrolled lifetime; `defaultOpen` is read again for that new button. +Control the popover with `infoButton.popover.open`, or use `defaultOpen` to initialize an uncontrolled button. The wrapper's `aria-owns` follows the actual open state, including when a controlled parent rejects an open or close request. Changing `open` in the parent does not call `onOpenChange`. Removing the information button or changing the React key of the `root` or `infoButton` slot starts a new uncontrolled button lifetime; `defaultOpen` is read again for that new button. Slot keys control these lifetimes even when a custom slot render function does not forward the key to its returned element. From d820f409fdf2251caa090b66224160551e99ec40 Mon Sep 17 00:00:00 2001 From: Paul Mardling Date: Tue, 20 Oct 2026 15:20:45 +0200 Subject: [PATCH 3/6] test(react-headless-components-preview): define InfoLabel tab destinations Add explicit outside controls and assert destination focus in InfoLabel Tab dismissal tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../library/src/components/InfoLabel/InfoLabel.cy.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx index fee090b80d9091..eb862a692d4ad1 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx @@ -48,6 +48,7 @@ describe('InfoLabel - close on tab-out', () => { info="Example non-focusable info" infoButton={{ className: 'info-button', children: 'i' }} /> + , ); @@ -58,6 +59,7 @@ describe('InfoLabel - close on tab-out', () => { cy.realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); openInfoButton(); cy.realPress('Tab').get(surfaceSelector).should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); }); it('single focusable element', () => { @@ -74,6 +76,7 @@ describe('InfoLabel - close on tab-out', () => { } infoButton={{ className: 'info-button', children: 'i' }} /> + , ); @@ -90,6 +93,7 @@ describe('InfoLabel - close on tab-out', () => { cy.realPress(['Shift', 'Tab']).realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); openInfoButton(); cy.realPress('Tab').realPress('Tab').get(surfaceSelector).should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); }); it('one or more focusable elements', () => { @@ -108,6 +112,7 @@ describe('InfoLabel - close on tab-out', () => { } infoButton={{ className: 'info-button', children: 'i' }} /> + , ); @@ -126,6 +131,7 @@ describe('InfoLabel - close on tab-out', () => { // checking that event does not propagate to children cy.realPress('Tab').realPress('Tab').realPress(['Shift', 'Tab']).get(surfaceSelector).should('exist'); cy.realPress('Tab').realPress('Tab').realPress('Tab').get(surfaceSelector).should('not.exist'); + cy.get('[data-testid=after]').should('have.focus'); }); }); From a6bcd28929cbf6ac899fae2eee87addc449a6f46 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Tue, 20 Oct 2026 15:29:02 +0200 Subject: [PATCH 4/6] fix(react-headless-components-preview): retain existing InfoButton focus dismissal Defer shared focus-dismissal integration and preserve the existing blur behavior. Keep controlled/default/keyed ownership fixes, and exercise their accepted and rejected requests through trigger toggles without excluding tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d006ca2-c036-45bf-bac0-93275c5dfe08 --- ...-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json | 2 +- .../InfoLabel/InfoButton/useInfoButton.tsx | 16 ++++++ .../src/components/InfoLabel/InfoLabel.cy.tsx | 25 --------- .../InfoLabel/InfoLabelOwnership.test.tsx | 52 +++++++------------ .../src/InfoLabel/InfoLabelDescription.md | 2 +- 5 files changed, 37 insertions(+), 60 deletions(-) diff --git a/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json b/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json index 00ef92fb7a8236..5b09eeb0509aa0 100644 --- a/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json +++ b/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: synchronize InfoLabel ownership with the mounted InfoButton open state and delegate blur dismissal to Popover", + "comment": "fix: synchronize InfoLabel ownership for controlled open state, defaults, and keyed button lifetimes", "packageName": "@fluentui/react-headless-components-preview", "email": "paulmardling@microsoft.com" } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx index a65b44d1a9d25f..29bcb6de917800 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx @@ -7,6 +7,8 @@ import { useControllableState, slot, useMergedRefs, + elementContains, + useEventCallback, } from '@fluentui/react-utilities'; import type { InfoButtonProps, InfoButtonState } from './InfoButton.types'; @@ -81,5 +83,19 @@ export const useInfoButton = (props: InfoButtonProps, ref: React.Ref = e => { + const nextFocused = e.relatedTarget; + + if (!nextFocused || (rootRef.current !== nextFocused && !elementContains(infoRef.current, nextFocused))) { + setPopoverOpen(false); + } + }; + + // eslint-disable-next-line react-hooks/refs + state.root.onBlur = useEventCallback(mergeCallbacks(state.root.onBlur, onBlurButtonOrInfo)); + const onInfoBlurCapture = state.info.onBlurCapture as React.FocusEventHandler | undefined; + // eslint-disable-next-line react-hooks/refs + state.info.onBlurCapture = useEventCallback(mergeCallbacks(onInfoBlurCapture, onBlurButtonOrInfo)); return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx index fee090b80d9091..064f7935c7c202 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx @@ -14,31 +14,6 @@ describe('InfoLabel - close on tab-out', () => { return cy.get('button.info-button').focus().realPress('{enter}'); }; - it('preserves destination focus and clears ownership through exactly one close notification', () => { - mount( - <> - Inside} - infoButton={{ - className: 'info-button', - popover: { onOpenChange: cy.spy().as('change') }, - }} - /> - - , - ); - openInfoButton().get(surfaceSelector).should('exist'); - cy.get('#ownership').should('have.attr', 'aria-owns'); - cy.get('@change').invoke('resetHistory'); - cy.get('[data-testid=inside]').focus().realPress('Tab'); - cy.get(surfaceSelector).should('not.exist'); - cy.get('[data-testid=after]').should('have.focus'); - cy.get('@change').should('have.been.calledOnce'); - cy.get('#ownership').should('not.have.attr', 'aria-owns'); - }); - it('no focusable elements', () => { mount( <> diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx index 1941056e696895..0974941b2b051f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx @@ -52,7 +52,7 @@ const renderButtonWithoutKey: SlotRenderFunction ; describe('InfoLabel ownership lifecycle', () => { - it('retains initial focus, consumer refs and blur handlers without closing on an unknown destination', () => { + it('retains initial focus, consumer refs and blur handlers when focus moves inside the button and surface', () => { const buttonRef = React.createRef(); const surfaceRef = React.createRef(); const buttonBlur = jest.fn(); @@ -78,8 +78,8 @@ describe('InfoLabel ownership lifecycle', () => { expect(buttonRef.current).toBe(trigger(container)); expect(surfaceRef.current).toBe(getByRole('note', { hidden: true })); expect(surfaceRef.current).toHaveFocus(); - fireEvent.blur(surfaceRef.current!, { relatedTarget: null }); - fireEvent.blur(buttonRef.current!, { relatedTarget: null }); + act(() => buttonRef.current?.focus()); + act(() => surfaceRef.current?.focus()); expect(buttonBlur).toHaveBeenCalledTimes(1); expect(surfaceBlur).toHaveBeenCalledTimes(1); expect(change).not.toHaveBeenCalled(); @@ -158,58 +158,43 @@ describe('InfoLabel ownership lifecycle', () => { expect(newChange).not.toHaveBeenCalled(); }); - it('closes through one merged callback after destination focus is established', async () => { + it('reports one merged callback per uncontrolled trigger toggle', async () => { const change = jest.fn(); - const buttonBlur = jest.fn(); - const infoBlur = jest.fn(); - const { container, getByTestId, getByRole, queryByRole } = render( - strict( - , - ), + const { container, getByRole, queryByRole } = render( + strict(), ); for (let cycle = 0; cycle < 2; cycle++) { fireEvent.click(trigger(container)); expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toHaveFocus(); + expect(change).toHaveBeenCalledTimes(1); + expect(change.mock.calls[0][1].open).toBe(true); change.mockClear(); - buttonBlur.mockClear(); - infoBlur.mockClear(); - act(() => getByRole('note', { hidden: true }).focus()); - act(() => trigger(container).focus()); - change.mockImplementation((_event, data) => { - if (!data.open) { - expect(getByTestId('outside')).toHaveFocus(); - } - }); - act(() => getByTestId('outside').focus()); + fireEvent.click(trigger(container)); await waitFor(() => expect(queryByRole('note', { hidden: true })).toBeNull()); expect(ownership(container)).not.toHaveAttribute('aria-owns'); expect(change).toHaveBeenCalledTimes(1); - expect(infoBlur).toHaveBeenCalledTimes(1); - expect(buttonBlur).toHaveBeenCalledTimes(1); + expect(change.mock.calls[0][1].open).toBe(false); + change.mockClear(); } }); it('keeps controlled state authoritative when opening and closing requests are rejected', async () => { const change = jest.fn(); - const { container, getByTestId, getByRole, rerender } = render( + const { container, getByRole, rerender } = render( strict(), ); fireEvent.click(trigger(container)); expect(change).toHaveBeenCalledTimes(1); + expect(change.mock.calls[0][1].open).toBe(true); expect(ownership(container)).not.toHaveAttribute('aria-owns'); change.mockClear(); rerender(strict()); expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); expect(change).not.toHaveBeenCalled(); - act(() => getByRole('note', { hidden: true }).focus()); - act(() => getByTestId('outside').focus()); + fireEvent.click(trigger(container)); await waitFor(() => expect(change).toHaveBeenCalledTimes(1)); + expect(change.mock.calls[0][1].open).toBe(false); expect(getByRole('note', { hidden: true })).toBeInTheDocument(); expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); change.mockClear(); @@ -236,14 +221,15 @@ describe('InfoLabel ownership lifecycle', () => { /> ); }; - const { container, getByTestId, queryByRole } = render(strict()); + const { container, queryByRole } = render(strict()); fireEvent.click(trigger(container)); expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); change.mockClear(); - act(() => getByTestId('outside').focus()); + fireEvent.click(trigger(container)); await waitFor(() => expect(queryByRole('note', { hidden: true })).toBeNull()); expect(ownership(container)).not.toHaveAttribute('aria-owns'); expect(change).toHaveBeenCalledTimes(1); + expect(change.mock.calls[0][1].open).toBe(false); }); (['explicit-null', 'automatic'] as const).forEach(removal => { diff --git a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md index 51f771653ddb14..cb22d2865f43ca 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/InfoLabelDescription.md @@ -2,4 +2,4 @@ An `InfoLabel` is a `Label` with an information button (`InfoButton`) next to it Use the `info` prop to provide the content shown in the popover. To customize the underlying button or popover, use the `infoButton` slot. -Control the popover with `infoButton.popover.open`, or use `defaultOpen` to initialize an uncontrolled button. The wrapper's `aria-owns` follows the actual open state, including when a controlled parent rejects an open or close request. Changing `open` in the parent does not call `onOpenChange`. Removing the information button or changing the React key of the `root` or `infoButton` slot starts a new uncontrolled button lifetime; `defaultOpen` is read again for that new button. Slot keys control these lifetimes even when a custom slot render function does not forward the key to its returned element. +Control the popover with `infoButton.popover.open`, or use `defaultOpen` to initialize an uncontrolled button. Controlled `open` values are authoritative for the wrapper's `aria-owns`, including when a parent rejects a trigger-generated open or close request. Changing `open` in the parent does not call `onOpenChange`. Removing the information button or changing the React key of the `root` or `infoButton` slot starts a new uncontrolled button lifetime; `defaultOpen` is read again for that new button. Slot keys control these lifetimes even when a custom slot render function does not forward the key to its returned element. From cabe151f181b6730b3fc265a346d20ee7068e715 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Tue, 20 Oct 2026 15:47:09 +0200 Subject: [PATCH 5/6] test(react-headless-components-preview): use compatible hook testing import Use the repository's react-hooks alias so ownership lifecycle tests run with React 17's Testing Library as well as React 18 and 19. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d006ca2-c036-45bf-bac0-93275c5dfe08 --- .../src/components/InfoLabel/InfoLabelOwnership.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx index 0974941b2b051f..dbb9941f502968 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { act, fireEvent, render, renderHook, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; import type { ExtractSlotProps, RefAttributes, SlotRenderFunction } from '@fluentui/react-utilities'; import { InfoLabel } from './InfoLabel'; import { useInfoLabel } from './useInfoLabel'; From 78a76c1a1cddd0f4c95328cf0d604f5face54ec6 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Tue, 20 Oct 2026 16:42:14 +0200 Subject: [PATCH 6/6] fix(react-headless-components-preview): report actual InfoButton ownership lifetime Replace the callback-driven open mirror with private mounted-instance reports. Reconcile ownership during layout for renderer-owned keys, renderer-mode changes, omitted buttons, and stale callbacks, while isolating nested buttons and preserving SSR bootstrap and consumer ownership. Keep legacy blur behavior, public API signatures, and shared JSX/Popover implementations unchanged. Add before/after regression coverage and real-browser returned-key replacement checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d006ca2-c036-45bf-bac0-93275c5dfe08 --- ...-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json | 2 +- .../InfoLabel/InfoButton/renderInfoButton.tsx | 15 +- .../InfoLabel/InfoButton/useInfoButton.tsx | 10 + .../src/components/InfoLabel/InfoLabel.cy.tsx | 43 ++++ .../InfoLabel/InfoLabelOwnership.test.tsx | 225 +++++++++++++----- .../components/InfoLabel/infoLabelContext.ts | 19 ++ .../components/InfoLabel/renderInfoLabel.tsx | 6 +- .../src/components/InfoLabel/useInfoLabel.ts | 73 +++--- .../src/InfoLabel/InfoLabelDescription.md | 4 +- 9 files changed, 302 insertions(+), 95 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/infoLabelContext.ts diff --git a/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json b/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json index 5b09eeb0509aa0..b85fe4126eea26 100644 --- a/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json +++ b/change/@fluentui-react-headless-components-preview-ec70b6e0-2f0e-4dd9-89ac-7bcb3ea04ba8.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "fix: synchronize InfoLabel ownership for controlled open state, defaults, and keyed button lifetimes", + "comment": "fix: synchronize InfoLabel ownership with the mounted InfoButton open state across controlled updates, defaults, and custom-rendered replacements", "packageName": "@fluentui/react-headless-components-preview", "email": "paulmardling@microsoft.com" } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/renderInfoButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/renderInfoButton.tsx index d5f858cfbeb33e..9833742e2a2e0e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/renderInfoButton.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/renderInfoButton.tsx @@ -5,6 +5,7 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { PopoverTrigger } from '../../Popover'; import type { InfoButtonState, InfoButtonSlots } from './InfoButton.types'; +import { InfoLabelOwnershipContext } from '../infoLabelContext'; /** * Render the final JSX of InfoButton @@ -13,11 +14,13 @@ export const renderInfoButton = (state: InfoButtonState): JSXElement => { assertSlots(state); return ( - - - - - - + + + + + + + + ); }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx index 29bcb6de917800..eb5aecab183ef2 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoButton/useInfoButton.tsx @@ -9,12 +9,14 @@ import { useMergedRefs, elementContains, useEventCallback, + useIsomorphicLayoutEffect, } from '@fluentui/react-utilities'; import type { InfoButtonProps, InfoButtonState } from './InfoButton.types'; import { Popover, PopoverSurface } from '../../Popover'; import type { PopoverProps } from '../../Popover'; import { toDataAttributeValue } from '../../../utils'; +import { InfoLabelOwnershipContext } from '../infoLabelContext'; /** * Create the state required to render InfoButton. @@ -71,6 +73,14 @@ export const useInfoButton = (props: InfoButtonProps, ref: React.Ref setPopoverOpen(data.open)); + const reportOwnership = React.useContext(InfoLabelOwnershipContext); + const [lifetime] = React.useState(() => ({})); + const surfaceId = state.popover.id; + useIsomorphicLayoutEffect(() => { + reportOwnership?.(lifetime, { open: popoverOpen, surfaceId }); + }, [reportOwnership, lifetime, popoverOpen, surfaceId]); + useIsomorphicLayoutEffect(() => () => reportOwnership?.(lifetime, null), [reportOwnership, lifetime]); + const infoRef = useMergedRefs(state.info.ref); state.info.ref = infoRef; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx index c2cd76bfa6aca9..a3f2bbd9ddacd4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabel.cy.tsx @@ -2,6 +2,9 @@ import * as React from 'react'; import { mount as mountBase } from '@fluentui/scripts-cypress'; import { InfoLabel } from './InfoLabel'; import { Provider } from '../Provider'; +import type { ExtractSlotProps, SlotRenderFunction } from '@fluentui/react-utilities'; +import type { InfoLabelProps } from './InfoLabel.types'; +import type { InfoButtonProps } from './InfoButton/InfoButton.types'; const mount = (element: React.ReactElement) => { mountBase({element}); @@ -9,6 +12,46 @@ const mount = (element: React.ReactElement) => { const surfaceSelector = '[role="note"]'; +describe('InfoLabel - rendered ownership lifetime', () => { + (['root', 'infoButton'] as const).forEach(target => { + it(`clears ownership after a ${target} renderer replaces its returned element`, () => { + let replace: () => void; + const change = cy.spy().as('change'); + const Example = () => { + const [replacement, setReplacement] = React.useState(false); + replace = () => setReplacement(true); + const key = replacement ? 'second' : 'first'; + const renderRoot: SlotRenderFunction> = (Component, props) => ( + + ); + const renderButton: SlotRenderFunction = (Component, props) => ( + + ); + return ( + + Label + + ); + }; + mount(); + cy.get(surfaceSelector).should('exist'); + cy.get('#ownership').should('have.attr', 'aria-owns'); + // Replace without moving focus outside first, which would dismiss the old popup. + cy.then(() => replace()); + cy.get(surfaceSelector).should('not.exist'); + cy.get('#ownership').should('not.have.attr', 'aria-owns'); + cy.get('@change').should('not.have.been.called'); + }); + }); +}); + describe('InfoLabel - close on tab-out', () => { const openInfoButton = () => { return cy.get('button.info-button').focus().realPress('{enter}'); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx index dbb9941f502968..047dcdd4e5a8ff 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderToString } from 'react-dom/server'; import type { ExtractSlotProps, RefAttributes, SlotRenderFunction } from '@fluentui/react-utilities'; import { InfoLabel } from './InfoLabel'; -import { useInfoLabel } from './useInfoLabel'; +import { InfoButton } from './InfoButton/InfoButton'; import type { InfoLabelProps } from './InfoLabel.types'; import type { InfoButtonProps } from './InfoButton/InfoButton.types'; import type { PopoverProps } from '../Popover/Popover.types'; @@ -105,60 +105,6 @@ describe('InfoLabel ownership lifecycle', () => { expect(change).not.toHaveBeenCalled(); }); - it('ignores a removed lifetime callback after reinserting the same key', () => { - const oldChange = jest.fn(); - const newChange = jest.fn(); - const initialProps: InfoLabelProps = { - info: 'Information', - infoButton: { key: 'same', popover: { onOpenChange: oldChange } }, - }; - const { result, rerender } = renderHook((props: InfoLabelProps) => useInfoLabel(props, null), { initialProps }); - const oldRequest = result.current.infoButton?.popover; - const event = new MouseEvent('click'); - if (!oldRequest || typeof oldRequest !== 'object' || !('onOpenChange' in oldRequest)) { - throw new Error('Expected a resolved popover slot'); - } - const oldCallback = oldRequest.onOpenChange; - rerender({ infoButton: null }); - rerender({ - info: 'Information', - infoButton: { key: 'same', popover: { defaultOpen: true, onOpenChange: newChange } }, - }); - expect(result.current.root['aria-owns']).toBeDefined(); - act(() => oldCallback?.(event, { type: 'click', event, open: false })); - expect(result.current.root['aria-owns']).toBeDefined(); - expect(oldChange).toHaveBeenCalledTimes(1); - expect(newChange).not.toHaveBeenCalled(); - }); - - it('ignores a previous root lifetime callback after the root key is reused', () => { - const oldChange = jest.fn(); - const newChange = jest.fn(); - const initialProps: InfoLabelProps = { - root: { key: 'first' }, - info: 'Information', - infoButton: { popover: { onOpenChange: oldChange } }, - }; - const { result, rerender } = renderHook((props: InfoLabelProps) => useInfoLabel(props, null), { initialProps }); - const oldRequest = result.current.infoButton?.popover; - const event = new MouseEvent('click'); - if (!oldRequest || typeof oldRequest !== 'object' || !('onOpenChange' in oldRequest)) { - throw new Error('Expected a resolved popover slot'); - } - const oldCallback = oldRequest.onOpenChange; - rerender({ root: { key: 'second' }, info: 'Information' }); - rerender({ - root: { key: 'first' }, - info: 'Information', - infoButton: { popover: { defaultOpen: true, onOpenChange: newChange } }, - }); - expect(result.current.root['aria-owns']).toBeDefined(); - act(() => oldCallback?.(event, { type: 'click', event, open: false })); - expect(result.current.root['aria-owns']).toBeDefined(); - expect(oldChange).toHaveBeenCalledTimes(1); - expect(newChange).not.toHaveBeenCalled(); - }); - it('reports one merged callback per uncontrolled trigger toggle', async () => { const change = jest.fn(); const { container, getByRole, queryByRole } = render( @@ -367,4 +313,171 @@ describe('InfoLabel ownership lifecycle', () => { expect(getByRole('note', { hidden: true })).toBeInTheDocument(); expect(change).not.toHaveBeenCalled(); }); + + (['root', 'infoButton'] as const).forEach(target => { + it(`follows actual ${target} renderer-owned keys without resetting for new inline render functions`, () => { + const change = jest.fn(); + const element = (returnedKey: string, defaultOpen: boolean) => { + const renderRoot: typeof renderRootWithoutKey = (Component, props) => ( + + ); + const renderButton: typeof renderButtonWithoutKey = (Component, props) => ( + + ); + return strict( + , + ); + }; + const { container, getByRole, queryByRole, rerender } = render(element('first', true)); + const firstButton = trigger(container); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + rerender(element('first', false)); + expect(trigger(container)).toBe(firstButton); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + rerender(element('second', false)); + expect(trigger(container)).not.toBe(firstButton); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + rerender(element('third', true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + expect(change).not.toHaveBeenCalled(); + }); + + it(`resets ownership for same-key ${target} default/custom renderer transitions in both directions`, () => { + const change = jest.fn(); + const element = (custom: boolean, defaultOpen = false) => + strict( + , + ); + const { container, getByRole, queryByRole, rerender } = render(element(false)); + for (const custom of [true, false, true, false]) { + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + const previousButton = trigger(container); + rerender(element(custom)); + expect(trigger(container)).not.toBe(previousButton); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + expect(queryByRole('note', { hidden: true })).toBeNull(); + expect(change).not.toHaveBeenCalled(); + } + rerender(element(true, true)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + expect(change).not.toHaveBeenCalled(); + }); + }); + + it.each(['root', 'infoButton'] as const)( + 'ignores callbacks retained from an actual rendered %s replacement', + target => { + const change = jest.fn(); + let oldCallback: PopoverProps['onOpenChange']; + const element = (key: string) => { + const renderButton: typeof renderButtonWithoutKey = (Component, props) => { + const popover = props.popover; + if (key === 'first' && popover && typeof popover === 'object' && 'onOpenChange' in popover) { + oldCallback = popover.onOpenChange; + } + return ; + }; + const renderRoot: typeof renderRootWithoutKey = (Component, props) => ; + return strict( + , + ); + }; + const { container, getByRole, rerender } = render(element('first')); + rerender(element('second')); + const event = new MouseEvent('click'); + act(() => oldCallback?.(event, { type: 'click', event, open: false })); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + expect(getByRole('note', { hidden: true })).toBeInTheDocument(); + expect(change).toHaveBeenCalledTimes(1); + }, + ); + + it('preserves consumer ownership while actual button state changes', () => { + const { container } = render(strict()); + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'consumer-popup'); + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'consumer-popup'); + }); + + it('reports mounted state separately for multiple InfoLabels', () => { + const { container, getByRole } = render( + strict( + <> + + First label + + + Second label + + , + ), + ); + const firstOwner = container.querySelector('#first-owner')!; + const secondOwner = container.querySelector('#second-owner')!; + expect(firstOwner).not.toHaveAttribute('aria-owns'); + expect(secondOwner).not.toHaveAttribute('aria-owns'); + fireEvent.click(getByRole('button', { name: 'First label information' })); + expect(firstOwner).toHaveAttribute('aria-owns'); + expect(secondOwner).not.toHaveAttribute('aria-owns'); + }); + + it('does not let a nested InfoButton replace the owning button report', () => { + const element = (nestedId: string) => + strict( + } + infoButton={{ popover: { defaultOpen: true } }} + />, + ); + const { container, rerender } = render(element('first-nested-popup')); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + rerender(element('second-nested-popup')); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + }); + + it('does not keep projected default ownership when a custom renderer omits the button', () => { + const { container, queryByRole } = render( + strict( null, popover: { defaultOpen: true } }} />), + ); + expect(queryByRole('button', { name: /information/ })).toBeNull(); + expect(ownership(container)).not.toHaveAttribute('aria-owns'); + }); + + it.each(['controlled', 'default'] as const)('projects initial %s ownership in server-rendered markup', mode => { + const markup = renderToString( + , + ); + expect(markup).toContain('aria-owns="information-popup"'); + }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/infoLabelContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/infoLabelContext.ts new file mode 100644 index 00000000000000..ea7f1e7cfc190b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/infoLabelContext.ts @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { InfoLabelState } from './InfoLabel.types'; + +export type InfoButtonOwnership = { + open: boolean; + surfaceId: string | undefined; +}; + +export type InfoLabelOwnershipReporter = (lifetime: object, ownership: InfoButtonOwnership | null) => void; + +export const InfoLabelOwnershipContext = React.createContext(undefined); + +export const infoLabelOwnershipReporter = Symbol('infoLabelOwnershipReporter'); + +export type InternalInfoLabelState = InfoLabelState & { + [infoLabelOwnershipReporter]?: InfoLabelOwnershipReporter; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx index 47aea8f556aebb..5d5bd99010fc25 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/renderInfoLabel.tsx @@ -4,6 +4,8 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { InfoLabelSlots, InfoLabelState } from './InfoLabel.types'; +import { InfoLabelOwnershipContext, infoLabelOwnershipReporter } from './infoLabelContext'; +import type { InternalInfoLabelState } from './infoLabelContext'; /** * Render the final JSX of InfoLabel @@ -14,7 +16,9 @@ export const renderInfoLabel = (state: InfoLabelState): JSXElement => { return ( - {state.infoButton && } + + {state.infoButton && } + ); }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts index e1c0afa3a585e9..60613f333d72a4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/useInfoLabel.ts @@ -1,11 +1,13 @@ 'use client'; import * as React from 'react'; -import { useId, slot, mergeCallbacks } from '@fluentui/react-utilities'; +import { useId, slot, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { Label } from '../Label'; import { InfoButton } from './InfoButton/InfoButton'; import type { InfoLabelProps, InfoLabelState } from './InfoLabel.types'; +import { infoLabelOwnershipReporter } from './infoLabelContext'; +import type { InfoButtonOwnership, InfoLabelOwnershipReporter, InternalInfoLabelState } from './infoLabelContext'; /** * Create the state required to render InfoLabel. @@ -58,33 +60,39 @@ export const useInfoLabel = (props: InfoLabelProps, ref: React.Ref ({ - present, - rootKey: normalizedRootKey, - key: normalizedKey, - generation: {}, - open: present && defaultOpen, - }); - const [storedLifecycle, setLifecycle] = React.useState(createLifecycle); - const identityChanged = - storedLifecycle.present !== present || - storedLifecycle.rootKey !== normalizedRootKey || - storedLifecycle.key !== normalizedKey; - const lifecycle = identityChanged ? createLifecycle() : storedLifecycle; - if (identityChanged) { - // Either keyed slot boundary can replace the mounted button. - setLifecycle(lifecycle); - } - const controlledOpen = infoButtonPopover.open; - const open = controlledOpen !== undefined ? controlledOpen : lifecycle.open; - - infoButtonPopover.onOpenChange = mergeCallbacks(infoButtonPopover.onOpenChange, (_, data) => { - setLifecycle(current => (current.generation === lifecycle.generation ? { ...current, open: data.open } : current)); - }); + const rootKey = root.key === undefined ? undefined : String(root.key); + const buttonKey = infoButton?.key === undefined ? undefined : String(infoButton.key); + const [reportedOwnership, setOwnership] = React.useState< + (InfoButtonOwnership & { lifetime: object; rootKey: string | undefined; buttonKey: string | undefined }) | null + >(); + const ownership = + reportedOwnership && (reportedOwnership.rootKey !== rootKey || reportedOwnership.buttonKey !== buttonKey) + ? undefined + : reportedOwnership; + const reportOwnership: InfoLabelOwnershipReporter = React.useCallback( + (lifetime, next) => { + setOwnership(current => { + if (!next) { + return current?.lifetime === lifetime ? null : current; + } + if ( + current?.lifetime === lifetime && + current.open === next.open && + current.surfaceId === next.surfaceId && + current.rootKey === rootKey && + current.buttonKey === buttonKey + ) { + return current; + } + return { lifetime, rootKey, buttonKey, ...next }; + }); + }, + [rootKey, buttonKey], + ); + useIsomorphicLayoutEffect(() => { + // A custom renderer can omit the button entirely, so it never sends a report. + setOwnership(current => (current === undefined ? null : current)); + }, []); if (infoButton) { const infoPopupId = baseId + '__info'; // used as a self-referencing aria-labelledby to name the popup @@ -99,12 +107,15 @@ export const useInfoLabel = (props: InfoLabelProps, ref: React.Ref