diff --git a/design-tokens/tokens/component/base.json b/design-tokens/tokens/component/base.json index 7aadd4b63..5cb60ddee 100644 --- a/design-tokens/tokens/component/base.json +++ b/design-tokens/tokens/component/base.json @@ -23,6 +23,22 @@ } }, "filter": { + "dropdown": { + "sizes": { + "leftPanelWidth": { + "default": { + "$type": "sizing", + "$value": "277px" + } + }, + "minHeight": { + "default": { + "$type": "sizing", + "$value": "350px" + } + } + } + }, "tag": { "sizes": { "maxWidth": { diff --git a/i18n/keys.json b/i18n/keys.json index 2414a7d9d..89a56b710 100644 --- a/i18n/keys.json +++ b/i18n/keys.json @@ -19,6 +19,26 @@ "defaultMessage": "Warning banner:", "description": "Screen reader only text that expresses the type of the Warning banner" }, + "filter.dropdown.apply": { + "defaultMessage": "Apply filters", + "description": "Label for the button that applies the pending filter selection" + }, + "filter.dropdown.categories.label": { + "defaultMessage": "Categories", + "description": "Accessible label for the filter categories listbox" + }, + "filter.dropdown.clear": { + "defaultMessage": "Clear filters", + "description": "Label for the button that clears all active filters" + }, + "filter.dropdown.label": { + "defaultMessage": "Filters", + "description": "Accessible label for the filter popover dialog" + }, + "filter.dropdown.search": { + "defaultMessage": "Search", + "description": "Placeholder text for the filter search input" + }, "filter.tag.dismiss": { "defaultMessage": "Remove {filter} filter", "description": "Accessible label for the dismiss button of a filter tag, where {filter} is the name of the filter to be removed" diff --git a/src/common/helpers/constants.ts b/src/common/helpers/constants.ts new file mode 100644 index 000000000..ae8c494ce --- /dev/null +++ b/src/common/helpers/constants.ts @@ -0,0 +1,22 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +export const EMPTY_ARRAY: readonly never[] = Object.freeze([]); + +export const KEYBOARD_THROTTLE_MS = 100; diff --git a/src/components/badges/BadgeCounter.tsx b/src/components/badges/BadgeCounter.tsx index 282145f9b..78beefb1a 100644 --- a/src/components/badges/BadgeCounter.tsx +++ b/src/components/badges/BadgeCounter.tsx @@ -54,7 +54,9 @@ export function BadgeCounter(props: Readonly) { BadgeCounter.displayName = 'BadgeCounter'; const BadgeCounterStyled = styled.span` - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; box-sizing: border-box; height: ${cssVar('line-height-10')}; min-width: ${cssVar('dimension-width-200')}; @@ -64,7 +66,6 @@ const BadgeCounterStyled = styled.span` font: ${cssVar('typography-text-small-semi-bold')}; color: var(--badge-counter-color); - text-align: center; background-color: var(--badge-counter-background-color); `; diff --git a/src/components/checkbox/Checkbox.tsx b/src/components/checkbox/Checkbox.tsx index eb1f38de8..b7d7f055a 100644 --- a/src/components/checkbox/Checkbox.tsx +++ b/src/components/checkbox/Checkbox.tsx @@ -18,16 +18,19 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import styled from '@emotion/styled'; -import * as RadixCheckbox from '@radix-ui/react-checkbox'; import { forwardRef, useCallback, useId } from 'react'; import { PropsWithLabelsAndHelpText } from '~types/utils'; -import { FormFieldLabel } from '../form/FormFieldLabel'; -import { Spinner } from '../spinner'; import { HelperText } from '../typography'; import { CheckboxIcon } from './CheckboxIcon'; - -import { cssVar } from '~utils/design-tokens'; +import { + CheckboxContainer, + CheckboxIndicator, + CheckboxInnerContainer, + CheckboxLabel, + CheckboxLabelWrapper, + CheckboxRoot, + CheckboxSpinner, +} from './CheckboxStyles'; interface CheckboxPropsBase { /** @@ -159,102 +162,3 @@ export const Checkbox = forwardRef((props, ref ); }); Checkbox.displayName = 'Checkbox'; - -const CheckboxContainer = styled.span` - display: inline-flex; - vertical-align: top; - - &[data-disabled] { - pointer-events: none; - } -`; -CheckboxContainer.displayName = 'CheckboxContainer'; - -const CheckboxInnerContainer = styled.span` - display: flex; - font-family: Arial, Helvetica, sans-serif; - font-size: 0.833rem; -`; -CheckboxInnerContainer.displayName = 'CheckboxInnerContainer'; - -const CheckboxRoot = styled(RadixCheckbox.Root)` - color: ${cssVar('color-text-on-color')}; - background-color: ${cssVar('color-surface-default')}; - border: ${cssVar('border-width-default')} solid ${cssVar('color-border-bolder')}; - - height: ${cssVar('dimension-height-400')}; - width: ${cssVar('dimension-width-200')}; - border-radius: ${cssVar('border-radius-100')}; - margin: ${cssVar('dimension-space-25')} 0; - box-sizing: border-box; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - &:focus, - &:focus-visible { - outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')}; - outline-offset: ${cssVar('focus-border-offset-default')}; - } - - &[aria-disabled='true'] { - color: ${cssVar('color-icon-disabled')}; - background-color: ${cssVar('color-surface-disabled')}; - border-color: ${cssVar('color-border-disabled')}; - - &[aria-checked='true'], - &[aria-checked='mixed'] { - background-color: ${cssVar('color-surface-disabled')}; - border-color: ${cssVar('color-border-disabled')}; - } - } - - &:not([aria-disabled='true']) { - &:hover { - background-color: ${cssVar('color-surface-hover')}; - } - - &[aria-checked='true'], - &[aria-checked='mixed'] { - background-color: ${cssVar('color-background-selected-bold-default')}; - border-color: ${cssVar('color-background-selected-bold-default')}; - - &:hover { - background-color: ${cssVar('color-background-selected-bold-hover')}; - border: ${cssVar('color-background-selected-bold-hover')}; - } - } - - &[data-error] { - border-color: ${cssVar('color-border-danger-default')}; - } - } -`; -CheckboxRoot.displayName = 'CheckboxRoot'; - -const CheckboxSpinner = styled(Spinner)` - margin: ${cssVar('dimension-space-25')} 0; -`; -CheckboxSpinner.displayName = 'CheckboxSpinner'; - -const CheckboxIndicator = styled(RadixCheckbox.Indicator)` - height: ${cssVar('dimension-height-400')}; - width: ${cssVar('dimension-width-200')}; -`; -CheckboxIndicator.displayName = 'CheckboxIndicator'; - -const CheckboxLabelWrapper = styled.span` - display: inline-flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - margin-left: ${cssVar('dimension-space-100')}; -`; -CheckboxLabelWrapper.displayName = 'CheckboxLabelWrapper'; - -const CheckboxLabel = styled(FormFieldLabel)` - color: ${cssVar('color-text-default')}; - font: ${cssVar('typography-others-label-medium')}; -`; -CheckboxLabel.displayName = 'CheckboxLabel'; diff --git a/src/components/checkbox/CheckboxStyles.tsx b/src/components/checkbox/CheckboxStyles.tsx new file mode 100644 index 000000000..03fb92c58 --- /dev/null +++ b/src/components/checkbox/CheckboxStyles.tsx @@ -0,0 +1,124 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import styled from '@emotion/styled'; +import * as RadixCheckbox from '@radix-ui/react-checkbox'; +import { cssVar } from '~utils/design-tokens'; +import { FormFieldLabel } from '../form/FormFieldLabel'; +import { Spinner } from '../spinner'; + +export const CheckboxContainer = styled.span` + display: inline-flex; + vertical-align: top; + + &[data-disabled] { + pointer-events: none; + } +`; +CheckboxContainer.displayName = 'CheckboxContainer'; + +export const CheckboxInnerContainer = styled.span` + display: flex; +`; +CheckboxInnerContainer.displayName = 'CheckboxInnerContainer'; + +export const CheckboxSpinner = styled(Spinner)` + margin: ${cssVar('dimension-space-25')} 0; +`; +CheckboxSpinner.displayName = 'CheckboxSpinner'; + +export const CheckboxLabelWrapper = styled.span` + display: inline-flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + margin-left: ${cssVar('dimension-space-100')}; +`; +CheckboxLabelWrapper.displayName = 'CheckboxLabelWrapper'; + +export const CheckboxLabel = styled(FormFieldLabel)` + color: ${cssVar('color-text-default')}; + font: ${cssVar('typography-others-label-medium')}; +`; +CheckboxLabel.displayName = 'CheckboxLabel'; + +export const CheckboxIndicator = styled(RadixCheckbox.Indicator)` + height: ${cssVar('dimension-height-400')}; + width: ${cssVar('dimension-width-200')}; +`; +CheckboxIndicator.displayName = 'CheckboxIndicator'; + +export const styleCheckboxRootBase = styled.div` + color: ${cssVar('color-text-on-color')}; + background-color: ${cssVar('color-surface-default')}; + border: ${cssVar('border-width-default')} solid ${cssVar('color-border-bolder')}; + + height: ${cssVar('dimension-height-400')}; + width: ${cssVar('dimension-width-200')}; + border-radius: ${cssVar('border-radius-100')}; + margin: ${cssVar('dimension-space-25')} 0; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +`.withComponent; + +export const CheckboxRoot = styled(styleCheckboxRootBase(RadixCheckbox.Root))` + &:focus, + &:focus-visible { + outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')}; + outline-offset: ${cssVar('focus-border-offset-default')}; + } + + &[aria-disabled='true'] { + color: ${cssVar('color-icon-disabled')}; + background-color: ${cssVar('color-surface-disabled')}; + border-color: ${cssVar('color-border-disabled')}; + + &[aria-checked='true'], + &[aria-checked='mixed'] { + background-color: ${cssVar('color-surface-disabled')}; + border-color: ${cssVar('color-border-disabled')}; + } + } + + &:not([aria-disabled='true']) { + &:hover { + background-color: ${cssVar('color-surface-hover')}; + } + + &[aria-checked='true'], + &[aria-checked='mixed'] { + background-color: ${cssVar('color-background-selected-bold-default')}; + border-color: ${cssVar('color-background-selected-bold-default')}; + + &:hover { + background-color: ${cssVar('color-background-selected-bold-hover')}; + border: ${cssVar('color-background-selected-bold-hover')}; + } + } + + &[data-error] { + border-color: ${cssVar('color-border-danger-default')}; + } + } +`; +CheckboxRoot.displayName = 'CheckboxRoot'; diff --git a/src/components/dropdown-menu/DropdownMenu.tsx b/src/components/dropdown-menu/DropdownMenu.tsx index 80fc9d221..5dafde5f6 100644 --- a/src/components/dropdown-menu/DropdownMenu.tsx +++ b/src/components/dropdown-menu/DropdownMenu.tsx @@ -28,8 +28,8 @@ import { cssVar } from '~utils/design-tokens'; import { THEME_DATA_ATTRIBUTE, ThemeContext } from '~utils/theme'; import { PortalContext } from '../../common/components/PortalContext'; import { HelperText, Label } from '../typography'; -import { styleDropdownMenuOverlay } from './DropdownMenuCommons'; import { DropdownMenuSeparator } from './DropdownMenuSeparator'; +import { styleDropdownMenuOverlay } from './DropdownMenuStyles'; export enum DropdownMenuAlign { Center = 'center', diff --git a/src/components/dropdown-menu/DropdownMenuItemBase.tsx b/src/components/dropdown-menu/DropdownMenuItemBase.tsx index 7c2f13c72..f5dedfeaa 100644 --- a/src/components/dropdown-menu/DropdownMenuItemBase.tsx +++ b/src/components/dropdown-menu/DropdownMenuItemBase.tsx @@ -22,9 +22,9 @@ import styled from '@emotion/styled'; import * as radixDropdownMenu from '@radix-ui/react-dropdown-menu'; import { forwardRef, MouseEventHandler, ReactNode, useCallback } from 'react'; import { TextNode } from '~types/utils'; -import { IconCheck } from '../icons/IconCheck'; - import { cssVar } from '~utils/design-tokens'; +import { IconCheck } from '../icons/IconCheck'; +import { styleDropdownItemBase } from './DropdownMenuStyles'; type CheckProps = | { @@ -199,40 +199,10 @@ const StyledSuffix = styled.span` `; StyledSuffix.displayName = 'StyledSuffix'; -const StyledRadixDropdownMenuItem = styled(radixDropdownMenu.Item)` - background-color: ${cssVar('color-surface-default')}; - color: ${cssVar('color-text-default')}; - font: ${cssVar('typography-text-default-regular')}; - - display: flex; - align-items: center; - justify-content: space-between; - column-gap: ${cssVar('dimension-space-100')}; - padding: ${cssVar('dimension-space-100')} ${cssVar('dimension-space-150')}; - - cursor: pointer; - - &:focus-visible { - border-radius: ${cssVar('border-radius-400')}; - outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')}; - outline-offset: -2px; - } - +const StyledRadixDropdownMenuItem = styled(styleDropdownItemBase(radixDropdownMenu.Item))` &:hover { - background-color: ${cssVar('color-surface-hover')}; border-radius: 0; outline: none; } - - /* when the item is clicked */ - &:active { - background-color: ${cssVar('color-surface-active')}; - } - - &[data-disabled] { - background-color: ${cssVar('color-surface-default')}; - color: ${cssVar('color-text-disabled')}; - cursor: default; - } `; StyledRadixDropdownMenuItem.displayName = 'StyledRadixDropdownMenuItem'; diff --git a/src/components/dropdown-menu/DropdownMenuCommons.tsx b/src/components/dropdown-menu/DropdownMenuStyles.tsx similarity index 62% rename from src/components/dropdown-menu/DropdownMenuCommons.tsx rename to src/components/dropdown-menu/DropdownMenuStyles.tsx index 4e3c4c260..2757cb7e0 100644 --- a/src/components/dropdown-menu/DropdownMenuCommons.tsx +++ b/src/components/dropdown-menu/DropdownMenuStyles.tsx @@ -19,9 +19,44 @@ */ import styled from '@emotion/styled'; - import { cssVar } from '~utils/design-tokens'; +export const styleDropdownItemBase = styled.div` + background-color: ${cssVar('color-surface-default')}; + color: ${cssVar('color-text-default')}; + font: ${cssVar('typography-text-default-regular')}; + + display: flex; + align-items: center; + justify-content: space-between; + column-gap: ${cssVar('dimension-space-100')}; + padding: ${cssVar('dimension-space-100')} ${cssVar('dimension-space-150')}; + + cursor: pointer; + + &:focus-visible { + border-radius: ${cssVar('border-radius-400')}; + outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')}; + outline-offset: -2px; + } + + &:hover { + background-color: ${cssVar('color-surface-hover')}; + } + + /* when the item is clicked */ + &:active { + background-color: ${cssVar('color-surface-active')}; + } + + &[data-disabled], + &:disabled { + background-color: ${cssVar('color-surface-default')}; + color: ${cssVar('color-text-disabled')}; + cursor: default; + } +`.withComponent; + export const styleDropdownMenuOverlay = styled.div` background-color: ${cssVar('color-surface-default')}; border: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; diff --git a/src/components/dropdown-menu/DropdownMenuSubMenu.tsx b/src/components/dropdown-menu/DropdownMenuSubMenu.tsx index 5d5912e7f..be3ce53c4 100644 --- a/src/components/dropdown-menu/DropdownMenuSubMenu.tsx +++ b/src/components/dropdown-menu/DropdownMenuSubMenu.tsx @@ -22,8 +22,8 @@ import styled from '@emotion/styled'; import * as radixDropdownMenu from '@radix-ui/react-dropdown-menu'; import { forwardRef, ReactNode } from 'react'; import { IconChevronRight } from '../icons'; -import { styleDropdownMenuOverlay } from './DropdownMenuCommons'; import { DropdownMenuItemBase } from './DropdownMenuItemBase'; +import { styleDropdownMenuOverlay } from './DropdownMenuStyles'; import { cssVar } from '~utils/design-tokens'; diff --git a/src/components/filters/FilterDropdown.tsx b/src/components/filters/FilterDropdown.tsx new file mode 100644 index 000000000..f88bb0679 --- /dev/null +++ b/src/components/filters/FilterDropdown.tsx @@ -0,0 +1,284 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import * as RadixPopover from '@radix-ui/react-popover'; +import { KeyboardEvent, useCallback, useContext, useId, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { PortalContext } from '~common/components/PortalContext'; +import { EMPTY_ARRAY } from '~common/helpers/constants'; +import { isDefined } from '~common/helpers/types'; +import { THEME_DATA_ATTRIBUTE, ThemeContext } from '~utils/theme'; +import { Button, ButtonGroup, ButtonVariety } from '../buttons'; +import { Divider } from '../divider'; +import { OVERLAY_ARROW_PADDING, OVERLAY_SIDE_OFFSET, PopoverArrow } from '../popover/PopoverStyles'; +import { FilterDropdownCategoryItem } from './FilterDropdownCategory'; +import { + FilterDropdownRightPanel, + FilterDropdownRightPanelHandle, +} from './FilterDropdownRightPanel'; +import { + StyledFilterContent, + StyledFilterLayout, + StyledFooter, + StyledLeftPanel, +} from './FilterDropdownStyles'; +import { FilterDropdownCategory, FilterDropdownProps } from './FilterDropdownTypes'; +import { useFilterDropdownKeyboardThrottle } from './useFilterDropdownKeyboardThrottle'; +import { useFilterDropdownRovingFocus } from './useFilterDropdownRovingFocus'; + +export function FilterDropdown(props: Readonly) { + const { + categories, + children, + className, + isOpen, + labelApply, + labelCategories, + labelClear, + labelFilters, + onApply, + onCategorySelect, + onClear, + onClose, + onItemSelect, + onOpen, + ref, + selectedValues = EMPTY_ARRAY, + ...restProps + } = props; + const { formatMessage } = useIntl(); + const [localIsOpen, setLocalIsOpen] = useState(false); + const [pendingValues, setPendingValues] = useState([]); + const [activeCategoryIndex, setActiveCategoryIndex] = useState(0); + + // Roving-focus helper for the left-panel category list. + // Right-panel item focus is managed inside FilterDropdownRightPanel. + const { register: categoryRegister, focus: categoryFocus } = useFilterDropdownRovingFocus(); + const rightPanelRef = useRef(null); + const allowCategoryKeyNav = useFilterDropdownKeyboardThrottle(); + + const portalContext = useContext(PortalContext); + const theme = useContext(ThemeContext); + const themeOverrideProp = isDefined(theme) ? { [THEME_DATA_ATTRIBUTE]: theme } : {}; + + const contentId = useId(); + const open = isOpen ?? localIsOpen; + + const activeCategory = categories[activeCategoryIndex]; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setLocalIsOpen(nextOpen); + if (nextOpen) { + setPendingValues([...selectedValues]); + setActiveCategoryIndex(0); + onOpen?.(); + if (categories.length > 0) { + onCategorySelect?.(categories[0].label); + } + } else { + onClose?.(); + } + }, + [categories, onCategorySelect, onClose, onOpen, selectedValues], + ); + + const handleCategoryClick = useCallback( + (index: number) => { + if (index !== activeCategoryIndex) { + setActiveCategoryIndex(index); + onCategorySelect?.(categories[index].label); + } + }, + [activeCategoryIndex, categories, onCategorySelect], + ); + + const handleItemToggle = useCallback( + (value: string) => { + if (!isDefined(activeCategory)) { + return; + } + + let newValues: string[]; + if (activeCategory.isMultiSelect) { + newValues = pendingValues.includes(value) + ? pendingValues.filter((v) => v !== value) + : [...pendingValues, value]; + } else { + const items = activeCategory.items ?? []; + const categoryValues = new Set(items.map((i) => i.value)); + const withoutCategory = pendingValues.filter((v) => !categoryValues.has(v)); + newValues = pendingValues.includes(value) ? withoutCategory : [...withoutCategory, value]; + } + + setPendingValues(newValues); + onItemSelect?.(newValues); + }, + [activeCategory, onItemSelect, pendingValues], + ); + + const handleApply = useCallback(() => { + onApply?.(pendingValues); + handleOpenChange(false); + }, [handleOpenChange, onApply, pendingValues]); + + const handleClear = useCallback(() => { + onClear(); + setPendingValues([]); + handleOpenChange(false); + }, [handleOpenChange, onClear]); + + const handleCategoryKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowRight' || e.key === 'Enter') { + e.preventDefault(); + rightPanelRef.current?.focusFirstItem(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (e.repeat && !allowCategoryKeyNav()) { + return; + } + if (e.key === 'ArrowDown') { + const nextIndex = Math.min(activeCategoryIndex + 1, categories.length - 1); + handleCategoryClick(nextIndex); + categoryFocus(nextIndex); + } else { + const previousIndex = Math.max(activeCategoryIndex - 1, 0); + handleCategoryClick(previousIndex); + categoryFocus(previousIndex); + } + } + }, + [ + activeCategoryIndex, + allowCategoryKeyNav, + categories.length, + categoryFocus, + handleCategoryClick, + ], + ); + + const handleCategoryFocusBack = useCallback(() => { + categoryFocus(activeCategoryIndex); + }, [activeCategoryIndex, categoryFocus]); + + const getCategorySelectionCount = useCallback( + (category: FilterDropdownCategory) => { + if (!isDefined(category.items)) { + return 0; + } + return category.items.filter((item) => pendingValues.includes(item.value)).length; + }, + [pendingValues], + ); + + return ( + + {children} + + + { + e.preventDefault(); + categoryFocus(0); + }} + ref={ref} + role="dialog" + sideOffset={OVERLAY_SIDE_OFFSET}> + + + {categories.map((category, index) => ( + handleCategoryClick(index)} + ref={categoryRegister(index)} + selectionCount={getCategorySelectionCount(category)} + tabIndex={index === activeCategoryIndex ? 0 : -1} + /> + ))} + + + + + + + + + + + {isDefined(onApply) && ( + + )} + + + + + + + ); +} +FilterDropdown.displayName = 'FilterDropdown'; diff --git a/src/components/filters/FilterDropdownCategory.tsx b/src/components/filters/FilterDropdownCategory.tsx new file mode 100644 index 000000000..3dfbd154c --- /dev/null +++ b/src/components/filters/FilterDropdownCategory.tsx @@ -0,0 +1,95 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import styled from '@emotion/styled'; +import { Ref } from 'react'; +import { truncate } from '~common/helpers/styles'; +import { cssVar } from '~utils/design-tokens'; +import { BadgeCounter, BadgeCounterVariety } from '../badges/BadgeCounter'; +import { styleDropdownItemBase } from '../dropdown-menu/DropdownMenuStyles'; +import { IconChevronRight } from '../icons/IconChevronRight'; +import { Text } from '../typography'; +import { filterDropdownRowStyles } from './FilterDropdownStyles'; + +/** @internal */ +export interface FilterDropdownCategoryProps { + /** Whether this category is currently active (selected). */ + isActive: boolean; + /** Label of the category. */ + label: string; + /** Callback invoked when the category is clicked. */ + onClick: () => void; + /** Ref forwarded to the underlying button element for roving-focus management. */ + ref?: Ref; + /** Number of selected items in this category. */ + selectionCount: number; + /** Roving tabindex value; 0 means this button is the current tab stop, -1 removes it. */ + tabIndex?: number; +} + +/** @internal */ +export function FilterDropdownCategoryItem(props: Readonly) { + const { isActive, label, onClick, ref, selectionCount, tabIndex } = props; + return ( + + {label} + {selectionCount > 0 && ( + + )} + + + ); +} +FilterDropdownCategoryItem.displayName = 'FilterDropdownCategoryItem'; + +const StyledCategoryLabel = styled(Text)` + flex: 1 1 auto; + + ${truncate} +`; +StyledCategoryLabel.displayName = 'StyledCategoryLabel'; + +const StyledCategoryButton = styled(styleDropdownItemBase('button'))` + ${filterDropdownRowStyles} + + &[data-active] { + background-color: ${cssVar('color-background-selected-weak-default')}; + } + + &[data-active] ${StyledCategoryLabel} { + color: ${cssVar('color-text-accent')}; + font: ${cssVar('typography-text-default-semi-bold')}; + } +`; +StyledCategoryButton.displayName = 'StyledCategoryButton'; + +const StyledChevronRight = styled(IconChevronRight)` + color: ${cssVar('color-icon-subtle')}; + font-size: ${cssVar('font-size-20')}; + flex-shrink: 0; +`; +StyledChevronRight.displayName = 'StyledChevronRight'; diff --git a/src/components/filters/FilterDropdownItem.tsx b/src/components/filters/FilterDropdownItem.tsx new file mode 100644 index 000000000..62891af7d --- /dev/null +++ b/src/components/filters/FilterDropdownItem.tsx @@ -0,0 +1,176 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import * as RadixCheckbox from '@radix-ui/react-checkbox'; +import * as RadixRadioGroup from '@radix-ui/react-radio-group'; +import { Ref, useCallback } from 'react'; +import { truncate } from '~common/helpers/styles'; +import { isDefined } from '~common/helpers/types'; +import { cssVar } from '~utils/design-tokens'; +import { CheckboxIcon } from '../checkbox/CheckboxIcon'; +import { CheckboxIndicator, styleCheckboxRootBase } from '../checkbox/CheckboxStyles'; +import { styleDropdownItemBase } from '../dropdown-menu/DropdownMenuStyles'; +import { + RadioButtonSelectionIndicator, + styleRadioButtonInputBase, +} from '../radio-button-group/RadioButtonStyles'; +import { Text, TextSize } from '../typography/Text'; +import { filterDropdownRowStyles } from './FilterDropdownStyles'; +import { FilterDropdownOption } from './FilterDropdownTypes'; + +/** @internal */ +export interface FilterDropdownItemProps extends FilterDropdownOption { + /** Whether the item is currently selected. */ + isSelected: boolean; + /** Callback invoked when the item's selection state changes. */ + onChange: () => void; + /** Ref forwarded to the underlying button element for roving-focus management. */ + ref?: Ref; + /** Roving tabindex value; 0 means this button is the current tab stop, -1 removes it. */ + tabIndex?: number; +} + +/** @internal */ +export function FilterDropdownItemMultiSelect(props: Readonly) { + const { isDisabled, isSelected, label, onChange, prefix, ref, suffix, tabIndex } = props; + const handleCheckedChange = useCallback(() => onChange(), [onChange]); + return ( + + + + + + + + + ); +} +FilterDropdownItemMultiSelect.displayName = 'FilterDropdownItemMultiSelect'; + +/** @internal */ +export function FilterDropdownItemSingleSelect(props: Readonly) { + const { isDisabled, isSelected, label, onChange, prefix, ref, suffix, value } = props; + const handleClick = useCallback(() => { + if (isSelected) { + onChange(); + } + }, [isSelected, onChange]); + + // Do not pass the tabIndex here, it's handled by radix-ui RadioButtonGroup + return ( + + + + + + + ); +} +FilterDropdownItemSingleSelect.displayName = 'FilterDropdownItemSingleSelect'; + +function FilterDropdownItemContent( + props: Readonly>, +) { + const { label, prefix, suffix } = props; + return ( + <> + {isDefined(prefix) && {prefix}} + {label} + {isDefined(suffix) && ( + + {suffix} + + )} + + ); +} + +FilterDropdownItemContent.displayName = 'FilterDropdownItemContent'; + +const indicatorSelectedStyles = css` + [data-state='checked'] & { + background-color: ${cssVar('color-background-selected-bold-default')}; + border-color: ${cssVar('color-background-selected-bold-default')}; + } + + [data-disabled] & { + background-color: ${cssVar('color-surface-disabled')}; + border-color: ${cssVar('color-border-disabled')}; + } +`; + +// Multi-select: the whole row IS the RadixCheckbox.Root (a button with role="checkbox"). +const StyledCheckboxRow = styled(styleDropdownItemBase(RadixCheckbox.Root))` + ${filterDropdownRowStyles} + + &[data-state='checked'] { + background-color: ${cssVar('color-background-selected-weak-default')}; + } +`; +StyledCheckboxRow.displayName = 'StyledCheckboxRow'; + +// Visual checkbox box inside the row — mirrors Echoes CheckboxRoot styling. +const StyledCheckboxBox = styled(styleCheckboxRootBase('span'))` + ${indicatorSelectedStyles} + + [data-disabled] & { + color: ${cssVar('color-icon-disabled')}; + } +`; +StyledCheckboxBox.displayName = 'StyledCheckboxBox'; + +// Only rendered when the checkbox is checked — Radix Checkbox.Indicator handles this. +const StyledCheckboxIndicator = styled(CheckboxIndicator)` + display: flex; +`; +StyledCheckboxIndicator.displayName = 'StyledCheckboxIndicator'; + +// Single-select: the whole row IS the RadixRadioGroup.Item (a button with role="radio"). +const StyledRadioRow = styled(styleDropdownItemBase(RadixRadioGroup.Item))` + ${filterDropdownRowStyles} +`; +StyledRadioRow.displayName = 'StyledRadioRow'; + +// Visual radio circle inside the row — mirrors Echoes RadioButtonInput styling. +const StyledRadioBox = styled(styleRadioButtonInputBase('span'))` + ${indicatorSelectedStyles} +`; +StyledRadioBox.displayName = 'StyledRadioBox'; + +const StyledPrefix = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; +`; +StyledPrefix.displayName = 'StyledPrefix'; + +const StyledItemText = styled(Text)` + flex: 1 1 auto; + + ${truncate} +`; +StyledItemText.displayName = 'StyledItemText'; diff --git a/src/components/filters/FilterDropdownItemsList.tsx b/src/components/filters/FilterDropdownItemsList.tsx new file mode 100644 index 000000000..dfd79fe17 --- /dev/null +++ b/src/components/filters/FilterDropdownItemsList.tsx @@ -0,0 +1,202 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { KeyboardEvent, Ref, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import { + FilterDropdownItemMultiSelect, + FilterDropdownItemSingleSelect, +} from './FilterDropdownItem'; +import { StyledItemsList, StyledItemsListRadioGroup } from './FilterDropdownStyles'; +import { FilterDropdownOption } from './FilterDropdownTypes'; +import { useFilterDropdownKeyboardThrottle } from './useFilterDropdownKeyboardThrottle'; +import { useFilterDropdownRovingFocus } from './useFilterDropdownRovingFocus'; + +/** @internal */ +export interface FilterDropdownItemsListHandle { + focusFirstItem: () => void; +} + +interface FilterDropdownItemsListProps { + /** Accessible label for the list container. */ + categoryLabel: string | undefined; + /** Filtered items to display. */ + items: FilterDropdownOption[]; + /** Moves keyboard focus back to the active category in the left panel. */ + onCategoryFocusBack: () => void; + /** Called when the user selects an item. */ + onItemToggle: (value: string) => void; + /** The current set of pending selected values, used to derive checked states. */ + pendingValues: string[]; + /** Ref exposing `focusFirstItem()` to the parent. */ + ref?: Ref; +} + +interface FilterDropdownMultiSelectListProps extends FilterDropdownItemsListProps { + /** Current search query — used to reset the roving focus index when search changes. */ + searchQuery: string; +} + +/** @internal */ +export function FilterDropdownMultiSelectList(props: Readonly) { + const { + categoryLabel, + items, + onCategoryFocusBack, + onItemToggle, + pendingValues, + ref, + searchQuery, + } = props; + const [activeItemIndex, setActiveItemIndex] = useState(0); + // Synchronous derived-state reset: when searchQuery changes, reset the roving focus index + // in the same render pass so no item is ever left without a valid tabIndex=0. + const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery); + if (prevSearchQuery !== searchQuery) { + setPrevSearchQuery(searchQuery); + setActiveItemIndex(0); + } + + const { register: itemRegister, focus: itemFocus } = useFilterDropdownRovingFocus(); + const allowItemKeyNav = useFilterDropdownKeyboardThrottle(); + + useImperativeHandle(ref, () => ({ + focusFirstItem: () => itemFocus(0), + })); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + onCategoryFocusBack(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (e.repeat && !allowItemKeyNav()) { + return; + } + if (e.key === 'ArrowDown') { + const nextIndex = findEnabledIndex(activeItemIndex, items, 1); + setActiveItemIndex(nextIndex); + itemFocus(nextIndex); + } else { + const previousIndex = findEnabledIndex(activeItemIndex, items, -1); + setActiveItemIndex(previousIndex); + itemFocus(previousIndex); + } + } + }, + [activeItemIndex, allowItemKeyNav, items, itemFocus, onCategoryFocusBack], + ); + + return ( + + {items.map((item, index) => ( + onItemToggle(item.value)} + prefix={item.prefix} + ref={itemRegister(index)} + suffix={item.suffix} + tabIndex={index === activeItemIndex ? 0 : -1} + value={item.value} + /> + ))} + + ); +} +FilterDropdownMultiSelectList.displayName = 'FilterDropdownMultiSelectList'; + +interface FilterDropdownSingleSelectListProps extends FilterDropdownItemsListProps { + /** The value of the currently selected item within this category. */ + currentValue: string; +} + +/** @internal */ +export function FilterDropdownSingleSelectList( + props: Readonly, +) { + const { + categoryLabel, + currentValue, + items, + onCategoryFocusBack, + onItemToggle, + pendingValues, + ref, + } = props; + const firstItemRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focusFirstItem: () => firstItemRef.current?.focus(), + })); + + const handleKeyDownCapture = useCallback( + (e: KeyboardEvent) => { + // Radix RadioGroup intercepts ALL four arrow keys (WAI-ARIA radio pattern). + // Use capture phase + stopPropagation so ArrowLeft never reaches Radix. + if (e.key === 'ArrowLeft') { + e.preventDefault(); + e.stopPropagation(); + onCategoryFocusBack(); + } + }, + [onCategoryFocusBack], + ); + + return ( + + {items.map((item, index) => ( + onItemToggle(item.value)} + prefix={item.prefix} + ref={index === 0 ? firstItemRef : undefined} + suffix={item.suffix} + value={item.value} + /> + ))} + + ); +} +FilterDropdownSingleSelectList.displayName = 'FilterDropdownSingleSelectList'; + +function findEnabledIndex( + activeItemIndex: number, + items: FilterDropdownOption[], + step: number, +): number { + let i = activeItemIndex + step; + while (i >= 0 && i < items.length) { + if (!items[i].isDisabled) { + return i; + } + i += step; + } + return activeItemIndex; +} diff --git a/src/components/filters/FilterDropdownRightPanel.tsx b/src/components/filters/FilterDropdownRightPanel.tsx new file mode 100644 index 000000000..0a485eb2b --- /dev/null +++ b/src/components/filters/FilterDropdownRightPanel.tsx @@ -0,0 +1,138 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { Ref, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { isDefined } from '~common/helpers/types'; +import { SearchInput } from '../search-input'; +import { Spinner } from '../spinner'; +import { + FilterDropdownItemsListHandle, + FilterDropdownMultiSelectList, + FilterDropdownSingleSelectList, +} from './FilterDropdownItemsList'; +import { StyledRightPanel, StyledSearchHeader, StyledSpinnerWrapper } from './FilterDropdownStyles'; +import { FilterDropdownCategory, FilterDropdownOption } from './FilterDropdownTypes'; + +/** @internal */ +export interface FilterDropdownRightPanelHandle { + focusFirstItem: () => void; +} + +interface FilterDropdownRightPanelProps { + /** The currently active category, whose items are displayed in this panel. */ + activeCategory: FilterDropdownCategory | undefined; + /** Items to display. `undefined` means the category is still loading. */ + items: FilterDropdownOption[] | undefined; + /** Moves keyboard focus back to the active category in the left panel. */ + onCategoryFocusBack: () => void; + /** Called when the user toggles an item's selection state. */ + onItemToggle: (value: string) => void; + /** The current set of pending selected values, used to derive checked states. */ + pendingValues: string[]; + /** Imperative handle ref, exposing `focusFirstItem()`. */ + ref?: Ref; +} + +/** @internal */ +export function FilterDropdownRightPanel(props: Readonly) { + const { activeCategory, onCategoryFocusBack, onItemToggle, pendingValues, ref, items } = props; + const { formatMessage } = useIntl(); + const [searchQuery, setSearchQuery] = useState(''); + const listRef = useRef(null); + + const isLoadingItems = !isDefined(items); + + // When onSearch is provided, the consumer handles filtering and updates items directly. + // Client-side filtering is only applied when isSearchable is true but onSearch is absent. + const filteredItems = + activeCategory?.isSearchable && !activeCategory?.onSearch + ? items?.filter( + (item) => !searchQuery || item.label.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : items; + + // For single-select, RadioGroup.Root needs the currently selected value within this category. + const activeCategoryCurrentValue = isDefined(items) + ? (items.find((i) => pendingValues.includes(i.value))?.value ?? '') + : ''; + + // Expose imperative handle to parent, allowing it to focus the first item in this panel. + useImperativeHandle(ref, () => ({ + focusFirstItem: () => listRef.current?.focusFirstItem(), + })); + + const handleSearchChange = useCallback( + (query: string) => { + setSearchQuery(query); + activeCategory?.onSearch?.(query); + }, + [activeCategory], + ); + + return ( + + {activeCategory?.isSearchable && ( + + + + )} + {isLoadingItems && ( + + + + )} + {!isLoadingItems && + (activeCategory?.isMultiSelect ? ( + + ) : ( + + ))} + + ); +} +FilterDropdownRightPanel.displayName = 'FilterDropdownRightPanel'; diff --git a/src/components/filters/FilterDropdownStyles.ts b/src/components/filters/FilterDropdownStyles.ts new file mode 100644 index 000000000..9f8b52fcf --- /dev/null +++ b/src/components/filters/FilterDropdownStyles.ts @@ -0,0 +1,107 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import * as RadixPopover from '@radix-ui/react-popover'; +import * as RadixRadioGroup from '@radix-ui/react-radio-group'; +import { cssVar } from '~utils/design-tokens'; +import { stylePopoverContentBase } from '../popover/PopoverStyles'; + +export const filterDropdownRowStyles = css` + border: none; + border-radius: ${cssVar('border-radius-400')}; + text-align: left; + width: 100%; +`; + +export const StyledFilterContent = styled(stylePopoverContentBase(RadixPopover.Content))` + display: flex; + flex-direction: column; + min-height: ${cssVar('filter-dropdown-sizes-min-height-default')}; + overflow: visible; + width: ${cssVar('sizes-overlays-width-default')}; +`; +StyledFilterContent.displayName = 'StyledFilterContent'; + +export const StyledFilterLayout = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +`; +StyledFilterLayout.displayName = 'StyledFilterLayout'; + +export const StyledLeftPanel = styled.div` + display: flex; + flex-direction: column; + gap: ${cssVar('dimension-space-50')}; + box-sizing: border-box; + flex-shrink: 0; + border-right: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; + padding: ${cssVar('dimension-space-100')}; + width: ${cssVar('filter-dropdown-sizes-left-panel-width-default')}; + overflow-y: auto; +`; +StyledLeftPanel.displayName = 'StyledLeftPanel'; + +export const StyledRightPanel = styled.div` + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +`; +StyledRightPanel.displayName = 'StyledRightPanel'; + +export const StyledSearchHeader = styled.div` + box-sizing: border-box; + padding: ${cssVar('dimension-space-150')}; +`; +StyledSearchHeader.displayName = 'StyledSearchHeader'; + +export const StyledItemsList = styled.div` + display: flex; + flex-direction: column; + gap: ${cssVar('dimension-space-50')}; + overflow-y: auto; + padding: ${cssVar('dimension-space-100')}; +`; +StyledItemsList.displayName = 'StyledItemsList'; + +export const StyledItemsListRadioGroup = StyledItemsList.withComponent(RadixRadioGroup.Root); +StyledItemsListRadioGroup.displayName = 'StyledItemsListRadioGroup'; + +export const StyledSpinnerWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 auto; +`; +StyledSpinnerWrapper.displayName = 'StyledSpinnerWrapper'; + +export const StyledFooter = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + box-sizing: border-box; + padding: ${cssVar('dimension-space-150')} ${cssVar('dimension-space-250')}; +`; +StyledFooter.displayName = 'StyledFooter'; diff --git a/src/components/filters/FilterDropdownTrigger.tsx b/src/components/filters/FilterDropdownTrigger.tsx new file mode 100644 index 000000000..e78b86241 --- /dev/null +++ b/src/components/filters/FilterDropdownTrigger.tsx @@ -0,0 +1,48 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { BadgeCounter, BadgeCounterVariety } from '../badges/BadgeCounter'; +import { Button } from '../buttons'; +import { ButtonAsButtonProps } from '../buttons/Button'; +import { IconChevronDown } from '../icons/IconChevronDown'; + +export interface FilterDropdownTriggerProps extends Omit { + /** Number of active filter selections; shows an accent badge when greater than 0. */ + selectedCount: number; +} + +export function FilterDropdownTrigger(props: Readonly) { + const { children, selectedCount, ...restProps } = props; + return ( + + ); +} +FilterDropdownTrigger.displayName = 'FilterDropdownTrigger'; diff --git a/src/components/filters/FilterDropdownTypes.ts b/src/components/filters/FilterDropdownTypes.ts new file mode 100644 index 000000000..e12b5b6ab --- /dev/null +++ b/src/components/filters/FilterDropdownTypes.ts @@ -0,0 +1,177 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { ReactNode, Ref } from 'react'; +import { TextNodeOptional } from '~types/utils'; + +/** + * A single selectable option within a FilterDropdown category. + */ +export interface FilterDropdownOption { + /** + * Whether the option is disabled and cannot be selected. + * @defaultValue false + */ + isDisabled?: boolean; + /** + * Display label for the option. + */ + label: string; + /** + * Optional icon or element shown between the checkbox/radio indicator and the label. + */ + prefix?: ReactNode; + /** + * Optional suffix shown to the right of the label. + */ + suffix?: TextNodeOptional; + /** + * Unique string value identifying this option. + */ + value: string; +} + +/** + * A category shown in the left panel of a FilterDropdown. + * Each category has its own list of items, multi-select mode, and optional search. + */ +export interface FilterDropdownCategory { + /** + * Whether items in this category support multi-selection (checkboxes). + * When false, only one item can be selected at a time (radio buttons). + * @defaultValue false + */ + isMultiSelect?: boolean; + /** + * Whether to show a search input above the items list for this category. + * @defaultValue false + */ + isSearchable?: boolean; + /** + * Items to display for this category. + * When `undefined`, the right panel shows a loading state and `onCategorySelect` is fired. + */ + items?: FilterDropdownOption[]; + /** + * Display label for this category. + */ + label: string; + /** + * Placeholder text for the search input of this category. + * @defaultValue "Search" + */ + labelSearchPlaceholder?: string; + /** + * Called every time the search query changes for this category. + * When provided, the component skips its built-in client-side filtering and + * delegates search entirely to the consumer. The consumer is expected to update + * `items` in response (e.g. after an async network call). + * /!\ This callback is NOT debounced — implement debouncing in the parent component if needed. + */ + onSearch?: (query: string) => void; +} + +/** + * Props for the FilterDropdown component. + */ +export interface FilterDropdownProps { + /** + * Categories to display in the left panel. + * Each category shows its items in the right panel when active. + */ + categories: FilterDropdownCategory[]; + /** + * The trigger element. Wrapped with Radix Popover.Trigger using asChild. + * The trigger button and its badge counter are the consumer's responsibility. + */ + children: ReactNode; + /** + * Additional CSS class name applied to the popover content container. + */ + className?: string; + /** + * Controls the open state externally. + * When omitted, the component manages its own open/closed state. + * + * **Controlled mode**: when `isOpen` is provided, the component cannot close itself — the + * consumer must flip `isOpen` to `false` in response to `onClose` (and to `true` in response + * to `onOpen`) for the popover to open and close correctly. + */ + isOpen?: boolean; + /** + * Label for the Apply button. + * @defaultValue "Apply filters" + */ + labelApply?: string; + /** + * Accessible label for the categories listbox (left panel). + * @defaultValue "Categories" + */ + labelCategories?: string; + /** + * Label for the Clear button. + * @defaultValue "Clear filters" + */ + labelClear?: string; + /** + * Accessible label for the filter popover dialog. + * @defaultValue "Filters" + */ + labelFilters?: string; + /** + * Called when the user clicks Apply. + * Receives the full set of pending selected values at the time of confirmation. + * When omitted, the Apply button is not rendered and the consumer should use + * `onItemSelect` to react to selection changes immediately. + */ + onApply?: (values: string[]) => void; + /** + * Called every time the active category changes, including on initial open. + * Use this to load or refresh items for the newly active category. + */ + onCategorySelect?: (categoryLabel: string) => void; + /** + * Called when the user clicks Clear. + */ + onClear: () => void; + /** + * Called when the popover closes (whether by Apply, Clear, or Escape/outside click). + */ + onClose?: () => void; + /** + * Called immediately after every item toggle with the full current pending selection. + * Useful when `onApply` is omitted and the consumer wants to react to changes without + * waiting for an explicit Apply action. + */ + onItemSelect?: (values: string[]) => void; + /** + * Called when the popover opens. + */ + onOpen?: () => void; + /** + * Ref forwarded to the popover content element. + */ + ref?: Ref; + /** + * The currently committed selected values. + * Used to initialize the pending selection state each time the popover opens. + */ + selectedValues?: string[]; +} diff --git a/src/components/filters/__tests__/FilterDropdown-test.tsx b/src/components/filters/__tests__/FilterDropdown-test.tsx new file mode 100644 index 000000000..a094f64df --- /dev/null +++ b/src/components/filters/__tests__/FilterDropdown-test.tsx @@ -0,0 +1,327 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { screen } from '@testing-library/react'; +import { render } from '~common/helpers/test-utils'; +import { FilterDropdown, FilterDropdownCategory, FilterDropdownProps } from '..'; + +const CATEGORIES: FilterDropdownCategory[] = [ + { + isMultiSelect: true, + label: 'Severity', + items: [ + { label: 'High', value: 'high' }, + { label: 'Medium', value: 'medium' }, + { label: 'Low', value: 'low' }, + ], + }, + { + isMultiSelect: true, + label: 'Type', + items: [ + { label: 'Bug', value: 'bug' }, + { label: 'Vulnerability', value: 'vulnerability' }, + ], + }, +]; + +describe('FilterDropdown', () => { + it('opens the popover and displays categories and items', async () => { + const { user } = renderFilterDropdown(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /severity/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /type/i })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'High' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Medium' })).toBeInTheDocument(); + }); + + it('switches items when a different category is clicked', async () => { + const { user } = renderFilterDropdown(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + expect(screen.getByRole('checkbox', { name: 'High' })).toBeInTheDocument(); + + await user.click(screen.getByRole('option', { name: /type/i })); + expect(screen.getByRole('checkbox', { name: 'Bug' })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: 'High' })).not.toBeInTheDocument(); + }); + + it('fires onCategorySelect on open and on category switch', async () => { + const onCategorySelect = jest.fn(); + const { user } = renderFilterDropdown({ onCategorySelect }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + expect(onCategorySelect).toHaveBeenCalledWith('Severity'); + + await user.click(screen.getByRole('option', { name: /type/i })); + expect(onCategorySelect).toHaveBeenCalledWith('Type'); + + await user.click(screen.getByRole('option', { name: /type/i })); + expect(onCategorySelect).toHaveBeenCalledTimes(2); + }); + + it('toggles a multi-select item on click', async () => { + const { user } = renderFilterDropdown(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + const checkbox = screen.getByRole('checkbox', { name: 'High' }); + + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + expect(checkbox).toBeChecked(); + await user.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it('calls onApply with selected values when Apply is clicked', async () => { + const onApply = jest.fn(); + const { user } = renderFilterDropdown({ onApply }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.click(screen.getByRole('checkbox', { name: 'High' })); + await user.click(screen.getByRole('checkbox', { name: 'Medium' })); + await user.click(screen.getByRole('button', { name: 'Apply filters' })); + + expect(onApply).toHaveBeenCalledWith(['high', 'medium']); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('calls onClear and closes on Clear click', async () => { + const onClear = jest.fn(); + const { user } = renderFilterDropdown({ onClear, selectedValues: ['high'] }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + + expect(onClear).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('discards pending selection when closed without applying', async () => { + const onApply = jest.fn(); + const { user } = renderFilterDropdown({ onApply }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.click(screen.getByRole('checkbox', { name: 'High' })); + await user.keyboard('{Escape}'); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + expect(screen.getByRole('checkbox', { name: 'High' })).not.toBeChecked(); + expect(onApply).not.toHaveBeenCalled(); + }); + + it('initializes checked items from selectedValues on open', async () => { + const { user } = renderFilterDropdown({ selectedValues: ['medium'] }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.getByRole('checkbox', { name: 'Medium' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'High' })).not.toBeChecked(); + }); + + it('shows a loading spinner when category items have never been loaded', async () => { + const { user } = renderFilterDropdown({ + categories: [{ label: 'Severity', items: undefined }], + }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('shows a selection count badge on a category with pending selections', async () => { + const { user } = renderFilterDropdown({ selectedValues: ['high', 'medium'] }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('supports single-select mode — selecting one item deselects others in the category', async () => { + const onApply = jest.fn(); + const { user } = renderFilterDropdown({ + categories: [ + { + isMultiSelect: false, + label: 'Severity', + items: [ + { label: 'High', value: 'high' }, + { label: 'Low', value: 'low' }, + ], + }, + ], + onApply, + }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.click(screen.getByRole('radio', { name: 'High' })); + await user.click(screen.getByRole('radio', { name: 'Low' })); + await user.click(screen.getByRole('button', { name: 'Apply filters' })); + + expect(onApply).toHaveBeenCalledWith(['low']); + }); + + it('focuses the first category button when the popover opens', async () => { + const { user } = renderFilterDropdown(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.getByRole('option', { name: /severity/i })).toHaveFocus(); + }); + + it('navigates categories with ArrowDown/ArrowUp, moving focus and firing onCategorySelect', async () => { + const onCategorySelect = jest.fn(); + const { user } = renderFilterDropdown({ onCategorySelect }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + onCategorySelect.mockClear(); + await user.keyboard('{ArrowDown}'); + expect(onCategorySelect).toHaveBeenCalledWith('Type'); + expect(screen.getByRole('option', { name: /type/i })).toHaveFocus(); + + onCategorySelect.mockClear(); + await user.keyboard('{ArrowUp}'); + expect(onCategorySelect).toHaveBeenCalledWith('Severity'); + expect(screen.getByRole('option', { name: /severity/i })).toHaveFocus(); + }); + + it('moves focus to the first item when pressing ArrowRight or Enter in the categories panel', async () => { + const { user } = renderFilterDropdown(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('checkbox', { name: 'High' })).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{Enter}'); + expect(screen.getByRole('checkbox', { name: 'High' })).toHaveFocus(); + }); + + it('moves focus between checkbox items with ArrowDown/ArrowUp without changing selection', async () => { + const { user } = renderFilterDropdown(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('checkbox', { name: 'High' })).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('checkbox', { name: 'Medium' })).toHaveFocus(); + expect(screen.getByRole('checkbox', { name: 'High' })).not.toBeChecked(); + + await user.keyboard('{ArrowUp}'); + expect(screen.getByRole('checkbox', { name: 'High' })).toHaveFocus(); + }); + + it.each([ + { description: 'multi-select (checkbox)', isMultiSelect: true, itemRole: 'checkbox' }, + { description: 'single-select (radio)', isMultiSelect: false, itemRole: 'radio' }, + ])( + 'returns focus to the active category when pressing ArrowLeft from a $description item', + async ({ isMultiSelect, itemRole }) => { + const { user } = renderFilterDropdown({ + categories: [ + { + isMultiSelect, + label: 'Severity', + items: [ + { label: 'High', value: 'high' }, + { label: 'Low', value: 'low' }, + ], + }, + ], + }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole(itemRole, { name: 'High' })).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + expect(screen.getByRole('option', { name: /severity/i })).toHaveFocus(); + }, + ); + + it('filters items when searching', async () => { + const { user } = renderFilterDropdown({ + categories: [ + { + isMultiSelect: true, + isSearchable: true, + label: 'Severity', + items: [ + { label: 'High', value: 'high' }, + { label: 'Medium', value: 'medium' }, + { label: 'Low', value: 'low' }, + ], + }, + ], + }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.type(screen.getByRole('searchbox'), 'hi'); + + expect(screen.getByRole('checkbox', { name: 'High' })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: 'Medium' })).not.toBeInTheDocument(); + }); + + it('does not render the Apply button when onApply is not provided', async () => { + const { user } = renderFilterDropdown({ onApply: undefined }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + expect(screen.queryByRole('button', { name: 'Apply filters' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear filters' })).toBeInTheDocument(); + }); + + it('calls onItemSelect with updated pending values on each item toggle', async () => { + const onItemSelect = jest.fn(); + const { user } = renderFilterDropdown({ onItemSelect }); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + await user.click(screen.getByRole('checkbox', { name: 'High' })); + + expect(onItemSelect).toHaveBeenCalledWith(['high']); + + await user.click(screen.getByRole('checkbox', { name: 'Medium' })); + + expect(onItemSelect).toHaveBeenCalledWith(['high', 'medium']); + }); + + it('passes the accessibility check', async () => { + const { container, user } = renderFilterDropdown(); + + await user.click(screen.getByRole('button', { name: 'Filters' })); + + await expect(container).toHaveNoA11yViolations(); + }); +}); + +function renderFilterDropdown(props: Partial = {}) { + return render( + + + , + ); +} diff --git a/src/components/filters/index.ts b/src/components/filters/index.ts index b9f7fe9d2..427c3a3b8 100644 --- a/src/components/filters/index.ts +++ b/src/components/filters/index.ts @@ -18,6 +18,18 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +export { FilterDropdown } from './FilterDropdown'; + +export type { + FilterDropdownCategory, + FilterDropdownOption, + FilterDropdownProps, +} from './FilterDropdownTypes'; + +export { FilterDropdownTrigger } from './FilterDropdownTrigger'; + +export type { FilterDropdownTriggerProps } from './FilterDropdownTrigger'; + export { FilterTag } from './FilterTag'; export type { FilterTagProps } from './FilterTag'; diff --git a/src/components/filters/useFilterDropdownKeyboardThrottle.ts b/src/components/filters/useFilterDropdownKeyboardThrottle.ts new file mode 100644 index 000000000..58729c7fe --- /dev/null +++ b/src/components/filters/useFilterDropdownKeyboardThrottle.ts @@ -0,0 +1,40 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { useCallback, useRef } from 'react'; +import { KEYBOARD_THROTTLE_MS } from '~common/helpers/constants'; + +/** + * @internal + * Returns a stable gate function. Call it before processing a key-repeat navigation event: + * returns `true` (and records the timestamp) when enough time has elapsed since the last + * allowed event, `false` when the event should be dropped. + */ +export function useFilterDropdownKeyboardThrottle() { + const lastFiredRef = useRef(0); + return useCallback(() => { + const now = Date.now(); + if (now - lastFiredRef.current < KEYBOARD_THROTTLE_MS) { + return false; + } + lastFiredRef.current = now; + return true; + }, []); +} diff --git a/src/components/filters/useFilterDropdownRovingFocus.ts b/src/components/filters/useFilterDropdownRovingFocus.ts new file mode 100644 index 000000000..ea296ee03 --- /dev/null +++ b/src/components/filters/useFilterDropdownRovingFocus.ts @@ -0,0 +1,55 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { useCallback, useRef } from 'react'; + +/** + * @internal + * Feature-local roving-tabindex helper for FilterDropdown's flat vertical lists. Similar implementation as radix-ui. + * + * Returns: + * - `register(index)` — a stable ref-callback to attach to each row. Caches one callback + * per index so React does not unnecessarily null-and-re-register elements across renders. + * The slot is set to null when the element unmounts (React 19 null-on-detach safe). + * - `focus(index)` — imperatively moves focus to the registered element at `index`. + */ +export function useFilterDropdownRovingFocus() { + const itemsRef = useRef<(HTMLButtonElement | null)[]>([]); + // One cached callback per index so React gets a stable reference across re-renders, + // avoiding unnecessary null → element → null detach/attach cycles. + const callbacksRef = useRef<((el: HTMLButtonElement | null) => void)[]>([]); + + const register = useCallback((index: number) => { + let cb = callbacksRef.current[index]; + if (cb === undefined) { + cb = (el: HTMLButtonElement | null) => { + itemsRef.current[index] = el; + }; + callbacksRef.current[index] = cb; + } + return cb; + }, []); + + const focus = useCallback((index: number) => { + itemsRef.current[index]?.focus(); + }, []); + + return { focus, register }; +} diff --git a/src/components/popover/Popover.tsx b/src/components/popover/Popover.tsx index 8f8a53887..eae19eb67 100644 --- a/src/components/popover/Popover.tsx +++ b/src/components/popover/Popover.tsx @@ -18,15 +18,20 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import styled from '@emotion/styled'; import * as RadixPopover from '@radix-ui/react-popover'; import { ReactElement, ReactNode, forwardRef, useContext } from 'react'; import { isDefined } from '~common/helpers/types'; import { TextNodeOptional } from '~types/utils'; import { THEME_DATA_ATTRIBUTE, ThemeContext } from '~utils/theme'; import { Heading, HeadingSize, Text } from '../typography'; - -import { cssVar } from '~utils/design-tokens'; +import { + OVERLAY_ARROW_PADDING, + OVERLAY_SIDE_OFFSET, + PopoverArrow, + PopoverContent, + PopoverExtraContent, + PopoverFooter, +} from './PopoverStyles'; export enum PopoverAlign { Start = 'start', @@ -53,12 +58,6 @@ export interface PopoverProps { title?: TextNodeOptional; } -/* This is the distance between the point of the arrow and the trigger */ -const POPOVER_OFFSET = 4; - -/* This is the padding between the edge of the tooltip and the arrow. */ -const ARROW_PADDING = 16; - /** * **Popovers must be attached to a button to be accessible.** * @@ -100,10 +99,10 @@ export const Popover = forwardRef((props, ref) + sideOffset={OVERLAY_SIDE_OFFSET}> {title && ( {title} @@ -123,43 +122,3 @@ export const Popover = forwardRef((props, ref) }); Popover.displayName = 'Popover'; - -const PopoverExtraContent = styled.div` - margin-top: ${cssVar('dimension-space-200')}; -`; - -const PopoverFooter = styled.div` - margin-top: ${cssVar('dimension-space-200')}; -`; - -const PopoverContent = styled(RadixPopover.Content)` - border: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; - border-radius: ${cssVar('border-radius-400')}; - padding: ${cssVar('dimension-space-300')} ${cssVar('dimension-space-250')}; - background-color: ${cssVar('color-surface-default')}; - box-shadow: ${cssVar('box-shadow-large')}; - - box-sizing: border-box; - max-width: ${cssVar('dimension-width-5000')}; - max-height: ${cssVar('sizes-overlays-max-height-default')}; - overflow-y: auto; - - // We are in a modal context, so we don't want to display the focus ring - &:focus, - &:focus-visible { - outline: none; - } -`; - -const PopoverArrow = styled(RadixPopover.Arrow)` - stroke: ${cssVar('color-border-weak')}; - fill: ${cssVar('color-surface-default')}; - height: 9px; - width: 15px; - - /* overlap the border by moving the arrow down over the box border and - * clipping it so its own borders don't overlap the box content - */ - clip-path: inset(0.9px 0 0 0); - margin-top: -2px; -`; diff --git a/src/components/popover/PopoverStyles.tsx b/src/components/popover/PopoverStyles.tsx new file mode 100644 index 000000000..f9c0f99b4 --- /dev/null +++ b/src/components/popover/PopoverStyles.tsx @@ -0,0 +1,76 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import styled from '@emotion/styled'; +import * as RadixPopover from '@radix-ui/react-popover'; +import { cssVar } from '~utils/design-tokens'; + +/** Distance between the arrow tip and the trigger element. */ +export const OVERLAY_SIDE_OFFSET = 4; + +/** Minimum distance between the arrow and the rounded corner of the content box. */ +export const OVERLAY_ARROW_PADDING = 16; + +/** + * Base styles shared by light-surface overlay content boxes (Popover, FilterDropdown). + * Apply inside a styled component template alongside component-specific overrides. + */ +export const stylePopoverContentBase = styled.div` + border: ${cssVar('border-width-default')} solid ${cssVar('color-border-weak')}; + border-radius: ${cssVar('border-radius-400')}; + background-color: ${cssVar('color-surface-default')}; + box-shadow: ${cssVar('box-shadow-large')}; + + box-sizing: border-box; + max-height: ${cssVar('sizes-overlays-max-height-default')}; + + &:focus, + &:focus-visible { + outline: none; + } +`.withComponent; + +export const PopoverContent = styled(stylePopoverContentBase(RadixPopover.Content))` + padding: ${cssVar('dimension-space-300')} ${cssVar('dimension-space-250')}; + max-width: ${cssVar('dimension-width-5000')}; + overflow-y: auto; +`; + +PopoverContent.displayName = 'PopoverContent'; + +export const PopoverArrow = styled(RadixPopover.Arrow)` + clip-path: inset(0.9px 0 0 0); + fill: ${cssVar('color-surface-default')}; + height: 9px; + margin-top: -2px; + stroke: ${cssVar('color-border-weak')}; + width: 15px; +`; +PopoverArrow.displayName = 'PopoverArrow'; + +export const PopoverExtraContent = styled.div` + margin-top: ${cssVar('dimension-space-200')}; +`; +PopoverExtraContent.displayName = 'PopoverExtraContent'; + +export const PopoverFooter = styled.div` + margin-top: ${cssVar('dimension-space-200')}; +`; +PopoverFooter.displayName = 'PopoverFooter'; diff --git a/src/components/radio-button-group/RadioButton.tsx b/src/components/radio-button-group/RadioButton.tsx index 07048faff..14d2f1afc 100644 --- a/src/components/radio-button-group/RadioButton.tsx +++ b/src/components/radio-button-group/RadioButton.tsx @@ -18,13 +18,16 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import styled from '@emotion/styled'; -import * as RadioGroup from '@radix-ui/react-radio-group'; -import { HelperText, Label } from '../typography'; +import { + RadioButtonInput, + RadioButtonLabelWrapper, + RadioButtonOptionHelpText, + RadioButtonOptionLabel, + RadioButtonOptionWrapper, + RadioButtonSelectionIndicator, +} from './RadioButtonStyles'; import { RadioOption } from './RadioButtonTypes'; -import { cssVar } from '~utils/design-tokens'; - interface InternalRadioButtonProps { groupId: string; hasError?: boolean; @@ -57,7 +60,7 @@ export function RadioButton(props: Readonly) { value={value}> - {label} + {label} {helpText && ( @@ -69,112 +72,3 @@ export function RadioButton(props: Readonly) { } RadioButton.displayName = 'RadioButton'; - -const RadioButtonOptionWrapper = styled.div` - display: flex; - flex-direction: column; -`; -RadioButtonOptionWrapper.displayName = 'RadioButtonOptionWrapper'; - -const RadioButtonInput = styled(RadioGroup.Item)` - appearance: none; - cursor: pointer; - background-color: ${cssVar('color-background-utility-transparent')}; - - box-sizing: border-box; - padding: 0; - border: ${cssVar('border-width-default')} solid ${cssVar('color-border-bolder')}; - border-radius: ${cssVar('border-radius-full')}; - - height: ${cssVar('dimension-height-400')}; - width: ${cssVar('dimension-width-200')}; - min-width: ${cssVar('dimension-width-200')}; - - &:hover { - background-color: ${cssVar('color-surface-hover')}; - } - - &[data-error='true'] { - border: ${cssVar('border-width-default')} solid ${cssVar('color-border-danger-default')}; - } - - &[data-state='checked'] { - background-color: ${cssVar('color-background-selected-bold-default')}; - border-color: ${cssVar('color-background-selected-bold-default')}; - - &:not(:disabled):hover { - background-color: ${cssVar('color-background-selected-bold-hover')}; - border-color: ${cssVar('color-background-selected-bold-hover')}; - } - } - - &:focus, - &:focus-visible { - outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')}; - outline-offset: ${cssVar('focus-border-offset-default')}; - } - - &:disabled { - cursor: default; - background-color: ${cssVar('color-surface-disabled')}; - border-color: ${cssVar('color-border-disabled')}; - } -`; -RadioButtonInput.displayName = 'RadioButtonInput'; - -const RadioButtonSelectionIndicator = styled(RadioGroup.Indicator)` - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - position: relative; - - &::after { - content: ''; - display: block; - - box-sizing: border-box; - height: ${cssVar('dimension-height-150')}; - width: ${cssVar('dimension-width-75')}; - border-radius: ${cssVar('border-radius-full')}; - - background-color: ${cssVar('color-icon-on-color')}; - } - - &[data-disabled]::after { - background-color: ${cssVar('color-icon-disabled')}; - } -`; -RadioButtonSelectionIndicator.displayName = 'RadioButtonSelectionIndicator'; - -const RadioButtonLabelWrapper = styled.span` - display: inline-flex; - gap: ${cssVar('dimension-space-100')}; - align-items: center; -`; -RadioButtonLabelWrapper.displayName = 'RadioButtonLabelWrapper'; - -const RadoiButtonOptionLabel = styled(Label)` - font: ${cssVar('typography-others-label-medium')}; - - display: block; - width: 100%; - - cursor: pointer; - - [data-disabled] > & { - color: ${cssVar('color-text-disabled')}; - cursor: default; - } -`; -RadoiButtonOptionLabel.displayName = 'OptionLabel'; - -const RadioButtonOptionHelpText = styled(HelperText)` - margin-left: calc(${cssVar('dimension-space-200')} + ${cssVar('dimension-space-100')}); - - &[data-disabled] { - color: ${cssVar('color-text-disabled')}; - } -`; -RadioButtonOptionHelpText.displayName = 'RadioButtonOptionHelpText'; diff --git a/src/components/radio-button-group/RadioButtonStyles.tsx b/src/components/radio-button-group/RadioButtonStyles.tsx new file mode 100644 index 000000000..d134e64f4 --- /dev/null +++ b/src/components/radio-button-group/RadioButtonStyles.tsx @@ -0,0 +1,135 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import styled from '@emotion/styled'; +import * as RadioGroup from '@radix-ui/react-radio-group'; +import { cssVar } from '~utils/design-tokens'; +import { HelperText, Label } from '../typography'; + +export const RadioButtonOptionWrapper = styled.div` + display: flex; + flex-direction: column; +`; +RadioButtonOptionWrapper.displayName = 'RadioButtonOptionWrapper'; + +export const styleRadioButtonInputBase = styled.div` + appearance: none; + cursor: pointer; + background-color: ${cssVar('color-background-utility-transparent')}; + + box-sizing: border-box; + padding: 0; + border: ${cssVar('border-width-default')} solid ${cssVar('color-border-bolder')}; + border-radius: ${cssVar('border-radius-full')}; + + height: ${cssVar('dimension-height-400')}; + width: ${cssVar('dimension-width-200')}; + min-width: ${cssVar('dimension-width-200')}; +`.withComponent; + +export const RadioButtonInput = styled(styleRadioButtonInputBase(RadioGroup.Item))` + &:hover { + background-color: ${cssVar('color-surface-hover')}; + } + + &[data-error='true'] { + border: ${cssVar('border-width-default')} solid ${cssVar('color-border-danger-default')}; + } + + &[data-state='checked'] { + background-color: ${cssVar('color-background-selected-bold-default')}; + border-color: ${cssVar('color-background-selected-bold-default')}; + + &:not(:disabled):hover { + background-color: ${cssVar('color-background-selected-bold-hover')}; + border-color: ${cssVar('color-background-selected-bold-hover')}; + } + } + + &:focus, + &:focus-visible { + outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')}; + outline-offset: ${cssVar('focus-border-offset-default')}; + } + + &:disabled { + cursor: default; + background-color: ${cssVar('color-surface-disabled')}; + border-color: ${cssVar('color-border-disabled')}; + } +`; +RadioButtonInput.displayName = 'RadioButtonInput'; + +export const RadioButtonSelectionIndicator = styled(RadioGroup.Indicator)` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: relative; + + &::after { + content: ''; + display: block; + + box-sizing: border-box; + height: ${cssVar('dimension-height-150')}; + width: ${cssVar('dimension-width-75')}; + border-radius: ${cssVar('border-radius-full')}; + + background-color: ${cssVar('color-icon-on-color')}; + } + + &[data-disabled]::after { + background-color: ${cssVar('color-icon-disabled')}; + } +`; +RadioButtonSelectionIndicator.displayName = 'RadioButtonSelectionIndicator'; + +export const RadioButtonLabelWrapper = styled.span` + display: inline-flex; + gap: ${cssVar('dimension-space-100')}; + align-items: center; +`; +RadioButtonLabelWrapper.displayName = 'RadioButtonLabelWrapper'; + +export const RadioButtonOptionLabel = styled(Label)` + font: ${cssVar('typography-others-label-medium')}; + + display: block; + width: 100%; + + cursor: pointer; + + [data-disabled] > & { + color: ${cssVar('color-text-disabled')}; + cursor: default; + } +`; +RadioButtonOptionLabel.displayName = 'RadioButtonOptionLabel'; + +export const RadioButtonOptionHelpText = styled(HelperText)` + margin-left: calc(${cssVar('dimension-space-200')} + ${cssVar('dimension-space-100')}); + + &[data-disabled] { + color: ${cssVar('color-text-disabled')}; + } +`; +RadioButtonOptionHelpText.displayName = 'RadioButtonOptionHelpText'; diff --git a/src/generated/design-tokens-base.css b/src/generated/design-tokens-base.css index 33c8d6721..237c04e49 100644 --- a/src/generated/design-tokens-base.css +++ b/src/generated/design-tokens-base.css @@ -123,6 +123,8 @@ --echoes-banner-sizes-height: 2.75rem; --echoes-badge-sizes-height-small: 1.25rem; --echoes-badge-sizes-height-medium: 1.75rem; + --echoes-filter-dropdown-sizes-left-panel-width-default: 277px; + --echoes-filter-dropdown-sizes-min-height-default: 350px; --echoes-filter-tag-sizes-max-width-default: 200px; --echoes-form-control-border-radius-default: 8px; --echoes-form-control-sizes-height-default: 2.25rem; diff --git a/src/generated/design-tokens-base.json b/src/generated/design-tokens-base.json index f0d5f2422..cf74ad1b8 100644 --- a/src/generated/design-tokens-base.json +++ b/src/generated/design-tokens-base.json @@ -101,6 +101,8 @@ "echoes-banner-sizes-height": "2.75rem", "echoes-badge-sizes-height-small": "1.25rem", "echoes-badge-sizes-height-medium": "1.75rem", + "echoes-filter-dropdown-sizes-left-panel-width-default": "277px", + "echoes-filter-dropdown-sizes-min-height-default": "350px", "echoes-filter-tag-sizes-max-width-default": "200px", "echoes-form-control-border-radius-default": "8px", "echoes-form-control-sizes-height-default": "2.25rem", diff --git a/stories/filters/FilterDropdown-stories.tsx b/stories/filters/FilterDropdown-stories.tsx new file mode 100644 index 000000000..b0f51eb9f --- /dev/null +++ b/stories/filters/FilterDropdown-stories.tsx @@ -0,0 +1,194 @@ +/* + * Echoes React + * Copyright (C) 2023-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useCallback, useMemo, useState } from 'react'; +import { + FilterDropdown, + FilterDropdownCategory, + FilterDropdownProps, + FilterDropdownTrigger, +} from '../../src'; +import { basicWrapperDecorator } from '../helpers/BasicWrapper'; + +const meta: Meta = { + component: FilterDropdown, + decorators: [basicWrapperDecorator], + argTypes: { + categories: { control: false }, + children: { control: false }, + onApply: { control: false }, + onCategorySelect: { control: false }, + onClear: { control: false }, + onClose: { control: false }, + onItemSelect: { control: false }, + onOpen: { control: false }, + ref: { control: false }, + selectedValues: { control: false }, + }, + title: 'Echoes/Filters/FilterDropdown', +}; + +export default meta; + +type Story = StoryObj; + +const SEVERITY_ITEMS = [ + { label: 'High', value: 'high', suffix: '42' }, + { label: 'Medium', value: 'medium', suffix: '17' }, + { label: 'Low', value: 'low', suffix: '8' }, + { label: 'Info', value: 'info', suffix: '3' }, +]; + +const TYPE_ITEMS = [ + { label: 'Bug', value: 'bug', suffix: '24' }, + { label: 'Vulnerability', value: 'vulnerability', suffix: '12' }, + { label: 'Code Smell', value: 'code-smell', suffix: '34' }, +]; + +const STATUS_ITEMS = [ + { label: 'Open', value: 'open', suffix: '55' }, + { label: 'Confirmed', value: 'confirmed', suffix: '11' }, + { label: 'Resolved', value: 'resolved', suffix: '4' }, +]; + +const ALL_ASSIGNEE_ITEMS = [ + { label: 'Alice Martin', value: 'alice' }, + { label: 'Bob Johnson', value: 'bob' }, + { label: 'Carol Williams', value: 'carol' }, + { label: 'David Brown', value: 'david' }, + { label: 'Eve Davis', value: 'eve' }, + { label: 'Frank Miller', value: 'frank' }, + { label: 'Grace Wilson', value: 'grace' }, + { label: 'Hank Moore', value: 'hank' }, + { label: 'Iris Taylor', value: 'iris' }, + { label: 'Jack Anderson', value: 'jack' }, +]; + +function randomDelay(minMs: number, maxMs: number) { + return Math.random() * (maxMs - minMs) + minMs; +} + +function withRandomCounts( + items: T[], +): (T & { suffix: string })[] { + return items.map((item) => ({ + ...item, + suffix: (Math.floor(Math.random() * 99) + 1).toString(), + })); +} + +/** + * Builds the shared category set used by all stories: + * - Severity: multi-select, sync items + * - Type: single-select, sync items + * - Status: multi-select, client-side search, sync items + * - Assignee: multi-select, async items, server-side search + */ +function useFilterDropdownCategories() { + const [assigneeItems, setAssigneeItems] = useState(undefined); + + const handleCategorySelect = useCallback( + (label: string) => { + // Only trigger the async load on the first visit; subsequent visits show cached items. + if (label === 'Assignee' && assigneeItems === undefined) { + setTimeout( + () => setAssigneeItems(withRandomCounts(ALL_ASSIGNEE_ITEMS)), + randomDelay(1000, 3000), + ); + } + }, + [assigneeItems], + ); + + // Simulate a server-side search: keep current items visible while fetching, + // then replace with filtered results once resolved. + const handleAssigneeSearch = useCallback((query: string) => { + setTimeout(() => { + const matched = ALL_ASSIGNEE_ITEMS.filter( + (item) => !query || item.label.toLowerCase().includes(query.toLowerCase()), + ); + setAssigneeItems(withRandomCounts(matched)); + }, 500); + }, []); + + const categories = useMemo( + () => [ + { isMultiSelect: true, label: 'Severity', items: SEVERITY_ITEMS }, + { isMultiSelect: false, label: 'Type', items: TYPE_ITEMS }, + { isMultiSelect: true, isSearchable: true, label: 'Status', items: STATUS_ITEMS }, + { + isMultiSelect: true, + isSearchable: true, + items: assigneeItems, + label: 'Assignee', + labelSearchPlaceholder: 'Search assignees…', + onSearch: handleAssigneeSearch, + }, + ], + [assigneeItems, handleAssigneeSearch], + ); + + return { categories, onCategorySelect: handleCategorySelect }; +} + +function FilterDropdownStory(props: Readonly) { + const [selectedValues, setSelectedValues] = useState([]); + const { categories, onCategorySelect } = useFilterDropdownCategories(); + + return ( + setSelectedValues([])} + selectedValues={selectedValues}> + Filters + + ); +} + +export const Default: Story = { + render: (args) => , +}; + +function ImmediateSelectStory(props: Readonly) { + const [selectedValues, setSelectedValues] = useState([]); + const { categories, onCategorySelect } = useFilterDropdownCategories(); + + return ( + setSelectedValues([])} + onItemSelect={setSelectedValues} + selectedValues={selectedValues}> + Filters + + ); +} + +// Demonstrates the pattern where onApply is omitted and selections take effect immediately. +export const ImmediateSelect: Story = { + render: (args) => , +};