From e04961497e4a73a3823d280a065b6f2bc958438b Mon Sep 17 00:00:00 2001 From: tischsoic Date: Mon, 7 Sep 2026 13:53:49 +0200 Subject: [PATCH] IBX-12475: Added option groups to the dropdown items The dropdown items list only knew a flat list, so admin-ui kept the legacy dropdown for every ChoiceType with optgroups. Entries may now be plain items or {label, items} groups: the panel renders a non-interactive group header (role=group, aria-labelledby) with its items nested, the source select emits matching optgroups, search keeps a group while one of its items matches and shows a no-results row otherwise, and keyboard focus walks leaf items only. Rendering moved into an ItemsList sub-component so ItemsContainer stays within the file size limit. Co-authored-by: Claude --- .../assets/src/scss/inputs/_dropdown.scss | 49 ++++++ .../assets/ibexa_design_system.en.xliff | 6 +- .../DropdownMultiInput.stories.tsx | 72 ++++++++- .../DropdownMultiInput.test.stories.tsx | 51 +++++++ .../DropdownMultiInput/DropdownMultiInput.tsx | 26 +++- .../DropdownMultiInput.types.ts | 6 +- .../DropdownSingleInput.stories.tsx | 71 ++++++++- .../DropdownSingleInput.test.stories.tsx | 139 ++++++++++++++++++ .../DropdownSingleInput.tsx | 28 +++- .../DropdownSingleInput.types.ts | 6 +- .../BaseDropdown/BaseDropdown.types.ts | 10 +- .../ItemsContainer/ItemsContainer.tsx | 62 ++++---- .../ItemsContainer/ItemsContainer.types.ts | 4 +- .../components/ItemsList/ItemsList.tsx | 69 +++++++++ .../components/ItemsList/ItemsList.types.ts | 11 ++ .../components/ItemsList/index.ts | 2 + .../src/partials/BaseDropdown/index.ts | 1 + .../src/partials/BaseDropdown/utils/items.ts | 35 +++++ src/storybook/utils/generators.ts | 21 +++ 19 files changed, 607 insertions(+), 62 deletions(-) create mode 100644 packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.tsx create mode 100644 packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.types.ts create mode 100644 packages/components/src/partials/BaseDropdown/components/ItemsList/index.ts create mode 100644 packages/components/src/partials/BaseDropdown/utils/items.ts diff --git a/packages/assets/src/scss/inputs/_dropdown.scss b/packages/assets/src/scss/inputs/_dropdown.scss index ee408821..403edff9 100644 --- a/packages/assets/src/scss/inputs/_dropdown.scss +++ b/packages/assets/src/scss/inputs/_dropdown.scss @@ -110,6 +110,55 @@ $item-gap: calculateRem(4px); white-space: nowrap; } + &__group { + display: flex; + flex-direction: column; + gap: $item-gap; + list-style: none; + margin: 0; + padding: 0; + + &:not(:first-child) { + margin-top: $item-gap; + } + } + + &__group-label { + display: flex; + align-items: center; + min-height: $item-height; + min-width: 0; + padding: calculateRem(4px) calculateRem(8px); + box-sizing: border-box; + font-size: $text-font-size-m; + font-weight: $text-font-weight-semi; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + user-select: none; + } + + &__group-items { + display: flex; + flex-direction: column; + gap: $item-gap; + list-style: none; + margin: 0; + padding: 0; + padding-inline-start: calculateRem(16px); + } + + &__no-results { + display: flex; + align-items: center; + min-height: $item-height; + padding: 0 calculateRem(16px) calculateRem(12px); + box-sizing: border-box; + color: $color-neutral-160; + font-size: $text-font-size-m; + } + &--error { #{$self}__placeholder { color: $color-error-90; diff --git a/packages/components/assets/ibexa_design_system.en.xliff b/packages/components/assets/ibexa_design_system.en.xliff index 0dcfb59a..b01f65cb 100644 --- a/packages/components/assets/ibexa_design_system.en.xliff +++ b/packages/components/assets/ibexa_design_system.en.xliff @@ -9,7 +9,7 @@ Select an item Select an item - + All All @@ -17,6 +17,10 @@ Search... Search... + + No results found + No results found + On On diff --git a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.stories.tsx b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.stories.tsx index cc744073..e50efa30 100644 --- a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.stories.tsx +++ b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.stories.tsx @@ -2,13 +2,20 @@ import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from 'storybook/test'; import { action } from 'storybook/actions'; +import { generateGroupedItemsArray, generateItemsArray } from '@ids-sb-utils/generators'; import { DropdownDecorator } from '@ids-sb-decorators/DropdownDecorator'; import { DropdownMultiInputStateful } from '.'; -import { generateItemsArray } from '@ids-sb-utils/generators'; const DEFAULT_ITEMS_LENGTH = 5; const MANY_ITEMS_LENGTH = 50; const WRAPPER_HEIGHT_FOR_LONG_LIST = 500; +const GROUPED_GROUPS_COUNT = 3; +const GROUPED_ITEMS_PER_GROUP = 3; +const GROUPED_UNGROUPED_COUNT = 1; +const MANY_GROUPED_GROUPS_COUNT = 6; +const MANY_GROUPED_ITEMS_PER_GROUP = 8; +const GROUPED_ITEMS = generateGroupedItemsArray(GROUPED_GROUPS_COUNT, GROUPED_ITEMS_PER_GROUP, GROUPED_UNGROUPED_COUNT); +const MANY_GROUPED_ITEMS = generateGroupedItemsArray(MANY_GROUPED_GROUPS_COUNT, MANY_GROUPED_ITEMS_PER_GROUP, GROUPED_UNGROUPED_COUNT); const NARROW_WRAPPER_WIDTH = 220; const LONG_LABEL_ITEMS = generateItemsArray(DEFAULT_ITEMS_LENGTH).map((item) => ({ ...item, @@ -185,3 +192,66 @@ export const NarrowLongLabelsOpenedMenu: Story = { await userEvent.click(dropdownWidget); }, }; + +export const Grouped: Story = { + name: 'Grouped', + args: { + items: GROUPED_ITEMS, + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, +}; + +export const GroupedOpenedMenu: Story = { + name: 'Grouped (Opened Menu)', + tags: ['!dev'], + args: { + items: GROUPED_ITEMS, + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await userEvent.click(dropdownWidget); + }, +}; + +export const GroupedSelectedOpenedMenu: Story = { + name: 'Grouped / Selected (Opened Menu)', + tags: ['!dev'], + args: { + items: GROUPED_ITEMS, + value: ['group-2-item-1'], + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, + play: async ({ canvasElement }) => { + const dropdownWidget = canvasElement.querySelector('.ids-dropdown__widget'); + + if (dropdownWidget) { + await userEvent.click(dropdownWidget); + } + }, +}; + +export const GroupedManyItemsOpenedMenu: Story = { + name: 'Grouped / Many Items (Opened Menu)', + tags: ['!dev'], + args: { + items: MANY_GROUPED_ITEMS, + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await userEvent.click(dropdownWidget); + }, +}; diff --git a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.test.stories.tsx b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.test.stories.tsx index 5c7017b7..9635921f 100644 --- a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.test.stories.tsx +++ b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.test.stories.tsx @@ -63,3 +63,54 @@ export const Default: Story = { }); }, }; + +export const Grouped: Story = { + name: 'Grouped', + args: { + items: [ + { id: 'ungrouped', label: 'Ungrouped item' }, + { + id: 'fruits', + items: [ + { id: 'apple', label: 'Apple' }, + { id: 'banana', label: 'Banana' }, + ], + label: 'Fruits', + }, + { + id: 'vegetables', + items: [{ id: 'carrot', label: 'Carrot' }], + label: 'Vegetables', + }, + ], + }, + play: async ({ canvasElement, step }) => { + const GROUPS_COUNT = 2; + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await step('Groups render with group semantics and checkbox items inside', async () => { + await userEvent.click(dropdownWidget); + + const groups = canvasElement.querySelectorAll('.ids-dropdown__items .ids-dropdown__group'); + const fruitsLabel = canvas.getByText('Fruits', { selector: '.ids-dropdown__group-label' }); + + await expect(groups).toHaveLength(GROUPS_COUNT); + await expect(groups[0]).toHaveAttribute('aria-labelledby', fruitsLabel.id); + await expect(groups[0].querySelectorAll('.ids-input--checkbox')).toHaveLength(GROUPS_COUNT); + }); + + await step('Selecting a grouped item shows its chip and keeps the optgroup in the source select', async () => { + const appleItem = canvas.getByText('Apple', { selector: '.ids-dropdown__item-label' }); + + await userEvent.click(appleItem); + await userEvent.click(canvasElement); + + const selectedChip = canvas.getByText('Apple', { selector: 'div' }); + const groupedOption = canvasElement.querySelector('select optgroup[label="Fruits"] option[value="apple"]'); + + await expect(selectedChip).toBeVisible(); + await expect(groupedOption).not.toBeNull(); + }); + }, +}; diff --git a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.tsx b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.tsx index 8a7cab04..805b79e7 100644 --- a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.tsx +++ b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import { BaseDropdown, flattenDropdownItems, isDropdownItemGroup } from '@ids-partials/BaseDropdown'; import { ExtraParamsType, getNextFocusableItem } from '../utils/focus'; -import { BaseDropdown } from '@ids-partials/BaseDropdown'; import { CheckboxInput } from '@ids-components/Checkbox'; import { Chip } from '@ids-components/Chip'; import { OverflowList } from '@ids-components/OverflowList'; @@ -47,7 +47,8 @@ export const DropdownMultiInput = ({ ); }; - const selectedItems = value.length ? items.filter((item) => value.includes(item.id)) : []; + const flatItems = flattenDropdownItems(items); + const selectedItems = value.length ? flatItems.filter((item) => value.includes(item.id)) : []; const renderSelectedItems = () => ( +{hiddenCount}} /> ); + const renderOption = (item: DropdownMultiInputItem) => ( + + ); const renderSource = () => { return ( ); }; const getFocusableElements = ({ itemsList, search }: ExtraParamsType): HTMLElement[] => { const focusableElements = [ ...(search instanceof HTMLElement ? [search] : []), - ...Array.from(itemsList.children).reduce((acc: HTMLElement[], child) => { + ...Array.from(itemsList.querySelectorAll('.ids-dropdown__item')).reduce((acc: HTMLElement[], child) => { if (child instanceof HTMLElement) { const checkbox = child.querySelector('.ids-input--checkbox'); diff --git a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.types.ts b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.types.ts index aa6eb6e7..60e97360 100644 --- a/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.types.ts +++ b/packages/components/src/components/Dropdown/DropdownMultiInput/DropdownMultiInput.types.ts @@ -1,7 +1,9 @@ +import { BaseDropdownEntry, BaseDropdownItem, BaseDropdownItemGroup } from '@ids-partials/BaseDropdown'; import { BaseComponentAttributes } from '@ids-types/general'; -import { BaseDropdownItem } from '@ids-partials/BaseDropdown'; export type DropdownMultiInputItem = BaseDropdownItem; +export type DropdownMultiInputItemGroup = BaseDropdownItemGroup; +export type DropdownMultiInputEntry = BaseDropdownEntry; export enum DropdownMultiInputAction { Check = 'check', @@ -12,7 +14,7 @@ export interface DropdownMultiInputProps extends BaseComponentAttributes { name: string; disabled?: boolean; error?: boolean; - items?: DropdownMultiInputItem[]; + items?: DropdownMultiInputEntry[]; onChange?: (value: string[], itemValue: string, action: DropdownMultiInputAction) => void; placeholder?: string; value?: string[]; diff --git a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.stories.tsx b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.stories.tsx index 17faa20c..135ff446 100644 --- a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.stories.tsx +++ b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.stories.tsx @@ -2,13 +2,20 @@ import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from 'storybook/test'; import { action } from 'storybook/actions'; +import { generateGroupedItemsArray, generateItemsArray } from '@ids-sb-utils/generators'; import { DropdownDecorator } from '@ids-sb-decorators/DropdownDecorator'; import { DropdownSingleInputStateful } from '.'; -import { generateItemsArray } from '@ids-sb-utils/generators'; const DEFAULT_ITEMS_LENGTH = 5; const MANY_ITEMS_LENGTH = 50; const WRAPPER_HEIGHT_FOR_LONG_LIST = 500; +const GROUPED_GROUPS_COUNT = 3; +const GROUPED_ITEMS_PER_GROUP = 3; +const GROUPED_UNGROUPED_COUNT = 1; +const MANY_GROUPED_GROUPS_COUNT = 6; +const MANY_GROUPED_ITEMS_PER_GROUP = 8; +const GROUPED_ITEMS = generateGroupedItemsArray(GROUPED_GROUPS_COUNT, GROUPED_ITEMS_PER_GROUP, GROUPED_UNGROUPED_COUNT); +const MANY_GROUPED_ITEMS = generateGroupedItemsArray(MANY_GROUPED_GROUPS_COUNT, MANY_GROUPED_ITEMS_PER_GROUP, GROUPED_UNGROUPED_COUNT); const meta: Meta = { component: DropdownSingleInputStateful, @@ -120,3 +127,65 @@ export const ManyItemsOpenedMenu: Story = { await userEvent.click(dropdownWidget); }, }; + +export const Grouped: Story = { + name: 'Grouped', + args: { + items: GROUPED_ITEMS, + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, +}; + +export const GroupedOpenedMenu: Story = { + name: 'Grouped (Opened Menu)', + tags: ['!dev'], + args: { + items: GROUPED_ITEMS, + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await userEvent.click(dropdownWidget); + }, +}; + +export const GroupedSelectedOpenedMenu: Story = { + name: 'Grouped / Selected (Opened Menu)', + tags: ['!dev'], + args: { + items: GROUPED_ITEMS, + value: 'group-2-item-1', + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Group 2 item 1', { selector: '.ids-dropdown__selection-info-items' }); + + await userEvent.click(dropdownWidget); + }, +}; + +export const GroupedManyItemsOpenedMenu: Story = { + name: 'Grouped / Many Items (Opened Menu)', + tags: ['!dev'], + args: { + items: MANY_GROUPED_ITEMS, + }, + parameters: { + wrapperHeight: WRAPPER_HEIGHT_FOR_LONG_LIST, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await userEvent.click(dropdownWidget); + }, +}; diff --git a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.test.stories.tsx b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.test.stories.tsx index 2bb4f390..159d7ff2 100644 --- a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.test.stories.tsx +++ b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.test.stories.tsx @@ -2,6 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from 'storybook/test'; import { DropdownSingleInputStateful } from '.'; +import { generateItemsArray } from '@ids-sb-utils/generators'; + +const SEARCH_ITEMS_PER_GROUP = 5; +const SEARCH_GROUPS_COUNT = 3; +const SINGLE_MATCHING_GROUP_COUNT = 1; const meta: Meta = { component: DropdownSingleInputStateful, @@ -119,3 +124,137 @@ export const ManyItems: Story = { }); }, }; + +export const Grouped: Story = { + name: 'Grouped', + args: { + items: [ + { id: 'ungrouped', label: 'Ungrouped item' }, + { + id: 'fruits', + items: [ + { id: 'apple', label: 'Apple' }, + { id: 'banana', label: 'Banana' }, + ], + label: 'Fruits', + }, + { + id: 'vegetables', + items: [{ id: 'carrot', label: 'Carrot' }], + label: 'Vegetables', + }, + ], + }, + play: async ({ canvasElement, step }) => { + const GROUPS_COUNT = 2; + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await step('Groups render with group semantics and headers are not focusable', async () => { + await userEvent.click(dropdownWidget); + + const groups = canvasElement.querySelectorAll('.ids-dropdown__items .ids-dropdown__group'); + const fruitsLabel = canvas.getByText('Fruits', { selector: '.ids-dropdown__group-label' }); + const ungroupedItem = canvas.getByText('Ungrouped item', { selector: '.ids-dropdown__item-label' }); + + await expect(groups).toHaveLength(GROUPS_COUNT); + await expect(groups[0]).toHaveAttribute('aria-labelledby', fruitsLabel.id); + await expect(fruitsLabel).not.toHaveAttribute('tabindex'); + await expect(document.activeElement).toBe(ungroupedItem.closest('li')); + }); + + await step('ArrowDown from the last ungrouped item lands on the first grouped item', async () => { + await userEvent.keyboard('{ArrowDown}'); + + const appleItem = canvas.getByText('Apple', { selector: '.ids-dropdown__item-label' }); + + await expect(document.activeElement).toBe(appleItem.closest('li')); + }); + + await step('Enter selects the grouped item and the source select keeps its optgroup', async () => { + await userEvent.keyboard('{Enter}'); + + const selectedInfo = canvas.getByText('Apple', { selector: 'div' }); + const groupedOption = canvasElement.querySelector('select optgroup[label="Fruits"] option[value="apple"]'); + + await expect(selectedInfo).toBeVisible(); + await expect(groupedOption).not.toBeNull(); + }); + }, +}; + +export const GroupedSearch: Story = { + name: 'Grouped Search', + args: { + items: [ + { + id: 'colors', + items: generateItemsArray(SEARCH_ITEMS_PER_GROUP, { label: 'Color' }).map((item) => ({ + ...item, + id: `color-${item.id}`, + label: `Color ${item.id}`, + })), + label: 'Colors', + }, + { + id: 'shapes', + items: generateItemsArray(SEARCH_ITEMS_PER_GROUP, { label: 'Shape' }).map((item) => ({ + ...item, + id: `shape-${item.id}`, + label: `Shape ${item.id}`, + })), + label: 'Shapes', + }, + { + id: 'sizes', + items: generateItemsArray(SEARCH_ITEMS_PER_GROUP, { label: 'Size' }).map((item) => ({ + ...item, + id: `size-${item.id}`, + label: `Size ${item.id}`, + })), + label: 'Sizes', + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const dropdownWidget = canvas.getByText('Select an item'); + + await step('Search keeps only the groups with a matching item', async () => { + await userEvent.click(dropdownWidget); + + const searchInput = canvas.getByRole('textbox'); + + await expect(canvasElement.querySelectorAll('.ids-dropdown__items .ids-dropdown__group')).toHaveLength(SEARCH_GROUPS_COUNT); + + await userEvent.type(searchInput, 'Shape'); + + await expect(canvasElement.querySelectorAll('.ids-dropdown__items .ids-dropdown__group')).toHaveLength( + SINGLE_MATCHING_GROUP_COUNT, + ); + await expect(canvas.getByText('Shapes', { selector: '.ids-dropdown__group-label' })).toBeVisible(); + }); + + await step('A term matching nothing shows the no-results message', async () => { + const searchInput = canvas.getByRole('textbox'); + + await userEvent.clear(searchInput); + await userEvent.type(searchInput, 'zzz'); + + const noResults = canvasElement.querySelector('.ids-dropdown__no-results'); + + await expect(canvasElement.querySelectorAll('.ids-dropdown__items .ids-dropdown__group')).toHaveLength(0); + await expect(noResults).not.toBeNull(); + await expect(noResults?.textContent).not.toBe(''); + }); + + await step('Clearing the term restores every group', async () => { + const searchInput = canvas.getByRole('textbox'); + + await userEvent.clear(searchInput); + + await expect(canvasElement.querySelectorAll('.ids-dropdown__items .ids-dropdown__group')).toHaveLength(SEARCH_GROUPS_COUNT); + await expect(canvasElement.querySelector('.ids-dropdown__no-results')).toBeNull(); + }); + }, +}; diff --git a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.tsx b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.tsx index c8d3a8ca..e850eee1 100644 --- a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.tsx +++ b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { BaseDropdown, ExtraDropdownItemClickParamsType } from '@ids-partials/BaseDropdown'; +import { BaseDropdown, ExtraDropdownItemClickParamsType, flattenDropdownItems, isDropdownItemGroup } from '@ids-partials/BaseDropdown'; import { ExtraParamsType, getNextFocusableItem } from '../utils/focus'; import { Icon, IconSize } from '@ids-components/Icon'; import { createCssClassNames } from '@ids-core'; @@ -24,7 +24,8 @@ export const DropdownSingleInput = ({ onChange(id); closeDropdown(); }; - const selectedItem = items.find((item) => item.id === value) ?? null; + const flatItems = flattenDropdownItems(items); + const selectedItem = flatItems.find((item) => item.id === value) ?? null; const isItemSelected = (item: DropdownSingleInputItem) => item.id === value; const renderItem = (item: DropdownSingleInputItem) => { return ( @@ -34,21 +35,32 @@ export const DropdownSingleInput = ({ ); }; + const renderOption = (item: DropdownSingleInputItem) => ( + + ); const renderSource = () => { return ( ); }; const getFocusableElements = ({ itemsList, search }: ExtraParamsType): HTMLElement[] => { const focusableElements = [ ...(search ? [search] : []), - ...Array.from(itemsList.children).filter((child) => !child.classList.contains('ids-dropdown__item--selected')), + ...Array.from(itemsList.querySelectorAll('.ids-dropdown__item')).filter( + (child) => !child.classList.contains('ids-dropdown__item--selected'), + ), ]; return focusableElements.filter((element): element is HTMLElement => element instanceof HTMLElement); diff --git a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.types.ts b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.types.ts index 72d0de61..5d8affd5 100644 --- a/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.types.ts +++ b/packages/components/src/components/Dropdown/DropdownSingleInput/DropdownSingleInput.types.ts @@ -1,13 +1,15 @@ +import { BaseDropdownEntry, BaseDropdownItem, BaseDropdownItemGroup } from '@ids-partials/BaseDropdown'; import { BaseComponentAttributes } from '@ids-types/general'; -import { BaseDropdownItem } from '@ids-partials/BaseDropdown'; export type DropdownSingleInputItem = BaseDropdownItem; +export type DropdownSingleInputItemGroup = BaseDropdownItemGroup; +export type DropdownSingleInputEntry = BaseDropdownEntry; export interface DropdownSingleInputProps extends BaseComponentAttributes { name: string; disabled?: boolean; error?: boolean; - items?: DropdownSingleInputItem[]; + items?: DropdownSingleInputEntry[]; onChange?: (value: string) => void; placeholder?: string; value?: string; diff --git a/packages/components/src/partials/BaseDropdown/BaseDropdown.types.ts b/packages/components/src/partials/BaseDropdown/BaseDropdown.types.ts index 3e44fdab..708063a3 100644 --- a/packages/components/src/partials/BaseDropdown/BaseDropdown.types.ts +++ b/packages/components/src/partials/BaseDropdown/BaseDropdown.types.ts @@ -7,13 +7,21 @@ export interface BaseDropdownItem { label: string; } +export interface BaseDropdownItemGroup { + items: T[]; + label: string; + id?: string; +} + +export type BaseDropdownEntry = T | BaseDropdownItemGroup; + export interface ExtraDropdownItemClickParamsType { closeDropdown: () => void; } export interface BaseDropdownProps extends BaseComponentAttributes { isItemSelected: (item: T) => boolean; - items: T[]; + items: BaseDropdownEntry[]; children?: React.ReactNode; disabled?: boolean; error?: boolean; diff --git a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx index 2476c2bd..41ea1092 100644 --- a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx +++ b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { usePopper } from 'react-popper'; +import { filterDropdownEntries, flattenDropdownItems } from '../../utils/items'; +import { ItemsList } from '../ItemsList'; import { Search } from '../Search'; -import { createCssClassNames } from '@ids-core'; +import { TranslatorContext } from '@ids-context/Translator'; import { useKeyDown } from '@ids-hooks/useKeyEvent'; import { @@ -28,6 +30,8 @@ export const ItemsContainer = ({ referenceElement, renderItem, }: ItemsContainerProps) => { + const Translator = useContext(TranslatorContext); + const groupIdPrefix = useId(); const searchRef = useRef(null); const itemsRef = useRef(null); const [isTopPlacementForced, setIsTopPlacementForced] = useState(false); @@ -40,14 +44,12 @@ export const ItemsContainer = ({ placement: isTopPlacementForced ? 'top-start' : 'bottom-start', strategy: 'fixed', }); - const hasSearchInput = items.length > maxVisibleItems; - const filteredItems = useMemo(() => { - if (!searchTerm) { - return items; - } - - return items.filter((item) => filterFunction(item, searchTerm)); - }, [items, searchTerm, filterFunction]); + const flatItems = useMemo(() => flattenDropdownItems(items), [items]); + const hasSearchInput = 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; + const firstFocusableItemId = hasSearchInput ? undefined : filteredItems[0]?.id; const onItemClick = (item: T) => { onDropdownItemClick(item, { closeDropdown, @@ -245,33 +247,21 @@ export const ItemsContainer = ({
    - {filteredItems.map((item, index) => { - const dropdownItemClassName = createCssClassNames({ - 'ids-dropdown__item': true, - 'ids-dropdown__item--selected': isItemSelected(item), - }); - - return ( -
  • { - onItemClick(item); - }} - ref={(node) => { - if (index === 0 && !hasSearchInput && node) { - node.focus(); - } - }} - role="button" - tabIndex={0} - {...getItemAttributes(item)} - > - {renderItem(item)} -
  • - ); - })} +
