diff --git a/CHANGELOG.md b/CHANGELOG.md
index 105d8624141..ce2e31808f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
## [`main`](https://github.com/elastic/eui/tree/main)
- Added the ability to control internal `EuiDataGrid` fullscreen, cell focus, and cell popover state via the `ref` prop ([#5590](https://github.com/elastic/eui/pull/5590))
+- Added `paddingSize` prop to `EuiSelectableList` ([#5581](https://github.com/elastic/eui/pull/5581))
+- Added `errorMessage` prop to `EuiSelectable` ([#5581](https://github.com/elastic/eui/pull/5581))
+- Refactored `EuiSelectable` accessibility ([#5581](https://github.com/elastic/eui/pull/5581))
**Bug fixes**
diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js
index cfc07fc6491..b1301294ed1 100644
--- a/src-docs/src/views/selectable/selectable_example.js
+++ b/src-docs/src/views/selectable/selectable_example.js
@@ -283,10 +283,11 @@ export const SelectableExample = {
The component comes with pre-composed messages for loading, empty,
and no search result states. To display your own messages, pass{' '}
- loadingMessage, emptyMessage,
- or noMatchesMessage respectively. Alternatively,
- you can replace the entire list display with your
- own message for any state. In which case, we recommend wrapping your
+ loadingMessage, emptyMessage,{' '}
+ errorMessage, or{' '}
+ noMatchesMessage respectively. Alternatively, you
+ can replace the entire list display with your own
+ message for any state. In which case, we recommend wrapping your
custom message in an EuiSelectableMessage{' '}
component.
@@ -302,6 +303,7 @@ export const SelectableExample = {
isLoading={isLoading}
loadingMessage={customLoadingMessage}
emptyMessage={customEmptyMessage}
+ errorMessage={hasError ? errorMessage : undefined}
noMatchesMessage={customNoMatchesMessage}>
{list => list}
`,
diff --git a/src-docs/src/views/selectable/selectable_messages.tsx b/src-docs/src/views/selectable/selectable_messages.tsx
index 416d59822d9..35799e69d8f 100644
--- a/src-docs/src/views/selectable/selectable_messages.tsx
+++ b/src-docs/src/views/selectable/selectable_messages.tsx
@@ -7,9 +7,11 @@ import { EuiSpacer } from '../../../../src/components/spacer';
export default () => {
const [useCustomMessage, setUseCustomMessage] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ const [hasError, setHasError] = useState(false);
const emptyMessage = 'You have no spice';
const loadingMessage = "Hey, I'm loading here!";
+ const errorMessage = 'Error!';
return (
@@ -24,6 +26,12 @@ export default () => {
onChange={(e) => setIsLoading(e.target.checked)}
checked={isLoading}
/>
+
+ setHasError(e.target.checked)}
+ checked={hasError}
+ />
{
isLoading={isLoading}
loadingMessage={useCustomMessage ? loadingMessage : undefined}
emptyMessage={useCustomMessage ? emptyMessage : undefined}
+ errorMessage={hasError ? errorMessage : undefined}
>
{(list) => list}
diff --git a/src-docs/src/views/selectable/selectable_popover.js b/src-docs/src/views/selectable/selectable_popover.js
index f7bc80f2d3a..d32a8404faf 100644
--- a/src-docs/src/views/selectable/selectable_popover.js
+++ b/src-docs/src/views/selectable/selectable_popover.js
@@ -146,7 +146,10 @@ export default () => {
- Using listProps.bordered=true
+ Using listProps.bordered=true and{' '}
+
+ listProps.paddingSize="none"
+
@@ -157,7 +160,7 @@ export default () => {
options={options}
onChange={() => {}}
style={{ width: 300 }}
- listProps={{ bordered: true }}
+ listProps={{ bordered: true, paddingSize: 'none' }}
>
{(list) => list}
diff --git a/src/components/selectable/__snapshots__/selectable.test.tsx.snap b/src/components/selectable/__snapshots__/selectable.test.tsx.snap
index dfb568309cf..7c1b1c129da 100644
--- a/src/components/selectable/__snapshots__/selectable.test.tsx.snap
+++ b/src/components/selectable/__snapshots__/selectable.test.tsx.snap
@@ -4,6 +4,12 @@ exports[`EuiSelectable custom options with data 1`] = `
+
+ Filter options
+
@@ -18,11 +24,12 @@ exports[`EuiSelectable custom options with data 1`] = `
style="height:96px;width:100%"
>
`;
+exports[`EuiSelectable errorMessage prop can render an element as the message 1`] = `
+
+`;
+
+exports[`EuiSelectable errorMessage prop does not render the message when not defined 1`] = `
+
+
+ Filter options
+
+
+
+
+
+ -
+
+
+
+
+ Titan
+
+
+
+
+ -
+
+
+
+
+ Enceladus
+
+
+
+
+ -
+
+
+
+
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`EuiSelectable errorMessage prop does renders the message when defined 1`] = `
+
+`;
+
exports[`EuiSelectable is rendered 1`] = `
{
expect(component).toMatchSnapshot();
});
});
+
+ describe('errorMessage prop', () => {
+ it('does not render the message when not defined', () => {
+ const component = render(
+
+ {(list) => list}
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('does renders the message when defined', () => {
+ const component = render(
+
+ {(list) => list}
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it('can render an element as the message', () => {
+ const component = render(
+
Element error!}
+ >
+ {(list) => list}
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+ });
});
diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx
index 5d4a63e8550..b875d573a38 100644
--- a/src/components/selectable/selectable.tsx
+++ b/src/components/selectable/selectable.tsx
@@ -26,6 +26,7 @@ import { EuiLoadingSpinner } from '../loading';
import { EuiSpacer } from '../spacer';
import { getMatchingOptions } from './matching_options';
import { keys, htmlIdGenerator } from '../../services';
+import { EuiScreenReaderLive, EuiScreenReaderOnly } from '../accessibility';
import { EuiI18n } from '../i18n';
import { EuiSelectableOption } from './selectable_option';
import { EuiSelectableOptionsListProps } from './selectable_list/selectable_list';
@@ -43,6 +44,14 @@ type OptionalEuiSelectableOptionsListProps = Omit<
type EuiSelectableOptionsListPropsWithDefaults = RequiredEuiSelectableOptionsListProps &
Partial
;
+// The `searchable` prop has significant implications for a11y.
+// When present, we effectively change from adhering
+// to the ARIA `listbox` spec (https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox)
+// to the ARIA `combobox` spec (https://www.w3.org/TR/wai-aria-practices-1.2/#combobox)
+// and (re)implement all relevant attributes and keyboard interactions.
+// Take note of logic that relies on `searchable` to ensure that any
+// modifications remain in alignment.
+//
// `searchProps` can only be specified when `searchable` is true
type EuiSelectableSearchableProps = ExclusiveUnion<
{
@@ -139,6 +148,14 @@ export type EuiSelectableProps = CommonProps &
* or a node to replace the whole content.
*/
emptyMessage?: ReactElement | string;
+ /**
+ * Add an error message.
+ * The message will be shown when the value is not `null` or `undefined`.
+ * Pass a string to simply change the text, or a node to replace the whole content.
+ *
+ * `errorMessage={hasErrors ? 'My error message' : null}`
+ */
+ errorMessage?: ReactElement | string | null;
/**
* Control whether or not options get filtered internally or if consumer will filter
* Default: false
@@ -166,10 +183,19 @@ export class EuiSelectable extends Component<
private containerRef = createRef();
private optionsListRef = createRef>();
private preventOnFocus = false;
- rootId = htmlIdGenerator();
+ rootId: (suffix?: string) => string;
+ messageContentId: string;
+ listId: string;
constructor(props: EuiSelectableProps) {
super(props);
+ this.rootId = props.id
+ ? (suffix) => `${props.id}${suffix ? `_${suffix}` : ''}`
+ : htmlIdGenerator();
+
+ this.listId = this.rootId();
+ this.messageContentId = this.rootId('messageContent');
+
const { options, singleSelection, isPreFiltered } = props;
const initialSearchValue = '';
@@ -276,6 +302,16 @@ export class EuiSelectable extends Component<
break;
case keys.ENTER:
+ case keys.SPACE:
+ if (event.key === keys.SPACE && this.props.searchable) {
+ // For non-searchable instances, SPACE interaction should align with
+ // the user expectation of selection toggling (e.g., input[type=checkbox]).
+ // ENTER is also a valid selection mechanism in this case.
+ //
+ // For searchable instances, SPACE is reserved as a character for filtering
+ // via the input box, and as such only ENTER will toggle selection.
+ return;
+ }
event.preventDefault();
event.stopPropagation();
if (this.state.activeOptionIndex != null && optionsList) {
@@ -357,8 +393,7 @@ export class EuiSelectable extends Component<
onContainerBlur = (e: React.FocusEvent) => {
// Ignore blur events when moving from search to option to avoid activeOptionIndex conflicts
if (
- ((e.relatedTarget as Node)?.firstChild as HTMLElement)?.id ===
- this.rootId('listbox')
+ ((e.relatedTarget as Node)?.firstChild as HTMLElement)?.id === this.listId
) {
return;
}
@@ -393,6 +428,9 @@ export class EuiSelectable extends Component<
this.optionsListRef.current?.listRef?.scrollToItem(index, align);
};
+ makeOptionId = (index?: number) =>
+ index != null ? `${this.listId}_option-${index}` : '';
+
render() {
const {
id,
@@ -413,6 +451,7 @@ export class EuiSelectable extends Component<
loadingMessage,
noMatchesMessage,
emptyMessage,
+ errorMessage,
isPreFiltered,
...rest
} = this.props;
@@ -463,20 +502,12 @@ export class EuiSelectable extends Component<
className
);
- /** Create Id's */
- let messageContentId = this.rootId('messageContent');
- const listId = this.rootId('listbox');
- const makeOptionId = (index: number | undefined) => {
- if (typeof index === 'undefined') {
- return '';
- }
-
- return `${listId}_option-${index}`;
- };
-
/** Create message content that replaces the list if no options are available (yet) */
let messageContent: ReactNode | undefined;
- if (isLoading) {
+ if (errorMessage != null) {
+ messageContent =
+ typeof errorMessage === 'string' ? {errorMessage}
: errorMessage;
+ } else if (isLoading) {
if (loadingMessage === undefined || typeof loadingMessage === 'string') {
messageContent = (
<>
@@ -494,7 +525,7 @@ export class EuiSelectable extends Component<
);
} else {
messageContent = React.cloneElement(loadingMessage, {
- id: messageContentId,
+ id: this.messageContentId,
...loadingMessage.props,
});
}
@@ -516,7 +547,7 @@ export class EuiSelectable extends Component<
);
} else {
messageContent = React.cloneElement(noMatchesMessage, {
- id: messageContentId,
+ id: this.messageContentId,
...noMatchesMessage.props,
});
}
@@ -534,12 +565,10 @@ export class EuiSelectable extends Component<
);
} else {
messageContent = React.cloneElement(emptyMessage, {
- id: messageContentId,
+ id: this.messageContentId,
...emptyMessage.props,
});
}
- } else {
- messageContentId = '';
}
/**
@@ -586,7 +615,7 @@ export class EuiSelectable extends Component<
const searchAccessibleName = getAccessibleName(
searchProps,
- messageContentId
+ this.messageContentId
);
const searchHasAccessibleName = Boolean(
Object.keys(searchAccessibleName).length
@@ -598,8 +627,8 @@ export class EuiSelectable extends Component<
key="listSearch"
options={options}
onChange={this.onSearchChange}
- listId={this.optionsListRef.current ? listId : undefined} // Only pass the listId if it exists on the page
- aria-activedescendant={makeOptionId(activeOptionIndex)} // the current faux-focused option
+ listId={this.optionsListRef.current ? this.listId : undefined} // Only pass the listId if it exists on the page
+ aria-activedescendant={this.makeOptionId(activeOptionIndex)} // the current faux-focused option
placeholder={placeholderName}
isPreFiltered={isPreFiltered ?? false}
{...(searchHasAccessibleName
@@ -611,44 +640,85 @@ export class EuiSelectable extends Component<
) : undefined;
- const listAccessibleName = getAccessibleName(listProps);
+ const resultsLength = visibleOptions.filter((option) => !option.disabled)
+ .length;
+ const listScreenReaderStatus = searchable && (
+
+ `${resultsLength} result${resultsLength === 1 ? '' : 's'} available`
+ }
+ values={{ resultsLength }}
+ />
+ );
+
+ const listAriaDescribedbyId = `${this.listId}-instructions`;
+ const listAccessibleName = getAccessibleName(
+ listProps,
+ listAriaDescribedbyId
+ );
const listHasAccessibleName = Boolean(
Object.keys(listAccessibleName).length
);
- const list = messageContent ? (
-
- {messageContent}
-
- ) : (
-
- {(placeholderName: string) => (
-
- key="list"
- options={options}
- visibleOptions={visibleOptions}
- searchValue={searchValue}
- activeOptionIndex={activeOptionIndex}
- setActiveOptionIndex={(index, cb) => {
- this.setState({ activeOptionIndex: index }, cb);
- }}
- onOptionClick={this.onOptionClick}
- singleSelection={singleSelection}
- ref={this.optionsListRef}
- renderOption={renderOption}
- height={height}
- allowExclusions={allowExclusions}
- searchable={searchable}
- makeOptionId={makeOptionId}
- listId={listId}
- {...(listHasAccessibleName
- ? listAccessibleName
- : searchable && { 'aria-label': placeholderName })}
- {...cleanedListProps}
- {...virtualizedProps}
- />
+ {([placeholderName, screenReaderInstructions]: string[]) => (
+ <>
+ {searchable && (
+
+ {messageContent || listScreenReaderStatus}
+
+ )}
+
+
+ {screenReaderInstructions}
+
+
+ {messageContent ? (
+
+ {messageContent}
+
+ ) : (
+
+ key="list"
+ options={options}
+ visibleOptions={visibleOptions}
+ searchValue={searchValue}
+ activeOptionIndex={activeOptionIndex}
+ setActiveOptionIndex={(index, cb) => {
+ this.setState({ activeOptionIndex: index }, cb);
+ }}
+ onOptionClick={this.onOptionClick}
+ singleSelection={singleSelection}
+ ref={this.optionsListRef}
+ renderOption={renderOption}
+ height={height}
+ allowExclusions={allowExclusions}
+ searchable={searchable}
+ makeOptionId={this.makeOptionId}
+ listId={this.listId}
+ {...(listHasAccessibleName
+ ? 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 e695d3effb2..2bf2ad1a208 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
@@ -16,10 +16,11 @@ exports[`EuiSelectableListItem is rendered 1`] = `
style="height:192px;width:100%"
>
Titan
+
+ - To select this option, press enter.
+
Enceladus
+
+ - To select this option, press enter.
+
Mimas
+
+ - To select this option, press enter.
+
Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+
+ - To select this option, press enter.
+
Tethys
+
+ - To select this option, press enter.
+
Hyperion
+
+ - To select this option, press enter.
+
@@ -569,10 +617,11 @@ exports[`EuiSelectableListItem props bordered 1`] = `
style="height:192px;width:100%"
>
-
-
-
-
-
-
`;
-exports[`EuiSelectableListItem props renderOption 1`] = `
+exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = `
-
- => Titan
+ Titan
-
- => Enceladus
+ Enceladus
-
- => Mimas
+ Mimas
-
- => Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
-
- => Tethys
+ Tethys
-
- => Hyperion
+ Hyperion
@@ -1430,7 +1508,7 @@ exports[`EuiSelectableListItem props renderOption 1`] = `
`;
-exports[`EuiSelectableListItem props rowHeight 1`] = `
+exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = `
-
-
-
-
-
-
`;
-exports[`EuiSelectableListItem props searchValue 1`] = `
+exports[`EuiSelectableListItem props renderOption 1`] = `
-
- Titan
+ => Titan
-
- Enceladus
+ => Enceladus
-
-
- Mi
-
- mas
+ => Mimas
-
- Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+ => Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
-
- Tethys
+ => Tethys
-
- Hyperion
+ => Hyperion
@@ -1791,7 +1876,7 @@ exports[`EuiSelectableListItem props searchValue 1`] = `
`;
-exports[`EuiSelectableListItem props searchValue 2`] = `
+exports[`EuiSelectableListItem props rowHeight 1`] = `
-
-
-
-
- Mi
-
- mas
+ Mimas
-
-
-
`;
-exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
+exports[`EuiSelectableListItem props searchValue 1`] = `
-
+
@@ -2013,10 +2104,11 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
-
+
@@ -2035,10 +2131,11 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
-
+
- Mimas
+
+ Mi
+
+ mas
-
+
@@ -2079,10 +2190,11 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
-
+
@@ -2101,10 +2217,11 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
-
+
@@ -2128,7 +2249,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
`;
-exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = `
+exports[`EuiSelectableListItem props searchValue 2`] = `
-
-
-
- Mimas
+
+ Mi
+
+ mas
-
-
-
`;
-exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = `
+exports[`EuiSelectableListItem props searchable enables correct screen reader instructions 1`] = `
-
-
-
-
-
-
`;
-exports[`EuiSelectableListItem props visibleOptions 1`] = `
+exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
-
-
-
-
-
- Mimas
-
-
+
+ Titan
-
-
-
-
-
- Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
-
-
+
+ Enceladus
-
-
-
-
-
+
+ Mimas
+
+
+ -
+
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+
+
+ -
+
+ Tethys
+
+
+ -
+
+ Hyperion
+
+
+
+
+
+
+`;
+
+exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = `
+
+
+
+
+ -
+
+
+
+
+ Titan
+
+
+
+
+ -
+
+
+
+
+ Enceladus
+
+
+
+
+ -
+
+
+
+
+ Mimas
+
+
+
+
+ -
+
+
+
+
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+
+
+
+
+ -
+
+
+
+
Tethys
-
+
+
+
+
+ Hyperion
+
+
+
+
+
+
+
+
+`;
+
+exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = `
+
+
+
+
+ -
+
+
+
+
+ Titan
+
+
+
+
+ -
+
+
+
+
+ Enceladus
+
+
+
+
+ -
+
+
+
+
+ Mimas
+
+
+
+
+ -
+
+
+
+
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+
+
+
+
+ -
+
+
+
+
+ Tethys
+
+
+
+
+ -
+
+
+
+
+ Hyperion
+
+
+
+
+
+
+
+
+`;
+
+exports[`EuiSelectableListItem props visibleOptions 1`] = `
+
+
+
+
+ -
+
+
+
+
+ Mimas
+
+
+
+
+ -
+
+
+
+
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
+
+
+
+
+ -
+
+
+
+
+ Tethys
+
+
+
+
+ -
@@ -24,8 +24,8 @@ exports[`EuiSelectableListItem is rendered 1`] = `
exports[`EuiSelectableListItem props append 1`] = `
-
+ >
+
+ - Checked option.
+
+
`;
exports[`EuiSelectableListItem props checked is on 1`] = `
-
+ >
+
+ - Checked option.
+
+
`;
exports[`EuiSelectableListItem props disabled 1`] = `
-
`;
-exports[`EuiSelectableListItem props prepend 1`] = `
+exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = `
-
@@ -204,10 +218,25 @@ exports[`EuiSelectableListItem props prepend 1`] = `
data-euiicon-type="empty"
/>
-
-
+ class="euiSelectableListItem__text"
+ />
+
+
+`;
+
+exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = `
+-
+
+
@@ -215,18 +244,35 @@ exports[`EuiSelectableListItem props prepend 1`] = `
`;
-exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
+exports[`EuiSelectableListItem props prepend 1`] = `
-
+
+
+
+
`;
+
+exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
+
+`;
diff --git a/src/components/selectable/selectable_list/_selectable_list_item.scss b/src/components/selectable/selectable_list/_selectable_list_item.scss
index 1f3623519d0..0cbd588acf6 100644
--- a/src/components/selectable/selectable_list/_selectable_list_item.scss
+++ b/src/components/selectable/selectable_list/_selectable_list_item.scss
@@ -3,7 +3,6 @@
display: inline-flex; // Necessary to make sure it doesn't force the whole popover to be too wide
width: 100%;
text-align: left;
- color: $euiTextColor;
cursor: pointer;
overflow: hidden;
@@ -25,10 +24,15 @@
color: $euiColorMediumShade;
cursor: not-allowed;
}
+
+ &--paddingSmall {
+ .euiSelectableListItem__content {
+ padding: $euiSelectableListItemPadding;
+ }
+ }
}
.euiSelectableListItem__content {
- padding: $euiSelectableListItemPadding;
width: 100%;
display: flex;
align-items: center;
diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx
index 2e36b3672ba..d4b722fecec 100644
--- a/src/components/selectable/selectable_list/selectable_list.test.tsx
+++ b/src/components/selectable/selectable_list/selectable_list.test.tsx
@@ -11,6 +11,7 @@ import { render } from 'enzyme';
import { requiredProps } from '../../../test/required_props';
import { EuiSelectableList } from './selectable_list';
+import { PADDING_SIZES } from './selectable_list_item';
import { EuiSelectableOption } from '../selectable_option';
const options: EuiSelectableOption[] = [
@@ -226,5 +227,33 @@ describe('EuiSelectableListItem', () => {
expect(component).toMatchSnapshot();
});
+
+ test('searchable enables correct screen reader instructions', () => {
+ const component = render(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ describe('paddingSize', () => {
+ PADDING_SIZES.forEach((size) => {
+ test(`${size} is rendered`, () => {
+ 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 98085662232..eb460b904b2 100644
--- a/src/components/selectable/selectable_list/selectable_list.tsx
+++ b/src/components/selectable/selectable_list/selectable_list.tsx
@@ -82,6 +82,10 @@ export type EuiSelectableOptionsListProps = CommonProps &
* The default content when `true` is `↩ to select/deselect/include/exclude`
*/
onFocusBadge?: EuiSelectableListItemProps['onFocusBadge'];
+ /**
+ * See #EuiSelectableListItemProps
+ */
+ paddingSize?: EuiSelectableListItemProps['paddingSize'];
} & EuiSelectableOptionsListVirtualizedProps;
export type EuiSelectableListProps = EuiSelectableOptionsListProps & {
@@ -223,6 +227,19 @@ export class EuiSelectableList extends Component> {
...optionRest
} = option;
+ const {
+ activeOptionIndex,
+ allowExclusions,
+ onFocusBadge,
+ paddingSize,
+ searchValue,
+ showIcons,
+ makeOptionId,
+ renderOption,
+ setActiveOptionIndex,
+ searchable,
+ } = this.props;
+
if (isGroupLabel) {
return (
- extends Component> {
}
const labelCount = data.filter((option) => option.isGroupLabel).length;
+ const id = makeOptionId(index);
return (
{
- this.props.setActiveOptionIndex(index);
+ setActiveOptionIndex(index);
}}
onClick={() => this.onAddOrRemoveOption(option)}
ref={ref ? ref.bind(null, index) : undefined}
- isFocused={this.props.activeOptionIndex === index}
+ isFocused={activeOptionIndex === index}
title={searchableLabel || label}
checked={checked}
disabled={disabled}
@@ -259,19 +277,21 @@ export class EuiSelectableList extends Component> {
append={append}
aria-posinset={index + 1 - labelCount}
aria-setsize={data.length - labelCount}
- onFocusBadge={this.props.onFocusBadge}
- allowExclusions={this.props.allowExclusions}
- showIcons={this.props.showIcons}
+ onFocusBadge={onFocusBadge}
+ allowExclusions={allowExclusions}
+ showIcons={showIcons}
+ paddingSize={paddingSize}
+ searchable={searchable}
{...(optionRest as EuiSelectableListItemProps)}
>
- {this.props.renderOption ? (
- this.props.renderOption(
+ {renderOption ? (
+ renderOption(
// @ts-ignore complex
{ ..._option, ...optionData },
this.props.searchValue
)
) : (
- {label}
+ {label}
)}
);
@@ -294,6 +314,7 @@ export class EuiSelectableList extends Component> {
visibleOptions,
allowExclusions,
bordered,
+ paddingSize,
searchable,
onFocusBadge,
listId,
@@ -301,6 +322,7 @@ export class EuiSelectableList extends Component> {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-describedby': ariaDescribedby,
+ role,
isVirtualized,
...rest
} = this.props;
diff --git a/src/components/selectable/selectable_list/selectable_list_item.test.tsx b/src/components/selectable/selectable_list/selectable_list_item.test.tsx
index ff865ff7750..12446c3e36e 100644
--- a/src/components/selectable/selectable_list/selectable_list_item.test.tsx
+++ b/src/components/selectable/selectable_list/selectable_list_item.test.tsx
@@ -10,7 +10,7 @@ import React from 'react';
import { render } from 'enzyme';
import { requiredProps } from '../../../test/required_props';
-import { EuiSelectableListItem } from './selectable_list_item';
+import { EuiSelectableListItem, PADDING_SIZES } from './selectable_list_item';
describe('EuiSelectableListItem', () => {
test('is rendered', () => {
@@ -62,6 +62,18 @@ describe('EuiSelectableListItem', () => {
expect(component).toMatchSnapshot();
});
+ describe('paddingSize', () => {
+ PADDING_SIZES.forEach((size) => {
+ test(`${size} is rendered`, () => {
+ const component = render(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+ });
+
describe('onFocusBadge', () => {
test('can be true', () => {
const component = render();
diff --git a/src/components/selectable/selectable_list/selectable_list_item.tsx b/src/components/selectable/selectable_list/selectable_list_item.tsx
index 9dc2e6f2cae..6d878c2e963 100644
--- a/src/components/selectable/selectable_list/selectable_list_item.tsx
+++ b/src/components/selectable/selectable_list/selectable_list_item.tsx
@@ -8,7 +8,7 @@
import classNames from 'classnames';
import React, { Component, LiHTMLAttributes } from 'react';
-import { CommonProps } from '../../common';
+import { CommonProps, keysOf } from '../../common';
import { EuiI18n } from '../../i18n';
import { EuiIcon, IconColor, IconType } from '../../icon';
import { EuiSelectableOptionCheckedType } from '../selectable_option';
@@ -26,6 +26,13 @@ function resolveIconAndColor(
: { icon: 'cross', color: 'text' };
}
+const paddingSizeToClassNameMap = {
+ none: null,
+ s: 'euiSelectableListItem--paddingSmall',
+};
+export const PADDING_SIZES = keysOf(paddingSizeToClassNameMap);
+export type EuiSelectablePaddingSize = typeof PADDING_SIZES[number];
+
export type EuiSelectableListItemProps = LiHTMLAttributes &
CommonProps & {
children?: React.ReactNode;
@@ -51,6 +58,22 @@ export type EuiSelectableListItemProps = LiHTMLAttributes &
* The default content when `true` is `↩ to select/deselect/include/exclude`
*/
onFocusBadge?: boolean | EuiBadgeProps;
+ /**
+ * Padding for the list items.
+ */
+ paddingSize?: EuiSelectablePaddingSize;
+ /**
+ * Whether the `EuiSelectable` instance is searchable.
+ * When true, the Space key will not toggle selection, as it will type into the search box instead. Screen reader instructions will be added instructing users to use the Enter key to select items.
+ * When false, the Space key will toggle item selection. No extra screen reader instructions will be added, as Space to toggle is a generally standard for most select/checked elements.
+ */
+ searchable?: boolean;
+ /**
+ * Attribute applied the option `
- `.
+ * If configured to something besides the default value of `option`,
+ * other ARIA attributes such as `aria-checked` will not be automatically configured.
+ */
+ role?: LiHTMLAttributes['role'];
};
// eslint-disable-next-line react/prefer-stateless-function
@@ -78,6 +101,9 @@ export class EuiSelectableListItem extends Component<
append,
allowExclusions,
onFocusBadge,
+ paddingSize = 's',
+ role = 'option',
+ searchable,
...rest
} = this.props;
@@ -86,6 +112,7 @@ export class EuiSelectableListItem extends Component<
{
'euiSelectableListItem-isFocused': isFocused,
},
+ paddingSizeToClassNameMap[paddingSize],
className
);
@@ -105,46 +132,53 @@ export class EuiSelectableListItem extends Component<
let instruction: React.ReactNode;
if (allowExclusions && checked === 'on') {
state = (
-
-
-
-
-
+
);
instruction = (
-
-
-
-
-
+
);
} else if (allowExclusions && checked === 'off') {
state = (
-
-
-
-
-
+
);
instruction = (
-
-
-
-
-
+
+ );
+ } else if (allowExclusions && !checked) {
+ instruction = (
+
+ );
+ }
+
+ const isChecked = !disabled && typeof checked === 'string';
+ if (!allowExclusions && isChecked) {
+ state = (
+
);
+ instruction = searchable ? (
+
+ ) : undefined;
}
let prependNode: React.ReactNode;
@@ -197,25 +231,42 @@ export class EuiSelectableListItem extends Component<
}
}
+ const instructions = (instruction || state) && (
+
+
+ {state || instruction ? ' - ' : null}
+ {state}
+ {state && instruction ? ' ' : null}
+ {instruction}
+
+
+ );
+
return (
-
-
- {optionIcon}
- {prependNode}
-
- {state}
- {children}
- {instruction}
+ {optionIcon || prependNode || appendNode ? (
+
+ {optionIcon}
+ {prependNode}
+
+ {children}
+ {instructions}
+
+ {appendNode}
- {appendNode}
-
+ ) : (
+ <>
+ {children}
+ {instructions}
+ >
+ )}
);
}