[Controls] Use new panelMinWidth prop in popovers (#165397)

Closes https://github.com/elastic/kibana/issues/164375

## Summary

This PR wraps up https://github.com/elastic/kibana/pull/162651 by fully
migrating to the `EuiInputPopover` for all controls - specifically, this
is made possible by the new `panelMinWidth` prop, which makes it so that
the popover can now extend past the size of the input **while
maintaining** the expected positioning.

| Before | After |
|--------|--------|
| The popover was centered underneath the control on the smallest
size:<br><br>![image](e2814ee2-6df6-47d6-925e-9f97cb8be2a5)
| The popover is left-aligned with the start of the input and expands to
the
right:<br><br>![image](7c698ef0-1534-43b6-ac95-9ae95f1c7613)
|
| The range slider popover could not extend past the control width,
regardless of how small that
was:<br><br>![image](12e33967-b616-4f0a-9ded-4374d65a51b2)
| The range slider popover now also has a minimum width, which makes it
more useable on the smallest
size:<br><br>![image](2fb844db-8f5d-44d8-a6dc-c9cb95d5a4ea)
|

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2023-09-22 13:02:48 -06:00 committed by GitHub
parent 5c7b57c88a
commit bafb23580b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 68 additions and 87 deletions

View file

@ -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 MIN_POPOVER_WIDTH = 300;

View file

@ -123,12 +123,6 @@ $controlMinWidth: $euiSize * 14;
&--small {
width: $smallControl;
min-width: $smallControl;
&:not(.controlFrameWrapper--grow) {
.controlFrame__labelToolTip {
max-width: 20%;
}
}
}
&--medium {

View file

@ -1,16 +1,13 @@
.optionsList--filterGroup {
width: 100%;
height: $euiSizeXXL;
box-shadow: none;
background-color: transparent;
box-shadow: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: $euiBorderRadius - 1px;
border-bottom-right-radius: $euiBorderRadius - 1px;
.optionsList__inputButtonOverride {
max-inline-size: none; // overwrite the default `max-inline-size` that's coming from EUI
}
.optionsList--filterBtn {
border-radius: 0 !important;
height: $euiButtonHeight;
&.optionsList--filterBtnPlaceholder {

View file

@ -9,9 +9,9 @@
import { Subject } from 'rxjs';
import classNames from 'classnames';
import { debounce, isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui';
import { EuiFilterButton, EuiFilterGroup, EuiInputPopover } from '@elastic/eui';
import { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
import { OptionsListStrings } from './options_list_strings';
@ -20,6 +20,7 @@ import { useOptionsList } from '../embeddable/options_list_embeddable';
import './options_list.scss';
import { ControlError } from '../../control_group/component/control_error_component';
import { MIN_POPOVER_WIDTH } from '../../constants';
export const OptionsListControl = ({
typeaheadSubject,
@ -28,9 +29,7 @@ export const OptionsListControl = ({
typeaheadSubject: Subject<string>;
loadMoreSubject: Subject<number>;
}) => {
const resizeRef = useRef(null);
const optionsList = useOptionsList();
const dimensions = useResizeObserver(resizeRef.current);
const error = optionsList.select((state) => state.componentState.error);
const isPopoverOpen = optionsList.select((state) => state.componentState.popoverOpen);
@ -124,26 +123,24 @@ export const OptionsListControl = ({
}, [exclude, existsSelected, validSelections, invalidSelections]);
const button = (
<div className="optionsList--filterBtnWrapper" ref={resizeRef}>
<EuiFilterButton
badgeColor="success"
iconType="arrowDown"
isLoading={debouncedLoading}
className={classNames('optionsList--filterBtn', {
'optionsList--filterBtnSingle': controlStyle !== 'twoLine',
'optionsList--filterBtnPlaceholder': !hasSelections,
})}
data-test-subj={`optionsList-control-${id}`}
onClick={() => optionsList.dispatch.setPopoverOpen(!isPopoverOpen)}
isSelected={isPopoverOpen}
numActiveFilters={validSelectionsCount}
hasActiveFilters={Boolean(validSelectionsCount)}
>
{hasSelections || existsSelected
? selectionDisplayNode
: placeholder ?? OptionsListStrings.control.getPlaceholder()}
</EuiFilterButton>
</div>
<EuiFilterButton
badgeColor="success"
iconType="arrowDown"
isLoading={debouncedLoading}
className={classNames('optionsList--filterBtn', {
'optionsList--filterBtnSingle': controlStyle !== 'twoLine',
'optionsList--filterBtnPlaceholder': !hasSelections,
})}
data-test-subj={`optionsList-control-${id}`}
onClick={() => optionsList.dispatch.setPopoverOpen(!isPopoverOpen)}
isSelected={isPopoverOpen}
numActiveFilters={validSelectionsCount}
hasActiveFilters={Boolean(validSelectionsCount)}
>
{hasSelections || existsSelected
? selectionDisplayNode
: placeholder ?? OptionsListStrings.control.getPlaceholder()}
</EuiFilterButton>
);
return error ? (
@ -154,26 +151,26 @@ export const OptionsListControl = ({
'optionsList--filterGroupSingle': controlStyle !== 'twoLine',
})}
>
<EuiPopover
<EuiInputPopover
ownFocus
button={button}
input={button}
hasArrow={false}
repositionOnScroll
isOpen={isPopoverOpen}
panelPaddingSize="none"
anchorPosition="downCenter"
panelMinWidth={MIN_POPOVER_WIDTH}
className="optionsList__inputButtonOverride"
initialFocus={'[data-test-subj=optionsList-control-search-input]'}
closePopover={() => optionsList.dispatch.setPopoverOpen(false)}
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
panelClassName="optionsList__popoverOverride"
panelProps={{ 'aria-label': OptionsListStrings.popover.getAriaLabel(fieldName) }}
>
<OptionsListPopover
width={dimensions.width}
isLoading={debouncedLoading}
updateSearchString={updateSearchString}
loadMoreSuggestions={loadMoreSuggestions}
/>
</EuiPopover>
</EuiInputPopover>
</EuiFilterGroup>
);
};

View file

@ -13,16 +13,15 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import { pluginServices } from '../../services';
import { mockOptionsListEmbeddable } from '../../../common/mocks';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
import { pluginServices } from '../../services';
describe('Options list popover', () => {
const defaultProps = {
width: 500,
isLoading: false,
updateSearchString: jest.fn(),
loadMoreSuggestions: jest.fn(),
@ -58,17 +57,6 @@ describe('Options list popover', () => {
showOnlySelectedButton.simulate('click');
};
test('available options list width responds to container size', async () => {
let popover = await mountComponent({ popoverProps: { width: 301 } });
let popoverDiv = findTestSubject(popover, 'optionsList-control-popover');
expect(popoverDiv.getDOMNode().getAttribute('style')).toBe('width: 301px; min-width: 300px;');
// the div cannot be smaller than 301 pixels wide
popover = await mountComponent({ popoverProps: { width: 300 } });
popoverDiv = findTestSubject(popover, 'optionsList-control-available-options');
expect(popoverDiv.getDOMNode().getAttribute('style')).toBe('width: 100%; height: 100%;');
});
test('no available options', async () => {
const popover = await mountComponent({ componentState: { availableOptions: [] } });
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');

View file

@ -9,7 +9,6 @@
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import { OptionsListStrings } from './options_list_strings';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import { OptionsListPopoverFooter } from './options_list_popover_footer';
import { OptionsListPopoverActionBar } from './options_list_popover_action_bar';
@ -17,14 +16,12 @@ import { OptionsListPopoverSuggestions } from './options_list_popover_suggestion
import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections';
export interface OptionsListPopoverProps {
width: number;
isLoading: boolean;
loadMoreSuggestions: (cardinality: number) => void;
updateSearchString: (newSearchString: string) => void;
}
export const OptionsListPopover = ({
width,
isLoading,
updateSearchString,
loadMoreSuggestions,
@ -36,43 +33,38 @@ export const OptionsListPopover = ({
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
const id = optionsList.select((state) => state.explicitInput.id);
const fieldName = optionsList.select((state) => state.explicitInput.fieldName);
const hideExclude = optionsList.select((state) => state.explicitInput.hideExclude);
const hideActionBar = optionsList.select((state) => state.explicitInput.hideActionBar);
const [showOnlySelected, setShowOnlySelected] = useState(false);
return (
<>
<div
id={`control-popover-${id}`}
className={'optionsList__popover'}
data-test-subj={`optionsList-control-popover`}
>
{field?.type !== 'boolean' && !hideActionBar && (
<OptionsListPopoverActionBar
showOnlySelected={showOnlySelected}
updateSearchString={updateSearchString}
setShowOnlySelected={setShowOnlySelected}
/>
)}
<div
id={`control-popover-${id}`}
className={'optionsList__popover'}
style={{ width, minWidth: 300 }}
data-test-subj={`optionsList-control-popover`}
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
data-test-subj={`optionsList-control-available-options`}
data-option-count={isLoading ? 0 : Object.keys(availableOptions ?? {}).length}
style={{ width: '100%', height: '100%' }}
>
{field?.type !== 'boolean' && !hideActionBar && (
<OptionsListPopoverActionBar
showOnlySelected={showOnlySelected}
updateSearchString={updateSearchString}
setShowOnlySelected={setShowOnlySelected}
/>
<OptionsListPopoverSuggestions
loadMoreSuggestions={loadMoreSuggestions}
showOnlySelected={showOnlySelected}
/>
{!showOnlySelected && invalidSelections && !isEmpty(invalidSelections) && (
<OptionsListPopoverInvalidSelections />
)}
<div
data-test-subj={`optionsList-control-available-options`}
data-option-count={isLoading ? 0 : Object.keys(availableOptions ?? {}).length}
style={{ width: '100%', height: '100%' }}
>
<OptionsListPopoverSuggestions
loadMoreSuggestions={loadMoreSuggestions}
showOnlySelected={showOnlySelected}
/>
{!showOnlySelected && invalidSelections && !isEmpty(invalidSelections) && (
<OptionsListPopoverInvalidSelections />
)}
</div>
{!hideExclude && <OptionsListPopoverFooter isLoading={isLoading} />}
</div>
</>
{!hideExclude && <OptionsListPopoverFooter isLoading={isLoading} />}
</div>
);
};

View file

@ -17,6 +17,7 @@ import { useRangeSlider } from '../embeddable/range_slider_embeddable';
import { ControlError } from '../../control_group/component/control_error_component';
import './range_slider.scss';
import { MIN_POPOVER_WIDTH } from '../../constants';
export const RangeSliderControl: FC = () => {
/** Controls Services Context */
@ -153,6 +154,9 @@ export const RangeSliderControl: FC = () => {
min={displayedMin}
max={displayedMax}
isLoading={isLoading}
inputPopoverProps={{
panelMinWidth: MIN_POPOVER_WIDTH,
}}
onMouseUp={() => {
// when the pin is dropped (on mouse up), cancel any pending debounced changes and force the change
// in value to happen instantly (which, in turn, will re-calculate the min/max for the slider due to