diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_Disabled_With_Tooltips.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_Disabled_With_Tooltips.png new file mode 100644 index 00000000000..83a13ad65bc Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_Disabled_With_Tooltips.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_With_Tooltips.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_With_Tooltips.png index 810832d6de9..3e656f2983b 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_With_Tooltips.png and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiButtonGroup_With_Tooltips.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_Disabled_With_Tooltips.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_Disabled_With_Tooltips.png new file mode 100644 index 00000000000..fd0f93f521e Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_Disabled_With_Tooltips.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_With_Tooltips.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_With_Tooltips.png index 5dc3ec06c71..f9a84163e50 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_With_Tooltips.png and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiButtonGroup_With_Tooltips.png differ diff --git a/packages/eui/changelogs/upcoming/9201.md b/packages/eui/changelogs/upcoming/9201.md new file mode 100644 index 00000000000..4abd17cdb7e --- /dev/null +++ b/packages/eui/changelogs/upcoming/9201.md @@ -0,0 +1,7 @@ +- Added beta prop `hasAriaDisabled` to all base button components: `EuiButton`, `EuiButtonEmpty`, `EuiButtonIcon`, `EuibuttonGroup`, `EuiFilterButton` +- Added `euiDisabledSelector` variable that combines CSS selectors `:disabled` and `[aria-disabled="true"]` +- Added custom test matchers that check for both `disabled` and `aria-disabled` attributes: + - React testing Library: `.toBeEuiDisabled()` + - Enzyme: `.toHaveEuiDisabledProp()` + - Cypress: `should('be.euiDisabled)` + diff --git a/packages/eui/cypress/support/component.tsx b/packages/eui/cypress/support/component.tsx index 50016cd0814..e0a0d741cae 100644 --- a/packages/eui/cypress/support/component.tsx +++ b/packages/eui/cypress/support/component.tsx @@ -23,6 +23,7 @@ import './keyboard/repeatRealPress'; import './copy/select_and_copy'; import './setup/mount'; import './setup/realMount'; +import './setup/matchers'; import './css/cssVar'; import './helpers/wait_for_position_to_settle'; diff --git a/packages/eui/cypress/support/index.d.ts b/packages/eui/cypress/support/index.d.ts index 54f3624eb9b..d1aa9c66e3d 100644 --- a/packages/eui/cypress/support/index.d.ts +++ b/packages/eui/cypress/support/index.d.ts @@ -69,5 +69,15 @@ declare global { */ waitForPositionToSettle(): Chainable>; } + interface Chainer { + (chainer: 'be.euiDisabled'): Chainable; + (chainer: 'be.euiEnabled'): Chainable; + } + } + namespace Chai { + interface Assertion { + euiDisabled: Assertion; + euiEnabled: Assertion; + } } } diff --git a/packages/eui/cypress/support/setup/matchers.ts b/packages/eui/cypress/support/setup/matchers.ts new file mode 100644 index 00000000000..44a46038116 --- /dev/null +++ b/packages/eui/cypress/support/setup/matchers.ts @@ -0,0 +1,11 @@ +/* + * 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 { setupEuiCypressMatchers } from '../../../src/test/cypress'; + +setupEuiCypressMatchers(); diff --git a/packages/eui/cypress/support/setup/realMount.tsx b/packages/eui/cypress/support/setup/realMount.tsx index cb114cb5dcf..7e4ea6551f1 100644 --- a/packages/eui/cypress/support/setup/realMount.tsx +++ b/packages/eui/cypress/support/setup/realMount.tsx @@ -8,8 +8,9 @@ import React, { ReactNode } from 'react'; import './mount'; +import { MountOptions } from './mount'; -const realMountCommand = (children: ReactNode) => { +const realMountCommand = (children: ReactNode, options: MountOptions = {}) => { cy.mount( <>
{ style={{ height: '1px', width: '1px' }} /> {children} - + , + options ).then(() => { cy.get('[data-test-subj="cypress-real-event-target"]').realClick({ position: 'topLeft', diff --git a/packages/eui/scripts/compile-eui.js b/packages/eui/scripts/compile-eui.js index 9febdf9d7cb..14ee2282bbf 100755 --- a/packages/eui/scripts/compile-eui.js +++ b/packages/eui/scripts/compile-eui.js @@ -249,7 +249,8 @@ async function compileBundle() { 'optimize/es/test', ].map((dir) => path.join(packageRootDir, dir)); - const testRtlDTSFiles = new glob.Glob('test/rtl/**/*.d.ts', { + const testDirectories = ['rtl', 'enzyme']; + const testDTSFiles = new glob.Glob('test/**/*.d.ts', { cwd: srcDir, realpath: true, }); @@ -278,12 +279,17 @@ async function compileBundle() { }, }); - await fs.mkdir(path.join(dir, 'rtl'), { recursive: true }); + for (const testDir of testDirectories) { + await fs.mkdir(path.join(dir, testDir), { recursive: true }); + } - for await (const filePath of testRtlDTSFiles) { + for await (const filePath of testDTSFiles) { const fullPath = path.join(srcDir, filePath); - const baseName = path.basename(filePath); - await fs.copyFile(fullPath, path.join(dir, 'rtl', baseName)); + + const relativePath = filePath.replace(/^test\//, ''); + const destPath = path.join(dir, relativePath); + + await fs.copyFile(fullPath, destPath); } } diff --git a/packages/eui/scripts/jest/config.js b/packages/eui/scripts/jest/config.js index 68146d6e63e..732ba30560c 100644 --- a/packages/eui/scripts/jest/config.js +++ b/packages/eui/scripts/jest/config.js @@ -54,6 +54,7 @@ const config = { setupFilesAfterEnv: [ '/scripts/jest/setup/polyfills.js', '/scripts/jest/setup/unmount_enzyme.js', + '/scripts/jest/setup/matchers.js', ], coverageDirectory: '/reports/jest-coverage', coverageReporters: ['json', 'html'], diff --git a/packages/eui/scripts/jest/setup/matchers.js b/packages/eui/scripts/jest/setup/matchers.js new file mode 100644 index 00000000000..d0c0509ada6 --- /dev/null +++ b/packages/eui/scripts/jest/setup/matchers.js @@ -0,0 +1,7 @@ +const setupEuiMatchers = + require('../../../src/test/rtl/matchers.ts').setupEuiMatchers; +const setupEuiEnzymeMatchers = + require('../../../src/test/enzyme/enzyme_matchers.ts').setupEuiEnzymeMatchers; + +setupEuiMatchers(); +setupEuiEnzymeMatchers(); diff --git a/packages/eui/src/components/button/__snapshots__/button.test.tsx.snap b/packages/eui/src/components/button/__snapshots__/button.test.tsx.snap index 6d78a4bfca7..6c607ef32d7 100644 --- a/packages/eui/src/components/button/__snapshots__/button.test.tsx.snap +++ b/packages/eui/src/components/button/__snapshots__/button.test.tsx.snap @@ -253,6 +253,7 @@ exports[`EuiButton props iconType is rendered 1`] = ` exports[`EuiButton props isDisabled is rendered 1`] = ` diff --git a/packages/eui/src/components/button/button_group/button_group.stories.tsx b/packages/eui/src/components/button/button_group/button_group.stories.tsx index 91f7588e3ec..2524f3d7292 100644 --- a/packages/eui/src/components/button/button_group/button_group.stories.tsx +++ b/packages/eui/src/components/button/button_group/button_group.stories.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { disableStorybookControls } from '../../../../.storybook/utils'; +import { LOKI_SELECTORS } from '../../../../.storybook/loki'; import { EuiSpacer } from '../../spacer'; import { EuiButtonGroup, @@ -41,6 +42,7 @@ const meta: Meta = { buttonSize: 's', color: 'text', isDisabled: false, + hasAriaDisabled: false, isFullWidth: false, isIconOnly: false, options: [], @@ -79,13 +81,13 @@ const StatefulEuiButtonGroupSingle = (props: any) => { }; export const SingleSelection: Story = { - render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - single selection', options, type: 'single', idSelected: 'button1', }, + render: ({ ...args }) => , }; const StatefulEuiButtonGroupMulti = (props: any) => { @@ -112,17 +114,21 @@ const StatefulEuiButtonGroupMulti = (props: any) => { }; export const MultiSelection: Story = { - render: ({ ...args }) => , args: { legend: 'EuiButtonGroup - multiple selections', options, type: 'multi', idToSelectedMap: { button1: true }, }, + render: ({ ...args }) => , }; export const WithTooltips: Story = { - render: ({ ...args }) => , + parameters: { + loki: { + chromeSelector: LOKI_SELECTORS.portal, + }, + }, args: { legend: 'EuiButtonGroup - tooltip UI testing', isIconOnly: true, // Start example with icons to demonstrate usefulness of tooltips @@ -137,7 +143,8 @@ export const WithTooltips: Story = { iconType: 'securitySignalResolved', label: 'Standard tooltip', toolTipContent: 'Hello world', - }, + autoFocus: true, // dev-only usage to showcase tooltip on load + } as EuiButtonGroupOptionProps, { id: 'customToolTipProps', iconType: 'securitySignalDetected', @@ -155,8 +162,26 @@ export const WithTooltips: Story = { type: 'multi', idToSelectedMap: { button1: true }, }, + render: ({ ...args }) => , +}; + +export const DisabledWithTooltips: Story = { + ...WithTooltips, + parameters: { + ...WithTooltips.parameters, + controls: { + include: ['options', 'isDisabled', 'hasAriaDisabled'], + }, + }, + args: { + ...WithTooltips.args, + isDisabled: true, + hasAriaDisabled: true, + }, }; +/** VRT only */ + export const IconOnly: Story = { tags: ['vrt-only'], args: { diff --git a/packages/eui/src/components/button/button_group/button_group.test.tsx b/packages/eui/src/components/button/button_group/button_group.test.tsx index cdfbb6891e0..49318d1ac74 100644 --- a/packages/eui/src/components/button/button_group/button_group.test.tsx +++ b/packages/eui/src/components/button/button_group/button_group.test.tsx @@ -146,6 +146,28 @@ describe('EuiButtonGroup', () => { }); }); + describe('hasAriaDisabled', () => { + it('renders buttons with `aria-disabled` when `isDisabled=true`', () => { + const { getByTestSubject } = render( + + ); + + const button = getByTestSubject('button01'); + const fieldset = getByTestSubject('button-group'); + + expect(button).toBeEuiDisabled(); + expect(fieldset).toBeEuiDisabled(); + + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(fieldset).toHaveAttribute('aria-disabled', 'true'); + }); + }); + describe('isFullWidth', () => { it('is rendered for single', () => { const { container } = render( @@ -252,6 +274,40 @@ describe('EuiButtonGroup', () => { await waitForEuiToolTipHidden(); }); + it('shows a tooltip on hover and focus when custom disabled via `hasAriaDisabled`', async () => { + const { getByTestSubject, findByRole } = render( + + ); + + // NOTE: uses `parentElement` as the hover event is triggered on the tooltip wrapper. + // The button itself doesn't allow mouse events when disabled. + fireEvent.mouseOver(getByTestSubject('buttonWithTooltip').parentElement!); + await waitForEuiToolTipVisible(); + + expect(await findByRole('tooltip')).toHaveTextContent('I am a tooltip'); + + fireEvent.mouseOut(getByTestSubject('buttonWithTooltip').parentElement!); + await waitForEuiToolTipHidden(); + + fireEvent.focus(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipVisible(); + fireEvent.blur(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipHidden(); + }); + it('allows customizing the tooltip via `toolTipProps`', async () => { const { getByTestSubject } = render( >; } -export type EuiButtonGroupProps = CommonProps & { - /** - * Typical sizing is `s`. Medium `m` size should be reserved for major features. - * `compressed` is meant to be used alongside and within compressed forms. - */ - buttonSize?: 's' | 'm' | 'compressed'; - isDisabled?: boolean; - /** - * Expands the whole group to the full width of the container. - * Each button gets equal widths no matter the content - */ - isFullWidth?: boolean; - /** - * Hides the label to only show the `iconType` provided by the `option` - */ - isIconOnly?: boolean; - /** - * A hidden group title (required for accessibility) - */ - legend: string; - /** - * Any of the named color palette options. - * - * Do not use the following colors for standalone buttons directly, - * they exist to serve other components: - * - accent - * - warning - */ - color?: _EuiButtonColor; - /** - * Actual type is `'single' | 'multi'`. - * Determines how the selection of the group should be handled. - * With `'single'` only one option can be selected at a time (similar to radio group). - * With `'multi'` multiple options selected (similar to checkbox group). - */ - type?: 'single' | 'multi'; - /** - * An array of {@link EuiButtonGroupOptionProps} - */ - options: EuiButtonGroupOptionProps[]; -} & ( +export type EuiButtonGroupProps = CommonProps & + EuiDisabledProps & { + /** + * Typical sizing is `s`. Medium `m` size should be reserved for major features. + * `compressed` is meant to be used alongside and within compressed forms. + */ + buttonSize?: 's' | 'm' | 'compressed'; + /** + * Expands the whole group to the full width of the container. + * Each button gets equal widths no matter the content + */ + isFullWidth?: boolean; + /** + * Hides the label to only show the `iconType` provided by the `option` + */ + isIconOnly?: boolean; + /** + * A hidden group title (required for accessibility) + */ + legend: string; + /** + * Any of the named color palette options. + * + * Do not use the following colors for standalone buttons directly, + * they exist to serve other components: + * - accent + * - warning + */ + color?: _EuiButtonColor; + /** + * Actual type is `'single' | 'multi'`. + * Determines how the selection of the group should be handled. + * With `'single'` only one option can be selected at a time (similar to radio group). + * With `'multi'` multiple options selected (similar to checkbox group). + */ + type?: 'single' | 'multi'; + /** + * An array of {@link EuiButtonGroupOptionProps} + */ + options: EuiButtonGroupOptionProps[]; + } & ( | { /** * Default for `type` is single so it can also be excluded @@ -153,6 +154,7 @@ export const EuiButtonGroup: FunctionComponent = ({ idSelected = '', idToSelectedMap = {}, isDisabled = false, + hasAriaDisabled = false, isFullWidth = false, isIconOnly = false, legend, @@ -182,12 +184,17 @@ export const EuiButtonGroup: FunctionComponent = ({ const typeIsSingle = type === 'single'; + const groupDisabledProps = { + disabled: hasAriaDisabled ? undefined : isDisabled, + 'aria-disabled': hasAriaDisabled ? isDisabled : undefined, + }; + return (
{legend} @@ -199,6 +206,7 @@ export const EuiButtonGroup: FunctionComponent = ({ { )} } - &:is(${selectedSelectors}):not(:disabled) { + &:is(${selectedSelectors}):not(${euiDisabledSelector}) { z-index: 1; /* prevent layout jumps due to missing border for selected/filled buttons */ border: ${euiTheme.border.width.thin} solid transparent; diff --git a/packages/eui/src/components/button/button_icon/__snapshots__/button_icon.test.tsx.snap b/packages/eui/src/components/button/button_icon/__snapshots__/button_icon.test.tsx.snap index 9cd23624384..d23d52f8231 100644 --- a/packages/eui/src/components/button/button_icon/__snapshots__/button_icon.test.tsx.snap +++ b/packages/eui/src/components/button/button_icon/__snapshots__/button_icon.test.tsx.snap @@ -202,6 +202,7 @@ exports[`EuiButtonIcon props isDisabled is rendered 1`] = ` diff --git a/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap b/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap index 6d5c1e5741c..e07f49f78a4 100644 --- a/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap +++ b/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap @@ -88,6 +88,7 @@ exports[`EuiFilterButton props isDisabled renders 1`] = ` > + ); +}; + +const Component = ( + props: EuiButtonPropsForButton & { hasAriaDisabled?: boolean } +) => ( + + + + + +); + +describe('useEuiDisabledElement()', () => { + describe('`hasAriaDisabled=false`', () => { + describe('`isDisabled=false`', () => { + it('renders enabled buttons', () => { + cy.realMount(); + + cy.get('[data-test-subj="button-1"]').should('be.euiEnabled'); + cy.get('[data-test-subj="button-2"]').should('be.euiEnabled'); + cy.get('[data-test-subj="button-3"]').should('be.euiEnabled'); + }); + + it('triggers events correctly', () => { + const onClickSpy = cy.spy().as('onClickSpy'); + const onMouseDownSpy = cy.spy().as('onMouseDownSpy'); + const onKeyDownSpy = cy.spy().as('onKeyDownSpy'); + + cy.realMount( + + ); + + cy.get('[data-test-subj="button-1"]').should('be.euiEnabled'); + cy.get('[data-test-subj="button-2"]').should('be.euiEnabled'); + cy.get('[data-test-subj="button-3"]').should('be.euiEnabled'); + + const button = cy.get('[data-test-subj="button-1"]'); + + button.realClick(); + cy.get('@onClickSpy').should('have.been.called'); + + button.realMouseDown(); + cy.get('@onMouseDownSpy').should('have.been.called'); + + button.realPress('Enter'); + cy.get('@onKeyDownSpy').should('have.been.called'); + }); + }); + + describe('`isDisabled=true`', () => { + it('renders disabled buttons', () => { + cy.realMount(); + + cy.get('[data-test-subj="button-1"]').should('be.euiDisabled'); + cy.get('[data-test-subj="button-2"]').should('be.euiDisabled'); + cy.get('[data-test-subj="button-3"]').should('be.euiDisabled'); + }); + }); + }); + + describe('`hasAriaDisabled=true`', () => { + describe('`isDisabled=false`', () => { + it('renders enabled buttons', () => { + cy.realMount(); + + cy.get('[data-test-subj="button-1"]').should('be.euiEnabled'); + cy.get('[data-test-subj="button-2"]').should('be.euiEnabled'); + cy.get('[data-test-subj="button-3"]').should('be.euiEnabled'); + }); + + it('triggers events', () => { + const onClickSpy = cy.spy().as('onClickSpy'); + + const onMouseDownSpy = cy.spy().as('onMouseDownSpy'); + const onMouseUpSpy = cy.spy().as('onMouseUpSpy'); + + const onPointerDownSpy = cy.spy().as('onPointerDownSpy'); + const onPointerUpSpy = cy.spy().as('onPointerUpSpy'); + + const onTouchStartSpy = cy.spy().as('onTouchStartSpy'); + const onTouchEndSpy = cy.spy().as('onTouchEndSpy'); + + const onKeyDownSpy = cy.spy().as('onKeyDownSpy'); + const onKeyUpSpy = cy.spy().as('onKeyUpSpy'); + const onKeyPressSpy = cy.spy().as('onKeyPressSpy'); + + cy.realMount( + + ); + + let button = cy.get('[data-test-subj="button-1"]'); + + button.realClick(); + cy.get('@onClickSpy').should('have.been.called'); + + button.realMouseDown(); + button.realMouseUp(); + cy.get('@onMouseDownSpy').should('have.been.called'); + cy.get('@onMouseUpSpy').should('have.been.called'); + + button = cy.get('[data-test-subj="button-2"]'); + + button.trigger('pointerdown'); + button.trigger('pointerup'); + cy.get('@onPointerDownSpy').should('have.been.called'); + cy.get('@onPointerUpSpy').should('have.been.called'); + + button.trigger('touchstart'); + button.trigger('touchend'); + cy.get('@onTouchStartSpy').should('have.been.called'); + cy.get('@onTouchEndSpy').should('have.been.called'); + + button = cy.get('[data-test-subj="button-3"]'); + + button.focus(); + button.realPress('Enter'); + cy.get('@onKeyDownSpy').should('have.been.called'); + cy.get('@onKeyPressSpy').should('have.been.called'); + cy.get('@onKeyUpSpy').should('have.been.called'); + }); + }); + + describe('`isDisabled=true`', () => { + it('renders disabled buttons', () => { + cy.realMount(); + + cy.get('[data-test-subj="button-1"]').should('be.euiDisabled'); + cy.get('[data-test-subj="button-2"]').should('be.euiDisabled'); + cy.get('[data-test-subj="button-3"]').should('be.euiDisabled'); + }); + + it('focuses buttons', () => { + cy.realMount(); + + cy.get('[data-test-subj="button-1"]').focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-test-subj', 'button-2'); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-test-subj', 'button-3'); + }); + + it('does not trigger events', () => { + const onClickSpy = cy.spy().as('onClickSpy'); + + const onMouseDownSpy = cy.spy().as('onMouseDownSpy'); + const onMouseUpSpy = cy.spy().as('onMouseUpSpy'); + + const onPointerDownSpy = cy.spy().as('onPointerDownSpy'); + const onPointerUpSpy = cy.spy().as('onPointerUpSpy'); + + const onTouchStartSpy = cy.spy().as('onTouchStartSpy'); + const onTouchEndSpy = cy.spy().as('onTouchEndSpy'); + + const onKeyDownSpy = cy.spy().as('onKeyDownSpy'); + const onKeyUpSpy = cy.spy().as('onKeyUpSpy'); + const onKeyPressSpy = cy.spy().as('onKeyPressSpy'); + + cy.realMount( + + ); + + let button = cy.get('[data-test-subj="button-1"]'); + + button.realClick(); + cy.get('@onClickSpy').should('not.have.been.called'); + + button.realMouseDown(); + button.realMouseUp(); + cy.get('@onMouseDownSpy').should('not.have.been.called'); + cy.get('@onMouseUpSpy').should('not.have.been.called'); + + button = cy.get('[data-test-subj="button-2"]'); + + button.trigger('pointerdown'); + button.trigger('pointerup'); + cy.get('@onPointerDownSpy').should('not.have.been.called'); + cy.get('@onPointerUpSpy').should('not.have.been.called'); + + button.trigger('touchstart'); + button.trigger('touchend'); + cy.get('@onTouchStartSpy').should('not.have.been.called'); + cy.get('@onTouchEndSpy').should('not.have.been.called'); + + button = cy.get('[data-test-subj="button-3"]'); + + button.focus(); + button.realPress('Enter'); + cy.get('@onKeyDownSpy').should('not.have.been.called'); + cy.get('@onKeyPressSpy').should('not.have.been.called'); + cy.get('@onKeyUpSpy').should('not.have.been.called'); + }); + + it('triggers allowed key events', () => { + const onKeyDownSpy = cy.spy().as('onKeyDownSpy'); + + cy.realMount( + + ); + + const button = cy.get('[data-test-subj="button-1"]'); + + button.focus(); + button.realPress('Tab'); + cy.get('@onKeyDownSpy').should('have.been.called'); + cy.focused().realPress('Escape'); + cy.get('@onKeyDownSpy').should('have.been.called'); + }); + }); + }); +}); diff --git a/packages/eui/src/services/hooks/useEuiDisabledElement.test.tsx b/packages/eui/src/services/hooks/useEuiDisabledElement.test.tsx new file mode 100644 index 00000000000..51728f8eae7 --- /dev/null +++ b/packages/eui/src/services/hooks/useEuiDisabledElement.test.tsx @@ -0,0 +1,486 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; +import { mount } from 'enzyme'; + +import { render, renderHook } from '../../test/rtl'; +import { findTestSubject } from '../../test'; +import { useEuiDisabledElement } from './useEuiDisabledElement'; + +describe('useEuiDisabledElement', () => { + describe('hasAriaDisabled=false', () => { + it('returns `disabled="true"` for `isDisabled=true`', () => { + const { result } = renderHook(() => + useEuiDisabledElement({ + isDisabled: true, + }) + ); + + render(); + + const { ref, ...props } = result.current; + + expect(props).toEqual({ disabled: true }); + expect(props).not.toEqual({ 'aria-disabled': true }); + }); + + it('returns `disabled="false"` for `isDisabled=false`', () => { + const { result } = renderHook(() => + useEuiDisabledElement({ + isDisabled: false, + }) + ); + + render(); + + const { ref, ...props } = result.current; + + expect(props).toEqual({ disabled: false }); + expect(props).not.toEqual({ 'aria-disabled': undefined }); + }); + }); + + describe('hasAriaDisabled=true', () => { + it('returns `aria-disabled="true"`', () => { + const { result } = renderHook(() => + useEuiDisabledElement({ + isDisabled: true, + hasAriaDisabled: true, + }) + ); + + render( + + ); + + const { ref, ...props } = result.current; + + expect(props).toEqual({ + 'aria-disabled': true, + disabled: undefined, + onClick: undefined, + onMouseDown: undefined, + onMouseUp: undefined, + onMouseOver: undefined, + onMouseOut: undefined, + onMouseEnter: undefined, + onMouseLeave: undefined, + onKeyDown: undefined, + onKeyUp: undefined, + onKeyPress: undefined, + onTouchStart: undefined, + onTouchEnd: undefined, + onTouchMove: undefined, + onPointerDown: undefined, + onPointerUp: undefined, + onPointerMove: undefined, + onPointerEnter: undefined, + onPointerLeave: undefined, + onPointerOver: undefined, + }); + + expect(props).not.toEqual({ disabled: true }); + }); + + it('returns `aria-disabled=undefined`', () => { + const { result } = renderHook(() => + useEuiDisabledElement({ + isDisabled: false, + hasAriaDisabled: true, + }) + ); + + render( + + ); + + const { ref, ...props } = result.current; + + expect(props).toEqual({ + disabled: false, + 'aria-disabled': undefined, + }); + }); + + it('returns `aria-disabled="true"` for custom elements', () => { + const { result } = renderHook(() => + useEuiDisabledElement({ + isDisabled: true, + hasAriaDisabled: true, + }) + ); + + render( +
+ button label +
+ ); + + const { ref, ...props } = result.current; + + expect(props).toEqual( + // checks only the disabled attributes not specifically the event handlers again + expect.objectContaining({ + 'aria-disabled': true, + disabled: undefined, + }) + ); + }); + }); + + describe('DOM event listeners', () => { + const clickHandler = jest.fn(); + + const mouseDownHandler = jest.fn(); + const mouseUpHandler = jest.fn(); + const mouseMoveHandler = jest.fn(); + const mouseOverHandler = jest.fn(); + const mouseOutHandler = jest.fn(); + const mouseEnterHandler = jest.fn(); + const mouseLeaveHandler = jest.fn(); + + const pointerDownHandler = jest.fn(); + const pointerUpHandler = jest.fn(); + const pointerMoveHandler = jest.fn(); + const pointerEnterHandler = jest.fn(); + const pointerLeaveHandler = jest.fn(); + const pointerOverHandler = jest.fn(); + + const touchStartHandler = jest.fn(); + const touchEndHandler = jest.fn(); + const touchMoveHandler = jest.fn(); + + const focusHandler = jest.fn(); + const blurHandler = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not trigger mouse, pointer or touch events', () => { + const { result } = renderHook( + ({ isDisabled, hasAriaDisabled }) => + useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }), + { + initialProps: { isDisabled: true, hasAriaDisabled: true }, + } + ); + + const { getByTestSubject } = render( + + ); + + const button = getByTestSubject('button'); + + button.addEventListener('click', clickHandler); + + button.addEventListener('mousedown', mouseDownHandler); + button.addEventListener('mouseup', mouseUpHandler); + button.addEventListener('mousemove', mouseMoveHandler); + button.addEventListener('mouseover', mouseOverHandler); + button.addEventListener('mouseout', mouseOutHandler); + button.addEventListener('mouseenter', mouseEnterHandler); + button.addEventListener('mouseleave', mouseLeaveHandler); + + button.addEventListener('pointerdown', pointerDownHandler); + button.addEventListener('pointerup', pointerUpHandler); + button.addEventListener('pointermove', pointerMoveHandler); + button.addEventListener('pointerenter', pointerEnterHandler); + button.addEventListener('pointerleave', pointerLeaveHandler); + button.addEventListener('pointerover', pointerOverHandler); + + button.addEventListener('touchstart', touchStartHandler); + button.addEventListener('touchend', touchEndHandler); + button.addEventListener('touchmove', touchMoveHandler); + + fireEvent.click(button); + fireEvent.mouseDown(button); + fireEvent.mouseUp(button); + fireEvent.mouseMove(button); + fireEvent.mouseOver(button); + fireEvent.mouseOut(button); + fireEvent.mouseEnter(button); + fireEvent.mouseLeave(button); + fireEvent.pointerDown(button); + fireEvent.pointerUp(button); + fireEvent.pointerMove(button); + fireEvent.pointerEnter(button); + fireEvent.pointerLeave(button); + fireEvent.pointerOver(button); + fireEvent.touchStart(button); + fireEvent.touchEnd(button); + fireEvent.touchMove(button); + + expect(clickHandler).not.toHaveBeenCalled(); + expect(mouseDownHandler).not.toHaveBeenCalled(); + expect(mouseUpHandler).not.toHaveBeenCalled(); + expect(mouseMoveHandler).not.toHaveBeenCalled(); + expect(mouseOverHandler).not.toHaveBeenCalled(); + expect(mouseOutHandler).not.toHaveBeenCalled(); + expect(mouseEnterHandler).not.toHaveBeenCalled(); + expect(mouseLeaveHandler).not.toHaveBeenCalled(); + expect(pointerDownHandler).not.toHaveBeenCalled(); + expect(pointerUpHandler).not.toHaveBeenCalled(); + expect(pointerMoveHandler).not.toHaveBeenCalled(); + expect(pointerEnterHandler).not.toHaveBeenCalled(); + expect(pointerLeaveHandler).not.toHaveBeenCalled(); + expect(pointerOverHandler).not.toHaveBeenCalled(); + expect(touchStartHandler).not.toHaveBeenCalled(); + expect(touchEndHandler).not.toHaveBeenCalled(); + expect(touchMoveHandler).not.toHaveBeenCalled(); + }); + + it('correctly resets events when updating to `isDisabled=false`', () => { + const { result, rerender } = renderHook( + ({ isDisabled, hasAriaDisabled }) => + useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }), + { + initialProps: { isDisabled: true, hasAriaDisabled: true }, + } + ); + + const { getByTestSubject } = render( + // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events + + ); + + const button = getByTestSubject('button'); + + button.addEventListener('click', clickHandler); + button.addEventListener('mousedown', mouseDownHandler); + + fireEvent.click(button); + fireEvent.mouseDown(button); + + expect(clickHandler).not.toHaveBeenCalled(); + expect(mouseDownHandler).not.toHaveBeenCalled(); + + rerender({ isDisabled: false, hasAriaDisabled: true }); + + fireEvent.click(button); + fireEvent.mouseDown(button); + + expect(clickHandler).toHaveBeenCalledTimes(1); + expect(mouseDownHandler).toHaveBeenCalledTimes(1); + }); + + it('allows focus and blur events', () => { + const { result } = renderHook( + ({ isDisabled, hasAriaDisabled }) => + useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }), + { + initialProps: { isDisabled: true, hasAriaDisabled: true }, + } + ); + + const { getByTestSubject } = render( + + ); + + const button = getByTestSubject('button'); + + button.addEventListener('focus', focusHandler); + button.addEventListener('blur', blurHandler); + + expect(document.activeElement).not.toBe(button); + + button.focus(); + expect(document.activeElement).toBe(button); + expect(focusHandler).toHaveBeenCalledTimes(1); + + button.blur(); + expect(document.activeElement).not.toBe(button); + expect(blurHandler).toHaveBeenCalledTimes(1); + }); + + describe('key events', () => { + it('does not trigger disallowed key events', () => { + const keyDownHandler = jest.fn(); + const keyUpHandler = jest.fn(); + const keyPressHandler = jest.fn(); + + const { result } = renderHook( + ({ isDisabled, hasAriaDisabled }) => + useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }), + { + initialProps: { isDisabled: true, hasAriaDisabled: true }, + } + ); + + const { getByTestSubject } = render( + + ); + + const button = getByTestSubject('button'); + + button.addEventListener('keydown', keyDownHandler); + button.addEventListener('keyup', keyUpHandler); + button.addEventListener('keypress', keyPressHandler); + + fireEvent.keyDown(button, { key: 'Space' }); + fireEvent.keyUp(button, { key: 'A' }); + fireEvent.keyPress(button, { key: 'B' }); + + expect(keyDownHandler).not.toHaveBeenCalled(); + expect(keyUpHandler).not.toHaveBeenCalled(); + expect(keyPressHandler).not.toHaveBeenCalled(); + }); + + it('triggers allowed key events correctly when the event listener is added after updating to `isDisabled=true`', () => { + const keyDownHandler = jest.fn(); + + const { result, rerender } = renderHook( + ({ isDisabled, hasAriaDisabled }) => + useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }), + { + initialProps: { isDisabled: true, hasAriaDisabled: true }, + } + ); + + const { getByTestSubject } = render( + + ); + + const button = getByTestSubject('button'); + button.addEventListener('keydown', keyDownHandler); + + fireEvent.keyDown(button, { key: 'Tab' }); + fireEvent.keyDown(button, { key: 'Escape' }); + fireEvent.keyDown(button, { key: 'Enter' }); // excluded + expect(keyDownHandler).toHaveBeenCalledTimes(2); + + rerender({ isDisabled: false, hasAriaDisabled: true }); + + fireEvent.keyDown(button, { key: 'Tab' }); + fireEvent.keyDown(button, { key: 'Escape' }); + fireEvent.keyDown(button, { key: 'Enter' }); + expect(keyDownHandler).toHaveBeenCalledTimes(5); + }); + + it('triggers allowed key events correctly when the event listener is added before updating to `isDisabled=true`', () => { + const keyDownHandler = jest.fn(); + + const { result, rerender } = renderHook( + ({ isDisabled, hasAriaDisabled }) => + useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }), + { + initialProps: { isDisabled: false, hasAriaDisabled: true }, + } + ); + + const { getByTestSubject } = render( + + ); + + const button = getByTestSubject('button'); + button.addEventListener('keydown', keyDownHandler); + + // rerender to disabled state + rerender({ isDisabled: true, hasAriaDisabled: true }); + + fireEvent.keyDown(button, { key: 'Tab' }); + fireEvent.keyDown(button, { key: 'Escape' }); + fireEvent.keyDown(button, { key: 'Enter' }); // excluded + expect(keyDownHandler).toHaveBeenCalledTimes(2); + + rerender({ isDisabled: false, hasAriaDisabled: true }); + + fireEvent.keyDown(button, { key: 'Tab' }); + fireEvent.keyDown(button, { key: 'Escape' }); + fireEvent.keyDown(button, { key: 'Enter' }); + expect(keyDownHandler).toHaveBeenCalledTimes(5); + }); + }); + }); + + describe('Enzyme (legacy)', () => { + const Component = ({ isDisabled = false, hasAriaDisabled = false }) => { + const disabledProps = useEuiDisabledElement({ + isDisabled, + hasAriaDisabled, + }); + + return ( + + ); + }; + + it('renders enabled buttons', () => { + const component = mount(); + + const button = findTestSubject(component, 'button'); + + expect(button.props()).not.toHaveEuiDisabledProp(); + }); + + it('renders `disabled` buttons', () => { + const component = mount(); + + const button = findTestSubject(component, 'button'); + + expect(button.props()).toHaveEuiDisabledProp(); + expect(button.props()).toEqual( + expect.objectContaining({ + disabled: true, + }) + ); + }); + + it('renders `aria-disabled` buttons', () => { + const component = mount(); + + const button = findTestSubject(component, 'button'); + + expect(button.props()).toHaveEuiDisabledProp(); + expect(button.props()).toEqual( + expect.objectContaining({ + 'aria-disabled': true, + disabled: undefined, + }) + ); + }); + }); +}); diff --git a/packages/eui/src/services/hooks/useEuiDisabledElement.ts b/packages/eui/src/services/hooks/useEuiDisabledElement.ts new file mode 100644 index 00000000000..002a47b2030 --- /dev/null +++ b/packages/eui/src/services/hooks/useEuiDisabledElement.ts @@ -0,0 +1,292 @@ +/* + * 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 { useCallback, useEffect, useRef } from 'react'; + +import { keys } from '../../services/keys'; + +export type EuiDisabledProps = { + /** + * Controls the disabled behavior via the native `disabled` attribute. + */ + isDisabled?: boolean; + /** + * NOTE: Beta feature, may be changed or removed in the future + * + * Changes the native `disabled` attribute to `aria-disabled` to preserve focusability. + * This results in a semantically disabled button without the default browser handling of the disabled state. + * + * Use e.g. when a disabled button should have a tooltip. + */ + hasAriaDisabled?: boolean; +}; + +type DisabledElementKeyEventHandlers = { + onKeyDown?: React.KeyboardEventHandler; + onKeyUp?: React.KeyboardEventHandler; + onKeyPress?: React.KeyboardEventHandler; +}; + +export type DisabledElementEventHandlers = DisabledElementKeyEventHandlers & { + onClick?: React.MouseEventHandler; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + onMouseOut?: React.MouseEventHandler; + onMouseMove?: React.MouseEventHandler; + onMouseOver?: React.MouseEventHandler; + onPointerDown?: React.PointerEventHandler; + onPointerUp?: React.PointerEventHandler; + onPointerEnter?: React.PointerEventHandler; + onPointerLeave?: React.PointerEventHandler; + onPointerMove?: React.PointerEventHandler; + onPointerOver?: React.PointerEventHandler; + onTouchStart?: React.TouchEventHandler; + onTouchEnd?: React.TouchEventHandler; + onTouchMove?: React.TouchEventHandler; + onSubmit?: React.FormEventHandler; +}; + +type DisabledElementProps = { + ref: React.Ref; + disabled?: boolean; +}; + +type AriaDisabledElementProps = + DisabledElementEventHandlers & + DisabledElementProps & { + ref: React.Ref; + 'aria-disabled'?: boolean; + }; + +type EuiDisabledElementArgs = EuiDisabledProps & + DisabledElementKeyEventHandlers; + +const DISABLED_ELEMENT_EVENTS = { + click: 'onClick', + mousedown: 'onMouseDown', + mouseup: 'onMouseUp', + mouseenter: 'onMouseEnter', + mouseleave: 'onMouseLeave', + mouseout: 'onMouseOut', + mousemove: 'onMouseMove', + mouseover: 'onMouseOver', + pointerdown: 'onPointerDown', + pointerup: 'onPointerUp', + pointerenter: 'onPointerEnter', + pointerleave: 'onPointerLeave', + pointermove: 'onPointerMove', + pointerover: 'onPointerOver', + touchstart: 'onTouchStart', + touchend: 'onTouchEnd', + touchmove: 'onTouchMove', + keydown: 'onKeyDown', + keyup: 'onKeyUp', + keypress: 'onKeyPress', + submit: 'onSubmit', +} as const; + +const ALLOWED_KEY_EVENTS = [keys.TAB, keys.ESCAPE] as string[]; + +const getReactEventHandlers = (): DisabledElementEventHandlers => { + return Object.values(DISABLED_ELEMENT_EVENTS).reduce((acc, curr) => { + acc[curr] = undefined; + return acc; + }, {} as DisabledElementEventHandlers); +}; + +const UNSET_REACT_EVENT_HANDLERS = getReactEventHandlers(); + +type ElementEventMethodState = { + click?: () => void; + dispatchEvent: (event: Event) => boolean; +}; + +const useCustomDisabledEvents = () => { + const elementMethodsRef = useRef(null); + + const isAllowedKeyEvent = (event: Event) => + event instanceof KeyboardEvent && ALLOWED_KEY_EVENTS.includes(event.key); + + const preventEvent = useCallback((event: Event) => { + if (isAllowedKeyEvent(event)) { + return; + } + + event.stopImmediatePropagation(); + event.preventDefault(); + event.stopPropagation(); + }, []); + + const preventElementEvents = useCallback( + (element: T) => { + if (elementMethodsRef.current) return; + + const originalEvents: ElementEventMethodState = { + click: 'click' in element ? element.click : undefined, + dispatchEvent: element.dispatchEvent, + }; + + try { + elementMethodsRef.current = originalEvents; + + // Add prevention listeners + Object.keys(DISABLED_ELEMENT_EVENTS).forEach((eventType) => { + element.addEventListener(eventType, preventEvent, { + capture: true, + }); + }); + + if ('click' in element && typeof element.click === 'function') { + element.click = () => {}; + } + + element.dispatchEvent = (event: Event) => { + if (Object.keys(DISABLED_ELEMENT_EVENTS).includes(event.type)) { + if (isAllowedKeyEvent(event)) { + return originalEvents.dispatchEvent.call(element, event); + } + return false; + } + return originalEvents.dispatchEvent.call(element, event); + }; + } catch (error) { + elementMethodsRef.current = null; + } + }, + [preventEvent] + ); + + const resetElementEvents = useCallback( + (element: T) => { + if (!elementMethodsRef.current) return; + + const { click, dispatchEvent } = elementMethodsRef.current; + + try { + // remove prevention listeners + Object.keys(DISABLED_ELEMENT_EVENTS).forEach((eventType) => { + element.removeEventListener(eventType, preventEvent, { + capture: true, + }); + }); + + // restore click method + if (click && 'click' in element) { + element.click = click; + } + + // restore dispatchEvent + element.dispatchEvent = dispatchEvent; + } catch (error) {} + + elementMethodsRef.current = null; + }, + [preventEvent] + ); + + return { + preventElementEvents, + resetElementEvents, + }; +}; + +/** + * NOTE: Beta feature, may be changed or removed in the future + * + * Utility to apply either the native or a custom semantic disabled state. + * + * It applies `aria-disabled` instead of `disabled` when `hasAriaDisabled=true` + * to ensure the element is semantically disabled while still focusable. + * + * It mimics the native `disabled` behavior by removing any programmatic mouse, pointer, touch + * or keyboard event handler but it differs to the native `disabled` behavior in that it preserves + * the focus, blur and tabIndex behavior. + */ +export const useEuiDisabledElement = ({ + isDisabled = false, + hasAriaDisabled = false, + onKeyDown, + onKeyUp, + onKeyPress, +}: EuiDisabledElementArgs): + | DisabledElementProps + | AriaDisabledElementProps => { + const elementRef = useRef(null); + const { preventElementEvents, resetElementEvents } = + useCustomDisabledEvents(); + const shouldBeDisabled = hasAriaDisabled && isDisabled; + + const setRef = useCallback( + (node: T | null) => { + if (elementRef.current) { + resetElementEvents(elementRef.current); + } + + elementRef.current = node; + + if (node && shouldBeDisabled) { + preventElementEvents(node); + } + }, + [shouldBeDisabled, preventElementEvents, resetElementEvents] + ); + + useEffect(() => { + if (!elementRef.current) return; + + if (shouldBeDisabled) { + preventElementEvents(elementRef.current); + } else { + resetElementEvents(elementRef.current); + } + + return () => { + if (elementRef.current) { + resetElementEvents(elementRef.current); + } + }; + }, [shouldBeDisabled, preventElementEvents, resetElementEvents]); + + if (!hasAriaDisabled) { + return { + ref: setRef, + disabled: isDisabled, + }; + } + + const onKeyboardEvent = ( + e: React.KeyboardEvent, + callback?: (e: React.KeyboardEvent) => void + ) => { + if (ALLOWED_KEY_EVENTS.includes(e.key)) { + callback?.(e); + } + }; + + const eventHandlers = shouldBeDisabled && { + ...UNSET_REACT_EVENT_HANDLERS, + onKeyDown: onKeyDown + ? (e: React.KeyboardEvent) => onKeyboardEvent(e, onKeyDown) + : undefined, + onKeyUp: onKeyUp + ? (e: React.KeyboardEvent) => onKeyboardEvent(e, onKeyUp) + : undefined, + onKeyPress: onKeyPress + ? (e: React.KeyboardEvent) => onKeyboardEvent(e, onKeyPress) + : undefined, + }; + + return { + ref: setRef, + 'aria-disabled': isDisabled ? true : undefined, + disabled: isDisabled ? undefined : false, + ...eventHandlers, + }; +}; diff --git a/packages/eui/src/test/cypress/index.d.ts b/packages/eui/src/test/cypress/index.d.ts new file mode 100644 index 00000000000..96bbde04650 --- /dev/null +++ b/packages/eui/src/test/cypress/index.d.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { + registerEuiCypressMatchers, + setupEuiCypressMatchers, +} from './matchers'; diff --git a/packages/eui/src/test/cypress/index.ts b/packages/eui/src/test/cypress/index.ts new file mode 100644 index 00000000000..96bbde04650 --- /dev/null +++ b/packages/eui/src/test/cypress/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { + registerEuiCypressMatchers, + setupEuiCypressMatchers, +} from './matchers'; diff --git a/packages/eui/src/test/cypress/matchers.d.ts b/packages/eui/src/test/cypress/matchers.d.ts new file mode 100644 index 00000000000..8871f7c9df8 --- /dev/null +++ b/packages/eui/src/test/cypress/matchers.d.ts @@ -0,0 +1,20 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +declare global { + namespace Chai { + interface Assertion { + euiDisabled: Assertion; + euiEnabled: Assertion; + } + } +} + +export declare const registerEuiCypressMatchers: () => void; +export declare const setupEuiCypressMatchers: () => void; diff --git a/packages/eui/src/test/cypress/matchers.ts b/packages/eui/src/test/cypress/matchers.ts new file mode 100644 index 00000000000..bcbe9807d5e --- /dev/null +++ b/packages/eui/src/test/cypress/matchers.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +/// + +export function registerEuiCypressMatchers() { + const chai = (window as any).chai; + + if (chai && !chai.__euiMatchersRegistered) { + chai.use((_chai: any) => { + _chai.Assertion.addProperty('euiDisabled', function (this: any) { + const element = this._obj[0] || this._obj; + + const hasDisabledAttribute = element.hasAttribute('disabled'); + const hasAriaDisabled = + element.getAttribute('aria-disabled') === 'true'; + const isDisabled = hasDisabledAttribute || hasAriaDisabled; + + this.assert( + isDisabled, + 'expected element to be EUI disabled (= have `disabled` attribute or `aria-disabled="true"`)', + 'expected element not to be EUI disabled (= not have `disabled` attribute or `aria-disabled="true"`)', + true, + isDisabled + ); + }); + + _chai.Assertion.addProperty('euiEnabled', function (this: any) { + const element = this._obj[0] || this._obj; + + const hasDisabledAttribute = element.hasAttribute('disabled'); + const hasAriaDisabled = + element.getAttribute('aria-disabled') === 'true'; + const isDisabled = hasDisabledAttribute || hasAriaDisabled; + + this.assert( + !isDisabled, + 'expected element to be EUI enabled (= not have `disabled` attribute or `aria-disabled="true"`)', + 'expected element not to be EUI enabled (= have `disabled` attribute or `aria-disabled="true"`)', + false, + isDisabled + ); + }); + }); + + // Mark as registered to prevent double registration + chai.__euiMatchersRegistered = true; + } +} + +// Register matchers when support file loads +export const setupEuiCypressMatchers = () => { + if (typeof window !== 'undefined') { + // Try to register immediately + if ((window as any).chai) { + registerEuiCypressMatchers(); + } else { + // Wait for chai to be available + const pollForChai = () => { + if ((window as any).chai) { + registerEuiCypressMatchers(); + } else { + setTimeout(pollForChai, 10); + } + }; + pollForChai(); + } + } +}; diff --git a/packages/eui/src/test/enzyme/enzyme_matchers.d.ts b/packages/eui/src/test/enzyme/enzyme_matchers.d.ts new file mode 100644 index 00000000000..519834049b8 --- /dev/null +++ b/packages/eui/src/test/enzyme/enzyme_matchers.d.ts @@ -0,0 +1,36 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ReactWrapper } from 'enzyme'; + +declare global { + namespace jest { + interface Matchers { + /** + * Checks if an Enzyme wrapper has EUI disabled state (it checks `disabled`, `isDisabled` and `aria-disabled` props) + */ + toHaveEuiDisabledProp(): R; + } + } +} + +export declare const toHaveEuiDisabledProp: (wrapper: ReactWrapper) => { + message: () => string; + pass: boolean; +}; + +export declare const hasEuiDisabledProp: ( + props: Record +) => boolean; + +export declare const euiEnzymeMatchers: { + toHaveEuiDisabledProp: typeof toHaveEuiDisabledProp; +}; + +export declare const setupEuiEnzymeMatchers: () => void; diff --git a/packages/eui/src/test/enzyme/enzyme_matchers.ts b/packages/eui/src/test/enzyme/enzyme_matchers.ts new file mode 100644 index 00000000000..652fa997e8d --- /dev/null +++ b/packages/eui/src/test/enzyme/enzyme_matchers.ts @@ -0,0 +1,53 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/* eslint-env jest */ + +export const euiEnzymeMatchers = { + /** + * Checks if an Enzyme wrapper has an EUI disabled state. + * It looks for `disabled`, `isDisabled` and `aria-disabled` props + */ + toHaveEuiDisabledProp(props: Record) { + if (!props || typeof props !== 'object') { + throw new Error( + 'toHaveEuiDisabledProp() must be called with the props value from ReactWrapper.props()' + ); + } + + const isDisabled = hasEuiDisabledProp(props); + + return { + message: () => + isDisabled + ? 'Expected component NOT to have EUI disabled prop, but it was disabled' + : 'Expected component to have EUI disabled prop (`disabled`, `isDisabled` or `aria-disabled="true"`)', + pass: isDisabled, + }; + }, +}; + +export const setupEuiEnzymeMatchers = () => { + expect.extend(euiEnzymeMatchers); +}; + +/* Utilities */ + +/** + * Checks if a ReactWrapper has one of the following disabled props enabled: + * `disabled`, `isDisabled` or attribute or `aria-disabled="true"`. + */ +export const hasEuiDisabledProp = (props: Record) => { + return ( + props.disabled === true || + props.isDisabled === true || + props['aria-disabled'] === true || + props['aria-disabled'] === 'true' + ); +}; diff --git a/packages/eui/src/test/enzyme/index.d.ts b/packages/eui/src/test/enzyme/index.d.ts new file mode 100644 index 00000000000..91aa492c830 --- /dev/null +++ b/packages/eui/src/test/enzyme/index.d.ts @@ -0,0 +1,14 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { + euiEnzymeMatchers, + setupEuiEnzymeMatchers, + hasEuiDisabledProp, +} from './enzyme_matchers'; diff --git a/packages/eui/src/test/enzyme/index.ts b/packages/eui/src/test/enzyme/index.ts new file mode 100644 index 00000000000..91aa492c830 --- /dev/null +++ b/packages/eui/src/test/enzyme/index.ts @@ -0,0 +1,14 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { + euiEnzymeMatchers, + setupEuiEnzymeMatchers, + hasEuiDisabledProp, +} from './enzyme_matchers'; diff --git a/packages/eui/src/test/rtl/index.d.ts b/packages/eui/src/test/rtl/index.d.ts index 50b7ae137b4..9e5dabe8908 100644 --- a/packages/eui/src/test/rtl/index.d.ts +++ b/packages/eui/src/test/rtl/index.d.ts @@ -1,3 +1,11 @@ export * from './component_helpers'; -export { queryByTestSubject, queryAllByTestSubject, getByTestSubject, getAllByTestSubject, findAllByTestSubject, findByTestSubject, } from './data_test_subj_queries'; +export { + queryByTestSubject, + queryAllByTestSubject, + getByTestSubject, + getAllByTestSubject, + findAllByTestSubject, + findByTestSubject, +} from './data_test_subj_queries'; export { render, screen, within } from './custom_render'; +export { euiMatchers, setupEuiMatchers, isEuiDisabled } from './matchers'; diff --git a/packages/eui/src/test/rtl/index.ts b/packages/eui/src/test/rtl/index.ts index 80ed2c9d9a1..53bd36eb125 100644 --- a/packages/eui/src/test/rtl/index.ts +++ b/packages/eui/src/test/rtl/index.ts @@ -17,3 +17,4 @@ export { } from './data_test_subj_queries'; export { render, screen, within } from './custom_render'; export * from './render_hook'; +export { euiMatchers, setupEuiMatchers, isEuiDisabled } from './matchers'; diff --git a/packages/eui/src/test/rtl/matchers.d.ts b/packages/eui/src/test/rtl/matchers.d.ts new file mode 100644 index 00000000000..25bdd87f2e6 --- /dev/null +++ b/packages/eui/src/test/rtl/matchers.d.ts @@ -0,0 +1,36 @@ +declare global { + /* eslint-disable-next-line @typescript-eslint/no-namespace,no-redeclare */ + namespace jest { + interface Matchers { + /** + * Custom matcher to check the disabled state of a DOM element. + * Ensures that both `disabled` and `aria-disabled` attributes are checked. + */ + toBeEuiDisabled(): R; + /** + * Custom matcher to check a DOM element is enabled (= not disabled). + * Ensures that both `disabled` and `aria-disabled` attributes are checked. + */ + toBeEuiEnabled(): R; + } + } +} + +export declare const toBeEuiDisabled: (element: HTMLElement) => { + message: () => string; + pass: boolean; +}; + +export declare const toBeEuiEnabled: (element: HTMLElement) => { + message: () => string; + pass: boolean; +}; + +export declare const isEuiDisabled: (element: HTMLElement) => boolean; + +export declare const euiMatchers: { + toBeEuiDisabled: typeof toBeEuiDisabled; + toBeEuiEnabled: typeof toBeEuiEnabled; +}; + +export declare const setupEuiMatchers: () => void; diff --git a/packages/eui/src/test/rtl/matchers.ts b/packages/eui/src/test/rtl/matchers.ts new file mode 100644 index 00000000000..73cc7800e1d --- /dev/null +++ b/packages/eui/src/test/rtl/matchers.ts @@ -0,0 +1,90 @@ +/* + * 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 { elementCanBeDisabled } from '../../utils'; + +const NATIVE_DISABLED_ATTR = 'disabled'; +const CUSTOM_DISABLED_ATTR = 'aria-disabled'; + +const toBeEuiDisabled = (element: HTMLElement) => { + const { isDisabled, canBeDisabled, isNativelyDisabled } = + getEuiDisabledState(element); + + return { + message: () => { + if (isDisabled) { + const method = isNativelyDisabled + ? `\`${NATIVE_DISABLED_ATTR}\`` + : `\`${CUSTOM_DISABLED_ATTR}="true"\``; + + if (!canBeDisabled) { + return `Element cannot be disabled (based on its role), but it was disabled via ${method}`; + } else { + return `Expected element NOT to be disabled, but it was disabled via ${method}`; + } + } else { + return `Expected element to be disabled via either \`${NATIVE_DISABLED_ATTR}\` or \`${CUSTOM_DISABLED_ATTR}="true"\` attribute, but found neither`; + } + }, + pass: isDisabled, + }; +}; + +const toBeEuiEnabled = (element: HTMLElement) => { + const { isDisabled, isNativelyDisabled, isAriaDisabled } = + getEuiDisabledState(element); + + return { + message: () => { + const attributes = [ + isNativelyDisabled ? `\`${NATIVE_DISABLED_ATTR}\`` : undefined, + isAriaDisabled ? `\`${CUSTOM_DISABLED_ATTR}="true"\`` : undefined, + ] + .filter((item) => item !== undefined) + .join(' and '); + + return `Expected element NOT to have attributes: ${attributes}.`; + }, + pass: !isDisabled, + }; +}; + +export const euiMatchers = { + toBeEuiDisabled, + toBeEuiEnabled, +}; + +export const setupEuiMatchers = () => { + expect.extend(euiMatchers); +}; + +export default euiMatchers; + +/* Utilities */ + +/** + * Retrieve an element's disabled state details. + * Checks wheather the element has an `disabled` attribute or `aria-disabled="true"` attribute + * @returns { isDisabled: boolean; canBeDisabled: boolean; isNativelyDisabled: boolean; isAriaDisabled: boolean } + */ +export const getEuiDisabledState = (element: HTMLElement) => { + const canBeDisabled = elementCanBeDisabled(element); + const isNativelyDisabled = element.hasAttribute('disabled'); + + const isAriaDisabled = element.getAttribute('aria-disabled') === 'true'; + const isDisabled = canBeDisabled && (isNativelyDisabled || isAriaDisabled); + + return { isDisabled, canBeDisabled, isNativelyDisabled, isAriaDisabled }; +}; + +/** + * Checks if an element is disabled via `disabled` attribute or `aria-disabled="true"`. + */ +export const isEuiDisabled = (element: HTMLElement) => { + return getEuiDisabledState(element).isDisabled; +}; diff --git a/packages/eui/src/utils/element_can_be_disabled.ts b/packages/eui/src/utils/element_can_be_disabled.ts new file mode 100644 index 00000000000..105a7244988 --- /dev/null +++ b/packages/eui/src/utils/element_can_be_disabled.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +const SUPPORTED_ELEMENTS = [ + 'fieldset', + 'input', + 'select', + 'optgroup', + 'option', + 'button', + 'textarea', +]; + +export const elementCanBeDisabled = ( + htmlElement: T | null +) => { + if (!htmlElement) return false; + + const tagName = htmlElement.tagName && htmlElement.tagName.toLowerCase(); + const roleName = htmlElement.getAttribute('role') ?? ''; + + return ( + SUPPORTED_ELEMENTS.includes(roleName) || + SUPPORTED_ELEMENTS.includes(tagName) + ); +}; diff --git a/packages/eui/src/utils/index.ts b/packages/eui/src/utils/index.ts index d76260bbd15..8f65b4bc217 100644 --- a/packages/eui/src/utils/index.ts +++ b/packages/eui/src/utils/index.ts @@ -9,3 +9,4 @@ export * from './prop_types'; export * from './is_jest'; export * from './type_guards'; +export { elementCanBeDisabled } from './element_can_be_disabled'; diff --git a/packages/website/docs/components/navigation/buttons/button.mdx b/packages/website/docs/components/navigation/buttons/button.mdx index f62a7d601e4..c7be537a7bc 100644 --- a/packages/website/docs/components/navigation/buttons/button.mdx +++ b/packages/website/docs/components/navigation/buttons/button.mdx @@ -588,6 +588,169 @@ export default () => { ); }; ``` +import { EuiBetaBadge } from '@elastic/eui'; + +### Disabled and focusable + +:::info Natively disabled buttons are not focusable +By default, when using `isDisabled` or `disabled` on **EuiButton**, the button will render with the native `disabled` attribute, making it unfocusable. +::: + +For accessibility, you may want a button to appear disabled but remain focusable (e.g., to provide context via tooltips). +In such cases, you may set `hasAriaDisabled={true}` which will change the native disabled behavior to a custom disabled behavior. This will renders a visually +identical disabled button that remains focusable. + +:::warning `hasAriaDisabled` is a new, experimental prop. + +Its behavior may change based on feedback and testing. +::: + +The key implementation differences of `hasAriaDisabled`: + +- Uses `aria-disabled="true"` instead of the native `disabled` attribute as semantic state indicator +- Manually unsets mouse and (most) keyboard event handlers to replicate the native disabled behavior +- Preserves focus events (`onFocus`, `onBlur`) and selected key events for navigation (`Tab`, `Escape`) + +```tsx interactive +import React, { useState } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; + +export default () => { + const [disableButton, setDisableButton] = useState(true); + + return( + <> + setDisableButton(!disableButton)} + /> + + + + + + {!disableButton ? 'Button' : 'Natively disabled'} + + + + + + {!disableButton ? 'Button' : 'Custom disabled'} + + + + + ); +}; +``` + +import { Example } from '@site/src/components'; +import { EuiSpacer } from '@elastic/eui'; + +#### CSS selector + +:::danger CSS selector `:disabled` does not apply +::: + +CSS selectors that rely on `:disabled` will not apply when using `hasAriaDisabled={true}`. Use `[aria-disabled="true"]` as selector instead. + +To streamline this, EUI provides a `euiDisabledSelector` variable (for usage in CSS-in-JS) which combines both `:disabled` and `[aria-disabled="true"]` selectors. + + + ```tsx + import { euiDisabledSelector } from '@elastic/eui'; + import { css } from '@emotion/react'; + + const styles = css` + ${euiDisabledSelector} { + cursor: not-allowed; + } + + &:not(${euiDisabledSelector}) { + cursor: default; + } + ` + ``` + + +#### Test matchers + +:::danger Default `disabled` test matchers fail +::: + +Test frameworks that rely on the native `disabled` attribute will fail when using `hasAriaDisabled={true}`. You will need to adjust your assertions accordingly. +To help with this, EUI provides alternative matchers for a few common test frameworks. + +##### Jest DOM + +Use `.toBeEuiDisabled()` instead of `.toBeDisabled()`. + + + + ```tsx + // setup file + import { setupEuiMatchers } from '@elastic/eui/lib/test/rtl/matchers'; + + setupEuiMatchers(); + + // usage + const { getByTestSubject } = render( + + button label + + ); + + expect(getByTestSubject('button')).toBeEuiDisabled(); + ``` + + +##### Cypress + +Use `.should('be.euiDisabled')` instead of `.should('be.disabled')`. + + + + ```tsx + // setup file + import { setupEuiCypressMatchers } from '@elastic/eui/lib/test/cypress/matchers'; + + setupEuiCypressMatchers(); + + // usage + cy.mount(); + cy.get('[data-test-subj="button"]').should('be.euiDisabled'); + ``` + + +##### Enzyme (Legacy) + +Use `.hasEuiDisabledProp()` instead of `.props().disabled`. + + + + ```tsx + // setup file + import { setupEuiEnzymeMatchers } from '@elastic/eui/lib/test/enzyme/enzyme_matchers'; + + setupEuiEnzymeMatchers(); + + // usage + import { findTestSubject } from '@elastic/eui/lib/test'; + + const component = mount(); + + expect(findTestSubject(component, 'button').props()).toHaveEuiDisabledProp(); + ``` + + ### Loading diff --git a/packages/website/docs/components/navigation/buttons/group.mdx b/packages/website/docs/components/navigation/buttons/group.mdx index d4aee9232ce..db5289c7348 100644 --- a/packages/website/docs/components/navigation/buttons/group.mdx +++ b/packages/website/docs/components/navigation/buttons/group.mdx @@ -280,6 +280,11 @@ Buttons within a button group will automatically display a default browser toolt To instead display an **EuiToolTip** around your button(s), pass the `toolTipContent` property. You can also use `toolTipProps` to customize tooltip placement, title, and any other prop that [EuiToolTip](../../display/tooltip.mdx) accepts. +:::note Beta feature: Showing tooltips on disabled **EuiButtonGroup** +If you need disabled buttons to show tooltips, add the `hasAriaDisabled` prop to switch from native to custom disabled behavior which preserves focusability. +Read more about [custom disabled behavior](./button.mdx#disabled-and-focusable-). +::: + ```tsx interactive import React, { useState } from 'react'; @@ -308,18 +313,42 @@ export default () => { ]; const [toggleIdSelected, setToggleIdSelected] = useState('buttonGroup__1'); + const [disableButton, setDisableButton] = useState(false); const onChange = (optionId) => { setToggleIdSelected(optionId); }; return ( - onChange(id)} - /> + <> + setDisableButton(!disableButton)} + /> + + + + onChange(id)} + /> + + + onChange(id)} + /> + + ); }; ```