Skip to content
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,11 +14,13 @@ export const renderInfoButton = (state: InfoButtonState): JSXElement => {
assertSlots<InfoButtonSlots>(state);

return (
<state.popover>
<PopoverTrigger>
<state.root />
</PopoverTrigger>
<state.info />
</state.popover>
<InfoLabelOwnershipContext.Provider value={undefined}>
<state.popover>
<PopoverTrigger>
<state.root />
</PopoverTrigger>
<state.info />
</state.popover>
</InfoLabelOwnershipContext.Provider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -67,9 +69,18 @@ export const useInfoButton = (props: InfoButtonProps, ref: React.Ref<HTMLButtonE
});

state.popover.open = popoverOpen;
state.popover.defaultOpen = undefined;
state.root['data-open'] = toDataAttributeValue(popoverOpen);
state.popover.onOpenChange = mergeCallbacks(state.popover.onOpenChange, (_e, data) => 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,56 @@ 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(<Provider>{element}</Provider>);
};

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<ExtractSlotProps<InfoLabelProps['root']>> = (Component, props) => (
<Component {...props} key={key} />
);
const renderButton: SlotRenderFunction<InfoButtonProps> = (Component, props) => (
<Component {...props} key={key} />
);
return (
<InfoLabel
root={{ id: 'ownership', children: target === 'root' ? renderRoot : undefined }}
info="Information"
infoButton={{
children: target === 'infoButton' ? renderButton : undefined,
popover: { defaultOpen: !replacement, onOpenChange: change },
}}
>
Label
</InfoLabel>
);
};
mount(<Example />);
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}');
Expand All @@ -23,6 +66,7 @@ describe('InfoLabel - close on tab-out', () => {
info="Example non-focusable info"
infoButton={{ className: 'info-button', children: 'i' }}
/>
<button data-testid="after">after</button>
</>,
);

Expand All @@ -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', () => {
Expand All @@ -49,6 +94,7 @@ describe('InfoLabel - close on tab-out', () => {
}
infoButton={{ className: 'info-button', children: 'i' }}
/>
<button data-testid="after">after</button>
</>,
);

Expand All @@ -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', () => {
Expand All @@ -83,6 +130,7 @@ describe('InfoLabel - close on tab-out', () => {
}
infoButton={{ className: 'info-button', children: 'i' }}
/>
<button data-testid="after">after</button>
</>,
);

Expand All @@ -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');
});
});

Expand Down
Loading
Loading