Skip to content

Commit

Permalink
[EuiFocusTrap] Allow gapMode and crossFrame props to be configure…
Browse files Browse the repository at this point in the history
…d via `EuiProvider.componentDefaults` (elastic#6942)

* [cleanup] Fix EuiFocusTrap's props / props table

- it's kind of a dumpster fire right now with many props not being picked up so I'm wholesale rearranging many things

- storybook props/controls also throws its own special brand of fun into this

* [cleanup] Update existing Storybook stories to account for new props controls

- we can remove the specified `crossFrame` arg now that it actually exists as a prop

- default `returnFocus` to a boolean, since it can also take a function

- add an `onDeactivation` state update (useful when testing `clickOutsideDisables=true`)

* [cleanup] Remove skipped focus trap Jest tests

- these were already converted to Cypress tests, so there's no point / no need to keep them

* [cleanup] Convert Jest tests to RTL

+ add a `shouldRenderCustomStyles` to confirm that react-focus-on accepts className/css/style props

* Configure `EuiFocusTrap` to accept component defaults

* Add Cypress test for `gapMode`

* Add Storybook story for `crossFrame` & misc EuiProvider QA

* changelog
  • Loading branch information
cee-chen committed Jul 25, 2023
1 parent 2eeddac commit 5e5ae65
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 195 deletions.
12 changes: 6 additions & 6 deletions src/components/focus_trap/__snapshots__/focus_trap.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,22 @@ exports[`EuiFocusTrap can be disabled 1`] = `
</div>
`;

exports[`EuiFocusTrap is rendered 1`] = `
Array [
exports[`EuiFocusTrap renders 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>,
/>
<div
data-focus-lock-disabled="false"
>
<div />
</div>,
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>,
]
/>
</div>
`;
26 changes: 25 additions & 1 deletion src/components/focus_trap/focus_trap.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
/// <reference types="../../../cypress/support" />

import React, { useRef, useState } from 'react';
import { EuiFocusTrap } from './focus_trap';

import { EuiProvider } from '../provider';
import { EuiPortal } from '../portal';

import { EuiFocusTrap } from './focus_trap';

describe('EuiFocusTrap', () => {
describe('focus', () => {
it('is set on the first focusable element by default', () => {
Expand Down Expand Up @@ -367,6 +370,27 @@ describe('EuiFocusTrap', () => {
expect(styles.getPropertyValue('padding-right')).to.equal('0px');
});
});

it('allows customizing gapMode via EuiProvider.componentDefaults', () => {
cy.realMount(
<EuiProvider
componentDefaults={{ EuiFocusTrap: { gapMode: 'margin' } }}
>
<ToggledFocusTrap />
</EuiProvider>
);
skipIfNoScrollbars();
cy.get('[data-test-subj="openFocusTrap"]').click();

cy.get('body').then(($body) => {
const styles = window.getComputedStyle($body[0]);

const margin = parseFloat(styles.getPropertyValue('margin-right'));
expect(margin).to.be.gt(0);

expect(styles.getPropertyValue('padding-right')).to.equal('0px');
});
});
});
});
});
24 changes: 20 additions & 4 deletions src/components/focus_trap/focus_trap.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,15 @@ import { EuiFieldText } from '../form';
import { EuiSpacer } from '../spacer';
import { EuiPanel } from '../panel';

import { EuiProvider } from '../provider';
import { EuiFocusTrap, EuiFocusTrapProps } from './focus_trap';

const meta: Meta<EuiFocusTrapProps> = {
title: 'EuiFocusTrap',
// @ts-ignore This still works for Storybook controls, even though Typescript complains
component: EuiFocusTrap,
argTypes: {
crossFrame: {
control: { type: 'boolean' },
},
returnFocus: { type: 'boolean' },
},
};

Expand All @@ -39,7 +38,11 @@ const StatefulFocusTrap = (props: Partial<EuiFocusTrapProps>) => {
</EuiButton>
<EuiSpacer />
<EuiPanel>
<EuiFocusTrap {...props} disabled={disabled}>
<EuiFocusTrap
{...props}
disabled={disabled}
onDeactivation={() => setDisabled(true)}
>
Focus trap is currently {disabled ? 'disabled' : 'enabled'}
<EuiFieldText />
<EuiButton size="s">Button inside focus trap</EuiButton>
Expand Down Expand Up @@ -74,3 +77,16 @@ export const Iframe: Story = {
),
args: { disabled: true, crossFrame: false },
};

export const EuiProviderComponentDefaults: Story = {
render: ({ ...args }) => (
<EuiProvider componentDefaults={{ EuiFocusTrap: { ...args } }}>
<StatefulFocusTrap disabled={true} />
<EuiSpacer />
This story is passing all controls and their arguments to EuiProvider's
`componentDefaults` instead of to EuiFocusTrap directly. It's primarily
useful for testing that configured defaults behave the same way as
individual props.
</EuiProvider>
),
};
187 changes: 17 additions & 170 deletions src/components/focus_trap/focus_trap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,26 @@
* Side Public License, v 1.
*/

import React, { EventHandler } from 'react';
import { mount } from 'enzyme';
import React from 'react';
import { render } from '../../test/rtl';
import { findTestSubject, takeMountedSnapshot } from '../../test';
import { shouldRenderCustomStyles } from '../../test/internal';

import { EuiEvent } from '../outside_click_detector/outside_click_detector';
import { EuiFocusTrap } from './focus_trap';
import { EuiPortal } from '../portal';

describe('EuiFocusTrap', () => {
test('is rendered', () => {
const component = mount(
shouldRenderCustomStyles(<EuiFocusTrap>Test</EuiFocusTrap>);

it('renders', () => {
const { container } = render(
<EuiFocusTrap>
<div />
</EuiFocusTrap>
);

expect(
takeMountedSnapshot(component, { hasArrayOutput: true })
).toMatchSnapshot();
expect(container).toMatchSnapshot();
});

test('can be disabled', () => {
it('can be disabled', () => {
const { container } = render(
<EuiFocusTrap disabled>
<div />
Expand All @@ -38,7 +35,7 @@ describe('EuiFocusTrap', () => {
expect(container).toMatchSnapshot();
});

test('accepts className and style', () => {
it('accepts className and style', () => {
const { container } = render(
<EuiFocusTrap className="testing" style={{ height: '100%' }}>
<div />
Expand All @@ -50,8 +47,8 @@ describe('EuiFocusTrap', () => {

describe('behavior', () => {
describe('focus', () => {
test('is set on the first focusable element by default', () => {
const component = mount(
it('is set on the first focusable element by default', () => {
const { getByTestSubject } = render(
<div>
<input data-test-subj="outside" />
<EuiFocusTrap>
Expand All @@ -63,13 +60,11 @@ describe('EuiFocusTrap', () => {
</div>
);

expect(findTestSubject(component, 'input').getDOMNode()).toBe(
document.activeElement
);
expect(getByTestSubject('input')).toBe(document.activeElement);
});

test('will blur focus when negating `autoFocus`', () => {
mount(
it('will blur focus when negating `autoFocus`', () => {
render(
<div>
<input data-test-subj="outside" />
<EuiFocusTrap autoFocus={false}>
Expand All @@ -84,8 +79,8 @@ describe('EuiFocusTrap', () => {
expect(document.body).toBe(document.activeElement);
});

test('is set on the element identified by `data-autofocus`', () => {
const component = mount(
it('is set on the element identified by `data-autofocus`', () => {
const { getByTestSubject } = render(
<div>
<input data-test-subj="outside" />
<EuiFocusTrap>
Expand All @@ -97,155 +92,7 @@ describe('EuiFocusTrap', () => {
</div>
);

expect(findTestSubject(component, 'input2').getDOMNode()).toBe(
document.activeElement
);
});
});

// skipping because react-focus-on / react-focus-lock uses two handlers,
// one on the container to record what element was clicked and a second
// on the document, checking if the event target is the same on both
// because enzyme doesn't bubble the event, it is difficult to simulate
// the browser behaviour - we can revisit these tests when we have an
// actual browser environment
describe.skip('clickOutsideDisables', () => {
// enzyme doesn't mount the components into the global jsdom `document`
// but that's where the click detector listener is,
// pass the top-level mounted component's click event on to document
const triggerDocumentMouseDown: EventHandler<any> = (
e: React.MouseEvent
) => {
const event = new Event('mousedown') as EuiEvent;
event.euiGeneratedBy = (
e.nativeEvent as unknown as EuiEvent
).euiGeneratedBy;
document.dispatchEvent(event);
};

const triggerDocumentMouseUp: EventHandler<any> = (
e: React.MouseEvent
) => {
const event = new Event('mousedown') as EuiEvent;
event.euiGeneratedBy = (
e.nativeEvent as unknown as EuiEvent
).euiGeneratedBy;
document.dispatchEvent(event);
};

test('trap remains enabled when false', () => {
const component = mount(
<div
onMouseDown={triggerDocumentMouseDown}
onMouseUp={triggerDocumentMouseUp}
>
<EuiFocusTrap>
<div data-test-subj="container">
<input data-test-subj="input" />
<input data-test-subj="input2" />
</div>
</EuiFocusTrap>
<button data-test-subj="outside" />
</div>
);

// The existence of `data-focus-lock-disabled=false` indicates that the trap is enabled.
expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
findTestSubject(component, 'outside').simulate('mousedown');
findTestSubject(component, 'outside').simulate('mouseup');
// `react-focus-lock` relies on real DOM events to move focus about.
// Exposed attributes are the most consistent way to attain its state.
// See https://github.com/theKashey/react-focus-lock/blob/master/_tests/FocusLock.spec.js for the lib in use
// Trap remains enabled
expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
});

test('trap remains enabled after internal clicks', () => {
const component = mount(
<div
onMouseDown={triggerDocumentMouseDown}
onMouseUp={triggerDocumentMouseUp}
>
<EuiFocusTrap clickOutsideDisables>
<div data-test-subj="container">
<input data-test-subj="input" />
<input data-test-subj="input2" />
</div>
</EuiFocusTrap>
<button data-test-subj="outside" />
</div>
);

expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
findTestSubject(component, 'input2').simulate('mousedown');
findTestSubject(component, 'input2').simulate('mouseup');
// Trap remains enabled
expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
});

test('trap remains enabled after internal portal clicks', () => {
const component = mount(
<div
onMouseDown={triggerDocumentMouseDown}
onMouseUp={triggerDocumentMouseUp}
>
<EuiFocusTrap clickOutsideDisables>
<div data-test-subj="container">
<input data-test-subj="input" />
<input data-test-subj="input2" />
<EuiPortal>
<input data-test-subj="input3" />
</EuiPortal>
</div>
</EuiFocusTrap>
<button data-test-subj="outside" />
</div>
);

expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
findTestSubject(component, 'input3').simulate('mousedown');
findTestSubject(component, 'input3').simulate('mouseup');
// Trap remains enabled
expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
});

test('trap becomes disabled on outside clicks', () => {
const component = mount(
<div
onMouseDown={triggerDocumentMouseDown}
onMouseUp={triggerDocumentMouseUp}
>
<EuiFocusTrap clickOutsideDisables>
<div data-test-subj="container">
<input data-test-subj="input" />
<input data-test-subj="input2" />
</div>
</EuiFocusTrap>
<button data-test-subj="outside" />
</div>
);

expect(
component.find('[data-focus-lock-disabled=false]').length
).not.toBeLessThan(1);
findTestSubject(component, 'outside').simulate('mousedown');
findTestSubject(component, 'outside').simulate('mouseup');
// Trap becomes disabled
expect(component.find('[data-focus-lock-disabled=false]').length).toBe(
0
);
expect(getByTestSubject('input2')).toBe(document.activeElement);
});
});
});
Expand Down
Loading

0 comments on commit 5e5ae65

Please sign in to comment.