From d47c8856764965ce99991b0f44be79ea9df4694f Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 10:54:04 +0100 Subject: [PATCH 01/15] feat: add useEuiDisabledButton hook --- packages/eui/src/services/hooks/index.ts | 4 + .../hooks/useEuiDisabledElement.spec.tsx | 277 ++++++++++ .../hooks/useEuiDisabledElement.test.tsx | 486 ++++++++++++++++++ .../services/hooks/useEuiDisabledElement.ts | 295 +++++++++++ 4 files changed, 1062 insertions(+) create mode 100644 packages/eui/src/services/hooks/useEuiDisabledElement.spec.tsx create mode 100644 packages/eui/src/services/hooks/useEuiDisabledElement.test.tsx create mode 100644 packages/eui/src/services/hooks/useEuiDisabledElement.ts diff --git a/packages/eui/src/services/hooks/index.ts b/packages/eui/src/services/hooks/index.ts index 00d6fc37840..52304b9ecdf 100644 --- a/packages/eui/src/services/hooks/index.ts +++ b/packages/eui/src/services/hooks/index.ts @@ -13,3 +13,7 @@ export * from './useLatest'; export * from './useDeepEqual'; export * from './useMouseMove'; export * from './useUpdateEffect'; +export { + type EuiDisabledProps, + useEuiDisabledElement, +} from './useEuiDisabledElement'; diff --git a/packages/eui/src/services/hooks/useEuiDisabledElement.spec.tsx b/packages/eui/src/services/hooks/useEuiDisabledElement.spec.tsx new file mode 100644 index 00000000000..bc1c7838bbe --- /dev/null +++ b/packages/eui/src/services/hooks/useEuiDisabledElement.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 { EuiFlexGroup } from '../../components/flex'; +import { EuiButtonPropsForButton } from '../../components/button/button'; +import { useEuiDisabledElement } from './useEuiDisabledElement'; + +const Button = ({ isDisabled = false, hasAriaDisabled = false, ...rest }) => { + const disabledProps = useEuiDisabledElement({ + isDisabled: isDisabled, + hasAriaDisabled: hasAriaDisabled, + ...rest, + }); + + return ( + + ); +}; + +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..9c933a87ad9 --- /dev/null +++ b/packages/eui/src/services/hooks/useEuiDisabledElement.ts @@ -0,0 +1,295 @@ +/* + * 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. + * + * The util uses the `ref` of the element to determine if it semantically can be disabled + * and to prevent events while its disabled. + * + * 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, + }; +}; From efc7fbccca4bdff22dd4f39655197b12c90096e2 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 10:54:18 +0100 Subject: [PATCH 02/15] feat: add euiDisabledSelector --- packages/eui/src/global_styling/index.ts | 1 + packages/eui/src/global_styling/utility/selectors.ts | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 packages/eui/src/global_styling/utility/selectors.ts diff --git a/packages/eui/src/global_styling/index.ts b/packages/eui/src/global_styling/index.ts index fbd28be2dff..f727a70faf7 100644 --- a/packages/eui/src/global_styling/index.ts +++ b/packages/eui/src/global_styling/index.ts @@ -11,3 +11,4 @@ export * from './functions'; export * from './variables'; export * from './mixins'; export * from './utility/animations'; +export { euiDisabledSelector } from './utility/selectors'; diff --git a/packages/eui/src/global_styling/utility/selectors.ts b/packages/eui/src/global_styling/utility/selectors.ts new file mode 100644 index 00000000000..580217c57dc --- /dev/null +++ b/packages/eui/src/global_styling/utility/selectors.ts @@ -0,0 +1,9 @@ +/* + * 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 const euiDisabledSelector = `:disabled, [aria-disabled="true"]`; From e801863c3ef19b190e68df2479ae592e7b455b5f Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 10:54:55 +0100 Subject: [PATCH 03/15] refactor: update button components to support hasAriaDisabled --- .../src/components/button/button.stories.tsx | 1 + packages/eui/src/components/button/button.tsx | 7 +- .../button_display/_button_display.styles.ts | 10 ++ .../button/button_display/_button_display.tsx | 41 +++++--- .../button_empty/button_empty.stories.tsx | 1 + .../button/button_empty/button_empty.tsx | 39 +++++--- .../button_group/button_group.stories.tsx | 33 ++++++- .../button/button_group/button_group.tsx | 96 ++++++++++--------- .../button_group_button.styles.ts | 3 +- .../button_icon/button_icon.stories.tsx | 1 + .../button/button_icon/button_icon.tsx | 33 +++++-- .../filter_group/filter_button.stories.tsx | 1 + .../components/filter_group/filter_button.tsx | 4 +- 13 files changed, 183 insertions(+), 87 deletions(-) diff --git a/packages/eui/src/components/button/button.stories.tsx b/packages/eui/src/components/button/button.stories.tsx index dfb9e9e7691..9df5c910fff 100644 --- a/packages/eui/src/components/button/button.stories.tsx +++ b/packages/eui/src/components/button/button.stories.tsx @@ -36,6 +36,7 @@ const meta: Meta = { iconSide: 'left', fullWidth: false, isDisabled: false, + hasAriaDisabled: false, isLoading: false, isSelected: false, }, diff --git a/packages/eui/src/components/button/button.tsx b/packages/eui/src/components/button/button.tsx index 9d720b46908..b8b4c892fa3 100644 --- a/packages/eui/src/components/button/button.tsx +++ b/packages/eui/src/components/button/button.tsx @@ -16,6 +16,7 @@ import { PropsForButton, } from '../common'; +import { EuiDisabledProps } from '../../services/hooks/useEuiDisabledElement'; import { BUTTON_COLORS, useEuiButtonColorCSS, @@ -34,7 +35,7 @@ export type EuiButtonColor = _EuiExtendedButtonColor; export const SIZES = ['s', 'm'] as const; export type EuiButtonSize = (typeof SIZES)[number]; -interface BaseProps { +interface BaseProps extends EuiDisabledProps { children?: ReactNode; /** * Make button a solid color for prominence @@ -55,10 +56,6 @@ interface BaseProps { * Use size `s` in confined spaces */ size?: EuiButtonSize; - /** - * `disabled` is also allowed - */ - isDisabled?: boolean; } export interface EuiButtonProps diff --git a/packages/eui/src/components/button/button_display/_button_display.styles.ts b/packages/eui/src/components/button/button_display/_button_display.styles.ts index 947c6a83715..320c37ef4f7 100644 --- a/packages/eui/src/components/button/button_display/_button_display.styles.ts +++ b/packages/eui/src/components/button/button_display/_button_display.styles.ts @@ -55,6 +55,16 @@ export const euiButtonDisplayStyles = (euiThemeContext: UseEuiTheme) => { // States isDisabled: css` cursor: not-allowed; + + /* prevent user (mouse) interactions for custom disabled buttons. + Covers user interaction only. Programmatic event handling is done in the \`useEuiDisabledElement\` hook */ + &[aria-disabled='true'] { + pointer-events: none; + + > * { + pointer-events: none; + } + } `, fullWidth: css` display: block; diff --git a/packages/eui/src/components/button/button_display/_button_display.tsx b/packages/eui/src/components/button/button_display/_button_display.tsx index b303405b277..bd4edb5129b 100644 --- a/packages/eui/src/components/button/button_display/_button_display.tsx +++ b/packages/eui/src/components/button/button_display/_button_display.tsx @@ -16,22 +16,28 @@ import React, { // @ts-ignore module doesn't export `createElement` import { createElement } from '@emotion/react'; -import { getSecureRelForTarget, useEuiMemoizedStyles } from '../../../services'; - +import { + getSecureRelForTarget, + useCombinedRefs, + useEuiMemoizedStyles, +} from '../../../services'; +import { validateHref } from '../../../services/security/href_validator'; +import { + EuiDisabledProps, + useEuiDisabledElement, +} from '../../../services/hooks/useEuiDisabledElement'; import { CommonProps, ExclusiveUnion, PropsForAnchor, PropsForButton, } from '../../common'; - import { euiButtonDisplayStyles } from './_button_display.styles'; import { EuiButtonDisplayContent, EuiButtonDisplayContentProps, EuiButtonDisplayContentType, } from './_button_display_content'; -import { validateHref } from '../../../services/security/href_validator'; const SIZES = ['xs', 's', 'm'] as const; export type EuiButtonDisplaySizes = (typeof SIZES)[number]; @@ -41,7 +47,8 @@ export type EuiButtonDisplaySizes = (typeof SIZES)[number]; * `iconType`, `iconSide`, and `textProps` */ export interface EuiButtonDisplayCommonProps - extends EuiButtonDisplayContentProps, + extends Omit, + EuiDisabledProps, CommonProps { element?: 'a' | 'button' | 'span'; children?: ReactNode; @@ -119,6 +126,7 @@ export const EuiButtonDisplay = forwardRef( size = 'm', isDisabled, disabled, + hasAriaDisabled = false, isLoading, isSelected, fullWidth, @@ -139,6 +147,15 @@ export const EuiButtonDisplay = forwardRef( isLoading, }); + const { ref: disabledRef, ...disabledButtonProps } = + useEuiDisabledElement({ + isDisabled: buttonIsDisabled, + hasAriaDisabled, + onKeyDown: rest.onKeyDown, + }); + + const setCombinedRef = useCombinedRefs([disabledRef, ref]); + const styles = useEuiMemoizedStyles(euiButtonDisplayStyles); const cssStyles = [ styles.euiButtonDisplay, @@ -166,13 +183,15 @@ export const EuiButtonDisplay = forwardRef( ); const element = buttonIsDisabled ? 'button' : href ? 'a' : _element; - let elementProps = {}; - // Element-specific attributes + const elementProps = { + ref: setCombinedRef, + }; + let buttonProps = {}; + if (element === 'button') { - elementProps = { - ...elementProps, - disabled: buttonIsDisabled, + buttonProps = { 'aria-pressed': isSelected, + ...disabledButtonProps, }; } @@ -196,10 +215,10 @@ export const EuiButtonDisplay = forwardRef( { css: cssStyles, style: minWidth ? { ...style, minInlineSize: minWidth } : style, - ref, ...elementProps, ...relObj, ...rest, + ...buttonProps, }, innerNode ); diff --git a/packages/eui/src/components/button/button_empty/button_empty.stories.tsx b/packages/eui/src/components/button/button_empty/button_empty.stories.tsx index 66602e0bcc4..21945984758 100644 --- a/packages/eui/src/components/button/button_empty/button_empty.stories.tsx +++ b/packages/eui/src/components/button/button_empty/button_empty.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { iconSize: 'm', iconSide: 'left', isDisabled: false, + hasAriaDisabled: false, isLoading: false, isSelected: false, }, diff --git a/packages/eui/src/components/button/button_empty/button_empty.tsx b/packages/eui/src/components/button/button_empty/button_empty.tsx index 6e5a71d5dad..7f4c3d61aaa 100644 --- a/packages/eui/src/components/button/button_empty/button_empty.tsx +++ b/packages/eui/src/components/button/button_empty/button_empty.tsx @@ -15,20 +15,26 @@ import { PropsForAnchor, PropsForButton, } from '../../common'; -import { useEuiMemoizedStyles, getSecureRelForTarget } from '../../../services'; - import { - EuiButtonDisplayContent, - EuiButtonDisplayContentProps, - EuiButtonDisplayContentType, -} from '../button_display/_button_display_content'; + useEuiMemoizedStyles, + getSecureRelForTarget, + useCombinedRefs, +} from '../../../services'; +import { + EuiDisabledProps, + useEuiDisabledElement, +} from '../../../services/hooks/useEuiDisabledElement'; import { useEuiButtonColorCSS, _EuiExtendedButtonColor, } from '../../../global_styling/mixins/_button'; +import { + EuiButtonDisplayContent, + EuiButtonDisplayContentProps, + EuiButtonDisplayContentType, +} from '../button_display/_button_display_content'; import { isButtonDisabled } from '../button_display/_button_display'; - import { euiButtonEmptyStyles } from './button_empty.styles'; export const SIZES = ['xs', 's', 'm'] as const; @@ -43,6 +49,7 @@ export type EuiButtonEmptyFlush = (typeof FLUSH_TYPES)[number]; */ export interface CommonEuiButtonEmptyProps extends EuiButtonDisplayContentProps, + EuiDisabledProps, CommonProps { /** * Any of the named color palette options. @@ -60,10 +67,6 @@ export interface CommonEuiButtonEmptyProps * Ensure the text of the button sits flush to the left, right, or both sides of its container */ flush?: EuiButtonEmptyFlush; - /** - * `disabled` is also allowed - */ - isDisabled?: boolean; /** * Force disables the button and changes the icon to a loading spinner */ @@ -106,6 +109,7 @@ export const EuiButtonEmpty: FunctionComponent = ({ flush, isDisabled: _isDisabled, disabled, + hasAriaDisabled = false, isLoading, href, target, @@ -122,6 +126,14 @@ export const EuiButtonEmpty: FunctionComponent = ({ href, isLoading, }); + const { ref: disabledRef, ...disabledButtonProps } = + useEuiDisabledElement({ + isDisabled: isDisabled, + hasAriaDisabled, + onKeyDown: rest.onKeyDown, + }); + + const setCombinedRef = useCombinedRefs([disabledRef, buttonRef]); const buttonColorStyles = useEuiButtonColorCSS({ display: 'empty', @@ -179,7 +191,7 @@ export const EuiButtonEmpty: FunctionComponent = ({ href={href} target={target} rel={secureRel} - ref={buttonRef as Ref} + ref={setCombinedRef as Ref} {...(rest as EuiButtonEmptyPropsForAnchor)} > {innerNode} @@ -193,9 +205,10 @@ export const EuiButtonEmpty: FunctionComponent = ({ className={classes} css={cssStyles} type={type} - ref={buttonRef as Ref} + ref={setCombinedRef as Ref} aria-pressed={isSelected} {...(rest as EuiButtonEmptyPropsForButton)} + {...disabledButtonProps} > {innerNode} 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.tsx b/packages/eui/src/components/button/button_group/button_group.tsx index 7ea07f1387b..375f5efce5e 100644 --- a/packages/eui/src/components/button/button_group/button_group.tsx +++ b/packages/eui/src/components/button/button_group/button_group.tsx @@ -15,6 +15,7 @@ import React, { } from 'react'; import { useEuiMemoizedStyles } from '../../../services'; +import { type EuiDisabledProps } from '../../../services/hooks/useEuiDisabledElement'; import { EuiScreenReaderOnly } from '../../accessibility'; import { CommonProps } from '../../common'; @@ -29,7 +30,8 @@ import { export interface EuiButtonGroupOptionProps extends EuiButtonDisplayContentProps, - CommonProps { + CommonProps, + EuiDisabledProps { /** * Each option must have a unique `id` for maintaining selection */ @@ -38,7 +40,6 @@ export interface EuiButtonGroupOptionProps * Each option must have a `label` even for icons which will be applied as the `aria-label` */ label: ReactNode; - isDisabled?: boolean; /** * The value of the radio input. */ @@ -63,47 +64,47 @@ export interface EuiButtonGroupOptionProps toolTipProps?: Partial>; } -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/button_icon.stories.tsx b/packages/eui/src/components/button/button_icon/button_icon.stories.tsx index 6cc10457d9c..1b11e2bb1c8 100644 --- a/packages/eui/src/components/button/button_icon/button_icon.stories.tsx +++ b/packages/eui/src/components/button/button_icon/button_icon.stories.tsx @@ -20,6 +20,7 @@ const meta: Meta = { size: 'xs', iconSize: 'm', isDisabled: false, + hasAriaDisabled: false, isLoading: false, isSelected: false, }, diff --git a/packages/eui/src/components/button/button_icon/button_icon.tsx b/packages/eui/src/components/button/button_icon/button_icon.tsx index d36053e6da8..95a914dd0c4 100644 --- a/packages/eui/src/components/button/button_icon/button_icon.tsx +++ b/packages/eui/src/components/button/button_icon/button_icon.tsx @@ -14,18 +14,23 @@ import React, { } from 'react'; import classNames from 'classnames'; -import { getSecureRelForTarget, useEuiMemoizedStyles } from '../../../services'; +import { + getSecureRelForTarget, + useCombinedRefs, + useEuiMemoizedStyles, +} from '../../../services'; +import { + EuiDisabledProps, + useEuiDisabledElement, +} from '../../../services/hooks/useEuiDisabledElement'; import { CommonProps, ExclusiveUnion, PropsForAnchor, PropsForButton, } from '../../common'; - import { IconType, IconSize, EuiIcon } from '../../icon'; - import { EuiLoadingSpinner } from '../../loading'; - import { useEuiButtonColorCSS, useEuiButtonFocusCSS, @@ -40,7 +45,7 @@ export type EuiButtonIconSizes = (typeof SIZES)[number]; export const DISPLAYS = ['base', 'empty', 'fill'] as const; type EuiButtonIconDisplay = (typeof DISPLAYS)[number]; -export interface EuiButtonIconProps extends CommonProps { +export interface EuiButtonIconProps extends CommonProps, EuiDisabledProps { iconType: IconType; /** * Any of the named color palette options. @@ -55,7 +60,6 @@ export interface EuiButtonIconProps extends CommonProps { color?: _EuiExtendedButtonColor; 'aria-label'?: string; 'aria-labelledby'?: string; - isDisabled?: boolean; /** * Overall size of button. * Matches the sizes of other EuiButtons @@ -114,6 +118,7 @@ export const EuiButtonIcon: FunctionComponent = ({ color = 'primary', isDisabled: _isDisabled, disabled, + hasAriaDisabled = false, href, type = 'button', display = 'empty', @@ -130,6 +135,17 @@ export const EuiButtonIcon: FunctionComponent = ({ href, isLoading, }); + const { ref: disabledRef, ...disabledButtonProps } = + useEuiDisabledElement({ + isDisabled: isDisabled, + hasAriaDisabled, + onKeyDown: rest.onKeyDown, + }); + + const setCombinedRef = useCombinedRefs([ + disabledRef, + buttonRef as Ref, + ]); const ariaHidden = rest['aria-hidden']; const isAriaHidden = ariaHidden === 'true' || ariaHidden === true; @@ -203,7 +219,7 @@ export const EuiButtonIcon: FunctionComponent = ({ href={href} target={target} rel={secureRel} - ref={buttonRef as Ref} + ref={setCombinedRef as Ref} {...(rest as AnchorHTMLAttributes)} > {buttonIcon} @@ -220,8 +236,9 @@ export const EuiButtonIcon: FunctionComponent = ({ className={classes} aria-pressed={isSelected} type={type as typeof buttonType} - ref={buttonRef as Ref} + ref={setCombinedRef as Ref} {...(rest as ButtonHTMLAttributes)} + {...disabledButtonProps} > {buttonIcon} diff --git a/packages/eui/src/components/filter_group/filter_button.stories.tsx b/packages/eui/src/components/filter_group/filter_button.stories.tsx index fa463502119..46db1526a66 100644 --- a/packages/eui/src/components/filter_group/filter_button.stories.tsx +++ b/packages/eui/src/components/filter_group/filter_button.stories.tsx @@ -30,6 +30,7 @@ const meta: Meta = { isToggle: false, isSelected: false, isDisabled: false, + hasAriaDisabled: false, withNext: false, hasActiveFilters: false, }, diff --git a/packages/eui/src/components/filter_group/filter_button.tsx b/packages/eui/src/components/filter_group/filter_button.tsx index 80dd6dfadba..6813d9f1bc8 100644 --- a/packages/eui/src/components/filter_group/filter_button.tsx +++ b/packages/eui/src/components/filter_group/filter_button.tsx @@ -16,6 +16,7 @@ import { useEuiTheme, useGeneratedHtmlId, } from '../../services'; +import { type EuiDisabledProps } from '../../services/hooks/useEuiDisabledElement'; import { useEuiI18n } from '../i18n'; import { useInnerText } from '../inner_text'; import { DistributiveOmit } from '../common'; @@ -88,7 +89,8 @@ export type EuiFilterButtonProps = { } & DistributiveOmit< EuiButtonEmptyProps, 'flush' | 'size' | 'color' | 'isSelected' ->; +> & + EuiDisabledProps; export const EuiFilterButton: FunctionComponent = ({ children, From 6bb17d7fff4b21b2d7dbe860233ee8847253fe86 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 10:55:13 +0100 Subject: [PATCH 04/15] refactor: Update focus color for disabled filled buttons --- .../global_styling/mixins/__snapshots__/_button.test.ts.snap | 4 ++-- packages/eui/src/global_styling/mixins/_button.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/eui/src/global_styling/mixins/__snapshots__/_button.test.ts.snap b/packages/eui/src/global_styling/mixins/__snapshots__/_button.test.ts.snap index 6652e89635e..4814da509ea 100644 --- a/packages/eui/src/global_styling/mixins/__snapshots__/_button.test.ts.snap +++ b/packages/eui/src/global_styling/mixins/__snapshots__/_button.test.ts.snap @@ -741,9 +741,9 @@ exports[`useEuiButtonColorCSS fill 1`] = ` }, "disabled": { "map": undefined, - "name": "105tcc3-displaysColorsMap-display-color", + "name": "1scgva8-displaysColorsMap-display-color", "next": undefined, - "styles": "color:#798EAF;background-color:#ECF1F9;outline-color:#07101F; + "styles": "color:#798EAF;background-color:#ECF1F9;outline-color:; &:hover:not(:disabled) { background-color: undefined; } diff --git a/packages/eui/src/global_styling/mixins/_button.ts b/packages/eui/src/global_styling/mixins/_button.ts index ed6f65edae9..abf55596350 100644 --- a/packages/eui/src/global_styling/mixins/_button.ts +++ b/packages/eui/src/global_styling/mixins/_button.ts @@ -265,7 +265,9 @@ const euiButtonDisplaysColors = (euiThemeContext: UseEuiTheme) => { outline-color: ${euiThemeContext.colorMode === 'DARK' && color === 'text' ? 'currentColor' - : euiThemeContext.euiTheme.colors.fullShade}; + : color !== 'disabled' + ? euiThemeContext.euiTheme.colors.fullShade + : ''}; ${_interactionStyles(euiThemeContext, buttonColors)} `; From 0607793258d2bb395eb24de3716eda9251cfe6fc Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 10:55:34 +0100 Subject: [PATCH 05/15] feat: add custom disabled test helpers/matchers --- packages/eui/cypress/support/component.tsx | 1 + packages/eui/cypress/support/index.d.ts | 10 +++ .../eui/cypress/support/setup/matchers.ts | 11 +++ .../eui/cypress/support/setup/realMount.tsx | 6 +- packages/eui/scripts/jest/config.js | 1 + packages/eui/scripts/jest/setup/matchers.js | 7 ++ packages/eui/src/test/cypress/index.d.ts | 12 +++ packages/eui/src/test/cypress/index.ts | 12 +++ packages/eui/src/test/cypress/matchers.d.ts | 20 +++++ packages/eui/src/test/cypress/matchers.ts | 74 +++++++++++++++ .../eui/src/test/enzyme/enzyme_matchers.d.ts | 36 ++++++++ .../eui/src/test/enzyme/enzyme_matchers.ts | 53 +++++++++++ packages/eui/src/test/enzyme/index.d.ts | 14 +++ packages/eui/src/test/enzyme/index.ts | 14 +++ packages/eui/src/test/rtl/index.d.ts | 10 ++- packages/eui/src/test/rtl/index.ts | 1 + packages/eui/src/test/rtl/matchers.d.ts | 36 ++++++++ packages/eui/src/test/rtl/matchers.ts | 90 +++++++++++++++++++ .../eui/src/utils/element_can_be_disabled.ts | 31 +++++++ packages/eui/src/utils/index.ts | 1 + 20 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 packages/eui/cypress/support/setup/matchers.ts create mode 100644 packages/eui/scripts/jest/setup/matchers.js create mode 100644 packages/eui/src/test/cypress/index.d.ts create mode 100644 packages/eui/src/test/cypress/index.ts create mode 100644 packages/eui/src/test/cypress/matchers.d.ts create mode 100644 packages/eui/src/test/cypress/matchers.ts create mode 100644 packages/eui/src/test/enzyme/enzyme_matchers.d.ts create mode 100644 packages/eui/src/test/enzyme/enzyme_matchers.ts create mode 100644 packages/eui/src/test/enzyme/index.d.ts create mode 100644 packages/eui/src/test/enzyme/index.ts create mode 100644 packages/eui/src/test/rtl/matchers.d.ts create mode 100644 packages/eui/src/test/rtl/matchers.ts create mode 100644 packages/eui/src/utils/element_can_be_disabled.ts 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/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/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'; From 30931e4169b66375d23473151c271ffe0b6fea44 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 21:55:31 +0100 Subject: [PATCH 06/15] test: add tooltip specific tests --- .../src/components/tool_tip/tool_tip.spec.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/eui/src/components/tool_tip/tool_tip.spec.tsx b/packages/eui/src/components/tool_tip/tool_tip.spec.tsx index 694584376c9..47c8d9f0772 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.spec.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.spec.tsx @@ -35,6 +35,28 @@ describe('EuiToolTip', () => { cy.get('[data-test-subj="tooltip"]').should('not.exist'); }); + it('shows the tooltip on hover and hides it on mouseout for a custom disabled trigger button', () => { + cy.mount( + + + Show tooltip + + + ); + cy.get('[data-test-subj="tooltip"]').should('not.exist'); + + // using the anchor wrapper as the mouse events are added there and the disabled button does't support them + cy.get('[data-test-subj="tooltipAnchor"]').trigger('mouseover'); + cy.get('[data-test-subj="tooltip"]').should('exist'); + + cy.get('[data-test-subj="tooltipAnchor"]').trigger('mouseout'); + cy.get('[data-test-subj="tooltip"]').should('not.exist'); + }); + it('shows the tooltip on keyboard focus and hides it on blur', () => { cy.mount( @@ -50,6 +72,23 @@ describe('EuiToolTip', () => { cy.get('[data-test-subj="tooltip"]').should('not.exist'); }); + it('shows the tooltip on keyboard focus and hides it on blur for a custom disabled trigger button', () => { + cy.mount( + + + Show tooltip + + + ); + cy.get('[data-test-subj="tooltip"]').should('not.exist'); + + cy.get('[data-test-subj="toggleToolTip"]').focus(); + cy.get('[data-test-subj="tooltip"]').should('exist'); + + cy.get('[data-test-subj="toggleToolTip"]').blur(); + cy.get('[data-test-subj="tooltip"]').should('not.exist'); + }); + it('does not show multiple tooltips if one tooltip toggle is focused and another tooltip toggle is hovered', () => { cy.mount( <> From 2b4c02a89abf7518781eb8f185fddd78317700d6 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 08:48:02 +0100 Subject: [PATCH 07/15] build: ensure test helpers are bundled --- packages/eui/scripts/compile-eui.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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); } } From 916644dcb824d6755bf75d9c85de04c1811f9877 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 11 Nov 2025 08:48:23 +0100 Subject: [PATCH 08/15] test: update snapshots --- .../button/__snapshots__/button.test.tsx.snap | 1 + .../eui/src/components/button/button.test.tsx | 21 ++++++- .../__snapshots__/button_empty.test.tsx.snap | 1 + .../button/button_empty/button_empty.test.tsx | 21 ++++++- .../button/button_group/button_group.test.tsx | 56 +++++++++++++++++++ .../__snapshots__/button_icon.test.tsx.snap | 1 + .../button/button_icon/button_icon.test.tsx | 32 ++++++++++- .../__snapshots__/filter_button.test.tsx.snap | 1 + .../filter_group/filter_button.test.tsx | 21 ++++++- 9 files changed, 150 insertions(+), 5 deletions(-) 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`] = `