diff --git a/packages/assets/src/scss/inputs/_dropdown.scss b/packages/assets/src/scss/inputs/_dropdown.scss index 403edff9..52d1641b 100644 --- a/packages/assets/src/scss/inputs/_dropdown.scss +++ b/packages/assets/src/scss/inputs/_dropdown.scss @@ -203,7 +203,8 @@ $item-gap: calculateRem(4px); } } - &--multi { + &--multi, + &--filter { #{$self} { &__widget.ids-input:hover:has(.ids-chip__delete:hover) { border-color: var(--ids-input-default-border-color, #{$color-neutral-80}); diff --git a/packages/assets/src/scss/inputs/_filter-dropdown.scss b/packages/assets/src/scss/inputs/_filter-dropdown.scss new file mode 100644 index 00000000..d4ff5a5f --- /dev/null +++ b/packages/assets/src/scss/inputs/_filter-dropdown.scss @@ -0,0 +1,132 @@ +@use '../functions' as *; +@use '../variables' as *; + +$trigger-height: calculateRem(40px); +$trigger-height-compact: calculateRem(32px); + +.ids-dropdown--filter { + $self: '.ids-dropdown'; + + display: inline-block; + + #{$self}__trigger { + display: inline-flex; + align-items: center; + gap: calculateRem(8px); + height: $trigger-height; + padding: 0 calculateRem(12px) 0 calculateRem(16px); + border: calculateRem(1px) solid $color-neutral-80; + border-radius: $border-radius-medium; + background-color: $color-neutral-10; + color: $color-neutral-240; + font-family: inherit; + font-size: $text-font-size-m; + font-weight: $text-font-weight-semi; + white-space: nowrap; + cursor: pointer; + + &:hover { + border-color: $color-neutral-120; + } + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: $box-shadow-focus-primary; + } + + &:disabled { + border-color: $color-neutral-50; + color: $color-neutral-160; + cursor: default; + } + + .ids-icon { + fill: currentColor; + } + } + + #{$self}__trigger-icon, + #{$self}__chevron { + display: flex; + } + + #{$self}__value { + font-weight: $text-font-weight-normal; + } + + #{$self}__counter { + display: inline-flex; + align-items: center; + height: calculateRem(16px); + padding: 0 calculateRem(8px); + border: calculateRem(1px) solid $color-neutral-50; + border-radius: calculateRem(4px); + background-color: $color-neutral-30; + color: $color-neutral-200; + font-size: $text-font-size-xs; + font-weight: $text-font-weight-normal; + } + + #{$self}__footer { + padding: 0 calculateRem(8px) calculateRem(8px); + } + + #{$self}__divider { + margin: 0 0 calculateRem(8px); + border: 0; + border-top: calculateRem(1px) solid $color-neutral-70; + } + + #{$self}__footer .ids-btn { + width: 100%; + } + + &.ids-dropdown--open { + #{$self}__trigger { + border-color: $color-primary-100; + } + + #{$self}__chevron .ids-icon { + transform: rotate(180deg); + } + } + + &.ids-dropdown--selected { + #{$self}__trigger { + border-color: $color-info-30; + background-color: $color-info-0; + } + } + + &.ids-dropdown--filter-dashboard { + #{$self}__trigger { + height: $trigger-height-compact; + padding: 0 calculateRem(8px) 0 calculateRem(12px); + } + } + + &.ids-dropdown--filter-more-filters { + #{$self}__trigger { + padding: 0 calculateRem(12px); + } + } + + &.ids-dropdown--filter-more-filters-small { + #{$self}__trigger { + padding: 0 calculateRem(10px); + } + } + + &.ids-dropdown--filter-more-filters, + &.ids-dropdown--filter-more-filters-small { + &.ids-dropdown--open { + #{$self}__trigger { + border-color: $color-neutral-120; + background-color: $color-neutral-50; + } + } + } +} diff --git a/packages/assets/src/scss/styles.scss b/packages/assets/src/scss/styles.scss index 0d7d173d..c942f4e8 100644 --- a/packages/assets/src/scss/styles.scss +++ b/packages/assets/src/scss/styles.scss @@ -25,6 +25,7 @@ @use 'inputs/alt-radio'; @use 'inputs/checkbox'; @use 'inputs/dropdown'; +@use 'inputs/filter-dropdown'; @use 'inputs/input-text'; @use 'inputs/radio-button'; @use 'inputs/toggle'; diff --git a/packages/components/assets/ibexa_design_system.en.xliff b/packages/components/assets/ibexa_design_system.en.xliff index b01f65cb..88a023be 100644 --- a/packages/components/assets/ibexa_design_system.en.xliff +++ b/packages/components/assets/ibexa_design_system.en.xliff @@ -37,6 +37,10 @@ Toggle password visibility Toggle password visibility + + Clear + Clear + Delete Delete diff --git a/packages/components/src/components/FilterDropdown/FilterDropdown.stories.tsx b/packages/components/src/components/FilterDropdown/FilterDropdown.stories.tsx new file mode 100644 index 00000000..c049ef05 --- /dev/null +++ b/packages/components/src/components/FilterDropdown/FilterDropdown.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from 'storybook/test'; +import { action } from 'storybook/actions'; + +import { FilterDropdownStateful, FilterDropdownType } from '.'; +import { DropdownDecorator } from '@ids-sb-decorators/DropdownDecorator'; +import { generateItemsArray } from '@ids-sb-utils/generators'; + +const DEFAULT_ITEMS_LENGTH = 6; +const WRAPPER_HEIGHT = 420; +const openPanel = async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('button', { expanded: false })); +}; + +const meta: Meta = { + component: FilterDropdownStateful, + tags: ['autodocs', 'foundation'], + args: { + items: generateItemsArray(DEFAULT_ITEMS_LENGTH), + label: 'Filter', + name: 'filter', + onChange: action('on-change'), + }, + argTypes: { + onChange: { control: { disable: true } }, + }, + decorators: [DropdownDecorator], + parameters: { + wrapperHeight: WRAPPER_HEIGHT, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', +}; + +export const DefaultOpenedMenu: Story = { + name: 'Default (Opened Menu)', + tags: ['!dev'], + play: openPanel, +}; + +export const DefaultSelected: Story = { + name: 'Default (Selected)', + args: { + value: ['2'], + }, +}; + +export const Dashboard: Story = { + name: 'Dashboard', + args: { + type: FilterDropdownType.Dashboard, + }, +}; + +export const DashboardOpenedMenu: Story = { + name: 'Dashboard (Opened Menu)', + tags: ['!dev'], + args: { + type: FilterDropdownType.Dashboard, + }, + play: openPanel, +}; + +export const DashboardSelected: Story = { + name: 'Dashboard (Selected)', + args: { + type: FilterDropdownType.Dashboard, + value: ['2'], + }, +}; + +export const MoreFilters: Story = { + name: 'More Filters', + args: { + label: 'More filters', + type: FilterDropdownType.MoreFilters, + }, +}; + +export const MoreFiltersOpenedMenu: Story = { + name: 'More Filters (Opened Menu)', + tags: ['!dev'], + args: { + label: 'More filters', + type: FilterDropdownType.MoreFilters, + }, + play: openPanel, +}; + +export const MoreFiltersSmall: Story = { + name: 'More Filters Small', + args: { + label: 'More filters', + type: FilterDropdownType.MoreFiltersSmall, + }, +}; + +export const Disabled: Story = { + name: 'Disabled', + args: { + disabled: true, + }, +}; + +export const WithoutSearch: Story = { + name: 'Without Search (Opened Menu)', + tags: ['!dev'], + args: { + hasSearch: false, + }, + play: openPanel, +}; + +export const EmptySearch: Story = { + name: 'Empty Search (Opened Menu)', + tags: ['!dev'], + play: async ({ canvasElement }) => { + await openPanel({ canvasElement }); + + await userEvent.type(within(canvasElement).getByRole('textbox'), 'zzz'); + }, +}; diff --git a/packages/components/src/components/FilterDropdown/FilterDropdown.test.stories.tsx b/packages/components/src/components/FilterDropdown/FilterDropdown.test.stories.tsx new file mode 100644 index 00000000..2dc03a2d --- /dev/null +++ b/packages/components/src/components/FilterDropdown/FilterDropdown.test.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import { FilterDropdownAction, FilterDropdownStateful, FilterDropdownType } from '.'; + +const meta: Meta = { + component: FilterDropdownStateful, + tags: ['!dev'], + args: { + items: [ + { id: 'value1', label: 'Item 1' }, + { id: 'value2', label: 'Item 2' }, + { id: 'value3', label: 'Item 3' }, + ], + label: 'Filter', + name: 'filter', + onChange: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button', { name: 'Filter' }); + + await step('Checking an item reports it and shows the counter', async () => { + await userEvent.click(trigger); + await userEvent.click(canvas.getByText('Item 1', { selector: '.ids-dropdown__item-label' })); + + await expect(args.onChange).toHaveBeenLastCalledWith(['value1'], 'value1', FilterDropdownAction.Check); + await expect(canvasElement.querySelector('.ids-dropdown__counter')).toHaveTextContent('1'); + await expect(canvasElement.querySelector('.ids-dropdown__items-container')).not.toBeNull(); + }); + + await step('Unchecking the item reports it', async () => { + await userEvent.click(canvas.getByText('Item 1', { selector: '.ids-dropdown__item-label' })); + + await expect(args.onChange).toHaveBeenLastCalledWith([], 'value1', FilterDropdownAction.Uncheck); + await expect(canvasElement.querySelector('.ids-dropdown__counter')).toBeNull(); + }); + + await step('Clear reports an empty value, keeps the panel open and disables itself', async () => { + await userEvent.click(canvas.getByText('Item 2', { selector: '.ids-dropdown__item-label' })); + await userEvent.click(canvas.getByRole('button', { name: 'Clear' })); + + await expect(args.onChange).toHaveBeenLastCalledWith([], '', FilterDropdownAction.Clear); + await expect(canvasElement.querySelector('.ids-dropdown__items-container')).not.toBeNull(); + await expect(canvas.getByRole('button', { name: 'Clear' })).toBeDisabled(); + }); + + await step('A search with no match shows the no-results row', async () => { + await userEvent.type(canvas.getByRole('textbox'), 'zzz'); + + await expect(canvasElement.querySelector('.ids-dropdown__no-results')).not.toBeNull(); + }); + + await step('Escape closes the panel and returns focus to the trigger', async () => { + await userEvent.keyboard('{Escape}'); + + await expect(canvasElement.querySelector('.ids-dropdown__items-container')).toBeNull(); + await expect(document.activeElement).toBe(trigger); + }); + }, +}; + +export const DashboardValue: Story = { + name: 'Dashboard value', + args: { + type: FilterDropdownType.Dashboard, + value: ['value2'], + }, + play: async ({ canvasElement }) => { + await expect(canvasElement.querySelector('.ids-dropdown__value')).toHaveTextContent('Item 2'); + await expect(canvasElement.querySelector('.ids-dropdown__trigger-label')).toHaveTextContent('Filter:'); + await expect(canvasElement.querySelector('.ids-dropdown__counter')).toBeNull(); + }, +}; + +export const WithoutSearchAndDisabled: Story = { + name: 'Without search and disabled', + args: { + disabled: true, + hasSearch: false, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('A disabled trigger does not open the panel', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'Filter' })); + + await expect(canvasElement.querySelector('.ids-dropdown__items-container')).toBeNull(); + }); + }, +}; diff --git a/packages/components/src/components/FilterDropdown/FilterDropdown.tsx b/packages/components/src/components/FilterDropdown/FilterDropdown.tsx new file mode 100644 index 00000000..d1e38c39 --- /dev/null +++ b/packages/components/src/components/FilterDropdown/FilterDropdown.tsx @@ -0,0 +1,150 @@ +import React, { useContext, useId, useState } from 'react'; + +import { Button, ButtonSize, ButtonType } from '@ids-components/Button'; +import { ExtraParamsType, getNextFocusableItem } from '../Dropdown/utils/focus'; +import { Icon, IconSize } from '@ids-components/Icon'; +import { CheckboxInput } from '@ids-components/Checkbox'; +import { ItemsContainer } from '@ids-partials/BaseDropdown/components/ItemsContainer'; +import { TranslatorContext } from '@ids-context/Translator'; +import { createCssClassNames } from '@ids-core'; +import { withStateValue } from '@ids-hoc/withStateValue'; + +import { FilterDropdownAction, FilterDropdownItem, FilterDropdownProps, FilterDropdownType } from './FilterDropdown.types'; + +const MAX_VISIBLE_ITEMS = 10; +const PANEL_MIN_WIDTH = 200; +const SINGLE_SELECTION_COUNT = 1; + +export const FilterDropdown = ({ + label, + name, + className = '', + disabled = false, + hasSearch = true, + items = [], + onChange = () => undefined, + type = FilterDropdownType.Default, + value = [], + ...restProps +}: FilterDropdownProps) => { + const Translator = useContext(TranslatorContext); + const panelId = useId(); + const [referenceElement, setReferenceElement] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const selectedItems = items.filter((item) => value.includes(item.id)); + const hasSelection = selectedItems.length > 0; + const isIconOnly = type === FilterDropdownType.MoreFiltersSmall; + const hasTriggerIcon = type === FilterDropdownType.MoreFilters || isIconOnly; + const hasChevron = type === FilterDropdownType.Default || type === FilterDropdownType.Dashboard; + const showsValue = type === FilterDropdownType.Dashboard && selectedItems.length === SINGLE_SELECTION_COUNT; + const rootClassName = createCssClassNames({ + 'ids-dropdown': true, + 'ids-dropdown--disabled': disabled, + 'ids-dropdown--filter': true, + [`ids-dropdown--filter-${type}`]: true, + 'ids-dropdown--open': isOpen, + 'ids-dropdown--selected': hasSelection, + [className]: !!className, + }); + const toggleDropdown = () => { + if (!disabled) { + setIsOpen(!isOpen); + } + }; + const changeValue = (id: string) => { + const isSelected = value.includes(id); + const newValue = isSelected ? value.filter((selectedValue) => selectedValue !== id) : [...value, id]; + + onChange(newValue, id, isSelected ? FilterDropdownAction.Uncheck : FilterDropdownAction.Check); + }; + const clearValue = () => { + onChange([], '', FilterDropdownAction.Clear); + }; + const isItemSelected = (item: FilterDropdownItem) => value.includes(item.id); + const getItemAttributes = () => ({ + role: undefined, + tabIndex: undefined, + }); + const renderItem = (item: FilterDropdownItem) => ( + <> + + {item.label} + + ); + const renderFooter = () => ( +
+
+ +
+ ); + const getFocusableElements = ({ itemsList, search }: ExtraParamsType): HTMLElement[] => { + const checkboxes = Array.from(itemsList.querySelectorAll('.ids-dropdown__item .ids-input--checkbox')); + const clearBtn = itemsList.parentElement?.querySelector('.ids-dropdown__footer .ids-btn'); + + return [...(search instanceof HTMLElement ? [search] : []), ...checkboxes, ...(clearBtn ? [clearBtn] : [])]; + }; + + return ( +
+
+ +
+ + { + setIsOpen(false); + }} + containerAttributes={{ 'aria-label': label, id: panelId, role: 'group' }} + filterFunction={(item, searchTerm) => item.label.toLowerCase().includes(searchTerm.toLowerCase())} + getItemAttributes={getItemAttributes} + getNextFocusableItem={getNextFocusableItem.bind(null, getFocusableElements)} + hasSearch={hasSearch} + isItemSelected={isItemSelected} + isOpen={isOpen} + items={items} + maxVisibleItems={MAX_VISIBLE_ITEMS} + minWidth={PANEL_MIN_WIDTH} + onDropdownItemClick={(item) => { + changeValue(item.id); + }} + referenceElement={referenceElement} + renderFooter={renderFooter} + renderItem={renderItem} + /> +
+ ); +}; + +export const FilterDropdownStateful = withStateValue(FilterDropdown); diff --git a/packages/components/src/components/FilterDropdown/FilterDropdown.types.ts b/packages/components/src/components/FilterDropdown/FilterDropdown.types.ts new file mode 100644 index 00000000..ffff6b51 --- /dev/null +++ b/packages/components/src/components/FilterDropdown/FilterDropdown.types.ts @@ -0,0 +1,28 @@ +import { BaseComponentAttributes } from '@ids-types/general'; +import { BaseDropdownItem } from '@ids-partials/BaseDropdown'; + +export type FilterDropdownItem = BaseDropdownItem; + +export enum FilterDropdownType { + Default = 'default', + Dashboard = 'dashboard', + MoreFilters = 'more-filters', + MoreFiltersSmall = 'more-filters-small', +} + +export enum FilterDropdownAction { + Check = 'check', + Uncheck = 'uncheck', + Clear = 'clear', +} + +export interface FilterDropdownProps extends BaseComponentAttributes { + label: string; + name: string; + disabled?: boolean; + hasSearch?: boolean; + items?: FilterDropdownItem[]; + onChange?: (value: string[], itemId: string, action: FilterDropdownAction) => void; + type?: FilterDropdownType; + value?: string[]; +} diff --git a/packages/components/src/components/FilterDropdown/index.ts b/packages/components/src/components/FilterDropdown/index.ts new file mode 100644 index 00000000..f3da9ba4 --- /dev/null +++ b/packages/components/src/components/FilterDropdown/index.ts @@ -0,0 +1,2 @@ +export * from './FilterDropdown'; +export * from './FilterDropdown.types'; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 240579ad..4678da9d 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -7,6 +7,7 @@ export * from './Checkbox'; export * from './ChoiceInputLabel'; export * from './Dropdown'; export * from './Expander'; +export * from './FilterDropdown'; export * from './HelperText'; export * from './Icon'; export * from './InputText'; diff --git a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx index 41ea1092..d1744028 100644 --- a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx +++ b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx @@ -19,15 +19,19 @@ const VIEWPORT_MARGIN = 16; export const ItemsContainer = ({ closeDropdown, + containerAttributes = {}, filterFunction, getItemAttributes, getNextFocusableItem, + hasSearch, isItemSelected, isOpen, items, maxVisibleItems, + minWidth = 0, onDropdownItemClick, referenceElement, + renderFooter, renderItem, }: ItemsContainerProps) => { const Translator = useContext(TranslatorContext); @@ -45,7 +49,7 @@ export const ItemsContainer = ({ strategy: 'fixed', }); const flatItems = useMemo(() => flattenDropdownItems(items), [items]); - const hasSearchInput = flatItems.length > maxVisibleItems; + const hasSearchInput = hasSearch ?? flatItems.length > maxVisibleItems; const filteredEntries = useMemo(() => filterDropdownEntries(items, searchTerm, filterFunction), [items, searchTerm, filterFunction]); const filteredItems = useMemo(() => flattenDropdownItems(filteredEntries), [filteredEntries]); const hasNoResults = !!searchTerm && filteredItems.length === 0; @@ -58,7 +62,7 @@ export const ItemsContainer = ({ const itemsContainerStyles: ItemsContainerStylesType = { ...styles.popper, '--ids-dropdown-available-width': itemsContainerAvailableWidth ? `${itemsContainerAvailableWidth}px` : undefined, - minWidth: itemsContainerWidth ? `${itemsContainerWidth}px` : 'auto', + minWidth: itemsContainerWidth || minWidth ? `${Math.max(itemsContainerWidth, minWidth)}px` : 'auto', }; const getItemsStyles = () => { const itemsStyles: ItemsContainerItemsStylesType = { @@ -244,8 +248,20 @@ export const ItemsContainer = ({ } return ( -
- +
+
    ({ {Translator.trans(/*@Desc("No results found")*/ 'ids.dropdown.search.no_results')}
)} + {renderFooter?.()}
); }; diff --git a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.types.ts b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.types.ts index 399b201d..f1f3e81f 100644 --- a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.types.ts +++ b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.types.ts @@ -29,6 +29,10 @@ export interface ItemsContainerProps { items: BaseDropdownEntry[]; maxVisibleItems: number; onDropdownItemClick: (item: T, extraParams: ExtraDropdownItemClickParamsType) => void; - referenceElement: HTMLDivElement | null; + referenceElement: HTMLElement | null; renderItem: (item: T) => React.ReactNode; + containerAttributes?: React.HTMLAttributes; + hasSearch?: boolean; + minWidth?: number; + renderFooter?: () => React.ReactNode; } diff --git a/packages/components/src/partials/BaseDropdown/components/Search/Search.tsx b/packages/components/src/partials/BaseDropdown/components/Search/Search.tsx index f0583c5e..fce8ed3a 100644 --- a/packages/components/src/partials/BaseDropdown/components/Search/Search.tsx +++ b/packages/components/src/partials/BaseDropdown/components/Search/Search.tsx @@ -5,7 +5,7 @@ import { TranslatorContext } from '@ids-context/Translator'; import { SearchProps } from './Search.types'; -export const Search = ({ isVisible, setSearchTerm, searchRef, searchTerm }: SearchProps) => { +export const Search = ({ isVisible, setSearchTerm, searchRef, searchTerm, hasSearchIcon = false }: SearchProps) => { const Translator = useContext(TranslatorContext); if (!isVisible) { @@ -17,10 +17,12 @@ export const Search = ({ isVisible, setSearchTerm, searchRef, searchTerm }: Sear return (
diff --git a/packages/components/src/partials/BaseDropdown/components/Search/Search.types.ts b/packages/components/src/partials/BaseDropdown/components/Search/Search.types.ts index b6f21ce9..742b61c2 100644 --- a/packages/components/src/partials/BaseDropdown/components/Search/Search.types.ts +++ b/packages/components/src/partials/BaseDropdown/components/Search/Search.types.ts @@ -3,4 +3,5 @@ export interface SearchProps { setSearchTerm: (value: string) => void; searchRef: React.RefObject; searchTerm: string; + hasSearchIcon?: boolean; }