From 38144361377b0b64b8813e15fac08ce6b84ed3f0 Mon Sep 17 00:00:00 2001 From: Trevor Pierce <1Copenut@users.noreply.github.com> Date: Wed, 5 Jan 2022 17:10:07 -0600 Subject: [PATCH 1/3] [TESTING] Add Cypress Real Events for a11y UI testing (#5510) * WIP. Adding first proof code for Cypress Real Events to EuiAccordion. * Adding more SR and keyboard tests for the EuiAccordion. * Updating docs and tests to use data-test-subj identifiers. * Adding Do's and Don'ts to the CRE documentation. * Took an extra formatting block out of code fence. * Moving some dependencies to streamline proof PR. * Refactoring setup to a custom Cypress Command helper. * Moved the first portion of the setup into a custom helper * Added a second promise return to the custom helper for `cy.get()` * DRY refactor of the real events setup completed. * Updating documentation heading levels. * Adding a helper for repeated keypresses, refactoring contrxt menu panel test. * Refactored context menu panel test, updated docs. * Added TypeScript definition to the realMount custom command. * Updated docs to explain both mounting commands. * Removing duplicated type definition. * Revising returned values from custom commands. * Removing extra cy.end calls from Accordion spec. * Small update to code sample in cypress-testing wiki entry. --- cypress/support/commands.js | 25 ++++ cypress/support/index.d.ts | 8 ++ cypress/support/index.js | 1 + package.json | 1 + src/components/accordion/accordion.spec.tsx | 120 ++++++++++++++++++ .../context_menu/context_menu_panel.spec.tsx | 53 +++----- tsconfig-cypress.json | 5 +- wiki/cypress-testing.md | 46 +++++++ yarn.lock | 5 + 9 files changed, 228 insertions(+), 36 deletions(-) create mode 100644 src/components/accordion/accordion.spec.tsx diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 796ad8435e3..bf637d62d16 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,4 +1,5 @@ import { mount as cypressMount } from '@cypress/react'; +import { React, Fragment } from 'react'; import { EuiProvider } from '../../src'; // Provide global cy.mount() shortcut that includes required providers @@ -6,3 +7,27 @@ import { EuiProvider } from '../../src'; Cypress.Commands.add('mount', (children) => { return cypressMount({children}); }); + +// This ensures the correct testing window has focus when using Cypress Real Events. +// @see https://github.com/dmtrKovalenko/cypress-real-events/issues/196 +Cypress.Commands.add('realMount', (children) => { + cy.mount( + +
+ {children} + + ).then(() => { + cy.get('[data-test-subj="cypress-real-event-target"]').realClick({ + position: 'topLeft', + }); + }); +}); + +Cypress.Commands.add('repeatRealPress', (keyToPress, count = 2) => { + for (let i = 0; i < count; i++) { + cy.realPress(keyToPress); + } +}); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index f42a67f5b73..0d3952f90e5 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -3,5 +3,13 @@ declare namespace Cypress { interface Chainable { mount(children: React.ReactNode): Cypress.Chainable; + realMount(children: React.ReactNode): void; + /** + * Repeat the Real Events `realPress()` method 2 or more times + * + * @param keyToPress Any valid key or array of keys https://docs.cypress.io/api/commands/type#Arguments + * @param count Number of times to invoke `realPress()`. Defaults to 2. + */ + repeatRealPress(keyToPress: string | string[], count?: number): void; } } diff --git a/cypress/support/index.js b/cypress/support/index.js index 0a7a68b89c1..da19e9c4fef 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -17,6 +17,7 @@ import '@cypress/code-coverage/support'; import './commands.js'; require(THEME_IMPORT); // defined by DefinePlugin in the cypress webpack config require('cypress-plugin-tab'); // adds the `.tab()` command to cypress chains, see https://docs.cypress.io/api/commands/type#Typing-tab-key-does-not-work +require('cypress-real-events/support'); // uses the Chrome Devtools Protocol to replace simulated events, see https://github.com/dmtrKovalenko/cypress-real-events#why // @see https://github.com/quasarframework/quasar/issues/2233#issuecomment-492975745 Cypress.on('uncaught:exception', (err) => { diff --git a/package.json b/package.json index 09eaa2cc698..e854986c777 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "cssnano": "^4.1.10", "cypress": "^9.1.1", "cypress-plugin-tab": "^1.0.5", + "cypress-real-events": "^1.6.0", "deasync": "^0.1.20", "dedent": "^0.7.0", "dts-generator": "^3.0.0", diff --git a/src/components/accordion/accordion.spec.tsx b/src/components/accordion/accordion.spec.tsx new file mode 100644 index 00000000000..0352cba3d11 --- /dev/null +++ b/src/components/accordion/accordion.spec.tsx @@ -0,0 +1,120 @@ +/* + * 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 { EuiAccordion, EuiAccordionProps } from './index'; +import { EuiPanel } from '../../components/panel'; +import { htmlIdGenerator } from '../../services'; + +const baseProps: EuiAccordionProps = { + buttonContent: 'Click me to toggle', + id: htmlIdGenerator()(), + initialIsOpen: false, +}; + +const noArrow = { arrowDisplay: 'none' }; +const noArrowProps: EuiAccordionProps = Object.assign(baseProps, noArrow); + +describe('EuiAccordion', () => { + describe('Keyboard and screen reader accessibility', () => { + it('renders with required props', () => { + cy.realMount( + + + Any content inside of EuiAccordion will appear + here. + + + ); + cy.realPress('Tab'); + cy.focused().contains('Click me to toggle'); + }); + + it('opens and closes on ENTER keypress', () => { + cy.realMount( + + + Any content inside of EuiAccordion will appear + here. + + + ); + cy.realPress('Tab'); + cy.focused().contains('Click me to toggle').realPress('Enter'); + cy.realPress(['Shift', 'Tab']); + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'true'); + cy.realPress('Enter'); + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'false'); + }); + + it('opens and closes on SPACE keypress', () => { + cy.realMount( + + + Any content inside of EuiAccordion will appear + here. + + + ); + cy.realPress('Tab'); + cy.focused().contains('Click me to toggle').realPress('Space'); + cy.realPress(['Shift', 'Tab']); + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'true'); + cy.realPress('Space'); + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'false'); + }); + }); + + describe('Props and keyboard navigation', () => { + it('should not have an arrow', () => { + cy.realMount( + + + Any content inside of EuiAccordion will appear + here. + + + ); + cy.get('.euiAccordion__iconButton').should('not.exist'); + }); + + it('manages focus when panel is opened', () => { + cy.realMount( + + + Any content inside of EuiAccordion will appear + here. We will include a link to confirm focus. + + + ); + cy.realPress('Tab'); + cy.focused().contains('Click me to toggle').realPress('Enter'); + cy.focused().invoke('attr', 'tabindex').should('equal', '-1'); + cy.focused().contains('Any content inside of EuiAccordion'); + cy.realPress('Tab'); + cy.focused().contains('a link'); + }); + + it('manages focus when forceState is open', () => { + cy.realMount( + + + Any content inside of EuiAccordion will appear + here. We will include a link to confirm focus. + + + ); + cy.realPress('Tab'); + cy.focused().contains('Click me to toggle'); + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'true'); + cy.focused().invoke('attr', 'tabindex').should('not.exist'); + cy.realPress('Tab'); + cy.focused().contains('a link'); + }); + }); +}); diff --git a/src/components/context_menu/context_menu_panel.spec.tsx b/src/components/context_menu/context_menu_panel.spec.tsx index 45bd94e5de1..3950b471483 100644 --- a/src/components/context_menu/context_menu_panel.spec.tsx +++ b/src/components/context_menu/context_menu_panel.spec.tsx @@ -19,7 +19,6 @@ describe('EuiContextMenuPanel', () => { ); - cy.focused().should('have.attr', 'data-test-subj', 'button'); }); @@ -29,7 +28,6 @@ describe('EuiContextMenuPanel', () => { ); - cy.focused().should('not.exist'); }); }); @@ -47,13 +45,9 @@ describe('EuiContextMenuPanel', () => { , ]; - // Intermittent flake workaround: without this, the first downarrow key does not always focus into the menu items as expected - const FLAKE_WAIT = 500; - describe('up/down keys', () => { beforeEach(() => { cy.mount(); - cy.wait(FLAKE_WAIT); }); it('focuses the panel by default', () => { @@ -61,35 +55,32 @@ describe('EuiContextMenuPanel', () => { }); it('down arrow key focuses the first menu item', () => { - cy.get('body').type('{downarrow}'); - + cy.focused().should('have.attr', 'class', 'euiContextMenuPanel'); + cy.realPress('{downarrow}'); cy.focused().should('have.attr', 'data-test-subj', 'itemA'); }); it('subsequently, down arrow key focuses the next menu item', () => { - cy.get('body').type('{downarrow}'); - cy.get('body').type('{downarrow}'); - + cy.focused().should('have.attr', 'class', 'euiContextMenuPanel'); + cy.repeatRealPress('{downarrow}'); cy.focused().should('have.attr', 'data-test-subj', 'itemB'); }); it('up arrow key wraps to last menu item', () => { - cy.get('body').type('{uparrow}'); - + cy.focused().should('have.attr', 'class', 'euiContextMenuPanel'); + cy.realPress('{uparrow}'); cy.focused().should('have.attr', 'data-test-subj', 'itemC'); }); it('down arrow key wraps to first menu item', () => { - cy.get('body').type('{uparrow}'); - cy.get('body').type('{downarrow}'); - + cy.focused().should('have.attr', 'class', 'euiContextMenuPanel'); + cy.repeatRealPress('{downarrow}', 4); cy.focused().should('have.attr', 'data-test-subj', 'itemA'); }); it('subsequently, up arrow key focuses the previous menu item', () => { - cy.get('body').type('{uparrow}'); - cy.get('body').type('{uparrow}'); - + cy.focused().should('have.attr', 'class', 'euiContextMenuPanel'); + cy.repeatRealPress('{uparrow}'); cy.focused().should('have.attr', 'data-test-subj', 'itemB'); }); }); @@ -103,14 +94,10 @@ describe('EuiContextMenuPanel', () => { showNextPanel={showNextPanelHandler} /> ); - cy.wait(FLAKE_WAIT); - - cy.get('body') - .type('{downarrow}') - .type('{rightarrow}') - .then(() => { - expect(showNextPanelHandler).to.be.called; - }); + cy.realPress('{downarrow}'); + cy.realPress('{rightarrow}').then(() => { + expect(showNextPanelHandler).to.be.called; + }); }); it('left arrow key shows previous panel', () => { @@ -121,14 +108,10 @@ describe('EuiContextMenuPanel', () => { showPreviousPanel={showPreviousPanelHandler} /> ); - cy.wait(FLAKE_WAIT); - - cy.get('body') - .type('{downarrow}') - .type('{leftarrow}') - .then(() => { - expect(showPreviousPanelHandler).to.be.called; - }); + cy.realPress('{downarrow}'); + cy.realPress('{leftarrow}').then(() => { + expect(showPreviousPanelHandler).to.be.called; + }); }); }); }); diff --git a/tsconfig-cypress.json b/tsconfig-cypress.json index 1478f5de3ab..9dc65460b68 100644 --- a/tsconfig-cypress.json +++ b/tsconfig-cypress.json @@ -3,5 +3,8 @@ "include": [ "./src/**/*.spec.tsx", ], - "exclude": ["node_modules"] + "compilerOptions": { + "types": ["cypress", "cypress-real-events"] + }, + "exclude": ["node_modules"], } diff --git a/wiki/cypress-testing.md b/wiki/cypress-testing.md index 47e79d1ae13..e3826a57144 100644 --- a/wiki/cypress-testing.md +++ b/wiki/cypress-testing.md @@ -81,6 +81,52 @@ contains `{component name}.tsx`. * DON'T depend upon class names or other implementation details for `find`ing nodes, if possible. * DON'T extend the `cy.` global namespace - instead prefer to import helper functions directly +### Cypress Real Events + +> Cypress default events are simulated. That means that all events like `cy.click` or `cy.type` are fired from JavaScript. That's why these events will be untrusted (`event.isTrusted` will be `false`) and they can behave a little different from real native events. But for some cases, it can be impossible to use simulated events, for example, to fill a native alert or copy to the clipboard. This plugin solves this problem. + +[Cypress Real Events](https://github.com/dmtrKovalenko/cypress-real-events#why) + +#### Why Cypress Real Events? + +Cypress Real Events uses the [Chrome Devtools Protocol](https://chromedevtools.github.io/devtools-protocol/) to handle behaviors like a real browser. This gives us a better way to test complex events like mouse hover and keyboard focus. By using real events and making assertions against them, we can test keyboard and screen reader accessibility as users change the local state. + +#### How to write Cypress (real event) tests + +The [Cypress Real Events API](https://github.com/dmtrKovalenko/cypress-real-events#api) works seamlessly with existing `cy()` methods. If you want to press a button using Cypress Real Events, you could use `realPress('Tab')` as a replacement for the `cy.tab()` synthetic method. All Cypress Real Events methods are prefixed with the string "real". Here's a small example test: + +```jsx +import TestComponent from './test_component'; + +describe('TestComponent', () => { + it('presses a button using the Enter key', () => { + /* Use the `realMount()` command to set focus in the test window */ + cy.realMount(); + + /* Activate a button with a real keypress event */ + cy.get('[data-test-subj="submitButton"]').realPress('Enter'); + + /* Assert the button has focus and the aria-expanded attribute has updated */ + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'true'); + }); + + it('presses a button using the Space key', () => { + /* Assert the button also accepts the Spacebar keypress */ + cy.realMount(); + cy.get('[data-test-subj="submitButton"]').realPress('Space'); + cy.focused().invoke('attr', 'aria-expanded').should('equal', 'true'); + }); +}); +``` + +#### Do's and don'ts for Cypress Real Events + +* DO follow [all previous guidance](#dos-and-donts) for writing Cypress tests +* DO use the correct mounting method: + * Use `cy.realMount()` if your component doesn't receive focus automatically **OR** + * Use `cy.mount()` for components that receive focus on render +* DO be on the lookout for new features! + ## Debugging tests For debugging failures locally, use `yarn test-cypress-dev`, which allows you to run a single specific test suite and runs tests in a browser window, making dev tools available to you so you can pause and inspect DOM as needed. diff --git a/yarn.lock b/yarn.lock index 36548abe313..b91629f06d9 100755 --- a/yarn.lock +++ b/yarn.lock @@ -6665,6 +6665,11 @@ cypress-plugin-tab@^1.0.5: dependencies: ally.js "^1.4.1" +cypress-real-events@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.6.0.tgz#277024b62a324b6937760a700e831e795c021040" + integrity sha512-QxXm0JsQkCrb2uH+fMXNDQ5kNWTzX3OtndBafdsZmNV19j+6JuTK9n52B1YVxrDrr/qzPAojcHJc5PNoQvwp+w== + cypress@^9.1.1: version "9.1.1" resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.1.1.tgz#26720ca5a22077cd85f49745616b7a08152a298f" From d4f7bfb82d9b18371110c51c5630950aeac6833a Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 5 Jan 2022 16:08:31 -0800 Subject: [PATCH 2/3] [EuiSearchBar] Allow phrases with leading and trailing spaces (#5514) * Update EuiSearchBar to preserve phrases with leading and trailing spaces * Add changelog entry * [PR feedback] Breaking change note --- CHANGELOG.md | 1 + src/components/search_bar/query/default_syntax.test.ts | 8 ++++---- src/components/search_bar/query/default_syntax.ts | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6728405d0..47d55dd0fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ **Breaking changes** +- Changed `EuiSearchBar` to preserve phrases with leading and trailing spaces, instead of dropping surrounding whitespace ([#5514](https://github.com/elastic/eui/pull/5514)) - Removed `data-test-subj="dataGridWrapper"` from `EuiDataGrid` in favor of `data-test-subj="euiDataGridBody"` ([#5506](https://github.com/elastic/eui/pull/5506)) ## [`44.0.0`](https://github.com/elastic/eui/tree/v44.0.0) diff --git a/src/components/search_bar/query/default_syntax.test.ts b/src/components/search_bar/query/default_syntax.test.ts index df51fdc8f26..0114d50582f 100644 --- a/src/components/search_bar/query/default_syntax.test.ts +++ b/src/components/search_bar/query/default_syntax.test.ts @@ -562,8 +562,8 @@ describe('defaultSyntax', () => { expect(printedQuery).toBe(query); }); - test('relaxed phrases with spaces', () => { - const query = 'f:" this is a relaxed phrase \t"'; + test('phrases with spaces', () => { + const query = 'f:" this is a phrase that preserves spaces\t"'; const ast = defaultSyntax.parse(query); expect(ast).toBeDefined(); @@ -574,10 +574,10 @@ describe('defaultSyntax', () => { expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('f'); - expect(clause.value).toBe('this is a relaxed phrase'); + expect(clause.value).toBe(' this is a phrase that preserves spaces '); const printedQuery = defaultSyntax.print(ast); - expect(printedQuery).toBe('f:"this is a relaxed phrase"'); + expect(printedQuery).toBe('f:" this is a phrase that preserves spaces "'); }); test('phrases with extra characters', () => { diff --git a/src/components/search_bar/query/default_syntax.ts b/src/components/search_bar/query/default_syntax.ts index 0e054276891..d538b529575 100644 --- a/src/components/search_bar/query/default_syntax.ts +++ b/src/components/search_bar/query/default_syntax.ts @@ -146,9 +146,9 @@ containsValue / word phrase - = '"' space? phrase:( - (phraseWord+)? (space phraseWord+)* { return unescapePhraseValue(text()); } - ) space? '"' { return Exp.string(phrase, location()); } + = '"' phrase:( + space? (phraseWord+)? (space phraseWord+)* space? { return unescapePhraseValue(text()); } + ) '"' { return Exp.string(phrase, location()); } phraseWord // not a backslash, quote, or space From d26becf26c582c10124d8529151c109588a6a6f6 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Tue, 11 Jan 2022 15:28:13 -0600 Subject: [PATCH 3/3] [EuiSelectable] `isVirtualized` and `data` (#5521) * isVirtualized and labelProps * update docs * CL * review feedback * update list types * labelProps -> data * Update docs with virtualization toggle and hide overflow of selectable item Co-authored-by: cchaos --- CHANGELOG.md | 3 + .../selectable/selectable_custom_render.js | 35 +++- .../views/selectable/selectable_example.js | 30 +++- .../__snapshots__/selectable.test.tsx.snap | 102 +++++++++++ src/components/selectable/selectable.test.tsx | 43 +++++ src/components/selectable/selectable.tsx | 23 ++- .../selectable_list.test.tsx.snap | 165 ++++++++++++++++++ .../_selectable_list_item.scss | 3 +- .../selectable/selectable_list/index.ts | 1 + .../selectable_list/selectable_list.test.tsx | 12 ++ .../selectable_list/selectable_list.tsx | 112 ++++++++---- .../selectable/selectable_option.tsx | 5 + 12 files changed, 484 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d55dd0fe8..5b089c8b4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [`main`](https://github.com/elastic/eui/tree/main) +- Added virtulized rendering option to `EuiSelectableList` with `isVirtualized` ([#5521](https://github.com/elastic/eui/pull/5521)) +- Added expanded option properties to `EuiSelectableOption` with `data` ([#5521](https://github.com/elastic/eui/pull/5521)) + **Breaking changes** - Changed `EuiSearchBar` to preserve phrases with leading and trailing spaces, instead of dropping surrounding whitespace ([#5514](https://github.com/elastic/eui/pull/5514)) diff --git a/src-docs/src/views/selectable/selectable_custom_render.js b/src-docs/src/views/selectable/selectable_custom_render.js index 75e4f2050e8..ef4c3dcea12 100644 --- a/src-docs/src/views/selectable/selectable_custom_render.js +++ b/src-docs/src/views/selectable/selectable_custom_render.js @@ -12,6 +12,7 @@ import { createDataStore } from '../tables/data_store'; export default () => { const [useCustomContent, setUseCustomContent] = useState(false); + const [isVirtualized, setIsVirtualized] = useState(true); const countries = createDataStore().countries.map((country) => { return { @@ -20,6 +21,9 @@ export default () => { prepend: country.flag, append: {country.code}, showIcons: false, + data: { + secondaryContent: 'I am secondary content, I am!', + }, }; }); @@ -38,15 +42,19 @@ export default () => { setUseCustomContent(e.currentTarget.checked); }; + const onVirtualized = (e) => { + setIsVirtualized(e.currentTarget.checked); + }; + const renderCountryOption = (option, searchValue) => { return ( <> {option.label} -
- + {/*
*/} + - I am secondary content, I am! + {option.secondaryContent} @@ -54,33 +62,42 @@ export default () => { ); }; + let listProps = { + isVirtualized, + }; + let customProps; if (useCustomContent) { customProps = { height: 240, renderOption: renderCountryOption, - listProps: { - rowHeight: 50, - showIcons: false, - }, + }; + listProps = { + rowHeight: 50, + isVirtualized, }; } return ( <> + {' '} +   - - {(list, search) => ( diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index 9f36c676b1f..cfc07fc6491 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -347,6 +347,20 @@ export const SelectableExample = { similar to a title. Add one of these by setting the{' '} option.isGroupLabel to true.{' '}

+

Row height and virtualization

+

+ When virtualization is on,{' '} + every row must be the same height in order for the + list to know how to scroll to the selected or highlighted option. It + applies the listProps.rowHeight (in pixels) + directly to each option hiding any overflow. +

+

+ If listProps.isVirtualized is set to{' '} + false, each row will fit its contents and removes + all scrolling. Therefore, we recommend having a large enough + container to accomodate all optons. +

Custom content

While it is best to stick to the{' '} @@ -357,15 +371,17 @@ export const SelectableExample = { searchValue to use for highlighting.

- In order for the list to know how to scroll to the selected or - highlighted option, it must also know the height of the rows. It - applies this pixel height directly to options. If your custom - content is taller than the default of 32px tall, - you will need to recalculate this height and apply it via{' '} - listProps.rowHeight. + To provide data that can be used by the{' '} + renderOption function that does not match the + standard option API, use option.data which will + make custom data available in the option{' '} + parameter. See the secondaryContent configuration + in the following example.

- Every row must be the same height. + Also, if your custom content is taller than the default{' '} + listProps.rowHeight of 32px{' '} + tall, you will need to pass in a custom value to this prop.

), diff --git a/src/components/selectable/__snapshots__/selectable.test.tsx.snap b/src/components/selectable/__snapshots__/selectable.test.tsx.snap index 35ce39017b3..dfb568309cf 100644 --- a/src/components/selectable/__snapshots__/selectable.test.tsx.snap +++ b/src/components/selectable/__snapshots__/selectable.test.tsx.snap @@ -1,5 +1,107 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EuiSelectable custom options with data 1`] = ` +
+
+
+
+
    +
  • + + + + + VI: Titan + + + +
  • +
  • + + + + + II: Enceladus + + + +
  • +
  • + + + + + XVII: Pandora is one of Saturn's moons, named for a Titaness of Greek mythology + + + +
  • +
+
+
+
+
+`; + exports[`EuiSelectable is rendered 1`] = `
{ (component.find('EuiSelectableList').props() as any).visibleOptions ).toEqual(options); }); + + test('with data', () => { + type WithData = { + numeral?: string; + }; + const options = [ + { + label: 'Titan', + data: { + numeral: 'VI', + }, + }, + { + label: 'Enceladus', + data: { + numeral: 'II', + }, + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + data: { + numeral: 'XVII', + }, + }, + ]; + const component = render( + + options={options} + renderOption={(option) => { + return ( + + {option.numeral}: {option.label} + + ); + }} + > + {(list) => list} + + ); + + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index b2cd6af939d..5d4a63e8550 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -18,7 +18,10 @@ import classNames from 'classnames'; import { CommonProps, ExclusiveUnion } from '../common'; import { EuiSelectableSearch } from './selectable_search'; import { EuiSelectableMessage } from './selectable_message'; -import { EuiSelectableList } from './selectable_list'; +import { + EuiSelectableList, + EuiSelectableOptionsListVirtualizedProps, +} from './selectable_list'; import { EuiLoadingSpinner } from '../loading'; import { EuiSpacer } from '../spacer'; import { getMatchingOptions } from './matching_options'; @@ -434,8 +437,23 @@ export class EuiSelectable extends Component< const { 'aria-label': listAriaLabel, 'aria-describedby': listAriaDescribedby, + isVirtualized, + rowHeight, ...cleanedListProps - } = listProps || unknownAccessibleName; + } = (listProps || unknownAccessibleName) as typeof listProps & + typeof unknownAccessibleName; + + let virtualizedProps: EuiSelectableOptionsListVirtualizedProps; + + if (isVirtualized === false) { + virtualizedProps = { + isVirtualized, + }; + } else if (rowHeight != null) { + virtualizedProps = { + rowHeight, + }; + } const classes = classNames( 'euiSelectable', @@ -629,6 +647,7 @@ export class EuiSelectable extends Component< ? listAccessibleName : searchable && { 'aria-label': placeholderName })} {...cleanedListProps} + {...virtualizedProps} /> )} diff --git a/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap index 0a69ca20b06..e695d3effb2 100644 --- a/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap +++ b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap @@ -1087,6 +1087,171 @@ exports[`EuiSelectableListItem props height is full 1`] = `
`; +exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` +
+
+
    +
  • + + + + + Titan + + + +
  • +
  • + + + + + Enceladus + + + +
  • +
  • + + + + + Mimas + + + +
  • +
  • + + + + + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology + + + +
  • +
  • + + + + + Tethys + + + +
  • +
  • + + + + + Hyperion + + + +
  • +
+
+
+`; + exports[`EuiSelectableListItem props renderOption 1`] = `
{ expect(component).toMatchSnapshot(); }); + + test('isVirtualized can be false', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index a4cd93b029c..98085662232 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -6,7 +6,13 @@ * Side Public License, v 1. */ -import React, { Component, HTMLAttributes, ReactNode, memo } from 'react'; +import React, { + Component, + HTMLAttributes, + ReactNode, + memo, + CSSProperties, +} from 'react'; import classNames from 'classnames'; import { FixedSizeList, @@ -14,7 +20,7 @@ import { ListChildComponentProps as ReactWindowListChildComponentProps, areEqual, } from 'react-window'; -import { CommonProps } from '../../common'; +import { CommonProps, ExclusiveUnion } from '../../common'; import { EuiAutoSizer } from '../../auto_sizer'; import { EuiHighlight } from '../../highlight'; import { EuiSelectableOption } from '../selectable_option'; @@ -24,10 +30,29 @@ import { } from './selectable_list_item'; interface ListChildComponentProps - extends ReactWindowListChildComponentProps { + extends Omit { data: Array>; + style?: CSSProperties; } +export type EuiSelectableOptionsListVirtualizedProps = ExclusiveUnion< + { + /** + * Use virtualized rendering for list items with `react-window`. + * Sets each row's height to the value of `rowHeight`. + */ + isVirtualized?: true; + /** + * The height of each option in pixels. Defaults to `32`. + * Has no effect if `isVirtualized=false`. + */ + rowHeight: number; + }, + { + isVirtualized: false; + } +>; + // Consumer Configurable Props via `EuiSelectable.listProps` export type EuiSelectableOptionsListProps = CommonProps & HTMLAttributes & { @@ -37,10 +62,6 @@ export type EuiSelectableOptionsListProps = CommonProps & * directly to that option */ activeOptionIndex?: number; - /** - * The height of each option in pixels. Defaults to `32` - */ - rowHeight: number; /** * Show the check/cross selection indicator icons */ @@ -61,7 +82,7 @@ export type EuiSelectableOptionsListProps = CommonProps & * The default content when `true` is `↩ to select/deselect/include/exclude` */ onFocusBadge?: EuiSelectableListItemProps['onFocusBadge']; - }; + } & EuiSelectableOptionsListVirtualizedProps; export type EuiSelectableListProps = EuiSelectableOptionsListProps & { /** @@ -109,6 +130,7 @@ export class EuiSelectableList extends Component> { static defaultProps = { rowHeight: 32, searchValue: '', + isVirtualized: true, }; listRef: FixedSizeList | null = null; @@ -186,6 +208,7 @@ export class EuiSelectableList extends Component> { ListRow = memo(({ data, index, style }: ListChildComponentProps) => { const option = data[index]; + const { data: optionData, ..._option } = option; const { label, isGroupLabel, @@ -196,6 +219,7 @@ export class EuiSelectableList extends Component> { ref, key, searchableLabel, + data: _data, ...optionRest } = option; @@ -241,7 +265,11 @@ export class EuiSelectableList extends Component> { {...(optionRest as EuiSelectableListItemProps)} > {this.props.renderOption ? ( - this.props.renderOption(option, this.props.searchValue) + this.props.renderOption( + // @ts-ignore complex + { ..._option, ...optionData }, + this.props.searchValue + ) ) : ( {label} )} @@ -273,6 +301,7 @@ export class EuiSelectableList extends Component> { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, + isVirtualized, ...rest } = this.props; @@ -293,9 +322,9 @@ export class EuiSelectableList extends Component> { if (numVisibleMoreThanMax) { // Show only half of the last one to indicate there's more to scroll to - calculatedHeight = (maxVisibleOptions - 0.5) * rowHeight; + calculatedHeight = (maxVisibleOptions - 0.5) * rowHeight!; } else { - calculatedHeight = numVisibleOptions * rowHeight; + calculatedHeight = numVisibleOptions * rowHeight!; } } @@ -310,26 +339,47 @@ export class EuiSelectableList extends Component> { return (
- - {({ width, height }) => ( - - {this.ListRow} - - )} - + {isVirtualized ? ( + + {({ width, height }) => ( + + {this.ListRow} + + )} + + ) : ( +
+
    + {optionArray.map((_, index) => + React.createElement( + this.ListRow, + { + key: index, + data: optionArray, + index, + }, + null + ) + )} +
+
+ )}
); } diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx index 7b4ca6bfbcb..17fdaeafb54 100644 --- a/src/components/selectable/selectable_option.tsx +++ b/src/components/selectable/selectable_option.tsx @@ -53,6 +53,11 @@ export type EuiSelectableOptionBase = CommonProps & { * Option item `id`s are coordinated at a higher level for a11y reasons. */ id?: never; + /** + * Option data to pass through to the `renderOptions` element. + * Bypass `EuiSelectableItem` and avoid DOM attribute warnings. + */ + data?: { [key: string]: any }; }; type _EuiSelectableGroupLabelOption = Omit<