diff --git a/change/@fluentui-react-infobutton-e6d4cef7-a090-4513-ac13-c1754d642d63.json b/change/@fluentui-react-infobutton-e6d4cef7-a090-4513-ac13-c1754d642d63.json new file mode 100644 index 00000000000000..4a6f683a2620d4 --- /dev/null +++ b/change/@fluentui-react-infobutton-e6d4cef7-a090-4513-ac13-c1754d642d63.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: Popover should close when tabbing out of the surface.", + "packageName": "@fluentui/react-infobutton", + "email": "esteban.230@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-infobutton/cypress.config.ts b/packages/react-components/react-infobutton/cypress.config.ts new file mode 100644 index 00000000000000..ca52cf041bbf2c --- /dev/null +++ b/packages/react-components/react-infobutton/cypress.config.ts @@ -0,0 +1,3 @@ +import { baseConfig } from '@fluentui/scripts-cypress'; + +export default baseConfig; diff --git a/packages/react-components/react-infobutton/package.json b/packages/react-components/react-infobutton/package.json index 8881653f0dfd08..47c0058cc63ac2 100644 --- a/packages/react-components/react-infobutton/package.json +++ b/packages/react-components/react-infobutton/package.json @@ -23,6 +23,8 @@ "type-check": "tsc -b tsconfig.json", "storybook": "start-storybook", "start": "yarn storybook", + "e2e": "cypress run --component", + "e2e:local": "cypress open --component", "test-ssr": "test-ssr \"./stories/**/*.stories.tsx\"" }, "devDependencies": { @@ -36,6 +38,7 @@ "@fluentui/react-icons": "^2.0.207", "@fluentui/react-label": "^9.1.33", "@fluentui/react-popover": "^9.8.3", + "@fluentui/react-portal": "^9.3.13", "@fluentui/react-tabster": "^9.12.8", "@fluentui/react-theme": "^9.1.12", "@fluentui/react-utilities": "^9.13.3", diff --git a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx index 8388065c67bafe..26839e39f0cada 100644 --- a/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx +++ b/packages/react-components/react-infobutton/src/components/InfoButton/useInfoButton.tsx @@ -1,6 +1,14 @@ import * as React from 'react'; import { DefaultInfoButtonIcon12, DefaultInfoButtonIcon16, DefaultInfoButtonIcon20 } from './DefaultInfoButtonIcons'; -import { getNativeElementProps, mergeCallbacks, useControllableState, slot } from '@fluentui/react-utilities'; +import { + getNativeElementProps, + mergeCallbacks, + useControllableState, + slot, + useMergedRefs, + isHTMLElement, +} from '@fluentui/react-utilities'; +import { elementContains } from '@fluentui/react-portal'; import { Popover, PopoverSurface } from '@fluentui/react-popover'; import type { InfoButtonProps, InfoButtonState } from './InfoButton.types'; import type { PopoverProps } from '@fluentui/react-popover'; @@ -76,5 +84,24 @@ export const useInfoButton_unstable = (props: InfoButtonProps, ref: React.Ref setPopoverOpen(data.open)); + const focusOutRef = React.useCallback( + (el: HTMLDivElement) => { + if (!el) { + return; + } + + el.addEventListener('focusout', e => { + const nextFocused = e.relatedTarget; + + if (isHTMLElement(nextFocused) && !elementContains(el, nextFocused)) { + setPopoverOpen(false); + } + }); + }, + [setPopoverOpen], + ); + + state.info.ref = useMergedRefs(state.info.ref, focusOutRef); + return state; }; diff --git a/packages/react-components/react-infobutton/src/components/InfoLabel/InfoLabel.cy.tsx b/packages/react-components/react-infobutton/src/components/InfoLabel/InfoLabel.cy.tsx new file mode 100644 index 00000000000000..f251f54f816da6 --- /dev/null +++ b/packages/react-components/react-infobutton/src/components/InfoLabel/InfoLabel.cy.tsx @@ -0,0 +1,82 @@ +/// + +import * as React from 'react'; +import { mount as mountBase } from '@cypress/react'; +import { FluentProvider } from '@fluentui/react-provider'; +import { teamsLightTheme } from '@fluentui/react-theme'; +import { InfoLabel } from '@fluentui/react-infobutton'; + +const mount = (element: JSX.Element) => { + mountBase({element}); +}; + +const surfaceSelector = '[role="note"]'; + +describe('InfoLabel - close on tab-out', () => { + const openInfoButton = () => { + cy.get('button').focus().realPress('{enter}'); + }; + + it('no focusable elements', () => { + mount(); + + openInfoButton(); + cy.realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); + openInfoButton(); + cy.realPress('Tab').get(surfaceSelector).should('not.exist'); + }); + + it('single focusable element', () => { + mount( + + Example non-focusable info + + + } + />, + ); + + openInfoButton(); + cy.realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); + openInfoButton(); + // moving into the focusable item + cy.realPress('Tab').get(surfaceSelector).should('exist'); + // tabbing out with shift + tab from the first focusable item should close the surface since + // the surface is only focusable programmatically + cy.realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); + openInfoButton(); + cy.realPress('Tab').realPress('Tab').get(surfaceSelector).should('not.exist'); + }); + + it('one or more focusable elements', () => { + mount( + + Example non-focusable info + + + + + } + />, + ); + + openInfoButton(); + cy.realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); + openInfoButton(); + // moving into the focusable item + cy.realPress('Tab').get(surfaceSelector).should('exist'); + // tabbing out with shift + tab from the first focusable item should close the surface since + // the surface is only focusable programmatically + cy.realPress(['Shift', 'Tab']).get(surfaceSelector).should('not.exist'); + openInfoButton(); + // 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'); + }); +}); diff --git a/packages/react-components/react-infobutton/tsconfig.cy.json b/packages/react-components/react-infobutton/tsconfig.cy.json new file mode 100644 index 00000000000000..93a140885851da --- /dev/null +++ b/packages/react-components/react-infobutton/tsconfig.cy.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "types": ["node", "cypress", "cypress-storybook/cypress", "cypress-real-events"], + "lib": ["ES2019", "dom"] + }, + "include": ["**/*.cy.ts", "**/*.cy.tsx"] +} diff --git a/packages/react-components/react-infobutton/tsconfig.json b/packages/react-components/react-infobutton/tsconfig.json index 1941a041d46c19..1317f81620ca5e 100644 --- a/packages/react-components/react-infobutton/tsconfig.json +++ b/packages/react-components/react-infobutton/tsconfig.json @@ -20,6 +20,9 @@ }, { "path": "./.storybook/tsconfig.json" + }, + { + "path": "./tsconfig.cy.json" } ] }