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 0000000000000..b85fe4126eea2 --- /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 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 d5f858cfbeb33..9833742e2a2e0 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 d235405604a40..eb5aecab183ef 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. @@ -67,9 +69,18 @@ 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 064f7935c7c20..a3f2bbd9ddacd 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}'); @@ -23,6 +66,7 @@ describe('InfoLabel - close on tab-out', () => { info="Example non-focusable info" infoButton={{ className: 'info-button', children: 'i' }} /> + , ); @@ -33,6 +77,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', () => { @@ -49,6 +94,7 @@ describe('InfoLabel - close on tab-out', () => { } infoButton={{ className: 'info-button', children: 'i' }} /> + , ); @@ -65,6 +111,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', () => { @@ -83,6 +130,7 @@ describe('InfoLabel - close on tab-out', () => { } infoButton={{ className: 'info-button', children: 'i' }} /> + , ); @@ -101,6 +149,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'); }); }); 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 0000000000000..047dcdd4e5a8f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InfoLabel/InfoLabelOwnership.test.tsx @@ -0,0 +1,483 @@ +import * as React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import type { ExtractSlotProps, RefAttributes, SlotRenderFunction } from '@fluentui/react-utilities'; +import { InfoLabel } from './InfoLabel'; +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'; + +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')!; +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 when focus moves inside the button and surface', () => { + 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(); + act(() => buttonRef.current?.focus()); + act(() => surfaceRef.current?.focus()); + 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('reports one merged callback per uncontrolled trigger toggle', async () => { + const change = jest.fn(); + 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(); + 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); + change.mockClear(); + } + }); + + it('keeps controlled state authoritative when opening and closing requests are rejected', async () => { + const change = jest.fn(); + 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(); + 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(); + 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, queryByRole } = render(strict()); + fireEvent.click(trigger(container)); + expect(ownership(container)).toHaveAttribute('aria-owns', 'information-popup'); + change.mockClear(); + 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 => { + 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(); + } + }); + }); + + (['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) => + 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(); + }); + + (['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 0000000000000..ea7f1e7cfc190 --- /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 07fbc9ce36c36..5d5bd99010fc2 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 @@ -12,9 +14,11 @@ 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 9fc942b443cf0..60613f333d72a 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, useEventCallback, 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. @@ -27,7 +29,6 @@ export const useInfoLabel = (props: InfoLabelProps, ref: React.Ref { - setOpen(data.open); - }), + 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 @@ -78,12 +107,15 @@ export const useInfoLabel = (props: InfoLabelProps, ref: React.Ref