diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 231a3cc9c1a15..80e138e0db6ae 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -102,8 +102,12 @@ jobs: # only run e2e tests when the appropriate storybook is published by scoping to relevant packages - script: | - yarn e2e $(sinceArg) --scope @fluentui/react-components --scope @fluentui/react - displayName: Cypress E2E tests + yarn e2e $(sinceArg) --scope @fluentui/react-components + displayName: v9 Cypress E2E tests + + - script: | + yarn e2e $(sinceArg) --scope @fluentui/react + displayName: v8 Cypress E2E tests - template: .devops/templates/cleanup.yml diff --git a/packages/react-examples/src/e2e/utils.ts b/packages/react-examples/src/e2e/utils.ts new file mode 100644 index 0000000000000..b635b866c7e12 --- /dev/null +++ b/packages/react-examples/src/e2e/utils.ts @@ -0,0 +1,33 @@ +import * as React from 'react'; + +/** + * Define a global function on `window` and clean it up after the test finishes. + * NOTE: This only runs once (updates to the function are not respected). + */ +export function useGlobal(name: keyof Globals, func: Required[typeof name]) { + React.useEffect(() => { + ((window as unknown) as Globals)[name] = func; + return () => { + // Clean up the global to avoid timing issues where a test tries to call the version of a + // global defined by a previous test (this can happen in headless mode) + delete ((window as unknown) as Globals)[name]; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run on mount + }, []); +} + +export type UsePropsGlobals = { + /** Callback allowing a test to update the value returned by `useProps` inside a story. */ + setProps?: (props: TProps) => void; +}; + +/** + * Define a global `window.setProps` to set props for the story, and clean it up + * after the test finishes. + * @returns the latest props + */ +export function useProps() { + const [props, setProps] = React.useState(); + useGlobal>('setProps' as const, setProps); + return props; +} diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.FocusStack.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.FocusStack.stories.tsx new file mode 100644 index 0000000000000..410c8e41ccb0f --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.FocusStack.stories.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { FocusTrapZone } from '@fluentui/react'; +import { useGlobal } from '../../../e2e/utils'; +import { FTZTestGlobals } from './types'; +import { rootClass } from './shared'; + +/** + * It maintains a proper stack of FocusTrapZones as more are mounted/unmounted + */ +export const FocusStack = () => { + // Whether to render each FocusTrapZone + const [shouldRender, setShouldRender] = React.useState([true, false, false, false, false]); + + const updateFTZ = (num: 1 | 2 | 3 | 4, newValue: boolean) => { + setShouldRender(prevValues => { + const newValues = [...prevValues]; + newValues[num] = newValue; + return newValues; + }); + }; + + useGlobal('getFocusStack', () => FocusTrapZone.focusStack); + + return ( +
+ + ftz0 + + + + + + {shouldRender[1] && ( + + ftz1 + + + )} + {shouldRender[2] && ( + + ftz2 + + + + )} + {shouldRender[3] && ( + + ftz3 + + + )} + {shouldRender[4] && ( + + ftz4 + + )} +
+ ); +}; diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.ImperativeFocus.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.ImperativeFocus.stories.tsx new file mode 100644 index 0000000000000..fc29274d74d6c --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.ImperativeFocus.stories.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { FocusZone, FocusTrapZone } from '@fluentui/react'; +import type { IFocusTrapZone, IFocusTrapZoneProps } from '@fluentui/react'; +import { useGlobal, useProps } from '../../../e2e/utils'; +import type { FTZTestGlobals } from './types'; +import { rootClass } from './shared'; + +/** Imperatively focusing the FTZ */ +export const ImperativeFocus = () => { + const props = useProps(); + const focusTrapZoneRef = React.useRef(null); + + useGlobal('imperativeFocus', () => focusTrapZoneRef.current?.focus()); + + return ( + // don't render until props have been set + props && ( +
+ + + + + + + + +
+ ) + ); +}; diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.NoTabbableItems.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.NoTabbableItems.stories.tsx new file mode 100644 index 0000000000000..45b6678f1049c --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.NoTabbableItems.stories.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { FocusTrapZone } from '@fluentui/react'; +import type { IFocusTrapZoneProps } from '@fluentui/react'; +import { useProps } from '../../../e2e/utils'; +import { rootClass } from './shared'; + +/** + * Tab and shift-tab when the FTZ contains 0 tabbable items + */ +export const NoTabbableItems = () => { + const props = useProps(); + + return ( + // don't render until props have been set + props && ( +
+ + + + + + + +
+ ) + ); +}; diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.PropValues.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.PropValues.stories.tsx new file mode 100644 index 0000000000000..ee6a6b66f26e8 --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.PropValues.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { FocusTrapZone } from '@fluentui/react'; +import type { IFocusTrapZoneProps } from '@fluentui/react'; +import { useProps } from '../../../e2e/utils'; +import { rootClass } from './shared'; + +/** Respects default and explicit prop values */ +export const PropValues = () => { + const [buttonClicked, setButtonClicked] = React.useState(''); + const props = useProps(); + + return ( + // don't render until props have been set + props && ( +
setButtonClicked((ev.target as HTMLButtonElement).textContent || '')}> + + clicked {buttonClicked} + + + + + + + + +
+ ) + ); +}; diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingButtonFocusZone.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingButtonFocusZone.stories.tsx new file mode 100644 index 0000000000000..16b8cfb7ef9d1 --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingButtonFocusZone.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { FocusZone, FocusTrapZone, FocusZoneDirection } from '@fluentui/react'; +import { rootClass } from './shared'; + +/** + * Tab and shift-tab wrap at extreme ends of the FTZ: + * + * can tab between a button and a FocusZone + */ +export const TabWrappingButtonFocusZone = () => { + return ( +
+ +
+ +
+ +
+ +
+
+
+ + + +
+
+
+
+
+ ); +}; diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingFocusZoneBumpers.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingFocusZoneBumpers.stories.tsx new file mode 100644 index 0000000000000..00f01ed2addf0 --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingFocusZoneBumpers.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { FocusZone, FocusTrapZone, FocusZoneDirection } from '@fluentui/react'; +import { rootClass } from './shared'; + +/** + * Tab and shift-tab wrap at extreme ends of the FTZ: + * + * can trap focus when FTZ bookmark elements are FocusZones, + * and those elements have inner elements focused that are not the first inner element + */ +export const TabWrappingFocusZoneBumpers = () => { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingMultiFocusZone.stories.tsx b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingMultiFocusZone.stories.tsx index f36a567391a16..563b28e4e52ff 100644 --- a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingMultiFocusZone.stories.tsx +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.TabWrappingMultiFocusZone.stories.tsx @@ -1,21 +1,11 @@ import * as React from 'react'; -import { FocusZone, FocusTrapZone, FocusZoneDirection, mergeStyles } from '@fluentui/react'; - -const rootClass = mergeStyles({ - position: 'relative', - button: { position: 'absolute', height: 30, width: 30 }, - '#a': { top: 0, left: 0 }, - '#b': { top: 0, left: 30 }, - '#c': { top: 0, left: 60 }, - '#d': { top: 30, left: 0 }, - '#e': { top: 30, left: 30 }, - '#f': { top: 30, left: 60 }, -}); +import { FocusZone, FocusTrapZone, FocusZoneDirection } from '@fluentui/react'; +import { rootClass } from './shared'; /** * Tab and shift-tab wrap at extreme ends of the FTZ: * - * can tab across FocusZones with different button structures + * can tab between multiple FocusZones with different button structures */ export const TabWrappingMultiFocusZone = () => { return ( @@ -23,21 +13,21 @@ export const TabWrappingMultiFocusZone = () => {
- +
- +
- +
- - - + + +
diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.e2e.ts b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.e2e.ts index 106232d71569c..9956b60af2d9a 100644 --- a/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.e2e.ts +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/FocusTrapZone.e2e.ts @@ -1,24 +1,485 @@ +import type { IFocusTrapZoneProps } from '@fluentui/react/lib/FocusTrapZone'; +import type { FTZTestGlobals } from './types'; + +/** Window with zero or more extra functions defined by some of the stories. */ +type FTZTestWindow = Cypress.AUTWindow & FTZTestGlobals; + const ftzStoriesTitle = 'Components/FocusTrapZone/e2e'; +/** + * Calls `window.setProps()` -- this must be defined by the story being tested + */ +function setProps(props: IFocusTrapZoneProps) { + // Use should() to ensure that the call retries if setProps isn't defined yet + // (this can happen in headless mode, if the example isn't finished rendering) + cy.window().should(win => { + (win as FTZTestWindow).setProps!(props); + }); +} + describe('FocusTrapZone', () => { before(() => { cy.visitStorybook({ qs: { e2e: '1' } }); }); + // These are basic tests of different props, but due to the reliance on focus behavior they're + // best done in the browser. + describe('Respects default and explicit prop values', () => { + beforeEach(() => { + cy.loadStory(ftzStoriesTitle, 'PropValues'); + }); + + it('Focuses first child on mount', () => { + setProps({}); + + cy.focused().should('have.text', 'first'); + }); + + it('Does not focus first child on mount with disableFirstFocus', () => { + setProps({ disableFirstFocus: true }); + + // Verify nothing is focused (note that document.activeElement will be body, but cy.focused() + // uses :focus, and that's not set on body even if it's active) + cy.focused().should('not.exist'); + }); + + it('Can click children inside the FTZ', () => { + setProps({}); + + // wait for first focus to finish + cy.focused().should('have.text', 'first'); + + // focus inside the FTZ + cy.contains('mid').realClick(); + cy.focused().should('have.text', 'mid'); + }); + + it('Restores focus to FTZ when clicking outside FTZ', () => { + setProps({}); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + // try to click on button outside FTZ + cy.contains('before').realClick(); + // it focuses first button inside FTZ instead + cy.focused().should('have.text', 'first'); + // and the click isn't respected + cy.get('#buttonClicked').should('have.text', 'clicked '); + }); + + it('Restores focus to FTZ when programmatically focusing outside FTZ', () => { + setProps({}); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + cy.contains('after').focus(); + cy.focused().should('have.text', 'first'); + }); + + it('Allows clicks outside FTZ with isClickableOutsideFocusTrap but restores focus inside', () => { + setProps({ isClickableOutsideFocusTrap: true }); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + // click the button and verify it worked (the story updates the text when a button is clicked) + cy.contains('before').realClick(); + cy.get('#buttonClicked').should('have.text', 'clicked before'); + + // but focus is kept within the FTZ + cy.focused().should('have.text', 'first'); + }); + + it('Focuses first element when focus enters FTZ with tab', () => { + setProps({ disableFirstFocus: true }); + + // Start by programmatically focusing an element outside + // (clicking it won't work in this case because that would send focus inside the trap) + cy.contains('before').focus(); + cy.focused().should('have.text', 'before'); + + // Tab to send focus to the first bumper, which passes focus on to the first element inside + cy.realPress('Tab'); + cy.focused().should('have.text', 'first'); + }); + + it('Focuses last element when focus enters FTZ with shift+tab', () => { + setProps({ disableFirstFocus: true }); + + // Start by programmatically focusing an element outside + // (clicking it won't work in this case because that would send focus inside the trap) + cy.contains('after').focus(); + cy.focused().should('have.text', 'after'); + + // Shift+tab will send focus to the last bumper, which passes focus on to the last element inside + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.text', 'last'); + }); + + // TODO: investigate why this intermittently fails and re-enable. + // It succeeds if you set disableFirstFocus: true, but the failure with first focus enabled + // may reflect an actual bug in the function component conversion. Also, the intermittent + // failure may indicate a timing issue with either the effects within FTZ, or with cypress. + xit('Does not restore focus to FTZ when forceFocusInsideTrap is false', () => { + setProps({ forceFocusInsideTrap: false }); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + // click a button outside => respected + cy.contains('after').realClick(); + cy.focused().should('have.text', 'after'); + + // focus back inside + cy.contains('mid').realClick(); + cy.focused().should('have.text', 'mid'); + + // programmatic focus outside => respected + cy.contains('after').focus(); + cy.focused().should('have.text', 'after'); + }); + + it('Does not focus first on mount while disabled', () => { + setProps({ disabled: true }); + + // verify story rendered (to make sure we're not checking the base state of the page) + cy.contains('first').should('exist'); + + cy.focused().should('not.exist'); + }); + + it('Focuses on firstFocusableSelector on mount', () => { + // deprecated: this is actually a className, not a selector + setProps({ firstFocusableSelector: 'last-class' }); + + cy.focused().should('have.text', 'last'); + }); + + it('Does not focus on firstFocusableSelector on mount while disabled', () => { + setProps({ firstFocusableSelector: 'last-class', disabled: true }); + + // verify story rendered (to make sure we're not checking the base state of the page) + cy.contains('first').should('exist'); + + cy.focused().should('not.exist'); + }); + + it('Falls back to first focusable element with invalid firstFocusableSelector', () => { + setProps({ firstFocusableSelector: 'invalidSelector' }); + + cy.focused().should('have.text', 'first'); + }); + + it('Focuses on firstFocusableTarget selector on mount', () => { + setProps({ firstFocusableTarget: '#last' }); + + cy.focused().should('have.text', 'last'); + }); + + it('Focuses on firstFocusableTarget callback on mount', () => { + setProps({ firstFocusableTarget: element => element.querySelector('#last') }); + + cy.focused().should('have.text', 'last'); + }); + + it('Does not focus on firstFocusableTarget selector on mount while disabled', () => { + setProps({ firstFocusableTarget: '#last', disabled: true }); + + // verify story rendered (to make sure we're not checking the base state of the page) + cy.contains('first').should('exist'); + + cy.focused().should('not.exist'); + }); + + it('Does not focus on firstFocusableTarget callback on mount while disabled', () => { + setProps({ + firstFocusableTarget: (element: HTMLElement) => element.querySelector('#last'), + disabled: true, + }); + + // verify story rendered (to make sure we're not checking the base state of the page) + cy.contains('first').should('exist'); + + cy.focused().should('not.exist'); + }); + + it('Falls back to first focusable element with invalid firstFocusableTarget selector', () => { + setProps({ firstFocusableTarget: '.invalidSelector' }); + + cy.focused().should('have.text', 'first'); + }); + + it('Falls back to first focusable element with invalid firstFocusableTarget callback', () => { + setProps({ firstFocusableTarget: () => null }); + + cy.focused().should('have.text', 'first'); + }); + }); + describe('Tab and shift-tab wrap at extreme ends of the FTZ', () => { - it('can tab across FocusZones with different button structures', () => { + // Note: all of the IDs in these tests refer to buttons + + it('can tab between a button and a FocusZone', () => { + // This story has a FTZ containing a button and a FocusZone (containing more buttons) + cy.loadStory(ftzStoriesTitle, 'TabWrappingButtonFocusZone'); + + // initial focus goes to the button + cy.focused().should('have.text', 'first'); + + // shift+tab to focus first bumper => wraps to FocusZone => first button inside it + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.text', 'fzFirst'); + + // tab to focus last bumper => wraps to first button + cy.realPress('Tab'); + cy.focused().should('have.text', 'first'); + }); + + it('can tab between multiple FocusZones with different button structures', () => { + // This story has a FTZ containing two FocusZones (both containing buttons) cy.loadStory(ftzStoriesTitle, 'TabWrappingMultiFocusZone'); - cy.get('#a').focus(); - cy.focused().should('have.id', 'a'); + // initial focus goes into the first FocusZone + cy.focused().should('have.text', 'fz1First'); - // shift+tab to focus first bumper + // shift+tab to focus first bumper => wraps to second FocusZone => first button inside it cy.realPress(['Shift', 'Tab']); - cy.focused().should('have.id', 'd'); + cy.focused().should('have.text', 'fz2First'); - // tab to focus last bumper + // tab to focus last bumper => wraps to first FocusZone => first button inside it cy.realPress('Tab'); - cy.focused().should('have.id', 'a'); + cy.focused().should('have.text', 'fz1First'); + }); + + it( + 'can trap focus when FTZ bookmark elements are FocusZones, ' + + 'and those elements have inner elements focused that are not the first inner element', + () => { + // This story has a FTZ containing a FocusZone (with buttons), a button, and another FocusZone (with buttons). + // "Bookmark" refers to the first and last elements inside the FTZ. + cy.loadStory(ftzStoriesTitle, 'TabWrappingFocusZoneBumpers'); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'fz1First'); + + // Focus the middle button in the first FZ. + cy.contains('fz1First').click().realPress('ArrowRight'); + cy.focused().should('have.text', 'fz1Mid'); + + // Focus the middle button in the second FZ. + cy.contains('fz2Mid').click().realPress('ArrowRight'); + cy.focused().should('have.text', 'fz2Last'); + + // tab to focus last bumper => wraps to first FocusZone => previously focused button inside it + cy.realPress('Tab'); + cy.focused().should('have.text', 'fz1Mid'); + + // shift+tab to focus first bumper => wraps to last FocusZone => previously focused button inside it + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.text', 'fz2Last'); + }, + ); + }); + + describe('Tab and shift-tab when the FTZ contains 0 tabbable items', () => { + beforeEach(() => { + // This story has a FocusTrapZone containing buttons with tabIndex=-1, so they can still be + // clicked or programmatically focused, but aren't keyboard-focusable with tab + cy.loadStory(ftzStoriesTitle, 'NoTabbableItems'); + }); + + it('focuses first focusable element when focusing first bumper', () => { + setProps({}); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + cy.contains('last').realClick(); + cy.focused().should('have.text', 'last'); + + // shift+tab focuses the first bumper (since the buttons inside aren't keyboard-focusable) + // => sends focus to first element + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.text', 'first'); + }); + + it('focuses first focusable element when focusing last bumper', () => { + setProps({}); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + cy.contains('mid').realClick(); + cy.focused().should('have.text', 'mid'); + + // tab wraps around to focus first bumper (??) => sends focus to first element + cy.realPress('Tab'); + cy.focused().should('have.text', 'first'); + }); + + it('focuses first focusable element when focusing outside of FTZ with 0 tabbable items', () => { + setProps({}); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + cy.contains('mid').realClick(); + cy.focused().should('have.text', 'mid'); + + // try to focus on button outside FTZ + cy.contains('before').realClick(); + // it focuses first button inside FTZ instead + cy.focused().should('have.text', 'first'); + }); + + it('focuses previously focused element when focusing outside of FTZ with 0 tabbable items', () => { + setProps({ focusPreviouslyFocusedInnerElement: true }); + + // wait for first focus to finish to avoid timing issue + cy.focused().should('have.text', 'first'); + + cy.contains('mid').realClick(); + cy.focused().should('have.text', 'mid'); + + // try to focus on button outside FTZ + cy.contains('before').realClick(); + // it focuses last focused button inside FTZ instead + cy.focused().should('have.text', 'mid'); + }); + }); + + describe('Imperatively focusing the FTZ', () => { + function imperativeFocus() { + cy.window().then(win => (win as FTZTestWindow).imperativeFocus!()); + } + + beforeEach(() => { + cy.loadStory(ftzStoriesTitle, 'ImperativeFocus'); + }); + + it('goes to previously focused element when focusing the FTZ', async () => { + setProps({ focusPreviouslyFocusedInnerElement: true }); + + // Manually focusing FTZ when FTZ has never had focus within should go to 1st focusable inner element. + imperativeFocus(); + cy.focused().should('have.text', 'first'); + + // Focus inside the trap zone, not the first element. + cy.contains('last').realClick(); + cy.focused().should('have.text', 'last'); + + // Focus outside the trap zone + cy.contains('after').realClick(); + cy.focused().should('have.text', 'after'); + + // Manually focusing FTZ should return to originally focused inner element. + imperativeFocus(); + + cy.focused().should('have.text', 'last'); + }); + + it('goes to first focusable element when focusing the FTZ', async () => { + setProps({ focusPreviouslyFocusedInnerElement: false }); + + // Manually focusing FTZ when FTZ has never had focus within should go to 1st focusable inner element. + imperativeFocus(); + cy.focused().should('have.text', 'first'); + + // Focus inside the trap zone, not the first element. + cy.contains('last').realClick(); + cy.focused().should('have.text', 'last'); + + // Focus outside the trap zone + cy.contains('after').realClick(); + cy.focused().should('have.text', 'after'); + + // Manually focusing FTZ should go to the first focusable element. + imperativeFocus(); + cy.focused().should('have.text', 'first'); + }); + }); + + describe('focus stack', () => { + it('maintains a proper stack of FocusTrapZones as more are mounted/unmounted', () => { + // TODO: try to find a way to test this concept without looking this deeply into the implementation + // or using global functions + // + // This test needs to look at FocusTrapZone.focusStack (at least with current implementation), + // and the easiest way to do that in cypress is having the story expose a getFocusStack() global. + // (Rendering FocusTrapZone.focusStack in the story doesn't work because updates to the array + // don't trigger React updates, so it gets out of date.) + + cy.loadStory(ftzStoriesTitle, 'FocusStack'); + + // There should now be one focus trap zone. + cy.get('#ftz0').should('exist'); + cy.focused().should('have.text', 'add ftz1'); // first button in ftz0 + cy.window().should(win => { + // NOTE: This expectation should NOT be done in a helper because there will be no useful + // line/stack info if it fails (due to being run with eval() inside the test window). + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0']); + }); + + // add ftz1 and verify there are now two FTZs in the stack + cy.contains('add ftz1').realClick(); + cy.get('#ftz1').should('exist'); + cy.focused().should('have.text', 'add ftz2'); // first button in ftz1 + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0', 'ftz1']); + }); + + // add ftz2 => three FTZ in stack + cy.contains('add ftz2').realClick(); + cy.get('#ftz2').should('exist'); + cy.focused().should('have.text', 'remove ftz1'); // first button in ftz2 + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0', 'ftz1', 'ftz2']); + }); + + // remove ftz1 => two FTZ in stack + cy.contains('remove ftz1').realClick(); + cy.get('#ftz1').should('not.exist'); + cy.focused().should('have.text', 'remove ftz1'); // first button in ftz2 + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0', 'ftz2']); + }); + + // remove ftz2 => one FTZ in stack + cy.contains('remove ftz2').realClick(); + cy.get('#ftz2').should('not.exist'); + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0']); + }); + // ftz2 will try to return focus to its initiator (the button in ftz1), but that button is gone, + // so focus goes to document.body + cy.focused().should('not.exist'); + // add ftz3 => two FTZ in stack + // (even though ftz3 has forceFocusInsideTrap=false) + cy.contains('add ftz3').realClick(); + cy.get('#ftz3').should('exist'); + cy.focused().should('have.text', 'remove ftz3'); // first button in ftz3 + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0', 'ftz3']); + }); + + // remove ftz3 => one FTZ in stack + cy.contains('remove ftz3').realClick(); + cy.get('#ftz3').should('not.exist'); + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0']); + }); + // ftz3 returns focus to initiator after unmount + cy.focused().should('have.text', 'add ftz3'); + + // add ftz4 => still only one FTZ in stack because ftz4 is disabled + cy.contains('add ftz4').realClick(); + cy.get('#ftz4').should('exist'); + cy.focused().should('have.text', 'add ftz4'); // clicked button in ftz0 + cy.window().should(win => { + expect((win as FTZTestWindow).getFocusStack!()).to.deep.equal(['ftz0']); + }); }); }); }); diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/shared.ts b/packages/react-examples/src/react/FocusTrapZone/e2e/shared.ts new file mode 100644 index 0000000000000..6040de0e51174 --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/shared.ts @@ -0,0 +1,12 @@ +import { mergeStyles } from '@fluentui/react'; + +/** Styles to make the example easier to visually follow when debugging */ +export const rootClass = mergeStyles({ + button: { display: 'inline-block', margin: 5, height: 25, minWidth: 60 }, + '> *': { margin: 5 }, + '*:focus': { outline: '2px dashed red' }, + // usually targets FocusTrapZone roots + '> div': { border: '2px dashed blue', padding: 5 }, + // targets FocusZone roots + '[data-focuszone-id]': { border: '2px dashed lightgray', margin: 5, padding: 5 }, +}); diff --git a/packages/react-examples/src/react/FocusTrapZone/e2e/types.ts b/packages/react-examples/src/react/FocusTrapZone/e2e/types.ts new file mode 100644 index 0000000000000..e5b2b5d9d2d58 --- /dev/null +++ b/packages/react-examples/src/react/FocusTrapZone/e2e/types.ts @@ -0,0 +1,13 @@ +import type { IFocusTrapZoneProps } from '@fluentui/react/lib/FocusTrapZone'; + +/** + * Globals set by some of the stories. A story may set some or none of these. + */ +export type FTZTestGlobals = { + /** Sets props of the FocusTrapZone (used by several stories) */ + setProps?: (props: IFocusTrapZoneProps) => void; + /** Calls FocusTrapZone's imperative `focus()` method on a `componentRef` (used by Focusing story) */ + imperativeFocus?: () => void; + /** Gets `FocusTrapZone.focusStack` (used by FocusStack story) */ + getFocusStack?: () => string[]; +}; diff --git a/packages/react/src/components/FocusTrapZone/FocusTrapZone.test.tsx b/packages/react/src/components/FocusTrapZone/FocusTrapZone.test.tsx index f5234233392fb..24d577a3fc954 100644 --- a/packages/react/src/components/FocusTrapZone/FocusTrapZone.test.tsx +++ b/packages/react/src/components/FocusTrapZone/FocusTrapZone.test.tsx @@ -1,1113 +1,64 @@ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import * as ReactTestUtils from 'react-dom/test-utils'; import { render } from '@testing-library/react'; -import { KeyCodes } from '../../Utilities'; -import { FocusZone, FocusZoneDirection } from '../../FocusZone'; import { FocusTrapZone } from './FocusTrapZone'; -import { createTestContainer, safeMount } from '@fluentui/test-utilities'; import { isConformant } from '../../common/isConformant'; -import type { IFocusTrapZoneProps } from './FocusTrapZone.types'; - -// rAF does not exist in node - let's mock it -window.requestAnimationFrame = (callback: FrameRequestCallback) => { - const r = window.setTimeout(callback, 0); - ReactTestUtils.act(() => { - jest.runAllTimers(); - }); - return r; -}; - -ReactTestUtils.act(() => { - jest.useFakeTimers(); -}); - -class FocusTrapZoneTestComponent extends React.Component< - {}, - { - isShowingFirst: boolean; - isShowingSecond: boolean; - isShowingThird: boolean; - isShowingFourth: boolean; - } -> { - constructor(props: {}) { - super(props); - this.state = { - isShowingFirst: false, - isShowingSecond: false, - isShowingThird: false, - isShowingFourth: false, - }; - } - - public render() { - return ( -
- - - - - - - - {this.state.isShowingFirst && ( - - First - - )} - {this.state.isShowingSecond && ( - - Second - - )} - {this.state.isShowingThird && ( - - Third - - )} - {this.state.isShowingFourth && ( - - Fourth - - )} -
- ); - } - - private _toggleFirst = () => { - this.setState({ isShowingFirst: !this.state.isShowingFirst }); - }; - - private _toggleSecond = () => { - this.setState({ isShowingSecond: !this.state.isShowingSecond }); - }; - - private _toggleThird = () => { - this.setState({ isShowingThird: !this.state.isShowingThird }); - }; - - private _toggleFourth = () => { - this.setState({ isShowingFourth: !this.state.isShowingFourth }); - }; -} +import { expectNoHiddenParents } from '../../common/testUtilities'; describe('FocusTrapZone', () => { - // document.activeElement can be used to detect activeElement after component mount, but it does not - // update based on focus events due to limitations of ReactDOM. Use lastFocusedElement to detect focus - // change events. - let lastFocusedElement: HTMLElement | undefined; - let testContainer: HTMLElement | undefined; - let addEventListener: any; - let componentEventListeners: any = {}; - const ftzClassname = 'ftzTestClassname'; - - function _onFocus(ev: any): void { - lastFocusedElement = ev.target; - } - isConformant({ Component: FocusTrapZone, displayName: 'FocusTrapZone', }); - function setupElement( - element: HTMLElement, - { - clientRect, - isVisible = true, - }: { - clientRect: { - top: number; - left: number; - bottom: number; - right: number; - }; - isVisible?: boolean; - }, - ): void { - ReactTestUtils.act(() => { - element.getBoundingClientRect = () => - ({ - top: clientRect.top, - left: clientRect.left, - bottom: clientRect.bottom, - right: clientRect.right, - width: clientRect.right - clientRect.left, - height: clientRect.bottom - clientRect.top, - } as DOMRect); - - element.setAttribute('data-is-visible', String(isVisible)); - - element.focus = () => ReactTestUtils.Simulate.focus(element); - }); - } - - /** - * Helper to get FocusTrapZone bumpers. Requires classname attribute of - * 'ftzClassname' on FTZ. - */ - function getFtzBumpers( - element: HTMLElement, - ): { - firstBumper: Element; - lastBumper: Element; - } { - const ftz = element.querySelector('.' + ftzClassname) as HTMLElement; - const ftzNodes = ftz.children; - const firstBumper = ftzNodes[0]; - const lastBumper = ftzNodes[ftzNodes.length - 1]; - - return { firstBumper, lastBumper }; - } - - beforeAll(() => { - // Test DOM won't bubble up events to window listeners, so instead we call the window listeners directly. - // By mocking window.addEventListener we can store callbacks that the component under test registers. - // Then we can call them directly to simulate window events. - addEventListener = window.addEventListener; - window.addEventListener = jest.fn().mockImplementation((event, cb) => { - componentEventListeners[event] = cb; - }); - }); - - beforeEach(() => { - lastFocusedElement = undefined; - }); - - afterEach(() => { - // Make sure registered listeners are cleared between tests. - componentEventListeners = {}; - if (testContainer) { - ReactDOM.unmountComponentAtNode(testContainer); - testContainer.remove(); - testContainer = undefined; - } - }); - - afterAll(() => { - window.addEventListener = addEventListener; - }); - - describe('Tab and shift-tab wrap at extreme ends of the FTZ', () => { - it('can tab across FocusZones with different button structures', async () => { - expect.assertions(3); - - testContainer = createTestContainer(); - - ReactTestUtils.act(() => { - ReactDOM.render( -
- - -
- -
-
- -
-
- -
-
- -
-
- - - -
-
-
-
-
, - testContainer!, - ); - }); - - const buttonA = testContainer.querySelector('.a') as HTMLElement; - const buttonB = testContainer.querySelector('.b') as HTMLElement; - const buttonC = testContainer.querySelector('.c') as HTMLElement; - const buttonD = testContainer.querySelector('.d') as HTMLElement; - const buttonE = testContainer.querySelector('.e') as HTMLElement; - const buttonF = testContainer.querySelector('.f') as HTMLElement; - - const { firstBumper, lastBumper } = getFtzBumpers(testContainer); - - // Assign bounding locations to buttons. - setupElement(buttonA, { - clientRect: { top: 0, bottom: 30, left: 0, right: 30 }, - }); - setupElement(buttonB, { - clientRect: { top: 0, bottom: 30, left: 30, right: 60 }, - }); - setupElement(buttonC, { - clientRect: { top: 0, bottom: 30, left: 60, right: 90 }, - }); - setupElement(buttonD, { - clientRect: { top: 30, bottom: 60, left: 0, right: 30 }, - }); - setupElement(buttonE, { - clientRect: { top: 30, bottom: 60, left: 30, right: 60 }, - }); - setupElement(buttonF, { - clientRect: { top: 30, bottom: 60, left: 60, right: 90 }, - }); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonA); - expect(lastFocusedElement).toBe(buttonA); - }); - - // Simulate shift+tab event which would focus first bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(firstBumper); - expect(lastFocusedElement).toBe(buttonD); - }); - - // Simulate tab event which would focus last bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(lastBumper); - expect(lastFocusedElement).toBe(buttonA); - }); - }); - - it('can tab across a FocusZone with different button structures', async () => { - expect.assertions(3); - - testContainer = createTestContainer(); - - ReactTestUtils.act(() => { - ReactDOM.render( -
- -
- -
- -
- -
-
-
- - - -
-
-
-
-
, - testContainer!, - ); - }); - - const buttonX = testContainer.querySelector('.x') as HTMLElement; - const buttonA = testContainer.querySelector('.a') as HTMLElement; - const buttonB = testContainer.querySelector('.b') as HTMLElement; - const buttonC = testContainer.querySelector('.c') as HTMLElement; - const buttonD = testContainer.querySelector('.d') as HTMLElement; - - const { firstBumper, lastBumper } = getFtzBumpers(testContainer); - - // Assign bounding locations to buttons. - setupElement(buttonX, { - clientRect: { top: 0, bottom: 30, left: 0, right: 30 }, - }); - setupElement(buttonA, { - clientRect: { top: 0, bottom: 30, left: 0, right: 30 }, - }); - setupElement(buttonB, { - clientRect: { top: 0, bottom: 30, left: 30, right: 60 }, - }); - setupElement(buttonC, { - clientRect: { top: 0, bottom: 30, left: 60, right: 90 }, - }); - setupElement(buttonD, { - clientRect: { top: 30, bottom: 60, left: 0, right: 30 }, - }); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonX); - }); - expect(lastFocusedElement).toBe(buttonX); - - // Simulate shift+tab event which would focus first bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(firstBumper); - }); - expect(lastFocusedElement).toBe(buttonA); - - // Simulate tab event which would focus last bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(lastBumper); - }); - expect(lastFocusedElement).toBe(buttonX); - }); - - it(`can trap focus when FTZ bookmark elements are FocusZones, and those elements have inner elements focused that - are not the first inner element`, async () => { - expect.assertions(4); - - testContainer = createTestContainer(); - - ReactTestUtils.act(() => { - ReactDOM.render( -
- - - - - - - - - - - - - - - -
, - testContainer!, - ); - }); - - const buttonZ1 = testContainer.querySelector('.z1') as HTMLElement; - const buttonA = testContainer.querySelector('.a') as HTMLElement; - const buttonB = testContainer.querySelector('.b') as HTMLElement; - const buttonC = testContainer.querySelector('.c') as HTMLElement; - const buttonD = testContainer.querySelector('.d') as HTMLElement; - const buttonE = testContainer.querySelector('.e') as HTMLElement; - const buttonF = testContainer.querySelector('.f') as HTMLElement; - const buttonG = testContainer.querySelector('.g') as HTMLElement; - const buttonZ2 = testContainer.querySelector('.z2') as HTMLElement; - - const { firstBumper, lastBumper } = getFtzBumpers(testContainer); - - // Assign bounding locations to buttons. - setupElement(buttonZ1, { - clientRect: { top: 0, bottom: 10, left: 0, right: 10 }, - }); - setupElement(buttonA, { - clientRect: { top: 10, bottom: 30, left: 0, right: 10 }, - }); - setupElement(buttonB, { - clientRect: { top: 10, bottom: 30, left: 10, right: 20 }, - }); - setupElement(buttonC, { - clientRect: { top: 10, bottom: 30, left: 20, right: 30 }, - }); - setupElement(buttonD, { - clientRect: { top: 30, bottom: 40, left: 0, right: 10 }, - }); - setupElement(buttonE, { - clientRect: { top: 40, bottom: 60, left: 0, right: 10 }, - }); - setupElement(buttonF, { - clientRect: { top: 40, bottom: 60, left: 10, right: 20 }, - }); - setupElement(buttonG, { - clientRect: { top: 40, bottom: 60, left: 20, right: 30 }, - }); - setupElement(buttonZ2, { - clientRect: { top: 60, bottom: 70, left: 0, right: 10 }, - }); - - // Focus the middle button in the first FZ. - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonA); - ReactTestUtils.Simulate.keyDown(buttonA, { which: KeyCodes.right }); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Focus the middle button in the second FZ. - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonE); - ReactTestUtils.Simulate.keyDown(buttonE, { which: KeyCodes.right }); - }); - expect(lastFocusedElement).toBe(buttonF); - - // Simulate tab event which would focus last bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(lastBumper); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Simulate shift+tab event which would focus first bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(firstBumper); - }); - expect(lastFocusedElement).toBe(buttonF); - }); - }); - - describe('Tab and shift-tab do nothing (keep focus where it is) when the FTZ contains 0 tabbable items', () => { - function setupTest(props: IFocusTrapZoneProps) { - testContainer = createTestContainer(); - - ReactTestUtils.act(() => { - ReactDOM.render( -
- - - - - - - -
, - testContainer!, - ); - }); - - const buttonZ1 = testContainer.querySelector('.z1') as HTMLElement; - const buttonA = testContainer.querySelector('.a') as HTMLElement; - const buttonB = testContainer.querySelector('.b') as HTMLElement; - const buttonC = testContainer.querySelector('.c') as HTMLElement; - const buttonZ2 = testContainer.querySelector('.z2') as HTMLElement; - - const { firstBumper, lastBumper } = getFtzBumpers(testContainer); - - // Have to set bumpers as "visible" for focus utilities to find them. - // This is needed for 0 tabbable element tests to make sure that next tabbable element - // from one bumper is the other bumper. - ReactTestUtils.act(() => { - firstBumper.setAttribute('data-is-visible', String(true)); - lastBumper.setAttribute('data-is-visible', String(true)); - }); - - // Assign bounding locations to buttons. - setupElement(buttonZ1, { - clientRect: { top: 0, bottom: 10, left: 0, right: 10 }, - }); - setupElement(buttonA, { - clientRect: { top: 10, bottom: 20, left: 0, right: 10 }, - }); - setupElement(buttonB, { - clientRect: { top: 20, bottom: 30, left: 0, right: 10 }, - }); - setupElement(buttonC, { - clientRect: { top: 30, bottom: 40, left: 0, right: 10 }, - }); - setupElement(buttonZ2, { - clientRect: { top: 40, bottom: 50, left: 0, right: 10 }, - }); - - return { - buttonZ1, - buttonA, - buttonB, - buttonC, - buttonZ2, - firstBumper, - lastBumper, - }; - } - - it('focuses first focusable element when focusing first bumper', async () => { - expect.assertions(2); - - const { buttonA, buttonB, firstBumper } = setupTest({}); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Simulate shift+tab event which would focus first bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(firstBumper); - }); - expect(lastFocusedElement).toBe(buttonA); - }); - - it('focuses first focusable element when focusing last bumper', async () => { - expect.assertions(2); - - const { buttonA, buttonB, lastBumper } = setupTest({}); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Simulate tab event which would focus first bumper - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(lastBumper); - }); - expect(lastFocusedElement).toBe(buttonA); - }); - - it('focuses first focusable element when focusing outside of FTZ with 0 tabbable items', async () => { - expect.assertions(2); - - const { buttonA, buttonB, buttonZ2 } = setupTest({}); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Directly call window listener to simulate focus leaving FTZ. - componentEventListeners.focus({ - target: buttonZ2, - preventDefault: () => { - /*noop*/ - }, - stopPropagation: () => { - /*noop*/ - }, - }); - expect(lastFocusedElement).toBe(buttonA); - }); - - it('focuses previously focused element when focusing outside of FTZ with 0 tabbable items', async () => { - expect.assertions(2); - - const { buttonB, buttonZ2 } = setupTest({ - focusPreviouslyFocusedInnerElement: true, - }); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Directly call window listener to simulate focus leaving FTZ. - componentEventListeners.focus({ - target: buttonZ2, - preventDefault: () => { - /*noop*/ - }, - stopPropagation: () => { - /*noop*/ - }, - }); - expect(lastFocusedElement).toBe(buttonB); - }); - }); - - describe('Focus behavior based on default and explicit prop values', () => { - function setupTest(props: IFocusTrapZoneProps) { - testContainer = createTestContainer(); - - // data-is-visible is embedded in buttons here for testing focus behavior on initial render. - // Components have to be marked visible before setupElement has a chance to apply the data-is-visible attribute. - ReactTestUtils.act(() => { - ReactDOM.render( -
-
- - - - - - - -
-
, - testContainer!, - ); - }); - - const buttonZ1 = testContainer.querySelector('.z1') as HTMLElement; - const buttonA = testContainer.querySelector('.a') as HTMLElement; - const buttonB = testContainer.querySelector('.b') as HTMLElement; - const buttonC = testContainer.querySelector('.c') as HTMLElement; - const buttonZ2 = testContainer.querySelector('.z2') as HTMLElement; - - const { firstBumper, lastBumper } = getFtzBumpers(testContainer); - - // Assign bounding locations to buttons. - setupElement(buttonZ1, { - clientRect: { top: 0, bottom: 10, left: 0, right: 10 }, - }); - setupElement(buttonA, { - clientRect: { top: 10, bottom: 20, left: 0, right: 10 }, - }); - setupElement(buttonB, { - clientRect: { top: 20, bottom: 30, left: 0, right: 10 }, - }); - setupElement(buttonC, { - clientRect: { top: 30, bottom: 40, left: 0, right: 10 }, - }); - setupElement(buttonZ2, { - clientRect: { top: 40, bottom: 50, left: 0, right: 10 }, - }); - - return { - buttonZ1, - buttonA, - buttonB, - buttonC, - buttonZ2, - firstBumper, - lastBumper, - }; - } - - it('Restores focus to FTZ when clicking outside FTZ', async () => { - expect.assertions(2); - - const { buttonA, buttonB, buttonZ2 } = setupTest({}); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Directly call window listener to simulate focus leaving FTZ. - componentEventListeners.click({ - target: buttonZ2, - preventDefault: () => { - /*noop*/ - }, - stopPropagation: () => { - /*noop*/ - }, - }); - expect(lastFocusedElement).toBe(buttonA); - }); - - it('Does not restore focus to FTZ when clicking outside FTZ with isClickableOutsideFocusTrap', async () => { - expect.assertions(1); - - setupTest({ isClickableOutsideFocusTrap: true }); - - // FTZ doesn't register a window click listener when isClickableOutsideFocusTrap is true, so we can't simulate - // clicks directly. Therefore we test indirectly by making sure FTZ doesn't register a window click listener. - expect(componentEventListeners.click).toBeUndefined(); - }); - - it('Focuses first element when FTZ does not have focus and first bumper receives focus', async () => { - expect.assertions(2); - - const { buttonA, buttonZ1, firstBumper } = setupTest({ - disableFirstFocus: true, - isClickableOutsideFocusTrap: true, - }); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonZ1); - }); - expect(lastFocusedElement).toBe(buttonZ1); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(firstBumper); - }); - expect(lastFocusedElement).toBe(buttonA); - }); - - it('Focuses last element when FTZ does not have focus and last bumper receives focus', async () => { - expect.assertions(2); - - const { buttonC, buttonZ2, lastBumper } = setupTest({ - disableFirstFocus: true, - isClickableOutsideFocusTrap: true, - }); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonZ2); - }); - expect(lastFocusedElement).toBe(buttonZ2); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(lastBumper); - }); - expect(lastFocusedElement).toBe(buttonC); - }); - - it('Restores focus to FTZ when focusing outside FTZ', async () => { - expect.assertions(2); - - const { buttonA, buttonB, buttonZ2 } = setupTest({}); - - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Directly call window listener to simulate focus leaving FTZ. - componentEventListeners.focus({ - target: buttonZ2, - preventDefault: () => { - /*noop*/ - }, - stopPropagation: () => { - /*noop*/ - }, - }); - expect(lastFocusedElement).toBe(buttonA); - }); - - it('Does not restore focus to FTZ when forceFocusInsideTrap is false', async () => { - expect.assertions(1); - - setupTest({ forceFocusInsideTrap: false }); - - // FTZ doesn't register a window focus listener when isClickableOutsideFocusTrap is true, so we can't simulate - // focus directly. Therefore we test indirectly by making sure FTZ doesn't register a window focus listener. - expect(componentEventListeners.focus).toBeUndefined(); - }); - - it('Focuses first on mount', async () => { - expect.assertions(1); - - const { buttonA } = setupTest({}); - - expect(document.activeElement).toBe(buttonA); - }); - - it('Does not focus first on mount with disableFirstFocus', async () => { - expect.assertions(1); - - const activeElement = document.activeElement; - - setupTest({ disableFirstFocus: true }); - - // document.activeElement can be used to detect activeElement after component mount, but it does not - // update based on focus events due to limitations of ReactDOM. - // Make sure activeElement didn't change. - expect(document.activeElement).toBe(activeElement); - }); - - it('Does not focus first on mount while disabled', async () => { - expect.assertions(1); - - const activeElement = document.activeElement; - - setupTest({ disabled: true }); - - // document.activeElement can be used to detect activeElement after component mount, but it does not - // update based on focus events due to limitations of ReactDOM. - // Make sure activeElement didn't change. - expect(document.activeElement).toBe(activeElement); - }); - - it('Focuses on firstFocusableSelector on mount', async () => { - expect.assertions(1); - - const { buttonC } = setupTest({ firstFocusableSelector: 'c' }); - - expect(document.activeElement).toBe(buttonC); - }); - - it('Does not focus on firstFocusableSelector on mount while disabled', async () => { - expect.assertions(1); - - const activeElement = document.activeElement; - - setupTest({ firstFocusableSelector: 'c', disabled: true }); - - expect(document.activeElement).toBe(activeElement); - }); - - it('Falls back to first focusable element with invalid firstFocusableSelector', async () => { - const { buttonA } = setupTest({ - firstFocusableSelector: 'invalidSelector', - }); - - expect(document.activeElement).toBe(buttonA); - }); - - it('Focuses on firstFocusableTarget selector on mount', async () => { - expect.assertions(1); - - const { buttonC } = setupTest({ firstFocusableTarget: '.c' }); - - expect(document.activeElement).toBe(buttonC); - }); - - it('Focuses on firstFocusableTarget callback on mount', async () => { - expect.assertions(1); - - const { buttonC } = setupTest({ - firstFocusableTarget: (element: HTMLElement) => element.querySelector('.c'), - }); - - expect(document.activeElement).toBe(buttonC); - }); - - it('Does not focus on firstFocusableTarget selector on mount while disabled', async () => { - expect.assertions(1); - - const activeElement = document.activeElement; - - setupTest({ firstFocusableTarget: '.c', disabled: true }); - - expect(document.activeElement).toBe(activeElement); - }); - - it('Does not focus on firstFocusableTarget callback on mount while disabled', async () => { - expect.assertions(1); - - const activeElement = document.activeElement; - - setupTest({ - firstFocusableTarget: (element: HTMLElement) => element.querySelector('.c'), - disabled: true, - }); - - expect(document.activeElement).toBe(activeElement); - }); - - it('Falls back to first focusable element with invalid firstFocusableTarget selector', async () => { - const { buttonA } = setupTest({ firstFocusableTarget: '.invalidSelector' }); - - expect(document.activeElement).toBe(buttonA); - }); - - it('Falls back to first focusable element with invalid firstFocusableTarget callback', async () => { - const { buttonA } = setupTest({ firstFocusableTarget: () => null }); - - expect(document.activeElement).toBe(buttonA); - }); - }); - - describe('Focusing the FTZ', () => { - function setupTest(focusPreviouslyFocusedInnerElement: boolean) { - testContainer = createTestContainer(); - - const focusTrapZoneRef = React.createRef(); - ReactTestUtils.act(() => { - ReactDOM.render( -
- - - - - - - - -
, - testContainer!, - ); - }); - - const buttonF = testContainer.querySelector('.f') as HTMLElement; - const buttonA = testContainer.querySelector('.a') as HTMLElement; - const buttonB = testContainer.querySelector('.b') as HTMLElement; - const buttonZ = testContainer.querySelector('.z') as HTMLElement; - - // Assign bounding locations to buttons. - setupElement(buttonF, { - clientRect: { top: 0, bottom: 10, left: 0, right: 10 }, - }); - setupElement(buttonA, { - clientRect: { top: 10, bottom: 20, left: 0, right: 10 }, - }); - setupElement(buttonB, { - clientRect: { top: 20, bottom: 30, left: 0, right: 10 }, - }); - setupElement(buttonZ, { - clientRect: { top: 30, bottom: 40, left: 0, right: 10 }, - }); - - return { - focusTrapZone: focusTrapZoneRef.current!, - buttonF, - buttonA, - buttonB, - buttonZ, - }; - } - - it('goes to previously focused element when focusing the FTZ', async () => { - expect.assertions(4); - - const { focusTrapZone, buttonF, buttonB, buttonZ } = setupTest(true /*focusPreviouslyFocusedInnerElement*/); - - // Manually focusing FTZ when FTZ has never - // had focus within should go to 1st focusable inner element. - ReactTestUtils.act(() => { - focusTrapZone.focus(); - }); - expect(lastFocusedElement).toBe(buttonF); - - // Focus inside the trap zone, not the first element. - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Focus outside the trap zone - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonZ); - }); - expect(lastFocusedElement).toBe(buttonZ); - - // Manually focusing FTZ should return to originally focused inner element. - ReactTestUtils.act(() => { - focusTrapZone.focus(); - }); - - expect(lastFocusedElement).toBe(buttonB); - }); - - it('goes to first focusable element when focusing the FTZ', async () => { - expect.assertions(4); - - const { focusTrapZone, buttonF, buttonB, buttonZ } = setupTest(false /*focusPreviouslyFocusedInnerElement*/); - - // Manually focusing FTZ when FTZ has never - // had focus within should go to 1st focusable inner element. - ReactTestUtils.act(() => { - focusTrapZone.focus(); - }); - expect(lastFocusedElement).toBe(buttonF); - - // Focus inside the trap zone, not the first element. - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonB); - }); - expect(lastFocusedElement).toBe(buttonB); - - // Focus outside the trap zone - ReactTestUtils.act(() => { - ReactTestUtils.Simulate.focus(buttonZ); - }); - expect(lastFocusedElement).toBe(buttonZ); - - // Manually focusing FTZ should go to the first focusable element. - ReactTestUtils.act(() => { - focusTrapZone?.focus(); - }); - expect(lastFocusedElement).toBe(buttonF); - }); - }); - - describe('Nested FocusTrapZones Stack Behavior', () => { - beforeAll(() => { - FocusTrapZone.focusStack = []; - }); - - it('FocusTrapZone maintains a proper stack of FocusTrapZones as more are mounted/unmounted.', async () => { - safeMount(, wrapper => { - const buttonA = wrapper.find('.a'); - const buttonB = wrapper.find('.b'); - const buttonC = wrapper.find('.c'); - const buttonD = wrapper.find('.d'); - - // There should now be one focus trap zone. - expect(FocusTrapZone.focusStack.length).toBe(1); - - ReactTestUtils.act(() => { - buttonA.simulate('click'); - }); - - // There should now be two focus trap zones. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1', 'fz2']); - - ReactTestUtils.act(() => { - buttonB.simulate('click'); - }); - - // There should now be three focus trap zones. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1', 'fz2', 'fz3']); - - ReactTestUtils.act(() => { - buttonA.simulate('click'); - }); - - // There should now be two focus trap zones after removing second focusTrapZone. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1', 'fz3']); - - ReactTestUtils.act(() => { - buttonB.simulate('click'); - }); - - // There should now be one focus trap zone after removing third focusTrapZone. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1']); - - ReactTestUtils.act(() => { - buttonC.simulate('click'); - }); - - // There should now be two focus trap zone. The FocusStack should correctly handle forceFocusInsideTrap prop. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1', 'fz4']); - - ReactTestUtils.act(() => { - buttonC.simulate('click'); - }); - - // There should now be two focus trap zones after removing third focusTrapZone. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1']); + it('defaults to enableAriaHiddenSiblings=false', () => { + const { getByText } = render( +
+
sibling
+ + + +
, + ); - ReactTestUtils.act(() => { - buttonD.simulate('click'); - }); + expectNoHiddenParents(getByText('sibling')); - // There should only be one focus trap zone (base) since fz5 disabled prop is equal to true. - expect(FocusTrapZone.focusStack).toStrictEqual(['fz1']); - }); - }); + expectNoHiddenParents(getByText('content')); }); - it('defaults to enableAriaHiddenSiblings=true', () => { + it('respects enableAriaHiddenSiblings=true', () => { const { getByText } = render(
sibling
- +
, ); - const bodyChildren = Array.from(document.body.children) as HTMLElement[]; + expect(getByText('sibling').getAttribute('aria-hidden')).toBe('true'); - const content = getByText('content'); - const contentParent = bodyChildren.find(el => el.contains(content)); - expect(contentParent).toBeTruthy(); - expect(contentParent!.getAttribute('aria-hidden')).toBeNull(); - - for (const node of bodyChildren) { - if (node !== contentParent) { - expect(node.getAttribute('aria-hidden')).toBe('true'); - } - } + expectNoHiddenParents(getByText('content')); }); - it('respects enableAriaHiddenSiblings=false', () => { - render( + it('un-hides siblings when unmounting', () => { + const { getByText, rerender } = render(
sibling
- +
, ); - const bodyChildren = Array.from(document.body.children) as HTMLElement[]; - for (const node of bodyChildren) { - expect(node.getAttribute('aria-hidden')).toBeNull(); - } + const sibling = getByText('sibling'); + expect(sibling.getAttribute('aria-hidden')).toBe('true'); + + rerender( +
+
sibling
+
, + ); + expect(getByText('sibling')).toBe(sibling); // make sure it's the same DOM node + expect(sibling.getAttribute('aria-hidden')).toBeNull(); }); }); diff --git a/scripts/cypress.js b/scripts/cypress.js index 7dcdbc1cc4825..812cafa85a90f 100755 --- a/scripts/cypress.js +++ b/scripts/cypress.js @@ -30,10 +30,11 @@ const argv = require('yargs') }) .demandOption('mode').argv; +const isLocalRun = !process.env.DEPLOYURL; + +/** @type {Cypress.ConfigOptions} */ const baseConfig = { - baseUrl: process.env.DEPLOYURL - ? `${process.env.DEPLOYURL}/${argv.package}/storybook` - : `http://localhost:${argv.port}`, + baseUrl: isLocalRun ? `http://localhost:${argv.port}` : `${process.env.DEPLOYURL}/${argv.package}/storybook`, fixturesFolder: path.join(__dirname, 'cypress/fixtures'), integrationFolder: '.', pluginsFile: path.join(__dirname, 'cypress/plugins/index.js'), @@ -41,7 +42,9 @@ const baseConfig = { runMode: 2, openMode: 0, }, - screenshotOnRunFailure: false, + // Screenshots go under /cypress/screenshots and can be useful to look at after failures in + // local headless runs (especially if the failure is specific to headless runs) + screenshotOnRunFailure: isLocalRun && argv.mode === 'run', // due to https://github.com/cypress-io/cypress/issues/8599 this must point to a path within the package, // not a relative path into scripts supportFile: 'e2e/support.js',