mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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> | The popover is left-aligned with the start of the input and expands to the right:<br><br> | | The range slider popover could not extend past the control width, regardless of how small that was:<br><br> | The range slider popover now also has a minimum width, which makes it more useable on the smallest size:<br><br> | ### 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:
parent
5c7b57c88a
commit
bafb23580b
7 changed files with 68 additions and 87 deletions
9
src/plugins/controls/public/constants.ts
Normal file
9
src/plugins/controls/public/constants.ts
Normal 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;
|
|
@ -123,12 +123,6 @@ $controlMinWidth: $euiSize * 14;
|
||||||
&--small {
|
&--small {
|
||||||
width: $smallControl;
|
width: $smallControl;
|
||||||
min-width: $smallControl;
|
min-width: $smallControl;
|
||||||
|
|
||||||
&:not(.controlFrameWrapper--grow) {
|
|
||||||
.controlFrame__labelToolTip {
|
|
||||||
max-width: 20%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--medium {
|
&--medium {
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
.optionsList--filterGroup {
|
.optionsList--filterGroup {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: $euiSizeXXL;
|
box-shadow: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
box-shadow: none;
|
.optionsList__inputButtonOverride {
|
||||||
border-top-left-radius: 0;
|
max-inline-size: none; // overwrite the default `max-inline-size` that's coming from EUI
|
||||||
border-bottom-left-radius: 0;
|
}
|
||||||
border-top-right-radius: $euiBorderRadius - 1px;
|
|
||||||
border-bottom-right-radius: $euiBorderRadius - 1px;
|
|
||||||
|
|
||||||
.optionsList--filterBtn {
|
.optionsList--filterBtn {
|
||||||
border-radius: 0 !important;
|
|
||||||
height: $euiButtonHeight;
|
height: $euiButtonHeight;
|
||||||
|
|
||||||
&.optionsList--filterBtnPlaceholder {
|
&.optionsList--filterBtnPlaceholder {
|
||||||
|
|
|
@ -9,9 +9,9 @@
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { debounce, isEmpty } from 'lodash';
|
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 { MAX_OPTIONS_LIST_REQUEST_SIZE } from '../types';
|
||||||
import { OptionsListStrings } from './options_list_strings';
|
import { OptionsListStrings } from './options_list_strings';
|
||||||
|
@ -20,6 +20,7 @@ import { useOptionsList } from '../embeddable/options_list_embeddable';
|
||||||
|
|
||||||
import './options_list.scss';
|
import './options_list.scss';
|
||||||
import { ControlError } from '../../control_group/component/control_error_component';
|
import { ControlError } from '../../control_group/component/control_error_component';
|
||||||
|
import { MIN_POPOVER_WIDTH } from '../../constants';
|
||||||
|
|
||||||
export const OptionsListControl = ({
|
export const OptionsListControl = ({
|
||||||
typeaheadSubject,
|
typeaheadSubject,
|
||||||
|
@ -28,9 +29,7 @@ export const OptionsListControl = ({
|
||||||
typeaheadSubject: Subject<string>;
|
typeaheadSubject: Subject<string>;
|
||||||
loadMoreSubject: Subject<number>;
|
loadMoreSubject: Subject<number>;
|
||||||
}) => {
|
}) => {
|
||||||
const resizeRef = useRef(null);
|
|
||||||
const optionsList = useOptionsList();
|
const optionsList = useOptionsList();
|
||||||
const dimensions = useResizeObserver(resizeRef.current);
|
|
||||||
|
|
||||||
const error = optionsList.select((state) => state.componentState.error);
|
const error = optionsList.select((state) => state.componentState.error);
|
||||||
const isPopoverOpen = optionsList.select((state) => state.componentState.popoverOpen);
|
const isPopoverOpen = optionsList.select((state) => state.componentState.popoverOpen);
|
||||||
|
@ -124,26 +123,24 @@ export const OptionsListControl = ({
|
||||||
}, [exclude, existsSelected, validSelections, invalidSelections]);
|
}, [exclude, existsSelected, validSelections, invalidSelections]);
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<div className="optionsList--filterBtnWrapper" ref={resizeRef}>
|
<EuiFilterButton
|
||||||
<EuiFilterButton
|
badgeColor="success"
|
||||||
badgeColor="success"
|
iconType="arrowDown"
|
||||||
iconType="arrowDown"
|
isLoading={debouncedLoading}
|
||||||
isLoading={debouncedLoading}
|
className={classNames('optionsList--filterBtn', {
|
||||||
className={classNames('optionsList--filterBtn', {
|
'optionsList--filterBtnSingle': controlStyle !== 'twoLine',
|
||||||
'optionsList--filterBtnSingle': controlStyle !== 'twoLine',
|
'optionsList--filterBtnPlaceholder': !hasSelections,
|
||||||
'optionsList--filterBtnPlaceholder': !hasSelections,
|
})}
|
||||||
})}
|
data-test-subj={`optionsList-control-${id}`}
|
||||||
data-test-subj={`optionsList-control-${id}`}
|
onClick={() => optionsList.dispatch.setPopoverOpen(!isPopoverOpen)}
|
||||||
onClick={() => optionsList.dispatch.setPopoverOpen(!isPopoverOpen)}
|
isSelected={isPopoverOpen}
|
||||||
isSelected={isPopoverOpen}
|
numActiveFilters={validSelectionsCount}
|
||||||
numActiveFilters={validSelectionsCount}
|
hasActiveFilters={Boolean(validSelectionsCount)}
|
||||||
hasActiveFilters={Boolean(validSelectionsCount)}
|
>
|
||||||
>
|
{hasSelections || existsSelected
|
||||||
{hasSelections || existsSelected
|
? selectionDisplayNode
|
||||||
? selectionDisplayNode
|
: placeholder ?? OptionsListStrings.control.getPlaceholder()}
|
||||||
: placeholder ?? OptionsListStrings.control.getPlaceholder()}
|
</EuiFilterButton>
|
||||||
</EuiFilterButton>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return error ? (
|
return error ? (
|
||||||
|
@ -154,26 +151,26 @@ export const OptionsListControl = ({
|
||||||
'optionsList--filterGroupSingle': controlStyle !== 'twoLine',
|
'optionsList--filterGroupSingle': controlStyle !== 'twoLine',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<EuiPopover
|
<EuiInputPopover
|
||||||
ownFocus
|
ownFocus
|
||||||
button={button}
|
input={button}
|
||||||
hasArrow={false}
|
hasArrow={false}
|
||||||
repositionOnScroll
|
repositionOnScroll
|
||||||
isOpen={isPopoverOpen}
|
isOpen={isPopoverOpen}
|
||||||
panelPaddingSize="none"
|
panelPaddingSize="none"
|
||||||
anchorPosition="downCenter"
|
panelMinWidth={MIN_POPOVER_WIDTH}
|
||||||
|
className="optionsList__inputButtonOverride"
|
||||||
initialFocus={'[data-test-subj=optionsList-control-search-input]'}
|
initialFocus={'[data-test-subj=optionsList-control-search-input]'}
|
||||||
closePopover={() => optionsList.dispatch.setPopoverOpen(false)}
|
closePopover={() => optionsList.dispatch.setPopoverOpen(false)}
|
||||||
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
|
|
||||||
panelClassName="optionsList__popoverOverride"
|
panelClassName="optionsList__popoverOverride"
|
||||||
|
panelProps={{ 'aria-label': OptionsListStrings.popover.getAriaLabel(fieldName) }}
|
||||||
>
|
>
|
||||||
<OptionsListPopover
|
<OptionsListPopover
|
||||||
width={dimensions.width}
|
|
||||||
isLoading={debouncedLoading}
|
isLoading={debouncedLoading}
|
||||||
updateSearchString={updateSearchString}
|
updateSearchString={updateSearchString}
|
||||||
loadMoreSuggestions={loadMoreSuggestions}
|
loadMoreSuggestions={loadMoreSuggestions}
|
||||||
/>
|
/>
|
||||||
</EuiPopover>
|
</EuiInputPopover>
|
||||||
</EuiFilterGroup>
|
</EuiFilterGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,16 +13,15 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||||
import { FieldSpec } from '@kbn/data-views-plugin/common';
|
import { FieldSpec } from '@kbn/data-views-plugin/common';
|
||||||
|
|
||||||
|
import { pluginServices } from '../../services';
|
||||||
import { mockOptionsListEmbeddable } from '../../../common/mocks';
|
import { mockOptionsListEmbeddable } from '../../../common/mocks';
|
||||||
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
|
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
|
||||||
import { OptionsListComponentState, OptionsListReduxState } from '../types';
|
import { OptionsListComponentState, OptionsListReduxState } from '../types';
|
||||||
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
|
import { OptionsListEmbeddableContext } from '../embeddable/options_list_embeddable';
|
||||||
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
|
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
|
||||||
import { pluginServices } from '../../services';
|
|
||||||
|
|
||||||
describe('Options list popover', () => {
|
describe('Options list popover', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
width: 500,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
updateSearchString: jest.fn(),
|
updateSearchString: jest.fn(),
|
||||||
loadMoreSuggestions: jest.fn(),
|
loadMoreSuggestions: jest.fn(),
|
||||||
|
@ -58,17 +57,6 @@ describe('Options list popover', () => {
|
||||||
showOnlySelectedButton.simulate('click');
|
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 () => {
|
test('no available options', async () => {
|
||||||
const popover = await mountComponent({ componentState: { availableOptions: [] } });
|
const popover = await mountComponent({ componentState: { availableOptions: [] } });
|
||||||
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
|
const availableOptionsDiv = findTestSubject(popover, 'optionsList-control-available-options');
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { OptionsListStrings } from './options_list_strings';
|
|
||||||
import { useOptionsList } from '../embeddable/options_list_embeddable';
|
import { useOptionsList } from '../embeddable/options_list_embeddable';
|
||||||
import { OptionsListPopoverFooter } from './options_list_popover_footer';
|
import { OptionsListPopoverFooter } from './options_list_popover_footer';
|
||||||
import { OptionsListPopoverActionBar } from './options_list_popover_action_bar';
|
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';
|
import { OptionsListPopoverInvalidSelections } from './options_list_popover_invalid_selections';
|
||||||
|
|
||||||
export interface OptionsListPopoverProps {
|
export interface OptionsListPopoverProps {
|
||||||
width: number;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
loadMoreSuggestions: (cardinality: number) => void;
|
loadMoreSuggestions: (cardinality: number) => void;
|
||||||
updateSearchString: (newSearchString: string) => void;
|
updateSearchString: (newSearchString: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionsListPopover = ({
|
export const OptionsListPopover = ({
|
||||||
width,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
updateSearchString,
|
updateSearchString,
|
||||||
loadMoreSuggestions,
|
loadMoreSuggestions,
|
||||||
|
@ -36,43 +33,38 @@ export const OptionsListPopover = ({
|
||||||
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
|
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
|
||||||
|
|
||||||
const id = optionsList.select((state) => state.explicitInput.id);
|
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 hideExclude = optionsList.select((state) => state.explicitInput.hideExclude);
|
||||||
const hideActionBar = optionsList.select((state) => state.explicitInput.hideActionBar);
|
const hideActionBar = optionsList.select((state) => state.explicitInput.hideActionBar);
|
||||||
|
|
||||||
const [showOnlySelected, setShowOnlySelected] = useState(false);
|
const [showOnlySelected, setShowOnlySelected] = useState(false);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
id={`control-popover-${id}`}
|
data-test-subj={`optionsList-control-available-options`}
|
||||||
className={'optionsList__popover'}
|
data-option-count={isLoading ? 0 : Object.keys(availableOptions ?? {}).length}
|
||||||
style={{ width, minWidth: 300 }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
data-test-subj={`optionsList-control-popover`}
|
|
||||||
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
|
|
||||||
>
|
>
|
||||||
{field?.type !== 'boolean' && !hideActionBar && (
|
<OptionsListPopoverSuggestions
|
||||||
<OptionsListPopoverActionBar
|
loadMoreSuggestions={loadMoreSuggestions}
|
||||||
showOnlySelected={showOnlySelected}
|
showOnlySelected={showOnlySelected}
|
||||||
updateSearchString={updateSearchString}
|
/>
|
||||||
setShowOnlySelected={setShowOnlySelected}
|
{!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>
|
</div>
|
||||||
</>
|
{!hideExclude && <OptionsListPopoverFooter isLoading={isLoading} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { useRangeSlider } from '../embeddable/range_slider_embeddable';
|
||||||
import { ControlError } from '../../control_group/component/control_error_component';
|
import { ControlError } from '../../control_group/component/control_error_component';
|
||||||
|
|
||||||
import './range_slider.scss';
|
import './range_slider.scss';
|
||||||
|
import { MIN_POPOVER_WIDTH } from '../../constants';
|
||||||
|
|
||||||
export const RangeSliderControl: FC = () => {
|
export const RangeSliderControl: FC = () => {
|
||||||
/** Controls Services Context */
|
/** Controls Services Context */
|
||||||
|
@ -153,6 +154,9 @@ export const RangeSliderControl: FC = () => {
|
||||||
min={displayedMin}
|
min={displayedMin}
|
||||||
max={displayedMax}
|
max={displayedMax}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
inputPopoverProps={{
|
||||||
|
panelMinWidth: MIN_POPOVER_WIDTH,
|
||||||
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
// when the pin is dropped (on mouse up), cancel any pending debounced changes and force the change
|
// 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
|
// in value to happen instantly (which, in turn, will re-calculate the min/max for the slider due to
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue