Skip to content

Commit

Permalink
[refactor] Rewrite focus/hover selectors
Browse files Browse the repository at this point in the history
- to reduce unnecessary selectors
- to comment everything in one place
+ write E2E tests checking for expected CSS behavior
+ add UI enhancement that shows the outline when cell focus traps are entered
  • Loading branch information
cee-chen committed Sep 10, 2024
1 parent 01dbd90 commit f3fab78
Show file tree
Hide file tree
Showing 4 changed files with 362 additions and 34 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

/// <reference types="cypress" />
/// <reference types="cypress-real-events" />
/// <reference types="../../../../../cypress/support" />

import React from 'react';
import { EuiDataGrid } from '../../data_grid';

const EXPECTED_HOVER_COLOR = 'rgb(105, 112, 125)';
const EXPECTED_FOCUS_COLOR = 'rgb(0, 119, 204)';
const ANIMATION = {
DELAY: 350,
DURATION: 150,
BUFFER: 25, // extra wait buffer to reduce flakiness
};

describe('Cell outline styles', () => {
const baseProps = {
'aria-label': 'Test',
width: 300,
rowCount: 1,
renderCellValue: () => (
<>
<button data-test-subj="interactiveChildA">A</button>
<button data-test-subj="interactiveChildB">B</button>
</>
),
columns: [
{ id: 'expandable', isExpandable: true },
{
id: 'notExpandable',
isExpandable: false,
display: (
<button data-test-subj="interactiveHeader">interactive</button>
),
},
],
columnVisibility: {
setVisibleColumns: () => {},
visibleColumns: ['expandable', 'notExpandable'],
},
};

// Test utils
const getExpandableRowCell = () =>
cy.get('.euiDataGridRowCell[data-gridcell-column-id="expandable"]');
const getCellExpansionPopover = () => cy.get('.euiDataGridRowCell__popover');
const getActions = () => cy.get('.euiDataGridRowCell__actions');
const getActionsHeight = () =>
getActions().then(($el) => {
const { height } = $el[0].getBoundingClientRect();
return height;
});
const getOutlineColor = (el: HTMLElement) => {
// get Window reference from element
const win = el.ownerDocument.defaultView!;
// use getComputedStyle to read the pseudo selector
const pseudoElement = win.getComputedStyle(el, 'after');

return pseudoElement.getPropertyValue('border-color');
};

it('does not show cell actions if not focused or hovered', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);
getActions().should('not.exist');
});

describe('keyboard UI/UX', () => {
const tabToDataGrid = () => {
cy.repeatRealPress('Tab', 4);
};
const moveToRowCell = () => {
cy.realPress('ArrowDown');
};

it('shows the cell outline and actions as blue on focus', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();
moveToRowCell();

getExpandableRowCell().then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});
getActions().should('have.css', 'background-color', EXPECTED_FOCUS_COLOR);
});

it('runs the actions height animation without a delay on focus', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();
moveToRowCell();

cy.wait(ANIMATION.DURATION + ANIMATION.BUFFER);
getActionsHeight().then((height) => expect(height).to.eq(22));
});

it('does not re-run the actions height animation on popover keyboard close', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();
moveToRowCell();

cy.realPress('Enter');
getCellExpansionPopover().should('be.visible');
cy.wait(ANIMATION.DURATION + ANIMATION.BUFFER);
getActionsHeight().then((height) => expect(height).to.eq(22));

cy.realPress('Escape');
getCellExpansionPopover().should('not.exist');
getActionsHeight().then((height) => expect(height).to.eq(22));
});

describe('focus trap', () => {
it('should show gray hover styles on header cells when the focus trap is entered', () => {
const getHeaderCell = () =>
cy.get(
'.euiDataGridHeaderCell[data-gridcell-column-id="notExpandable"]'
);

cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();
cy.realPress('ArrowRight');
getHeaderCell()
.should('be.focused')
.then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});

cy.realPress('Enter');
getHeaderCell().then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_HOVER_COLOR);
});
cy.get('[data-test-subj="interactiveHeader"]').should('be.focused');

cy.realPress('Escape');
getHeaderCell()
.should('be.focused')
.then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});
});

it('should show gray hover styles on row cells when the focus trap is entered', () => {
const getRowCell = () =>
cy.get(
'.euiDataGridRowCell[data-gridcell-column-id="notExpandable"]'
);

cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();
cy.realPress('ArrowRight');
moveToRowCell();
getRowCell()
.should('be.focused')
.then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});

cy.realPress('Enter');
getRowCell().then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_HOVER_COLOR);
});
cy.get('[data-test-subj="interactiveChildA"]').should('be.focused');

cy.realPress('Escape');
getRowCell()
.should('be.focused')
.then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});
});
});

describe('open popovers', () => {
it('should always show the focus color state when the cell header actions popover is open', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();

cy.realPress('Enter');
cy.get(
'[data-test-subj="dataGridHeaderCellActionGroup-expandable"]'
).should('be.visible');

cy.get(
'.euiDataGridHeaderCell[data-gridcell-column-id="expandable"]'
).then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});
});

it('should always show the focus color state when the cell expansion popover is open', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);
tabToDataGrid();
moveToRowCell();

cy.realPress('Enter');
getCellExpansionPopover().should('be.visible');

getExpandableRowCell().then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_FOCUS_COLOR);
});
});
});
});

describe('mouse UI/UX', () => {
it('shows the cell outline and actions as gray on hover', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);

getExpandableRowCell().realHover();

getExpandableRowCell().then(($el) => {
expect(getOutlineColor($el[0])).to.eq(EXPECTED_HOVER_COLOR);
});
getActions().should('have.css', 'background-color', EXPECTED_HOVER_COLOR);
});

it('waits to run the actions height animation on hover', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);

getExpandableRowCell().realHover();
getActionsHeight().then((height) => expect(height).to.eq(0));

cy.wait(ANIMATION.DELAY + ANIMATION.DURATION + ANIMATION.BUFFER);
getActionsHeight().then((height) => expect(height).to.eq(22));
});

it('immediately runs the actions height animation if clicked after hover', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);

getExpandableRowCell().realHover();
getActionsHeight().then((height) => expect(height).to.eq(0));

getExpandableRowCell().realClick();
cy.wait(ANIMATION.DURATION + ANIMATION.BUFFER);
getActionsHeight().then((height) => expect(height).to.eq(22));
});

it('does not flash between hover and focus colors when cell expansion is toggled via click', () => {
const clickExpandAction = () =>
cy
.get('[data-test-subj="euiDataGridCellExpandButton"]')
.realMouseMove(0, 0, { position: 'center' })
.realClick();

cy.realMount(<EuiDataGrid {...baseProps} />);

getExpandableRowCell().realHover();
cy.wait(ANIMATION.DELAY + ANIMATION.DURATION + ANIMATION.BUFFER);
clickExpandAction();
getCellExpansionPopover().should('be.visible');
getActions().should('have.css', 'background-color', EXPECTED_FOCUS_COLOR);

clickExpandAction();
getCellExpansionPopover().should('not.exist');
getActions().should('have.css', 'background-color', EXPECTED_FOCUS_COLOR);
});

it('has an invisible hover zone to the right of the cell actions', () => {
cy.realMount(<EuiDataGrid {...baseProps} />);

getExpandableRowCell().realHover();
getActions().should('be.visible');

getActions()
.realMouseMove(16, 0, { position: 'right' })
.should('be.visible')
.realMouseMove(70, 0, { position: 'right' }) // ~50% of cell width
.should('not.exist');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const euiDataGridCellOutlineStyles = ({ euiTheme }: UseEuiTheme) => {
focusColor,
focusStyles: `
/* Remove outline as we're handling it manually. Needed to override global styles */
&:focus:focus-visible {
&:focus-visible {
outline: none;
}
Expand All @@ -49,27 +49,68 @@ export const euiDataGridCellOutlineStyles = ({ euiTheme }: UseEuiTheme) => {
border-color: ${hoverColor};
}
`,
rowCellFocusSelectors: [
':focus', // cell has been clicked or keyboard navigated to
'.euiDataGridRowCell--open', // always show when the cell expansion popover is open
'[data-keyboard-closing]', // prevents the animation from replaying when keyboard focus is moved from the popover back to the cell
].join(', '),
};
};

export const euiDataGridCellOutlineSelectors = (parentSelector = '&') => {
// Focus selectors
const focus = ':focus'; // cell has been clicked or keyboard navigated to
const isOpen = '.euiDataGridRowCell--open'; // always show when the cell expansion popover is open
const isClosing = '[data-keyboard-closing]'; // prevents the animation from replaying when keyboard focus is moved from the popover back to the cell
const isEntered = ':has([data-focus-lock-disabled="false"])'; // cell focus trap has been entered - ideally show the outline still, but grayed out

// Hover selectors
const hover = ':hover'; // hover styles should not supercede focus styles
const focusWithin = ':focus-within'; // used by :hover:not() to prevent flash of gray when mouse users are opening/closing the expansion popover via cell action click

// Cell header specific selectors
const headerActionsOpen = '.euiDataGridHeaderCell--isActionsPopoverOpen';

// Utils
const selectors = (...args: string[]) => [...args].join(', ');
const is = (selectors: string) => `${parentSelector}:is(${selectors})`;
const hoverNot = (selectors: string) =>
`${parentSelector}:hover:not(${selectors})`;
const _ = (selectors: string) => `${parentSelector}${selectors}`;

return {
outline: {
show: is(selectors(hover, focus, isOpen, isEntered)),
hover: hoverNot(selectors(focus, focusWithin, isOpen)),
focusTrapped: _(isEntered),
},

actions: {
hoverZone: hoverNot(selectors(focus, isOpen)),
hoverColor: hoverNot(selectors(focus, focusWithin, isOpen)),
showAnimation: is(selectors(hover, focus, isOpen, isClosing)),
hoverAnimation: hoverNot(selectors(focus, isOpen, isClosing)),
},

header: {
focus: is(selectors(focus, focusWithin, headerActionsOpen)), // :focus-within here is primarily intended for when the column actions button has been clicked twice
focusTrapped: _(isEntered),
},
};
};

export const euiDataGridRowCellStyles = (euiThemeContext: UseEuiTheme) => {
const cellOutline = euiDataGridCellOutlineStyles(euiThemeContext);
const { outline: outlineSelectors } = euiDataGridCellOutlineSelectors();

return {
euiDataGridRowCell: css`
position: relative; /* Needed for .euiDataGridRowCell__actions */
&:hover,
${cellOutline.rowCellFocusSelectors} {
${outlineSelectors.show} {
${cellOutline.focusStyles}
}
&:hover:not(${cellOutline.rowCellFocusSelectors}) {
${outlineSelectors.hover} {
${cellOutline.hoverStyles}
}
${outlineSelectors.focusTrapped} {
${cellOutline.hoverStyles}
}
`,
Expand Down
Loading

0 comments on commit f3fab78

Please sign in to comment.