diff --git a/.buildkite/pipelines/pipeline_pull_request_test.yml b/.buildkite/pipelines/pipeline_pull_request_test.yml index 74e3c9587f4..58b07441730 100644 --- a/.buildkite/pipelines/pipeline_pull_request_test.yml +++ b/.buildkite/pipelines/pipeline_pull_request_test.yml @@ -73,3 +73,14 @@ steps: artifact_paths: - "cypress/screenshots/**/*.png" - "cypress/videos/**/*.mp4" + + - command: .buildkite/scripts/pipeline_test.sh + label: ":axe: Cypress accessibility (a11y) tests on React 18" + agents: + provider: "gcp" + env: + TEST_TYPE: 'cypress:a11y' + if: build.branch != "main" + artifact_paths: + - "cypress/screenshots/**/*.png" + - "cypress/videos/**/*.mp4" diff --git a/.buildkite/scripts/pipelines/pipeline_test.sh b/.buildkite/scripts/pipelines/pipeline_test.sh index 7ff953c49ca..b8cd1d0c9be 100644 --- a/.buildkite/scripts/pipelines/pipeline_test.sh +++ b/.buildkite/scripts/pipelines/pipeline_test.sh @@ -54,6 +54,11 @@ case $TEST_TYPE in DOCKER_OPTIONS+=(bash -c "/opt/yarn*/bin/yarn && yarn cypress install && yarn test-cypress --node-options=--max_old_space_size=2048") ;; + cypress:a11y) + echo "[TASK]: Running Cypress accessibility tests against React 18" + DOCKER_OPTIONS+=(bash -c "/opt/yarn*/bin/yarn && yarn cypress install && yarn run test-cypress-a11y --node-options=--max_old_space_size=2048") + ;; + *) echo "[ERROR]: Unknown task" echo "Exit code: 1" diff --git a/.github/workflows/add_issues_to_project.yml b/.github/workflows/add_issues_to_project.yml deleted file mode 100644 index c276c51c9fd..00000000000 --- a/.github/workflows/add_issues_to_project.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Adds issues to project board - -on: - issues: - types: - - opened - -jobs: - add-to-project: - name: Add issue to project - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@v0.4.0 - with: - project-url: https://github.com/orgs/elastic/projects/1079 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} diff --git a/changelogs/upcoming/7352.md b/changelogs/upcoming/7352.md new file mode 100644 index 00000000000..0ae57cf1474 --- /dev/null +++ b/changelogs/upcoming/7352.md @@ -0,0 +1,2 @@ +- Updated `EuiListGroupItem` to render an external icon and screen reader affordance for links with `target` set to to `_blank` +- Updated `EuiListGroupItem` with a new `external` prop, which allows enabling or disabling the new external link icon diff --git a/changelogs/upcoming/7360.md b/changelogs/upcoming/7360.md new file mode 100644 index 00000000000..18056dd00c7 --- /dev/null +++ b/changelogs/upcoming/7360.md @@ -0,0 +1 @@ +- Updated `EuiText` to no longer set any opinionated styles on child `` tags - use `EuiImage` for image display within text instead 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/scripts/test-a11y-docker.js b/scripts/test-a11y-docker.js index b30e0fd2b48..65cc57244bf 100644 --- a/scripts/test-a11y-docker.js +++ b/scripts/test-a11y-docker.js @@ -5,15 +5,16 @@ execSync('docker pull docker.elastic.co/eui/ci:5.6', { }); /* eslint-disable-next-line no-multi-str */ execSync( - 'docker run \ - -i --rm --cap-add=SYS_ADMIN --volume=$(pwd):/app --workdir=/app \ + "docker run \ + -i --rm --cap-add=SYS_ADMIN --volume=$(pwd):/app --workdir=/app --platform=linux/amd64 \ -e GIT_COMMITTER_NAME=test -e GIT_COMMITTER_EMAIL=test -e HOME=/tmp \ --user=$(id -u):$(id -g) \ docker.elastic.co/eui/ci:5.6 \ - bash -c \'npm config set spin false \ - && /opt/yarn*/bin/yarn \ + bash -c '/opt/yarn*/bin/yarn \ && yarn cypress install \ - && NODE_OPTIONS="--max-old-space-size=2048" npm run test-cypress-a11y\'', + && yarn run test-cypress-a11y \ + --node-options=--max_old_space_size=2048 \ + --skip-css '", // Skipping CSS because compiling has a tendency to hang on Apple Silicon { stdio: 'inherit', } diff --git a/src-docs/src/views/list_group/list_group_example.js b/src-docs/src/views/list_group/list_group_example.js index b34bad0618f..76a23f0b8d0 100644 --- a/src-docs/src/views/list_group/list_group_example.js +++ b/src-docs/src/views/list_group/list_group_example.js @@ -79,6 +79,17 @@ export const ListGroupExample = { isActive and isDisabled{' '} properties.

+

+ If your link is external or will open in a new tab, you can manually{' '} + set the external property. However, just like{' '} + with the{' '} + + EuiLink + {' '} + component, setting{' '} + {'target="_blank"'} defaults to{' '} + {'external={true}'}. +

As is done in this example, the EuiListGroup{' '} component can also accept an array of items via the{' '} diff --git a/src-docs/src/views/list_group/list_group_links.tsx b/src-docs/src/views/list_group/list_group_links.tsx index 34fb9abeaee..283c4597e2d 100644 --- a/src-docs/src/views/list_group/list_group_links.tsx +++ b/src-docs/src/views/list_group/list_group_links.tsx @@ -26,9 +26,10 @@ const myContent = [ iconType: 'copyClipboard', }, { - label: 'Fifth link', - href: '#/display/list-group', + label: 'Fifth link will open in new tab', + href: 'http://www.elastic.co', iconType: 'crosshairs', + target: '_blank', }, ]; 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/basic_table.a11y.tsx b/src/components/basic_table/basic_table.a11y.tsx index 7a04e284fe7..45db1e36c4d 100644 --- a/src/components/basic_table/basic_table.a11y.tsx +++ b/src/components/basic_table/basic_table.a11y.tsx @@ -231,8 +231,7 @@ describe('EuiTable', () => { describe('Keyboard accessibility', () => { it('has zero violations after expanding a row', () => { - cy.repeatRealPress('Tab'); - cy.get('button#1').should('have.focus'); + cy.get('button#1').focus(); cy.realPress('Enter'); cy.get('tr.euiTableRow-isExpandedRow div.euiTableCellContent').should( 'exist' 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