diff --git a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.spec.tsx b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.spec.tsx new file mode 100644 index 000000000000..a77dbae6db39 --- /dev/null +++ b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.spec.tsx @@ -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. + */ + +/// +/// +/// + +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: () => ( + <> + + + + ), + columns: [ + { id: 'expandable', isExpandable: true }, + { + id: 'notExpandable', + isExpandable: false, + display: ( + + ), + }, + ], + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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'); + }); + }); +}); diff --git a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.ts b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.ts index eabc61a23ca2..5150ad882157 100644 --- a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.ts +++ b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.styles.ts @@ -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; } @@ -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} } `, diff --git a/packages/eui/src/components/datagrid/body/cell/data_grid_cell_actions.styles.ts b/packages/eui/src/components/datagrid/body/cell/data_grid_cell_actions.styles.ts index 0a7a633cbe69..c551ee37d1ab 100644 --- a/packages/eui/src/components/datagrid/body/cell/data_grid_cell_actions.styles.ts +++ b/packages/eui/src/components/datagrid/body/cell/data_grid_cell_actions.styles.ts @@ -17,14 +17,21 @@ import { } from '../../../../global_styling'; import { euiDataGridVariables } from '../../data_grid.styles'; -import { euiDataGridCellOutlineStyles } from './data_grid_cell.styles'; +import { + euiDataGridCellOutlineStyles, + euiDataGridCellOutlineSelectors, +} from './data_grid_cell.styles'; export const euiDataGridCellActionsStyles = (euiThemeContext: UseEuiTheme) => { const { euiTheme } = euiThemeContext; const { levels } = euiDataGridVariables(euiThemeContext); - const cellOutline = euiDataGridCellOutlineStyles(euiThemeContext); const borderWidth = euiTheme.border.width.thin; + const cellOutline = euiDataGridCellOutlineStyles(euiThemeContext); + const { actions: cellSelectors } = euiDataGridCellOutlineSelectors( + '.euiDataGridRowCell' + ); + return { euiDataGridRowCell__actionsWrapper: css` position: absolute; @@ -40,20 +47,16 @@ export const euiDataGridCellActionsStyles = (euiThemeContext: UseEuiTheme) => { z-index: ${levels.stickyHeader + 1}; } - /* If a cell is not hovered nor focused nor open via popover, don't show the actions */ - .euiDataGridRowCell:not(:hover, ${cellOutline.rowCellFocusSelectors}) & { - display: none; - } - - /* Increase non-visible hitbox of cell on hover, to reduce UX friction - * for users mousing from the cell diagonally over to the actions */ - .euiDataGridRowCell:hover:not(${cellOutline.rowCellFocusSelectors}) & { + /* Increase non-visible hover zone, to reduce UX friction for + * users mousing from the cell diagonally over to the actions */ + ${cellSelectors.hoverZone} & { ${logicalCSS('min-width', '50%')} ${logicalCSS('padding-right', euiTheme.size.base)} } `, euiDataGridRowCell__actions: css` + position: relative; display: flex; gap: ${euiTheme.size.xxs}; ${logicalCSS('width', 'fit-content')} @@ -73,14 +76,20 @@ export const euiDataGridCellActionsStyles = (euiThemeContext: UseEuiTheme) => { ${logicalCSS('top', '100%')} ${logicalCSS('left', `-${borderWidth}`)} ${logicalSizeCSS(mathWithUnits(borderWidth, (x) => x * 2))} - background-color: ${cellOutline.focusColor}; + background-color: inherit; + } + + /* When hovered and not focused, cell actions should match the gray focus outline */ + ${cellSelectors.hoverColor} & { + background-color: ${cellOutline.hoverColor}; + border-color: ${cellOutline.hoverColor}; } ${euiCanAnimate} { transform: scaleY(0); transform-origin: bottom; - .euiDataGridRowCell:is(:hover, ${cellOutline.rowCellFocusSelectors}) & { + ${cellSelectors.showAnimation} & { animation-duration: ${euiTheme.animation.fast}; animation-name: ${slideUp}; animation-iteration-count: 1; @@ -88,14 +97,8 @@ export const euiDataGridCellActionsStyles = (euiThemeContext: UseEuiTheme) => { } /* Delay the actions showing on hover only, show instantly otherwise */ - .euiDataGridRowCell:hover:not(${cellOutline.rowCellFocusSelectors}) & { - animation-delay: ${euiTheme.animation.slow}; /* 2 */ - background-color: ${cellOutline.hoverColor}; - border-color: ${cellOutline.hoverColor}; - - &::after { - background-color: ${cellOutline.hoverColor}; - } + ${cellSelectors.hoverAnimation} & { + animation-delay: ${euiTheme.animation.slow}; } } `, diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts index 7655a8db29cd..5ea2bfc551a0 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.styles.ts @@ -10,7 +10,10 @@ import { css } from '@emotion/react'; import { UseEuiTheme } from '../../../../services'; -import { euiDataGridCellOutlineStyles } from '../cell/data_grid_cell.styles'; +import { + euiDataGridCellOutlineStyles, + euiDataGridCellOutlineSelectors, +} from '../cell/data_grid_cell.styles'; /** * Styles that apply to both control and non-control columns @@ -18,15 +21,19 @@ import { euiDataGridCellOutlineStyles } from '../cell/data_grid_cell.styles'; export const euiDataGridHeaderCellWrapperStyles = ( euiThemeContext: UseEuiTheme ) => { - const { focusStyles } = euiDataGridCellOutlineStyles(euiThemeContext); + const { focusStyles, hoverStyles } = + euiDataGridCellOutlineStyles(euiThemeContext); + const { header: outlineSelectors } = euiDataGridCellOutlineSelectors(); return { euiDataGridHeaderCell: css` - &:focus, - &:has(.euiDataGridHeaderCell__button:focus), - &.euiDataGridHeaderCell--isActionsPopoverOpen { + ${outlineSelectors.focus} { ${focusStyles} } + + ${outlineSelectors.focusTrapped} { + ${hoverStyles} + } `, }; };