[Unified Search] rewrite scss to emotion (#223025)

## Summary

Part of https://github.com/elastic/kibana/issues/207852 

There are very minor changes to styling to use the brand colors instead
of shaded or tinted scss helpers (for filters negate 'red' color and for
autocomplete suggestions)

<img width="442" alt="Screenshot 2025-06-10 at 13 58 22"
src="https://github.com/user-attachments/assets/a4eda133-13e9-4241-8f51-923e9803dca8"
/>


<img width="601" alt="Screenshot 2025-06-10 at 13 55 24"
src="https://github.com/user-attachments/assets/8b1c9d37-b747-4ea8-bc52-8af31b76bf83"
/>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2025-06-20 22:49:31 +02:00 committed by GitHub
parent bda0dc5d3e
commit 1845f76637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 641 additions and 598 deletions

View file

@ -1,33 +0,0 @@
.kbnFilterButtonGroup {
position: relative;
height: $euiFormControlHeight;
background-color: $euiFormInputGroupLabelBackground;
border-radius: $euiFormControlBorderRadius;
&::after {
content: '';
position: absolute;
inset: 0;
border: $euiBorderWidthThin solid $euiFormBorderColor;
border-radius: inherit;
pointer-events: none;
}
// Targets any interactable elements
*:enabled {
transform: none !important;
}
&--s {
height: $euiFormControlCompressedHeight;
}
&--attached {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
> *:not(:last-of-type) {
border-right: 1px solid $euiFormBorderColor;
}
}

View file

@ -7,11 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import './filter_button_group.scss';
import React, { FC, ReactNode } from 'react';
import classNames from 'classnames';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { useMemoCss } from '../../use_memo_css';
interface Props {
items: ReactNode[];
@ -26,6 +26,7 @@ interface Props {
}
export const FilterButtonGroup: FC<Props> = ({ items, attached, size = 'm', ...rest }: Props) => {
const styles = useMemoCss(filterButtonStyles);
return (
<EuiFlexGroup
className={classNames('kbnFilterButtonGroup', {
@ -34,6 +35,7 @@ export const FilterButtonGroup: FC<Props> = ({ items, attached, size = 'm', ...r
})}
gutterSize="none"
responsive={false}
css={styles.wrapper}
{...rest}
>
{items.map((item, i) =>
@ -46,3 +48,35 @@ export const FilterButtonGroup: FC<Props> = ({ items, attached, size = 'm', ...r
</EuiFlexGroup>
);
};
const filterButtonStyles = {
wrapper: ({ euiTheme }: UseEuiTheme) =>
css({
position: 'relative',
height: euiTheme.size.xxl,
backgroundColor: euiTheme.colors.backgroundBaseFormsPrepend,
borderRadius: euiTheme.border.radius.medium,
'&::after': {
content: "''",
position: 'absolute',
inset: 0,
border: `${euiTheme.border.thin} solid ${euiTheme.colors.borderBasePlain}`,
borderRadius: 'inherit',
pointerEvents: 'none',
},
// Targets any interactable elements
'*:enabled': {
transform: 'none !important',
},
'&.kbnFilterButtonGroup--s': {
height: euiTheme.size.xl,
},
' &.kbnFilterButtonGroup--attached': {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
'> *:not(:last-of-type)': {
borderRight: `1px solid ${euiTheme.colors.borderBasePlain}`,
},
}),
};

View file

@ -49,6 +49,8 @@ import { CodeEditor } from '@kbn/code-editor';
import { cx } from '@emotion/css';
import { WithEuiThemeProps } from '@elastic/eui/src/services/theme';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { GenericComboBox } from './generic_combo_box';
import {
getFieldFromFilter,
@ -69,6 +71,8 @@ import {
} from './filter_editor.styles';
import { SuggestionsAbstraction } from '../../typeahead/suggestions_component';
const editorFormStyle = css({ padding: euiThemeVars.euiSizeM });
export const strings = {
getPanelTitleAdd: () =>
i18n.translate('unifiedSearch.filter.filterEditor.addFilterPopupTitle', {
@ -239,6 +243,7 @@ class FilterEditorComponent extends Component<FilterEditorProps, State> {
</EuiButtonEmpty>
</EuiFlexItem>
);
return (
<div>
<EuiPopoverTitle paddingSize="s">
@ -252,12 +257,12 @@ class FilterEditorComponent extends Component<FilterEditorProps, State> {
</EuiPopoverTitle>
{this.state.isLoadingDataView ? (
<div className="globalFilterItem__editorForm">
<div css={editorFormStyle}>
<EuiLoadingSpinner />
</div>
) : (
<EuiForm>
<div className="globalFilterItem__editorForm">
<div css={editorFormStyle}>
{this.renderIndexPatternInput()}
{this.state.isCustomEditorOpen

View file

@ -1,3 +0,0 @@
$kbnGlobalFilterItemBorderColor: tintOrShade($euiColorMediumShade, 35%, 20%);
$kbnGlobalFilterItemBorderColorExcluded: tintOrShade($euiColorDanger, 70%, 50%);
$kbnGlobalFilterItemPinnedColorExcluded: tintOrShade($euiColorDanger, 30%, 20%);

View file

@ -1,76 +0,0 @@
@import './variables';
/**
* 1. Allow wrapping of long filter items
*/
.globalFilterItem {
line-height: $euiSize;
color: $euiTextColor;
padding-block: calc($euiSizeM / 2);
white-space: normal; /* 1 */
&:not(.globalFilterItem-isDisabled) {
border-color: $kbnGlobalFilterItemBorderColor; // Make the actual border more visible
}
}
.globalFilterItem-isDisabled {
color: $euiColorDarkShade;
background-color: transparentize($euiColorLightShade, .5);
border-color: transparent;
text-decoration: line-through;
font-weight: $euiFontWeightRegular;
}
.globalFilterItem-isError, .globalFilterItem-isWarning {
.globalFilterLabel__value {
font-weight: $euiFontWeightBold;
}
}
.globalFilterItem-isError {
.globalFilterLabel__value {
color: makeHighContrastColor($euiColorDangerText, $euiColorLightShade);
}
}
.globalFilterItem-isWarning {
.globalFilterLabel__value {
color: makeHighContrastColor($euiColorWarningText, $euiColorLightShade);
}
}
.globalFilterItem-isPinned {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: $euiSizeXS;
background-color: $kbnGlobalFilterItemBorderColor;
}
}
.globalFilterItem-isExcluded {
&:not(.globalFilterItem-isDisabled) {
border-color: $kbnGlobalFilterItemBorderColorExcluded;
&::before {
background-color: $kbnGlobalFilterItemPinnedColorExcluded;
}
}
}
.globalFilterItem__editorForm {
padding: $euiSizeM;
}
.globalFilterItem__readonlyPanel {
min-width: auto;
padding: $euiSizeM;
}

View file

@ -7,15 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import './filter_item.scss';
import {
EuiContextMenu,
EuiContextMenuPanel,
EuiPopover,
EuiPopoverProps,
UseEuiTheme,
euiShadowMedium,
useEuiTheme,
} from '@elastic/eui';
import { InjectedIntl } from '@kbn/i18n-react';
import {
@ -26,18 +24,12 @@ import {
toggleFilterDisabled,
} from '@kbn/es-query';
import classNames from 'classnames';
import React, {
MouseEvent,
useState,
useEffect,
HTMLAttributes,
useMemo,
useCallback,
} from 'react';
import type { DocLinksStart, IUiSettingsClient } from '@kbn/core/public';
import React, { MouseEvent, useState, useEffect, HTMLAttributes, useCallback } from 'react';
import { type DocLinksStart, type IUiSettingsClient } from '@kbn/core/public';
import { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
import { css } from '@emotion/react';
import { getIndexPatternFromFilter, getDisplayValueFromFilter } from '@kbn/data-plugin/public';
import { useMemoCss } from '../../use_memo_css';
import { FilterEditor } from '../filter_editor/filter_editor';
import { FilterView } from '../filter_view';
import { FilterPanelOption } from '../../types';
@ -92,25 +84,12 @@ function FilterItemComponent(props: FilterItemProps) {
const [renderedComponent, setRenderedComponent] = useState('menu');
const { id, filter, indexPatterns, hiddenPanelOptions, readOnly = false, docLinks } = props;
const styles = useMemoCss(filterItemStyles);
const closePopover = useCallback(() => {
onCloseFilterPopover([() => setIsPopoverOpen(false)]);
}, [onCloseFilterPopover]);
const euiTheme = useEuiTheme();
/** @todo important style should be remove after fixing elastic/eui/issues/6314. */
const popoverDragAndDropStyle = useMemo(
() =>
css`
// Always needed for popover with drag & drop in them
transform: none !important;
transition: none !important;
filter: none !important;
${euiShadowMedium(euiTheme)}
`,
[euiTheme]
);
useEffect(() => {
if (isPopoverOpen) {
setRenderedComponent('menu');
@ -361,6 +340,7 @@ function FilterItemComponent(props: FilterItemProps) {
filterLabelStatus: valueLabelConfig.status,
errorMessage: valueLabelConfig.message,
className: getClasses(!!filter.meta.negate, valueLabelConfig),
css: styles.filterItem,
dataViews: indexPatterns,
iconOnClick: handleIconClick,
onClick: handleBadgeClick,
@ -375,7 +355,7 @@ function FilterItemComponent(props: FilterItemProps) {
button: <FilterView {...filterViewProps} />,
panelPaddingSize: 'none',
panelProps: {
css: popoverDragAndDropStyle,
css: styles.popoverDragAndDrop,
},
};
@ -388,7 +368,7 @@ function FilterItemComponent(props: FilterItemProps) {
) : (
<EuiContextMenuPanel
items={[
<div css={{ width: FILTER_EDITOR_WIDTH, maxWidth: '100%' }} key="filter-editor">
<div css={styles.filterItemEditorContainer} key="filter-editor">
<FilterEditor
filter={filter}
indexPatterns={indexPatterns}
@ -412,3 +392,72 @@ function FilterItemComponent(props: FilterItemProps) {
}
export const FilterItem = withCloseFilterEditorConfirmModal(FilterItemComponent);
const filterItemStyles = {
/** @todo important style should be remove after fixing elastic/eui/issues/6314. */
popoverDragAndDrop: (euiThemeContext: UseEuiTheme) =>
css`
// Always needed for popover with drag & drop in them
transform: none !important;
transition: none !important;
filter: none !important;
${euiShadowMedium(euiThemeContext)}
`,
filterItemEditorContainer: ({ euiTheme }: UseEuiTheme) =>
css({
width: FILTER_EDITOR_WIDTH,
maxWidth: '100%',
}),
filterItem: ({ euiTheme }: UseEuiTheme) =>
css({
lineHeight: euiTheme.size.base,
color: euiTheme.colors.text,
paddingBlock: `calc(${euiTheme.size.m} / 2)`,
whiteSpace: 'normal',
borderColor: euiTheme.colors.borderBasePlain,
'&:not(.globalFilterItem-isDisabled)': {
borderColor: euiTheme.colors.borderBasePlain,
},
'&.globalFilterItem-isExcluded': {
borderColor: euiTheme.colors.borderBaseDanger,
'&::before': {
backgroundColor: euiTheme.colors.backgroundFilledDanger,
},
},
'&.globalFilterItem-isDisabled': {
color: euiTheme.colors.darkShade,
backgroundColor: euiTheme.colors.disabled,
borderColor: 'transparent',
textDecoration: 'line-through',
fontWeight: euiTheme.font.weight.regular,
},
'&.globalFilterItem-isError, &.globalFilterItem-isWarning': {
'.globalFilterLabel__value': {
fontWeight: euiTheme.font.weight.bold,
},
},
'&.globalFilterItem-isError': {
'.globalFilterLabel__value': {
color: euiTheme.colors.dangerText,
},
},
'&.globalFilterItem-isWarning': {
'.globalFilterLabel__value': {
color: euiTheme.colors.warningText,
},
},
'&.globalFilterItem-isPinned': {
position: 'relative',
overflow: 'hidden',
'&::before': {
content: "''",
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: euiTheme.size.xs,
backgroundColor: euiTheme.colors.mediumShade,
},
},
}),
};

View file

@ -1 +0,0 @@
@import './typeahead/suggestion';

View file

@ -1,5 +0,0 @@
.kbnQueryBar__datePickerWrapper {
.euiDatePopoverButton-isInvalid {
background-image: euiFormControlGradient($euiColorDanger);
}
}

View file

@ -38,6 +38,7 @@ import {
EuiButton,
EuiButtonIcon,
useEuiTheme,
UseEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeHistoryContract, getQueryLog } from '@kbn/data-plugin/public';
@ -58,7 +59,6 @@ import type {
SuggestionsAbstraction,
SuggestionsListSize,
} from '../typeahead/suggestions_component';
import './query_bar.scss';
export const strings = {
getNeedsUpdatingLabel: () =>
@ -522,7 +522,11 @@ export const QueryBarTopRow = React.memo(
);
const component = getWrapperWithTooltip(datePicker, enableTooltip, props.query);
return <EuiFlexItem className={wrapperClasses}>{component}</EuiFlexItem>;
return (
<EuiFlexItem className={wrapperClasses} css={inputStringStyles.datePickerWrapper}>
{component}
</EuiFlexItem>
);
}
function renderCancelButton() {
@ -816,3 +820,12 @@ export const QueryBarTopRow = React.memo(
return isQueryEqual && shallowEqual(prevProps, nextProps);
}
) as GenericQueryBarTopRow;
const inputStringStyles = {
datePickerWrapper: ({ euiTheme }: UseEuiTheme) =>
css({
'.euiDatePopoverButton-isInvalid': {
backgroundImage: `linear-gradient(0deg,${euiTheme.colors.danger},${euiTheme.colors.danger} ${euiTheme.size.xxs},#0000 0,#0000)`,
},
}),
};

View file

@ -1,74 +0,0 @@
.kbnQueryBar__wrap {
width: 100%;
z-index: $euiZContentMenu;
height: $euiFormControlHeight;
display: flex;
>[aria-expanded='true'] {
// Using filter allows it to adhere the children's bounds
filter: drop-shadow(0 5.7px 12px rgba($euiShadowColor, shadowOpacity(.05)));
}
}
.kbnQueryBar__textareaWrap {
position: relative;
overflow: visible !important; // Override EUI form control
display: flex;
flex: 1 1 100%;
&--withSuggestionVisible .kbnQueryBar__textarea {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
>.euiFormControlLayoutIcons {
max-height: $euiFormControlHeight;
}
}
.kbnQueryBar__textarea {
z-index: $euiZContentMenu;
height: $euiFormControlHeight;
// Unlike most inputs within layout control groups, the text area still needs a border
// for multi-line content. These adjusts help it sit above the control groups
// shadow to line up correctly.
padding: $euiSizeS;
padding-top: $euiSizeS + 2px;
padding-left: $euiSizeXXL; // Account for search icon
// Firefox adds margin to textarea
margin: 0;
&--isClearable {
padding-right: $euiSizeXXL; // Account for clear button
}
&:not(.kbnQueryBar__textarea--autoHeight) {
overflow-y: hidden;
overflow-x: hidden;
}
// When focused, let it scroll
&.kbnQueryBar__textarea--autoHeight {
overflow-x: auto;
overflow-y: auto;
white-space: pre-wrap;
max-height: calc(35vh - 100px);
min-height: $euiFormControlHeight;
}
~.euiFormControlLayoutIcons {
// By default form control layout icon is vertically centered, but our textarea
// can expand to be multi-line, so we position it with padding that matches
// the parent textarea padding
z-index: $euiZContentMenu + 1;
top: $euiSizeM;
bottom: unset;
}
&--withPrepend {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: -1px;
width: calc(100% + 1px);
}
}

View file

@ -0,0 +1,101 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { UseEuiTheme } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { useMemoCss } from '../use_memo_css';
const queryStringInputStyles = {
container: ({ euiTheme }: UseEuiTheme) =>
css({
width: '100%',
zIndex: euiThemeVars.euiZContentMenu,
height: euiTheme.size.xxl,
display: 'flex',
'> [aria-expanded="true"]': {
// Using filter allows it to adhere the children's bounds
filter: `drop-shadow(0 ${euiTheme.size.s} ${euiTheme.size.base} rgba(${euiTheme.colors.shadow}, 0.05))`,
},
'.kbnQueryBar__textareaWrapOuter': {
position: 'relative',
width: '100%',
zIndex: euiTheme.levels.flyout,
},
'.kbnQueryBar__textareaWrap': {
position: 'relative',
overflow: 'visible !important', // Override EUI form control
display: 'flex',
flex: '1 1 100%',
'&.kbnQueryBar__textareaWrap--withSuggestionVisible .kbnQueryBar__textarea': {
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
},
'> .euiFormControlLayoutIcons': {
maxHeight: euiTheme.size.xxl,
},
},
'.kbnQueryBar__textarea': {
zIndex: euiTheme.levels.content,
height: euiTheme.size.xxl,
// Unlike most inputs within layout control groups, the text area still needs a border
// for multi-line content. These adjusts help it sit above the control groups
// shadow to line up correctly.
padding: euiTheme.size.s,
paddingTop: `calc(${euiTheme.size.s} + 2px)`,
paddingLeft: euiTheme.size.xxl, // Account for search icon
// Firefox adds margin to textarea
margin: 0,
'&.kbnQueryBar__textarea--isClearable': {
paddingRight: euiTheme.size.xxl, // Account for clear button
},
'&:not(.kbnQueryBar__textarea--autoHeight)': {
overflowY: 'hidden',
overflowX: 'hidden',
},
// When focused, let it scroll
'&.kbnQueryBar__textarea--autoHeight': {
overflowX: 'auto',
overflowY: 'auto',
whiteSpace: `pre-wrap`,
maxHeight: `calc(35vh - 100px)`,
minHeight: euiTheme.size.xxl,
},
'~.euiFormControlLayoutIcons': {
// By default form control layout icon is vertically centered, but our textarea
// can expand to be multi-line, so we position it with padding that matches
// the parent textarea padding
zIndex: euiTheme.levels.flyout,
top: euiTheme.size.m,
bottom: 'unset',
},
'&.kbnQueryBar__textarea--withPrepend': {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
marginLeft: '-1px',
width: 'calc(100% + 1px)',
},
},
}),
};
export const StyledDiv = ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
const styles = useMemoCss(queryStringInputStyles);
return (
<div css={styles.container} {...props}>
{children}
</div>
);
};

View file

@ -30,7 +30,6 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { compact, debounce, isEmpty, isEqual, isFunction, partition } from 'lodash';
import { CoreStart, DocLinksStart, Toast } from '@kbn/core/public';
import type { Query } from '@kbn/es-query';
import { euiThemeVars } from '@kbn/ui-theme';
import { DataPublicPluginStart, getQueryLog } from '@kbn/data-plugin/public';
import { type DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { PersistedLog } from '@kbn/data-plugin/public';
@ -57,7 +56,7 @@ import { onRaf } from '../utils';
import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group';
import { AutocompleteService, QuerySuggestion, QuerySuggestionTypes } from '../autocomplete';
import { getCoreStart } from '../services';
import './query_string_input.scss';
import { StyledDiv } from './query_string_input.styles';
export const strings = {
getSearchInputPlaceholderForText: () =>
@ -800,17 +799,17 @@ export class QueryStringInput extends PureComponent<QueryStringInputProps, State
isSuggestionsVisible && !isEmpty(this.state.suggestions),
});
return (
<div className={containerClassName} onFocus={this.onFocusWithin} onBlur={this.onBlurWithin}>
<StyledDiv
className={containerClassName}
onFocus={this.onFocusWithin}
onBlur={this.onBlurWithin}
>
{prependElement}
<EuiOutsideClickDetector onOutsideClick={this.onOutsideClick}>
<div
{...ariaCombobox}
css={{
position: 'relative',
width: '100%',
zIndex: euiThemeVars.euiZLevel1,
}}
className="kbnQueryBar__textareaWrapOuter"
aria-label={strings.getQueryBarComboboxAriaLabel(this.props.appName)}
aria-haspopup="true"
aria-expanded={this.state.isSuggestionsVisible}
@ -890,7 +889,7 @@ export class QueryStringInput extends PureComponent<QueryStringInputProps, State
</EuiPortal>
</div>
</EuiOutsideClickDetector>
</div>
</StyledDiv>
);
}

View file

@ -1,13 +0,0 @@
.kbnSavedQueryManagement__listWrapper {
// Addition height will ensure one item is "cutoff" to indicate more below the scroll
max-height: $euiFormMaxWidth + $euiSize;
overflow-y: hidden;
}
.kbnSavedQueryManagement__list {
max-height: inherit; // Fixes overflow for applied max-height
// Left/Right padding is calculated to match the left alignment of the
// popover text and buttons
padding: calc($euiSizeM / 2) $euiSizeXS !important; // Override flush
@include euiYScrollWithShadows;
}

View file

@ -26,6 +26,7 @@ import {
EuiProgress,
PrettyDuration,
EuiSelectableProps,
UseEuiTheme,
} from '@elastic/eui';
import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu';
import { i18n } from '@kbn/i18n';
@ -34,11 +35,11 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public';
import type { SavedQueryAttributes } from '@kbn/data-plugin/common';
import './saved_query_management_list.scss';
import { euiThemeVars } from '@kbn/ui-theme';
import { debounce } from 'lodash';
import useLatest from 'react-use/lib/useLatest';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { css } from '@emotion/react';
import { useMemoCss } from '../use_memo_css';
import type { IUnifiedSearchPluginServices } from '../types';
import { strings as queryBarMenuPanelsStrings } from '../query_string_input/query_bar_menu_panels';
import { PanelTitle } from '../query_string_input/panel_title';
@ -221,6 +222,8 @@ export const SavedQueryManagementList = ({
const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState<SavedQuery | null>(null);
const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false);
const styles = useMemoCss(savedQueryListStyles);
const debouncedSetSearchTerm = useMemo(() => {
return debounce((newSearchTerm: string) => {
setSearchTerm((currentSearchTerm) => {
@ -373,7 +376,7 @@ export const SavedQueryManagementList = ({
<>
{option.attributes ? itemLabel(option.attributes) : option.label}
{option.value === loadedSavedQuery?.id && (
<EuiBadge color="hollow" css={{ marginLeft: euiThemeVars.euiSizeS }}>
<EuiBadge color="hollow" css={styles.activeBadge}>
{i18n.translate('unifiedSearch.search.searchBar.savedQueryActiveBadgeText', {
defaultMessage: 'Active',
})}
@ -382,7 +385,7 @@ export const SavedQueryManagementList = ({
</>
);
},
[loadedSavedQuery?.id]
[loadedSavedQuery?.id, styles]
);
const countDisplay = useMemo(() => {
@ -412,9 +415,10 @@ export const SavedQueryManagementList = ({
gutterSize="none"
responsive={false}
className="kbnSavedQueryManagement__listWrapper"
css={styles.listWrapper}
data-test-subj="saved-query-management-list"
>
<EuiFlexItem grow={false} css={{ position: 'relative' }}>
<EuiFlexItem grow={false} css={styles.listWrapperInner}>
{isLoading && <EuiProgress size="xs" color="accent" position="absolute" />}
<EuiSelectable<SelectableProps>
ref={selectableRef}
@ -451,16 +455,11 @@ export const SavedQueryManagementList = ({
}
onChange={handleSelect}
renderOption={renderOption}
css={{
'.euiSelectableList__list': {
WebkitMaskImage: 'unset',
maskImage: 'unset',
},
}}
css={styles.option}
>
{(list, search) => (
<>
<EuiPanel color="transparent" paddingSize="s" css={{ paddingBottom: 0 }}>
<EuiPanel color="transparent" paddingSize="s" css={styles.search}>
{search}
</EuiPanel>
<EuiPanel color="transparent" paddingSize="s">
@ -475,7 +474,7 @@ export const SavedQueryManagementList = ({
</EuiSelectable>
</EuiFlexItem>
{totalQueryCount > SAVED_QUERY_PAGE_SIZE && (
<EuiFlexItem grow={false} css={{ padding: euiThemeVars.euiSizeS }}>
<EuiFlexItem grow={false} css={styles.pagination}>
<EuiFlexGroup responsive={false} justifyContent="center">
<EuiFlexItem grow={false}>
<EuiPagination
@ -489,10 +488,7 @@ export const SavedQueryManagementList = ({
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiPopoverFooter
paddingSize="s"
css={{ backgroundColor: euiThemeVars.euiColorLightestShade }}
>
<EuiPopoverFooter paddingSize="s" css={styles.footer}>
<EuiFlexGroup
responsive={false}
gutterSize="xs"
@ -643,3 +639,36 @@ const ListTitle = ({ queryBarMenuRef }: { queryBarMenuRef: RefObject<EuiContextM
/>
);
};
const savedQueryListStyles = {
footer: ({ euiTheme }: UseEuiTheme) =>
css({
backgroundColor: euiTheme.colors.lightestShade,
}),
option: css({
'.euiSelectableList__list': {
WebkitMaskImage: 'unset',
maskImage: 'unset',
},
}),
activeBadge: ({ euiTheme }: UseEuiTheme) =>
css({
marginLeft: euiTheme.size.s,
}),
listWrapper: ({ euiTheme }: UseEuiTheme) =>
css({
// Addition height will ensure one item is "cutoff" to indicate more below the scroll
maxHeight: `${euiTheme.base * 26}px `,
'overflow-y': 'hidden',
}),
listWrapperInner: css({
position: 'relative',
}),
search: css({
paddingBottom: 0,
}),
pagination: ({ euiTheme }: UseEuiTheme) =>
css({
padding: euiTheme.size.s,
}),
};

View file

@ -1,73 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SuggestionComponent Should display the suggestion and use the provided ariaId 1`] = `
<div
aria-selected={false}
className="kbnTypeahead__item"
data-test-subj="autocompleteSuggestion-value-as-promised,-not-helpful"
id="suggestion-1"
onClick={[Function]}
onMouseEnter={[Function]}
role="option"
>
<div
className="kbnSuggestionItem kbnSuggestionItem--value"
>
<div
className="kbnSuggestionItem__type"
>
<EuiIcon
type="kqlValue"
/>
</div>
<div
className="kbnSuggestionItem__text"
data-test-subj="autoCompleteSuggestionText"
>
as promised, not helpful
</div>
<div
className="kbnSuggestionItem__description"
data-test-subj="autoCompleteSuggestionDescription"
>
This is not a helpful suggestion
</div>
</div>
</div>
`;
exports[`SuggestionComponent Should make the element active if the selected prop is true 1`] = `
<div
aria-selected={true}
className="kbnTypeahead__item active"
data-test-subj="autocompleteSuggestion-value-as-promised,-not-helpful"
id="suggestion-1"
onClick={[Function]}
onMouseEnter={[Function]}
role="option"
>
<div
className="kbnSuggestionItem kbnSuggestionItem--value"
>
<div
className="kbnSuggestionItem__type"
>
<EuiIcon
type="kqlValue"
/>
</div>
<div
className="kbnSuggestionItem__text"
data-test-subj="autoCompleteSuggestionText"
>
as promised, not helpful
</div>
<div
className="kbnSuggestionItem__description"
data-test-subj="autoCompleteSuggestionDescription"
>
This is not a helpful suggestion
</div>
</div>
</div>
`;

View file

@ -1,164 +0,0 @@
// These are the various types in the dropdown, they each get a color
$kbnTypeaheadTypes: (
field: $euiColorWarning,
value: $euiColorSuccess,
operator: $euiColorPrimary,
conjunction: $euiColorVis3,
recentSearch: $euiColorMediumShade,
);
.kbnTypeahead.kbnTypeahead--small {
max-height: 20vh;
}
.kbnTypeahead__popover--top {
@include euiBottomShadowFlat;
border-top-left-radius: $euiBorderRadius;
border-top-right-radius: $euiBorderRadius;
// Clips the shadow so it doesn't show above the input (below)
clip-path: polygon(-50px -50px, calc(100% + 50px) -50px, calc(100% + 50px) 100%, -50px 100%);
}
.kbnTypeahead__popover--bottom {
@include euiBottomShadow;
border-bottom-left-radius: $euiBorderRadius;
border-bottom-right-radius: $euiBorderRadius;
// Clips the shadow so it doesn't show above the input (top)
clip-path: polygon(-50px 1px, calc(100% + 50px) 1px, calc(100% + 50px) calc(100% + 50px), -50px calc(100% + 50px));
}
.kbnTypeahead {
max-height: 60vh;
.kbnTypeahead__popover {
max-height: inherit;
@include euiScrollBar;
border: 1px solid;
border-color: $euiBorderColor;
color: $euiTextColor;
background-color: $euiColorEmptyShade;
position: relative;
z-index: $euiZContentMenu;
width: 100%;
overflow-y: auto;
.kbnTypeahead__item {
height: $euiSizeXL;
white-space: nowrap;
font-size: $euiFontSizeXS;
vertical-align: middle;
padding: 0;
border-bottom: none;
line-height: normal;
}
.kbnTypeahead__item:hover {
cursor: pointer;
}
.kbnTypeahead__item:last-child {
border-bottom: none;
border-radius: 0 0 $euiBorderRadius $euiBorderRadius;
}
.kbnTypeahead__item:first-child {
border-bottom: none;
}
.kbnTypeahead__item.active {
background-color: $euiColorLightestShade;
.kbnSuggestionItem__callout {
background: $euiColorEmptyShade;
}
.kbnSuggestionItem__text {
color: $euiColorFullShade;
}
.kbnSuggestionItem__type {
color: $euiColorFullShade;
}
@each $name, $color in $kbnTypeaheadTypes {
.kbnSuggestionItem--#{$name} {
.kbnSuggestionItem__type {
background-color: tintOrShade($color, 80%, 60%);
}
}
}
}
}
}
.kbnSuggestionItem {
display: inline-flex;
align-items: center;
font-size: $euiFontSizeXS;
white-space: nowrap;
width: 100%;
@each $name, $color in $kbnTypeaheadTypes {
&.kbnSuggestionItem--#{$name} {
.kbnSuggestionItem__type {
background-color: tintOrShade($color, 90%, 50%);
color: makeHighContrastColor($color, tintOrShade($color, 90%, 50%));
}
}
}
}
.kbnSuggestionItem__text,
.kbnSuggestionItem__type,
.kbnSuggestionItem__description {
padding-right: $euiSize;
}
.kbnSuggestionItem__type {
display: flex;
flex-direction: column;
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
width: $euiSizeXL;
height: $euiSizeXL;
text-align: center;
overflow: hidden;
padding: $euiSizeXS;
justify-content: center;
align-items: center;
}
.kbnSuggestionItem__text {
font-family: $euiCodeFontFamily;
overflow: hidden;
text-overflow: ellipsis;
padding-left: $euiSizeS;
color: $euiTextColor;
min-width: 250px;
}
.kbnSuggestionItem__description {
color: $euiColorDarkShade;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
// In case the description contains a paragraph in which the truncation needs to be at this level
> p {
overflow: hidden;
text-overflow: ellipsis;
}
&:empty {
width: 0;
}
}
.kbnSuggestionItem__callout {
font-family: $euiCodeFontFamily;
background: $euiColorLightestShade;
color: $euiColorFullShade;
padding: 0 $euiSizeXS;
display: inline-block;
}

View file

@ -7,14 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mount, shallow } from 'enzyme';
import React from 'react';
import { QuerySuggestion, QuerySuggestionTypes } from '../autocomplete';
import { render, screen } from '@testing-library/react';
import { SuggestionComponent } from './suggestion_component';
import { QuerySuggestion, QuerySuggestionTypes } from '../autocomplete';
import { userEvent } from '@testing-library/user-event';
const noop = () => {
return;
};
const noop = () => {};
const mockSuggestion: QuerySuggestion = {
description: 'This is not a helpful suggestion',
@ -25,8 +24,8 @@ const mockSuggestion: QuerySuggestion = {
};
describe('SuggestionComponent', () => {
it('Should display the suggestion and use the provided ariaId', () => {
const component = shallow(
it('displays the suggestion and uses the provided ariaId', () => {
render(
<SuggestionComponent
index={0}
onClick={noop}
@ -34,16 +33,21 @@ describe('SuggestionComponent', () => {
selected={false}
suggestion={mockSuggestion}
innerRef={noop}
ariaId={'suggestion-1'}
ariaId="suggestion-1"
shouldDisplayDescription={true}
/>
);
expect(component).toMatchSnapshot();
const item = screen.getByText(/as promised, not helpful/i);
expect(item).toBeInTheDocument();
expect(screen.getByRole('option', { name: /as promised, not helpful/i })).toHaveAttribute(
'id',
'suggestion-1'
);
});
it('Should make the element active if the selected prop is true', () => {
const component = shallow(
it('marks element as active when selected', () => {
render(
<SuggestionComponent
index={0}
onClick={noop}
@ -51,73 +55,81 @@ describe('SuggestionComponent', () => {
selected={true}
suggestion={mockSuggestion}
innerRef={noop}
ariaId={'suggestion-1'}
ariaId="suggestion-1"
shouldDisplayDescription={true}
/>
);
expect(component).toMatchSnapshot();
expect(screen.getByRole('option', { name: /as promised, not helpful/i })).toHaveAttribute(
'id',
'suggestion-1'
);
expect(screen.getByRole('option', { name: /as promised, not helpful/i })).toHaveClass(
'kbnTypeahead__item active'
);
});
it('Should call innerRef with a reference to the root div element', () => {
const innerRefCallback = (index: number, ref: HTMLDivElement) => {
expect(ref.className).toBe('kbnTypeahead__item');
expect(ref.id).toBe('suggestion-1');
expect(index).toBe(0);
};
it('calls innerRef with the reference to the root element', () => {
const innerRefMock = jest.fn();
mount(
render(
<SuggestionComponent
index={0}
onClick={noop}
onMouseEnter={noop}
selected={false}
suggestion={mockSuggestion}
innerRef={innerRefCallback}
ariaId={'suggestion-1'}
innerRef={innerRefMock}
ariaId="suggestion-1"
shouldDisplayDescription={true}
/>
);
expect(innerRefMock).toHaveBeenCalledTimes(1);
const [indexArg, elementArg] = innerRefMock.mock.calls[0];
expect(indexArg).toBe(0);
expect(elementArg).toBeInstanceOf(HTMLDivElement);
expect(elementArg?.id).toBe('suggestion-1');
expect(elementArg?.className).toContain('kbnTypeahead__item');
});
it('Should call onClick with the provided suggestion', () => {
const mockHandler = jest.fn();
it('calls onClick with suggestion and index', async () => {
const clickHandler = jest.fn();
const component = shallow(
render(
<SuggestionComponent
index={0}
onClick={mockHandler}
onClick={clickHandler}
onMouseEnter={noop}
selected={false}
suggestion={mockSuggestion}
innerRef={noop}
ariaId={'suggestion-1'}
ariaId="suggestion-1"
shouldDisplayDescription={true}
/>
);
component.simulate('click');
expect(mockHandler).toHaveBeenCalledTimes(1);
expect(mockHandler).toHaveBeenCalledWith(mockSuggestion, 0);
await userEvent.click(screen.getByText(/as promised, not helpful/i));
expect(clickHandler).toHaveBeenCalledWith(mockSuggestion, 0);
expect(clickHandler).toHaveBeenCalledTimes(1);
});
it('Should call onMouseEnter when user mouses over the element', () => {
const mockHandler = jest.fn();
it('calls onMouseEnter when user hovers the element', async () => {
const mouseEnterHandler = jest.fn();
const component = shallow(
render(
<SuggestionComponent
index={0}
onClick={noop}
onMouseEnter={mockHandler}
onMouseEnter={mouseEnterHandler}
selected={false}
suggestion={mockSuggestion}
innerRef={noop}
ariaId={'suggestion-1'}
ariaId="suggestion-1"
shouldDisplayDescription={true}
/>
);
component.simulate('mouseenter');
expect(mockHandler).toHaveBeenCalledTimes(1);
await userEvent.hover(screen.getByText(/as promised, not helpful/i));
expect(mouseEnterHandler).toHaveBeenCalledTimes(1);
});
});

View file

@ -7,9 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiIcon } from '@elastic/eui';
import { EuiIcon, UseEuiTheme, euiFontSize } from '@elastic/eui';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { css } from '@emotion/react';
import { EmotionStyles, useMemoCss } from '../use_memo_css';
import { QuerySuggestion } from '../autocomplete';
import { SuggestionOnClick, SuggestionOnMouseEnter } from './types';
@ -58,14 +60,15 @@ export const SuggestionComponent = React.memo(function SuggestionComponent(props
onMouseEnter(suggestion, index);
}, [index, onMouseEnter, suggestion]);
const styles = useMemoCss(suggestionStyles);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div
className={classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
kbnTypeahead__item: true,
className={classNames('kbnTypeahead__item', {
active: props.selected,
})}
css={styles.suggestionItem}
role="option"
onMouseEnter={handleMouseEnter}
onClick={handleClick}
@ -100,3 +103,150 @@ export const SuggestionComponent = React.memo(function SuggestionComponent(props
</div>
);
});
// These are the various types in the dropdown, they each get a color
const kbnTypeaheadTypes = {
field: {
base: 'backgroundBaseWarning' as const,
active: 'backgroundLightWarning' as const,
text: 'textWarning' as const,
},
value: {
base: 'backgroundBaseSuccess' as const,
active: 'backgroundLightSuccess' as const,
text: 'textSuccess' as const,
},
operator: {
base: 'backgroundBasePrimary' as const,
active: 'backgroundLightPrimary' as const,
text: 'textPrimary' as const,
},
conjunction: {
base: 'backgroundBaseSubdued' as const,
active: 'backgroundLightText' as const,
text: 'textSubdued' as const,
},
recentSearch: {
base: 'backgroundBaseSubdued' as const,
active: 'backgroundLightText' as const,
text: 'textSubdued' as const,
},
};
const activeColors = (context: UseEuiTheme) =>
Object.entries(kbnTypeaheadTypes).map(([type, color]) => {
return {
[`.kbnSuggestionItem--${type}`]: {
'.kbnSuggestionItem__type': {
backgroundColor: context.euiTheme.colors[color.active],
},
},
};
});
const tokenColors = (context: UseEuiTheme) =>
Object.entries(kbnTypeaheadTypes).map(([type, color]) => {
return {
[`&.kbnSuggestionItem--${type}`]: {
'.kbnSuggestionItem__type': {
backgroundColor: context.euiTheme.colors[color.base],
color: context.euiTheme.colors[color.text],
},
},
};
});
const suggestionStyles: EmotionStyles = {
suggestionItem: (context: UseEuiTheme) =>
css({
'&.kbnTypeahead__item': {
height: context.euiTheme.size.xl,
whiteSpace: 'nowrap',
fontSize: euiFontSize(context, 'xs').fontSize,
verticalAlign: 'middle',
padding: 0,
borderBottom: 'none',
lineHeight: 'normal',
'&:hover': {
cursor: 'pointer',
},
'&:last-child': {
borderBottom: 'none',
borderRadius: `0 0 ${context.euiTheme.border.radius.medium} ${context.euiTheme.border.radius.medium}`,
},
'&:first-child': {
borderBottom: 'none',
},
'&.active': css([
{
backgroundColor: context.euiTheme.colors.lightestShade,
'.kbnSuggestionItem__callout': {
background: context.euiTheme.colors.emptyShade,
},
'.kbnSuggestionItem__text': {
color: context.euiTheme.colors.fullShade,
},
'.kbnSuggestionItem__type': {
color: context.euiTheme.colors.fullShade,
},
},
activeColors(context),
]),
},
'.kbnSuggestionItem': css([
tokenColors(context),
{
display: 'inline-flex',
alignItems: 'center',
fontSize: euiFontSize(context, 'xs').fontSize,
whiteSpace: 'nowrap',
width: '100%',
},
]),
'.kbnSuggestionItem__type': {
display: 'flex',
flexDirection: 'column',
flexGrow: 0,
flexShrink: 0,
flexBasis: 'auto',
width: context.euiTheme.size.xl,
height: context.euiTheme.size.xl,
textAlign: 'center',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
padding: context.euiTheme.size.xs,
},
'.kbnSuggestionItem__text': {
fontFamily: context.euiTheme.font.familyCode,
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingLeft: context.euiTheme.size.s,
color: context.euiTheme.colors.text,
minWidth: '250px',
},
'.kbnSuggestionItem__description': {
color: context.euiTheme.colors.darkShade,
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 1,
// In case the description contains a paragraph in which the truncation needs to be at this level
'> p': {
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'&:empty': {
width: 0,
},
},
'.kbnSuggestionItem__callout': {
fontFamily: context.euiTheme.font.familyCode,
background: context.euiTheme.colors.lightestShade,
color: context.euiTheme.colors.fullShade,
padding: `0 ${context.euiTheme.size.xs}`,
display: 'inline-block',
},
}),
};

View file

@ -7,15 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mount, shallow } from 'enzyme';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QuerySuggestion, QuerySuggestionTypes } from '../autocomplete';
import { SuggestionComponent } from './suggestion_component';
import { SuggestionsComponent } from './suggestions_component';
import { EuiThemeProvider } from '@elastic/eui';
import { userEvent } from '@testing-library/user-event';
const noop = () => {
return;
};
const noop = () => {};
const mockContainerDiv = document.createElement('div');
@ -36,9 +35,13 @@ const mockSuggestions: QuerySuggestion[] = [
},
];
const renderWithTheme = (ui: React.ReactElement) => {
return render(<EuiThemeProvider>{ui}</EuiThemeProvider>);
};
describe('SuggestionsComponent', () => {
it('Should not display anything if the show prop is false', () => {
const component = shallow(
it('Should not render if show is false', () => {
const { container } = renderWithTheme(
<SuggestionsComponent
index={0}
onClick={noop}
@ -49,12 +52,11 @@ describe('SuggestionsComponent', () => {
inputContainer={mockContainerDiv}
/>
);
expect(component.isEmptyRender()).toBe(true);
expect(container).toBeEmptyDOMElement();
});
it('Should not display anything if there are no suggestions', () => {
const component = shallow(
it('Should not render if there are no suggestions', () => {
const { container } = renderWithTheme(
<SuggestionsComponent
index={0}
onClick={noop}
@ -65,12 +67,11 @@ describe('SuggestionsComponent', () => {
inputContainer={mockContainerDiv}
/>
);
expect(component.isEmptyRender()).toBe(true);
expect(container).toBeEmptyDOMElement();
});
it('Should display given suggestions if the show prop is true', () => {
const component = mount(
it('Should render suggestions when show is true', () => {
renderWithTheme(
<SuggestionsComponent
index={0}
onClick={noop}
@ -81,13 +82,12 @@ describe('SuggestionsComponent', () => {
inputContainer={mockContainerDiv}
/>
);
expect(component.isEmptyRender()).toBe(false);
expect(component.find(SuggestionComponent)).toHaveLength(2);
const items = screen.getAllByRole('option');
expect(items).toHaveLength(2);
});
it('Passing the index should control which suggestion is selected', () => {
const component = mount(
it('Should apply selection based on index prop', () => {
renderWithTheme(
<SuggestionsComponent
index={1}
onClick={noop}
@ -99,15 +99,16 @@ describe('SuggestionsComponent', () => {
/>
);
expect(component.find(SuggestionComponent).at(1).prop('selected')).toBe(true);
const selected = screen.getAllByRole('option')[1];
expect(selected.getAttribute('aria-selected')).toBe('true');
});
it('Should call onClick with the selected suggestion when it is clicked', () => {
const mockCallback = jest.fn();
const component = mount(
it('Should call onClick with selected suggestion when clicked', async () => {
const mockClick = jest.fn();
renderWithTheme(
<SuggestionsComponent
index={0}
onClick={mockCallback}
onClick={mockClick}
onMouseEnter={noop}
show={true}
suggestions={mockSuggestions}
@ -115,28 +116,26 @@ describe('SuggestionsComponent', () => {
inputContainer={mockContainerDiv}
/>
);
component.find(SuggestionComponent).at(1).simulate('click');
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1);
const item = screen.getAllByRole('option')[1];
await userEvent.click(item);
expect(mockClick).toHaveBeenCalledWith(mockSuggestions[1], 1);
});
it('Should call onMouseEnter with the index of the suggestion that was entered', () => {
const mockCallback = jest.fn();
const component = mount(
it('Should call onMouseEnter with correct index when suggestion is hovered', async () => {
const mockEnter = jest.fn();
renderWithTheme(
<SuggestionsComponent
index={0}
onClick={noop}
onMouseEnter={mockCallback}
onMouseEnter={mockEnter}
show={true}
suggestions={mockSuggestions}
loadMore={noop}
inputContainer={mockContainerDiv}
/>
);
component.find(SuggestionComponent).at(1).simulate('mouseenter');
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1);
const item = screen.getAllByRole('option')[1];
await userEvent.hover(item);
expect(mockEnter).toHaveBeenCalledWith(mockSuggestions[1], 1);
});
});

View file

@ -10,9 +10,11 @@
import React, { PureComponent, ReactNode } from 'react';
import { isEmpty } from 'lodash';
import classNames from 'classnames';
import { css } from '@emotion/react';
import useRafState from 'react-use/lib/useRafState';
import { UseEuiTheme, euiShadow, euiShadowFlat } from '@elastic/eui';
import { css } from '@emotion/react';
import { EmotionStyles, useMemoCss } from '../use_memo_css';
import { QuerySuggestion } from '../autocomplete';
import { SuggestionComponent } from './suggestion_component';
import {
@ -167,6 +169,7 @@ const ResizableSuggestionsListDiv: React.FC<{
const inputContainer = props.inputContainer;
const [{ documentHeight }, { pageYOffset }, containerRect] = useDimensions(inputContainer);
const styles = useMemoCss(suggestionsStyles);
if (!containerRect) return null;
@ -175,26 +178,25 @@ const ResizableSuggestionsListDiv: React.FC<{
documentHeight - (containerRect.top + containerRect.height) >
SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE;
const verticalListPosition = isSuggestionsListFittable
? `top: ${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;`
: `bottom: ${documentHeight - (pageYOffset + containerRect.top)}px;`;
const divPosition = css`
position: absolute;
z-index: 4001;
left: ${containerRect.left}px;
width: ${containerRect.width}px;
${verticalListPosition}
`;
? { top: `${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px` }
: { bottom: `${documentHeight - (pageYOffset + containerRect.top)}px` };
return (
<div css={divPosition}>
<div
css={styles.container}
style={{
left: `${containerRect.left}px`,
width: `${containerRect.width}px`,
...verticalListPosition,
}}
>
<div
className={classNames('kbnTypeahead', {
'kbnTypeahead--small': props.suggestionsSize === 's',
})}
>
<div
className={classNames('kbnTypeahead__popover', {
className={classNames('kbnTypeahead__popover', 'eui-scrollBar', {
['kbnTypeahead__popover--bottom']: isSuggestionsListFittable,
['kbnTypeahead__popover--top']: !isSuggestionsListFittable,
})}
@ -278,3 +280,46 @@ function useDimensions(
return [{ documentHeight }, pageOffset, containerRect];
}
const suggestionsStyles: EmotionStyles = {
container: (context: UseEuiTheme) =>
css({
position: 'absolute',
zIndex: 4001,
'.kbnTypeahead': {
maxHeight: '60vh',
'&.kbnTypeahead--small': {
maxHeight: '20vh',
},
},
'.kbnTypeahead__popover': {
maxHeight: 'inherit',
border: `1px solid ${context.euiTheme.colors.borderBaseSubdued}`,
color: context.euiTheme.colors.text,
backgroundColor: context.euiTheme.colors.emptyShade,
position: 'relative',
zIndex: context.euiTheme.levels.menu,
width: '100%',
overflowY: 'auto',
'&.kbnTypeahead__popover--top': css([
euiShadowFlat(context),
{
borderTopLeftRadius: context.euiTheme.border.radius.medium,
borderTopRightRadius: context.euiTheme.border.radius.medium,
// Clips the shadow so it doesn't show above the input (below)
clipPath: `polygon(-50px -50px, calc(100% + 50px) -50px, calc(100% + 50px) 100%, -50px 100%)`,
},
]),
'&.kbnTypeahead__popover--bottom': css([
euiShadow(context),
{
borderBottomLeftRadius: context.euiTheme.border.radius.medium,
borderBottomRightRadius: context.euiTheme.border.radius.medium,
// Clips the shadow so it doesn't show above the input (top)
clipPath: `polygon(-50px 1px, calc(100% + 50px) 1px, calc(100% + 50px) calc(100% + 50px), -50px calc(100% + 50px))`,
},
]),
},
}),
};

View file

@ -7,8 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import './index.scss';
export { DataViewsList } from './dataview_picker/dataview_list';
export { DataViewPicker } from './dataview_picker/data_view_picker';
export { DataViewSelector } from './dataview_picker/data_view_selector';

View file

@ -0,0 +1,51 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMemo } from 'react';
import { CSSInterpolation } from '@emotion/css';
import { UseEuiTheme, useEuiTheme } from '@elastic/eui';
// TODO: Move to use @kbn/css-utils when available https://github.com/elastic/kibana/pull/223933
export type EmotionStyles = Record<
string,
CSSInterpolation | ((theme: UseEuiTheme) => CSSInterpolation)
>;
type StaticEmotionStyles = Record<string, CSSInterpolation>;
/**
* Custom hook to reduce boilerplate when working with Emotion styles that may depend on
* the EUI theme.
*
* Accepts a map of styles where each entry is either a static Emotion style (via `css`)
* or a function that returns styles based on the current `euiTheme`.
*
* It returns a memoized version of the style map with all values resolved to static
* Emotion styles, allowing components to use a clean and unified object for styling.
*
* This helps simplify component code by centralizing theme-aware style logic.
*
* Example usage:
* const componentStyles = {
* container: css({ overflow: hidden }),
* leftPane: ({ euiTheme }) => css({ paddingTop: euiTheme.size.m }),
* }
* const styles = useMemoCss(componentStyles);
*/
export const useMemoCss = (styleMap: EmotionStyles) => {
const euiThemeContext = useEuiTheme();
const outputStyles = useMemo(() => {
return Object.entries(styleMap).reduce<StaticEmotionStyles>((acc, [key, value]) => {
acc[key] = typeof value === 'function' ? value(euiThemeContext) : value;
return acc;
}, {});
}, [euiThemeContext, styleMap]);
return outputStyles;
};