+ {hasNoResults && ( +
+ {Translator.trans(/*@Desc("No results found")*/ 'ids.dropdown.search.no_results')} +
+ )}
); }; 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 0f8fdea6..399b201d 100644 --- a/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.types.ts +++ b/packages/components/src/partials/BaseDropdown/components/ItemsContainer/ItemsContainer.types.ts @@ -1,4 +1,4 @@ -import { BaseDropdownItem, ExtraDropdownItemClickParamsType } from '../../BaseDropdown.types'; +import { BaseDropdownEntry, BaseDropdownItem, ExtraDropdownItemClickParamsType } from '../../BaseDropdown.types'; export enum ItemsContainerMoveActiveFocusDirection { Up = 'up', @@ -26,7 +26,7 @@ export interface ItemsContainerProps { getNextFocusableItem: GetNextFocusableItemType; isItemSelected: (item: T) => boolean; isOpen: boolean; - items: T[]; + items: BaseDropdownEntry[]; maxVisibleItems: number; onDropdownItemClick: (item: T, extraParams: ExtraDropdownItemClickParamsType) => void; referenceElement: HTMLDivElement | null; diff --git a/packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.tsx b/packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.tsx new file mode 100644 index 00000000..35780f86 --- /dev/null +++ b/packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import { createCssClassNames } from '@ids-core'; +import { isDropdownItemGroup } from '../../utils/items'; + +import { BaseDropdownItem } from '../../BaseDropdown.types'; +import { ItemsListProps } from './ItemsList.types'; + +export const ItemsList = ({ + entries, + firstFocusableItemId, + getItemAttributes, + groupIdPrefix, + isItemSelected, + onItemClick, + renderItem, +}: ItemsListProps) => { + const renderDropdownItem = (item: T) => { + const dropdownItemClassName = createCssClassNames({ + 'ids-dropdown__item': true, + 'ids-dropdown__item--selected': isItemSelected(item), + }); + + return ( +
  • { + onItemClick(item); + }} + ref={(node) => { + if (item.id === firstFocusableItemId && node) { + node.focus(); + } + }} + role="button" + tabIndex={0} + {...getItemAttributes(item)} + > + {renderItem(item)} +
  • + ); + }; + + return ( + <> + {entries.map((entry, index) => { + if (!isDropdownItemGroup(entry)) { + return renderDropdownItem(entry); + } + + if (entry.items.length === 0) { + return null; + } + + const groupId = entry.id ?? `${groupIdPrefix}-group-${index}`; + + return ( +
  • +
    + {entry.label} +
    +
      {entry.items.map(renderDropdownItem)}
    +
  • + ); + })} + + ); +}; diff --git a/packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.types.ts b/packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.types.ts new file mode 100644 index 00000000..a5610062 --- /dev/null +++ b/packages/components/src/partials/BaseDropdown/components/ItemsList/ItemsList.types.ts @@ -0,0 +1,11 @@ +import { BaseDropdownEntry, BaseDropdownItem } from '../../BaseDropdown.types'; + +export interface ItemsListProps { + entries: BaseDropdownEntry[]; + getItemAttributes: (item: T) => React.HTMLAttributes; + groupIdPrefix: string; + isItemSelected: (item: T) => boolean; + onItemClick: (item: T) => void; + renderItem: (item: T) => React.ReactNode; + firstFocusableItemId?: string; +} diff --git a/packages/components/src/partials/BaseDropdown/components/ItemsList/index.ts b/packages/components/src/partials/BaseDropdown/components/ItemsList/index.ts new file mode 100644 index 00000000..a2bf6e9c --- /dev/null +++ b/packages/components/src/partials/BaseDropdown/components/ItemsList/index.ts @@ -0,0 +1,2 @@ +export * from './ItemsList'; +export * from './ItemsList.types'; diff --git a/packages/components/src/partials/BaseDropdown/index.ts b/packages/components/src/partials/BaseDropdown/index.ts index 4c9faf09..63f64a0a 100644 --- a/packages/components/src/partials/BaseDropdown/index.ts +++ b/packages/components/src/partials/BaseDropdown/index.ts @@ -1,2 +1,3 @@ export * from './BaseDropdown'; export * from './BaseDropdown.types'; +export * from './utils/items'; diff --git a/packages/components/src/partials/BaseDropdown/utils/items.ts b/packages/components/src/partials/BaseDropdown/utils/items.ts new file mode 100644 index 00000000..8961f3cc --- /dev/null +++ b/packages/components/src/partials/BaseDropdown/utils/items.ts @@ -0,0 +1,35 @@ +import { BaseDropdownEntry, BaseDropdownItem, BaseDropdownItemGroup } from '../BaseDropdown.types'; + +export const isDropdownItemGroup = (entry: BaseDropdownEntry): entry is BaseDropdownItemGroup => + 'items' in entry && Array.isArray(entry.items); + +export const flattenDropdownItems = (entries: BaseDropdownEntry[]): T[] => + entries.flatMap((entry) => (isDropdownItemGroup(entry) ? entry.items : [entry])); + +export const filterDropdownEntries = ( + entries: BaseDropdownEntry[], + searchTerm: string, + filterFunction: (item: T, term: string) => boolean, +): BaseDropdownEntry[] => { + if (!searchTerm) { + return entries; + } + + return entries.reduce[]>((filteredEntries, entry) => { + if (isDropdownItemGroup(entry)) { + const matchingItems = entry.items.filter((item) => filterFunction(item, searchTerm)); + + if (matchingItems.length > 0) { + filteredEntries.push({ ...entry, items: matchingItems }); + } + + return filteredEntries; + } + + if (filterFunction(entry, searchTerm)) { + filteredEntries.push(entry); + } + + return filteredEntries; + }, []); +}; diff --git a/src/storybook/utils/generators.ts b/src/storybook/utils/generators.ts index 677d8da5..813ed26f 100644 --- a/src/storybook/utils/generators.ts +++ b/src/storybook/utils/generators.ts @@ -8,3 +8,24 @@ export const generateItemsArray = (length: number, { label }: { label?: string } label: label ?? `Item ${id}`, }; }); + +export const generateGroupedItemsArray = (groupsCount: number, itemsPerGroup: number, ungroupedCount = 0) => { + const ungroupedItems = generateItemsArray(ungroupedCount).map((item) => ({ + id: `ungrouped-${item.id}`, + label: `Ungrouped ${item.id}`, + })); + const groups = Array.from({ length: groupsCount }, (value, groupIndex) => { + const groupNumber = String(groupIndex + 1); // eslint-disable-line no-magic-numbers + + return { + id: `group-${groupNumber}`, + items: generateItemsArray(itemsPerGroup).map((item) => ({ + id: `group-${groupNumber}-item-${item.id}`, + label: `Group ${groupNumber} item ${item.id}`, + })), + label: `Group ${groupNumber}`, + }; + }); + + return [...ungroupedItems, ...groups]; +};