From c70c7ee3dc4f7f73501013527b0c8f25658cfeb1 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 27 Aug 2026 11:24:58 +0200 Subject: [PATCH 01/17] refactor(filterGroup): implement redesign styles --- .../filter_group/filter_button.styles.ts | 98 ++++++++++++++----- .../components/filter_group/filter_button.tsx | 48 +++++---- .../filter_group/filter_group.styles.ts | 54 +++------- .../components/filter_group/filter_group.tsx | 41 +++++++- .../filter_group/filter_group_context.tsx | 21 ++++ 5 files changed, 173 insertions(+), 89 deletions(-) create mode 100644 packages/eui/src/components/filter_group/filter_group_context.tsx diff --git a/packages/eui/src/components/filter_group/filter_button.styles.ts b/packages/eui/src/components/filter_group/filter_button.styles.ts index 9be3d18cdc36..39ba2c4f7505 100644 --- a/packages/eui/src/components/filter_group/filter_button.styles.ts +++ b/packages/eui/src/components/filter_group/filter_button.styles.ts @@ -16,6 +16,7 @@ import { euiTextTruncate, highContrastModeStyles, preventForcedColors, + euiButtonSizeMap, } from '../../global_styling'; import { euiFormVariables } from '../form/form.styles'; @@ -31,14 +32,28 @@ export const euiFilterButtonDisplay = (euiThemeContext: UseEuiTheme) => { export const euiFilterButtonStyles = (euiThemeContext: UseEuiTheme) => { const { euiTheme } = euiThemeContext; + const buttonSizeMap = euiButtonSizeMap(euiThemeContext); const selectedSelector = '.euiFilterButton-isSelected'; const withNextSelector = '& + .euiFilterButton__wrapper'; + const containerPadding = euiTheme.size.xs; + return { euiFilterButton: css` - position: relative; + --euiFilterButtonInsetSize: ${buttonSizeMap.m.getInsetHeight( + buttonSizeMap.m.height, + containerPadding + )}; + --euiFilterButtonRadius: ${buttonSizeMap.m.radiusInset}; + position: relative; + block-size: var(--euiFilterButtonInsetSize); ${logicalCSS('width', '100%')} + border: none; + border-radius: var( + --euiFilterButtonRadius, + ${euiTheme.border.radius.small} + ); &:not(${selectedSelector}) { &:hover, @@ -49,25 +64,17 @@ export const euiFilterButtonStyles = (euiThemeContext: UseEuiTheme) => { } } } + + /* use increased specificity over base button */ + &&:focus-visible { + outline-style: auto; + outline-offset: 0; + } `, buttonType: { - default: css` - border-radius: 0; - - &:focus-visible { - z-index: 1; - outline-offset: -${euiTheme.border.width.thick}; - border-radius: ${euiTheme.border.radius.small}; - transition: none; - } - `, toggle: css` ${euiFilterButtonDisplay(euiThemeContext)} - &:focus-visible { - outline-offset: ${mathWithUnits(euiTheme.focus.width, (x) => x / 2)}; - } - ${highContrastModeStyles(euiThemeContext, { forced: ` &:is(${selectedSelector}) { @@ -79,9 +86,21 @@ export const euiFilterButtonStyles = (euiThemeContext: UseEuiTheme) => { })} `, }, + compressed: css` + --euiFilterButtonInsetSize: ${buttonSizeMap.s.getInsetHeight( + buttonSizeMap.s.height, + containerPadding + )}; + --euiFilterButtonRadius: ${buttonSizeMap.s.radiusInset}; + `, withNext: css` ${withNextSelector} { - ${logicalCSS('margin-left', `-${euiTheme.size.xs}`)} + margin-inline-start: -${containerPadding}; + + /* account for group faux border spacing */ + &:where(.euiFilterGroup[data-dividers='true'] &) { + margin-inline-start: -${mathWithUnits([containerPadding, euiTheme.border.width.thin], (x, y) => x + y)}; + } /* Remove just the left faux border */ &::before { @@ -101,14 +120,27 @@ export const euiFilterButtonStyles = (euiThemeContext: UseEuiTheme) => { hasActiveFilters: css` font-weight: ${euiTheme.font.weight.medium}; `, + isSelected: css` + &${selectedSelector} { + background-color: ${euiTheme.colors.backgroundLightText}; + + ${highContrastModeStyles(euiThemeContext, { + preferred: ` + border: ${euiTheme.border.thin}; + `, + })} + } + `, }; }; export const euiFilterButtonWrapperStyles = (euiThemeContext: UseEuiTheme) => { const { euiTheme } = euiThemeContext; + const buttonSizeMap = euiButtonSizeMap(euiThemeContext); const { borderColor } = euiFormVariables(euiThemeContext); const border = `${euiTheme.border.width.thin} solid ${borderColor}`; + const containerPadding = euiTheme.size.xs; // Pseudo elements create borders without affecting width. We also prefer them // over box-shadow for Windows high contrast theme compatibility @@ -116,9 +148,13 @@ export const euiFilterButtonWrapperStyles = (euiThemeContext: UseEuiTheme) => { &::before { content: ''; position: absolute; - inset: 0; - ${logicalCSS('border-left', border)} - + inset-inline-start: -${euiTheme.border.width.thin}; + block-size: calc( + var(--euiFilterButtonSize, ${euiTheme.size.xl}) - ${euiTheme.size.base} + ); + inline-size: ${euiTheme.border.width.thin}; + border-inline-start: ${euiTheme.border.width.thin} solid + ${euiTheme.colors.borderBasePlain}; pointer-events: none; } `; @@ -127,7 +163,9 @@ export const euiFilterButtonWrapperStyles = (euiThemeContext: UseEuiTheme) => { &::after { content: ''; position: absolute; - inset: 0; + inset-block-start: -${euiTheme.border.width.thin}; + inset-inline-start: 0; + inline-size: calc(100% + ${euiTheme.border.width.thin}); ${logicalCSS('border-bottom', border)} pointer-events: none; } @@ -135,18 +173,30 @@ export const euiFilterButtonWrapperStyles = (euiThemeContext: UseEuiTheme) => { return { wrapper: css` + --euiFilterButtonSize: ${buttonSizeMap.m.height}; + ${euiFilterButtonDisplay(euiThemeContext)} position: relative; display: flex; align-items: center; + block-size: var(--euiFilterButtonSize, ${euiTheme.size.xl}); + + padding: ${containerPadding}; + & + :where(:has(.euiFilterButton)) { + margin-inline-start: -${containerPadding}; + } + `, + compressed: css` + --euiFilterButtonSize: ${buttonSizeMap.s.height}; + `, + hasDividers: css` ${leftBorder} ${bottomBorder} - ${logicalCSS('padding-vertical', euiTheme.size.xs)} - `, - hasToggle: css` - ${logicalCSS('padding-horizontal', euiTheme.size.xs)} + & + :where(:has(.euiFilterButton)) { + margin-inline-start: 0; + } `, }; }; diff --git a/packages/eui/src/components/filter_group/filter_button.tsx b/packages/eui/src/components/filter_group/filter_button.tsx index 54f0351c4b0d..96240e1e830f 100644 --- a/packages/eui/src/components/filter_group/filter_button.tsx +++ b/packages/eui/src/components/filter_group/filter_button.tsx @@ -23,14 +23,15 @@ import { DistributiveOmit } from '../common'; import { EuiNotificationBadge } from '../badge'; import { BadgeNotificationColor } from '../badge/notification_badge/badge_notification'; import { EuiButtonEmpty, EuiButtonEmptyProps } from '../button/button_empty'; +import { type Props as EuiButtonProps, EuiButton } from '../button/button'; +import { _compressedButtonFocusColors } from '../button/button_group/button_group_button.styles'; import { euiFilterButtonStyles, euiFilterButtonWrapperStyles, euiFilterButtonChildStyles, } from './filter_button.styles'; -import { EuiButtonGroupButton } from '../button/button_group/button_group_button'; -import { _compressedButtonFocusColors } from '../button/button_group/button_group_button.styles'; +import { useEuiFilterGroupContext } from './filter_group_context'; export type EuiFilterButtonProps = { /** @@ -78,6 +79,7 @@ export type EuiFilterButtonProps = { */ badgeColor?: BadgeNotificationColor; /** + * @deprecated - Will be fixed to 'text' in the future. * Any of the named color palette options. * * Do not use the following colors for standalone buttons directly, @@ -103,7 +105,8 @@ export const EuiFilterButton: FunctionComponent = ({ numFilters, numActiveFilters, isToggle, - isDisabled, + isDisabled: _isDisabled, + disabled, isSelected, type = 'button', grow = true, @@ -112,13 +115,17 @@ export const EuiFilterButton: FunctionComponent = ({ contentProps, ...rest }) => { + const { colorMode } = useEuiTheme(); + + const { compressed, display, showDividers } = useEuiFilterGroupContext(); + const isDisabled = _isDisabled || disabled; + const fill = + isToggle && isSelected && (display === 'highlighted' || color !== 'text'); + const id = useGeneratedHtmlId({ prefix: 'filter-button' }); const numFiltersDefined = numFilters != null; // != instead of !== to allow for null and undefined const numActiveFiltersDefined = !!numActiveFilters; - const euiThemeContext = useEuiTheme(); - const { colorMode } = euiThemeContext; - // assumption about type of usage based on icon usage // requires manual override to apply correct aria attributes for more custom usages const isCollapsible = !isToggle && iconType === 'chevronSingleDown'; @@ -129,13 +136,18 @@ export const EuiFilterButton: FunctionComponent = ({ const toggleVariantStyles = [ isToggle && styles.buttonType.toggle, + isToggle && + isSelected && + display !== 'highlighted' && + color === 'text' && + styles.isSelected, !isToggle && !isDisabled && focusColorStyles[color], - !isToggle && styles.buttonType.default, ]; const cssStyles = [ styles.euiFilterButton, hasActiveFilters && styles.hasActiveFilters, + compressed && styles.compressed, ...toggleVariantStyles, ]; @@ -145,7 +157,8 @@ export const EuiFilterButton: FunctionComponent = ({ wrapperStyles.wrapper, withNext && styles.withNext, numFiltersDefined && styles.hasNotification, - isToggle && wrapperStyles.hasToggle, + compressed && wrapperStyles.compressed, + showDividers && wrapperStyles.hasDividers, !grow && styles.noGrow, ]; @@ -274,23 +287,17 @@ export const EuiFilterButton: FunctionComponent = ({ return (
{isToggle && !isCollapsible ? ( - - {textContent} - {badgeElement} - - } className={classes} css={cssStyles} color={color} isSelected={isSelected} - size="compressed" + fill={fill} + size="s" isDisabled={isDisabled} iconSide={iconSide} iconType={iconType} - isIconOnly={false} type={type} textProps={false} contentProps={{ @@ -301,9 +308,12 @@ export const EuiFilterButton: FunctionComponent = ({ contentProps?.css, ], }} - {...rest} + {...(rest as EuiButtonProps)} onClick={onToggleClick} - /> + > + {textContent} + {badgeElement} + ) : ( button )} diff --git a/packages/eui/src/components/filter_group/filter_group.styles.ts b/packages/eui/src/components/filter_group/filter_group.styles.ts index bcb86f589fa5..a6bf3a05f6cc 100644 --- a/packages/eui/src/components/filter_group/filter_group.styles.ts +++ b/packages/eui/src/components/filter_group/filter_group.styles.ts @@ -25,9 +25,6 @@ export const euiFilterGroupStyles = (euiThemeContext: UseEuiTheme) => { const { backgroundColor } = euiFormVariables(euiThemeContext); const buttonSizeMap = euiButtonSizeMap(euiThemeContext); - const borderRadius = euiTheme.border.radius.small; - const borderRadiusCompressed = euiTheme.border.radius.small; - const containerPadding = euiTheme.size.xs; return { @@ -36,7 +33,6 @@ export const euiFilterGroupStyles = (euiThemeContext: UseEuiTheme) => { display: inline-flex; ${logicalCSS('max-width', '100%')} overflow: hidden; - background-color: ${backgroundColor}; /* Adds the border on a pseudo element to prevent height differences between wrapper and buttons. @@ -51,6 +47,16 @@ export const euiFilterGroupStyles = (euiThemeContext: UseEuiTheme) => { pointer-events: none; } + &:where([data-dividers='true']) { + /* space for dividers */ + gap: ${euiTheme.border.width.thin}; + } + + &:where([data-display='regular']) { + --euiFilterButtonToggleBackground: ${euiTheme.colors + .backgroundLightText}; + } + .euiFilterButton-isToggle { /* reduced padding to account for outer container padding */ padding-inline: ${mathWithUnits( @@ -88,46 +94,10 @@ export const euiFilterGroupStyles = (euiThemeContext: UseEuiTheme) => { display: flex; `, uncompressed: css` - border-radius: ${borderRadius}; - - .euiFilterButton__wrapper { - ${logicalCSS('height', buttonSizeMap.m.height)} - } - - .euiFilterButton { - ${logicalCSS('height', buttonSizeMap.m.height)} - } - - .euiFilterButton-isToggle { - ${logicalCSS( - 'height', - buttonSizeMap.m.getInsetHeight( - buttonSizeMap.m.height, - containerPadding - ) - )} - } + border-radius: ${buttonSizeMap.m.radius}; `, compressed: css` - border-radius: ${borderRadiusCompressed}; - - .euiFilterButton__wrapper { - ${logicalCSS('height', buttonSizeMap.s.height)} - } - - .euiFilterButton { - ${logicalCSS('height', buttonSizeMap.s.height)} - } - - .euiFilterButton-isToggle { - ${logicalCSS( - 'height', - buttonSizeMap.s.getInsetHeight( - buttonSizeMap.s.height, - containerPadding - ) - )} - } + border-radius: ${buttonSizeMap.s.radius}; `, /** * Not used in EuiFilterGroup directly, but used by EuiSearchBar and consumers diff --git a/packages/eui/src/components/filter_group/filter_group.tsx b/packages/eui/src/components/filter_group/filter_group.tsx index 802bbb848170..03a8c9b5aa46 100644 --- a/packages/eui/src/components/filter_group/filter_group.tsx +++ b/packages/eui/src/components/filter_group/filter_group.tsx @@ -6,13 +6,19 @@ * Side Public License, v 1. */ -import React, { HTMLAttributes, ReactNode, FunctionComponent } from 'react'; +import React, { + HTMLAttributes, + ReactNode, + FunctionComponent, + useMemo, +} from 'react'; import classNames from 'classnames'; import { useEuiMemoizedStyles } from '../../services'; import { CommonProps } from '../common'; import { euiFilterGroupStyles } from './filter_group.styles'; +import { EuiFilterGroupContext } from './filter_group_context'; export type EuiFilterGroupProps = HTMLAttributes & CommonProps & { @@ -25,6 +31,18 @@ export type EuiFilterGroupProps = HTMLAttributes & * When `true`, creates a shorter height filter group matching that of `compressed` form controls */ compressed?: boolean; + /** + * Visual display variant: + * - `'regular'`: subdued toggle state + * - `'highlighted'`: highlighted toggle state + * @default 'regular' + */ + display?: 'regular' | 'highlighted'; + /** + * Shows dividers between buttons. + * @default true + */ + showDividers?: boolean; }; /** @@ -35,6 +53,8 @@ export const EuiFilterGroup: FunctionComponent = ({ className, fullWidth = false, compressed, + display = 'regular', + showDividers = true, ...rest }) => { const styles = useEuiMemoizedStyles(euiFilterGroupStyles); @@ -46,9 +66,22 @@ export const EuiFilterGroup: FunctionComponent = ({ const classes = classNames('euiFilterGroup', className); + const contextValue = useMemo( + () => ({ compressed, display, showDividers }), + [compressed, display, showDividers] + ); + return ( -
- {children} -
+ +
+ {children} +
+
); }; diff --git a/packages/eui/src/components/filter_group/filter_group_context.tsx b/packages/eui/src/components/filter_group/filter_group_context.tsx new file mode 100644 index 000000000000..b1a2f413a371 --- /dev/null +++ b/packages/eui/src/components/filter_group/filter_group_context.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createContext, useContext } from 'react'; + +export interface EuiFilterGroupContextShape { + compressed?: boolean; + display?: 'regular' | 'highlighted'; + showDividers?: boolean; +} + +export const EuiFilterGroupContext = createContext( + {} +); + +export const useEuiFilterGroupContext = () => useContext(EuiFilterGroupContext); From 743fe151fbaade16098292cdfe70bb7eb64ac401 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 1 Sep 2026 11:10:19 +0200 Subject: [PATCH 02/17] refactor: adjust usage in form layout --- .../form/form_control_layout/form_control_layout.styles.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/eui/src/components/form/form_control_layout/form_control_layout.styles.ts b/packages/eui/src/components/form/form_control_layout/form_control_layout.styles.ts index d5e90d8bc362..5dc2775fabdf 100644 --- a/packages/eui/src/components/form/form_control_layout/form_control_layout.styles.ts +++ b/packages/eui/src/components/form/form_control_layout/form_control_layout.styles.ts @@ -258,6 +258,11 @@ export const euiFormControlLayoutSideNodeStyles = ( :where(:not(:has(:disabled))) label { color: ${form.labelColor}; } + + /* padding is handled in the form layout side wrapper */ + .euiFilterButton__wrapper { + padding: 0; + } `, uncompressed: { uncompressed: ` From fec73730d263b8874d2366b6f6e028730cf568d4 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 1 Sep 2026 11:10:51 +0200 Subject: [PATCH 03/17] docs: update docs and stories --- .../filter_group/filter_group.stories.tsx | 34 ++--- .../navigation/buttons/filter-group.mdx | 119 ++++++++++++------ 2 files changed, 100 insertions(+), 53 deletions(-) diff --git a/packages/eui/src/components/filter_group/filter_group.stories.tsx b/packages/eui/src/components/filter_group/filter_group.stories.tsx index 4befe10a6e07..8fa5fd8c6835 100644 --- a/packages/eui/src/components/filter_group/filter_group.stories.tsx +++ b/packages/eui/src/components/filter_group/filter_group.stories.tsx @@ -20,6 +20,8 @@ const meta: Meta = { // Component defaults compressed: false, fullWidth: false, + display: 'regular', + showDividers: true, }, }; @@ -27,21 +29,25 @@ export default meta; type Story = StoryObj; export const Playground: Story = { - render: ({ ...args }) => ( - - Toggle Filter - - Selection Filter - - - ), + render: function Render({ ...args }) { + const [isToggled, setToggled] = useState(false); + + return ( + + setToggled((toggled) => !toggled)} + > + Toggle Filter + + + + ); + }, }; -const FilterButtonPopover = () => { +const FilterButtonPopover = ({ label = 'Composers' }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); return ( @@ -57,7 +63,7 @@ const FilterButtonPopover = () => { hasActiveFilters={true} numActiveFilters={2} > - Composers + {label} } > diff --git a/packages/website/docs/components/navigation/buttons/filter-group.mdx b/packages/website/docs/components/navigation/buttons/filter-group.mdx index 159028658dcb..6d17e13fe859 100644 --- a/packages/website/docs/components/navigation/buttons/filter-group.mdx +++ b/packages/website/docs/components/navigation/buttons/filter-group.mdx @@ -5,9 +5,12 @@ sidebar_position: 4 # Filter group -## Filter buttons +Use **EuiFilterGroup** to wrap **EuiFilterButton** components into a container that looks nice against form fields (like search). + +Use `display="highlighted"` only when multiple filter groups are used together to visually distinguish one group above the others. +To hide the dividers between buttons, set `showDividers={false}`. -Use **EuiFilterGroup** to wrap **EuiFilterButtons** into a container that looks nice against form fields (like search). These buttons are used in two different patterns. The most simplest use is that of an on/off pattern to show whether a filter is on. Add the prop `withNext` to remove the border between it and the next EuiFilterButton to visually group similar or opposite style filters. +## Filter buttons Add the prop `withNext` to remove the border between it and the next EuiFilterButton to visually group similar or opposite style filters. @@ -17,50 +20,88 @@ Use `isToggle` to change the visual and functional type of the button element th ```tsx interactive import React, { useState } from 'react'; -import { EuiFilterGroup, EuiFilterButton } from '@elastic/eui'; +import { + EuiFilterGroup, + EuiFilterButton, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiSwitch, + EuiSpacer, +} from '@elastic/eui'; export default () => { + const [display, setDisplay] = useState<'regular' | 'highlighted'>('regular'); + const [showDividers, setShowDividers] = useState(true); const [isFilterOn, setIsFilterOn] = useState(false); const [isOnFilterOn, setIsOnFilterOn] = useState(false); const [isOffFilterOn, setIsOffFilterOn] = useState(false); - const toggleFilter = () => { - setIsFilterOn(!isFilterOn); - }; - - const toggleOnFilter = () => { - setIsOnFilterOn(!isOnFilterOn); - setIsOffFilterOn(isOffFilterOn && !isOnFilterOn ? false : isOffFilterOn); - }; - - const toggleOffFilter = () => { - setIsOffFilterOn(!isOffFilterOn); - setIsOnFilterOn(isOnFilterOn && !isOffFilterOn ? false : isOnFilterOn); - }; - return ( - - - Single filter - - - On - - - Off - - + <> + + + + setDisplay(e.target.value as 'regular' | 'highlighted') + } + /> + + + setShowDividers(e.target.checked)} + /> + + + + + setIsFilterOn(!isFilterOn)} + > + Single filter + + { + setIsOnFilterOn(!isOnFilterOn); + setIsOffFilterOn( + isOffFilterOn && !isOnFilterOn ? false : isOffFilterOn + ); + }} + > + On + + { + setIsOffFilterOn(!isOffFilterOn); + setIsOnFilterOn( + isOnFilterOn && !isOffFilterOn ? false : isOnFilterOn + ); + }} + > + Off + + + ); }; ``` From fc22f484e45e7be18959bf7c1b87418d514465d9 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Tue, 1 Sep 2026 11:11:24 +0200 Subject: [PATCH 04/17] test: update snapshots --- .../__snapshots__/filter_button.test.tsx.snap | 48 +++++++++---------- .../__snapshots__/filter_group.test.tsx.snap | 22 +++++---- .../__snapshots__/search_bar.test.tsx.snap | 12 +++-- .../search_filters.test.tsx.snap | 14 ++++-- .../field_value_toggle_filter.test.tsx.snap | 24 +++++----- ...ld_value_toggle_group_filter.test.tsx.snap | 24 +++++----- .../__snapshots__/is_filter.test.tsx.snap | 6 +-- 7 files changed, 81 insertions(+), 69 deletions(-) diff --git a/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap b/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap index e31877c2643f..fa62734b81d1 100644 --- a/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap +++ b/packages/eui/src/components/filter_group/__snapshots__/filter_button.test.tsx.snap @@ -2,11 +2,11 @@ exports[`EuiFilterButton does not render a badge or count if numFilters is not passed 1`] = `
`; exports[`EuiFilterButton props badgeColor renders 1`] = `
`; exports[`EuiFilterButton props grow can be turned off 1`] = `
`; exports[`EuiFilterButton props iconType and iconSide renders 1`] = `
`; exports[`EuiFilterButton props isSelected renders 1`] = `
`; exports[`EuiFilterButton props numActiveFilters and hasActiveFilters renders 1`] = `
`; exports[`EuiFilterButton props withNext renders 1`] = `
`; exports[`EuiFilterButton renders 1`] = `
`; exports[`EuiFilterButton renders zero properly 1`] = `