diff --git a/changelogs/upcoming/7361.md b/changelogs/upcoming/7361.md new file mode 100644 index 00000000000..d33f9a1974c --- /dev/null +++ b/changelogs/upcoming/7361.md @@ -0,0 +1,5 @@ +- Improved `EuiBasicTable`/`EuiInMemoryTable's mobile UI for custom actions + +**Accessibility** + +- Fixed custom `EuiBasicTable`/`EuiInMemoryTable` rendering nested interactive custom actions diff --git a/src-docs/src/views/tables/actions/actions.tsx b/src-docs/src/views/tables/actions/actions.tsx index 86ac089a0af..a233c872ac1 100644 --- a/src-docs/src/views/tables/actions/actions.tsx +++ b/src-docs/src/views/tables/actions/actions.tsx @@ -171,6 +171,11 @@ export default () => { ); }, }, + { + render: () => { + return {}}>Edit; + }, + }, ...actions, ]; } diff --git a/src/components/basic_table/__snapshots__/collapsed_item_actions.test.tsx.snap b/src/components/basic_table/__snapshots__/collapsed_item_actions.test.tsx.snap index f6ff384aa19..7d351bcfc26 100644 --- a/src/components/basic_table/__snapshots__/collapsed_item_actions.test.tsx.snap +++ b/src/components/basic_table/__snapshots__/collapsed_item_actions.test.tsx.snap @@ -1,6 +1,219 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CollapsedItemActions render 1`] = ` +exports[`CollapsedItemActions custom actions 1`] = ` + +
+
+ + + +
+
+
+
+
+ +
+
+ +`; + +exports[`CollapsedItemActions default actions 1`] = ` + +
+
+ + + +
+
+
+
+
+ +
+
+ +`; + +exports[`CollapsedItemActions renders 1`] = `
`; - -exports[`CollapsedItemActions render with href and _target provided 1`] = ` - - [Function] - - } - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="id-actions" - isOpen={true} - ownFocus={true} - panelPaddingSize="none" - popoverRef={[Function]} - repositionToCrossAxis={true} -> - - default1 - , - -
- , - - default2 - , - ] - } - /> - -`; diff --git a/src/components/basic_table/__snapshots__/custom_item_action.test.tsx.snap b/src/components/basic_table/__snapshots__/custom_item_action.test.tsx.snap index 578fe7defdf..f8b1953b4e4 100644 --- a/src/components/basic_table/__snapshots__/custom_item_action.test.tsx.snap +++ b/src/components/basic_table/__snapshots__/custom_item_action.test.tsx.snap @@ -4,10 +4,7 @@ exports[`CustomItemAction render 1`] = `
- + test
diff --git a/src/components/basic_table/action_types.ts b/src/components/basic_table/action_types.ts index cc527d97930..09fb84d0109 100644 --- a/src/components/basic_table/action_types.ts +++ b/src/components/basic_table/action_types.ts @@ -72,7 +72,7 @@ export type DefaultItemAction = ExclusiveUnion< export interface CustomItemAction { /** - * The function that renders the action. Note that the returned node is expected to have `onFocus` and `onBlur` functions + * Allows rendering a totally custom action */ render: (item: T, enabled: boolean) => ReactElement; /** diff --git a/src/components/basic_table/collapsed_item_actions.test.tsx b/src/components/basic_table/collapsed_item_actions.test.tsx index 591a03d5d3e..f369e459f9f 100644 --- a/src/components/basic_table/collapsed_item_actions.test.tsx +++ b/src/components/basic_table/collapsed_item_actions.test.tsx @@ -6,14 +6,18 @@ * Side Public License, v 1. */ -import React, { FocusEvent } from 'react'; -import { shallow } from 'enzyme'; -import { render } from '../../test/rtl'; +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { + render, + waitForEuiPopoverOpen, + waitForEuiPopoverClose, +} from '../../test/rtl'; + import { CollapsedItemActions } from './collapsed_item_actions'; -import { Action } from './action_types'; describe('CollapsedItemActions', () => { - test('render', () => { + it('renders', () => { const props = { actions: [ { @@ -29,9 +33,7 @@ describe('CollapsedItemActions', () => { ], itemId: 'id', item: { id: '1' }, - actionEnabled: (_: Action<{ id: string }>) => true, - onFocus: (_: FocusEvent) => {}, - onBlur: () => {}, + actionEnabled: () => true, }; const { container } = render(); @@ -39,18 +41,14 @@ describe('CollapsedItemActions', () => { expect(container.firstChild).toMatchSnapshot(); }); - test('render with href and _target provided', () => { + test('default actions', async () => { const props = { actions: [ { name: 'default1', description: 'default 1', onClick: () => {}, - }, - { - name: 'custom1', - description: 'custom 1', - render: () =>
, + 'data-test-subj': 'defaultAction', }, { name: 'default2', @@ -61,14 +59,45 @@ describe('CollapsedItemActions', () => { ], itemId: 'id', item: { id: 'xyz' }, - actionEnabled: (_: Action<{ id: string }>) => true, - onFocus: (_: FocusEvent) => {}, - onBlur: () => {}, + actionEnabled: () => true, + }; + + const { getByTestSubject, baseElement } = render( + + ); + fireEvent.click(getByTestSubject('euiCollapsedItemActionsButton')); + await waitForEuiPopoverOpen(); + + expect(baseElement).toMatchSnapshot(); + + fireEvent.click(getByTestSubject('defaultAction')); + await waitForEuiPopoverClose(); + }); + + test('custom actions', async () => { + const props = { + actions: [ + { render: () => }, + { render: () => world }, + ], + itemId: 'id', + item: { id: 'xyz' }, + actionEnabled: () => true, }; - const component = shallow(); - component.setState({ popoverOpen: true }); + const { getByTestSubject, baseElement } = render( + + ); + fireEvent.click(getByTestSubject('euiCollapsedItemActionsButton')); + await waitForEuiPopoverOpen(); + + expect( + baseElement.querySelector('.euiBasicTable__collapsedCustomAction') + ?.nodeName + ).toEqual('DIV'); + expect(baseElement).toMatchSnapshot(); - expect(component).toMatchSnapshot(); + fireEvent.click(getByTestSubject('customAction')); + await waitForEuiPopoverClose(); }); }); diff --git a/src/components/basic_table/collapsed_item_actions.tsx b/src/components/basic_table/collapsed_item_actions.tsx index 4e63e31dc1c..bc93d2ce296 100644 --- a/src/components/basic_table/collapsed_item_actions.tsx +++ b/src/components/basic_table/collapsed_item_actions.tsx @@ -6,200 +6,150 @@ * Side Public License, v 1. */ -import React, { Component, FocusEvent, ReactNode, ReactElement } from 'react'; +import React, { + useState, + useCallback, + useMemo, + ReactNode, + ReactElement, +} from 'react'; + import { isString } from '../../services/predicate'; import { EuiContextMenuItem, EuiContextMenuPanel } from '../context_menu'; import { EuiPopover } from '../popover'; import { EuiButtonIcon } from '../button'; import { EuiToolTip } from '../tool_tip'; import { EuiI18n } from '../i18n'; + import { Action, CustomItemAction } from './action_types'; import { ItemIdResolved } from './table_types'; -export interface CollapsedItemActionsProps { +export interface CollapsedItemActionsProps { actions: Array>; item: T; itemId: ItemIdResolved; actionEnabled: (action: Action) => boolean; className?: string; - onFocus?: (event: FocusEvent) => void; - onBlur?: () => void; -} - -interface CollapsedItemActionsState { - popoverOpen: boolean; } -function actionIsCustomItemAction( +const actionIsCustomItemAction = ( action: Action -): action is CustomItemAction { - return action.hasOwnProperty('render'); -} - -export class CollapsedItemActions extends Component< - CollapsedItemActionsProps, - CollapsedItemActionsState -> { - private popoverDiv: HTMLDivElement | null = null; - - state = { popoverOpen: false }; - - togglePopover = () => { - this.setState((prevState) => ({ popoverOpen: !prevState.popoverOpen })); - }; - - closePopover = () => { - this.setState({ popoverOpen: false }); - }; - - onPopoverBlur = () => { - // you must be asking... WTF? I know... but this timeout is - // required to make sure we process the onBlur events after the initial - // event cycle. Reference: - // https://medium.com/@jessebeach/dealing-with-focus-and-blur-in-a-composite-widget-in-react-90d3c3b49a9b - window.requestAnimationFrame(() => { - if ( - !this.popoverDiv!.contains(document.activeElement) && - this.props.onBlur - ) { - this.props.onBlur(); - } - }); - }; - - registerPopoverDiv = (popoverDiv: HTMLDivElement | null) => { - if (!this.popoverDiv) { - this.popoverDiv = popoverDiv; - this.popoverDiv && - this.popoverDiv.addEventListener('focusout', this.onPopoverBlur); - } - }; - - componentWillUnmount() { - if (this.popoverDiv) { - this.popoverDiv.removeEventListener('focusout', this.onPopoverBlur); - } - } - - onClickItem = (onClickAction: (() => void) | undefined) => { - this.closePopover(); - if (onClickAction) { - onClickAction(); - } - }; - - render() { - const { actions, itemId, item, actionEnabled, onFocus, className } = - this.props; - - const isOpen = this.state.popoverOpen; - - let allDisabled = true; - const controls = actions.reduce( - (controls, action, index) => { - const key = `action_${itemId}_${index}`; - const available = action.available ? action.available(item) : true; - if (!available) { - return controls; - } - const enabled = actionEnabled(action); - allDisabled = allDisabled && !enabled; - if (actionIsCustomItemAction(action)) { - const customAction = action as CustomItemAction; - const actionControl = customAction.render(item, enabled); - const actionControlOnClick = - actionControl && actionControl.props && actionControl.props.onClick; - controls.push( - - this.onClickItem( - actionControlOnClick - ? () => actionControlOnClick(item) - : undefined - ) - } - > - {actionControl} - - ); - } else { - const { - onClick, - name, - href, - target, - 'data-test-subj': dataTestSubj, - } = action; - - const buttonIcon = action.icon; - let icon; - if (buttonIcon) { - icon = isString(buttonIcon) ? buttonIcon : buttonIcon(item); - } - const buttonContent = typeof name === 'function' ? name(item) : name; - - controls.push( - - this.onClickItem(onClick ? () => onClick(item) : undefined) - } - > - {buttonContent} - - ); +): action is CustomItemAction => action.hasOwnProperty('render'); + +export const CollapsedItemActions = ({ + actions, + itemId, + item, + actionEnabled, + className, +}: CollapsedItemActionsProps) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [allDisabled, setAllDisabled] = useState(true); + + const onClickItem = useCallback((onClickAction?: () => void) => { + setPopoverOpen(false); + onClickAction?.(); + }, []); + + const controls = useMemo(() => { + return actions.reduce((controls, action, index) => { + const available = action.available?.(item) ?? true; + if (!available) return controls; + + const enabled = actionEnabled(action); + if (enabled) setAllDisabled(false); + + if (actionIsCustomItemAction(action)) { + const customAction = action as CustomItemAction; + const actionControl = customAction.render(item, enabled); + controls.push( + // Do not put the `onClick` on the EuiContextMenuItem itself - otherwise + // it renders a