diff --git a/src/community/components/form/select/select.stories.tsx b/src/community/components/form/select/select.stories.tsx index 700a9312b..8a71dbc00 100644 --- a/src/community/components/form/select/select.stories.tsx +++ b/src/community/components/form/select/select.stories.tsx @@ -10,6 +10,11 @@ import Select, { IGroupedOptions, ISelectOption } from './select'; const meta: Meta = { component: Select, title: 'Community/Form/Select', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; @@ -58,7 +63,7 @@ const groupedOptions2: OptionsOrGroups[] = [ + { + label: 'Letters', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B', isDisabled: true }, + ], + }, + { + label: 'Numbers', + options: [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, + ], + }, +]; + +describe('select-bulk-helpers', () => { + describe('isGroupedOptions', () => { + it('returns true for grouped options', () => { + expect(isGroupedOptions(grouped)).toBe(true); + }); + + it('returns false for flat options', () => { + expect(isGroupedOptions(flat)).toBe(false); + }); + + it('returns false for empty list', () => { + expect(isGroupedOptions([])).toBe(false); + }); + }); + + describe('getEnabledOptions', () => { + it('returns all enabled options from a flat list', () => { + expect(getEnabledOptions(flat).map((o) => o.value)).toEqual(['a', 'b']); + }); + + it('flattens grouped options and excludes disabled ones', () => { + expect(getEnabledOptions(grouped).map((o) => o.value)).toEqual(['a', '1', '2']); + }); + + it('handles empty list', () => { + expect(getEnabledOptions([])).toEqual([]); + }); + }); + + describe('getGroupEnabledOptions', () => { + it('returns enabled options of the passed group', () => { + expect(getGroupEnabledOptions(grouped[1]).map((o) => o.value)).toEqual(['1', '2']); + }); + + it('filters out disabled options within the group', () => { + // grouped[0] = { label: 'Letters', options: [a, b (disabled)] } + expect(getGroupEnabledOptions(grouped[0]).map((o) => o.value)).toEqual(['a']); + }); + + it('returns [] when group is null/undefined', () => { + expect(getGroupEnabledOptions(null)).toEqual([]); + expect(getGroupEnabledOptions(undefined)).toEqual([]); + }); + + it('returns [] when group has no options array', () => { + expect(getGroupEnabledOptions({ label: 'No options' } as never)).toEqual([]); + }); + + it('targets the correct group when two groups share the same label', () => { + // Regression: looking groups up by label would have always resolved to + // the first match, returning the wrong options for the second group. + const a: IGroupedOptions = { + label: 'Shared', + options: [{ value: 'a-1', label: 'A1' }], + }; + const b: IGroupedOptions = { + label: 'Shared', + options: [ + { value: 'b-1', label: 'B1' }, + { value: 'b-2', label: 'B2' }, + ], + }; + + expect(getGroupEnabledOptions(a).map((o) => o.value)).toEqual(['a-1']); + expect(getGroupEnabledOptions(b).map((o) => o.value)).toEqual(['b-1', 'b-2']); + }); + }); + + describe('areAllSelected', () => { + it('returns true when every enabled option is selected', () => { + const enabled = getEnabledOptions(flat); + expect(areAllSelected(enabled, enabled)).toBe(true); + }); + + it('returns false when some are missing', () => { + const enabled = getEnabledOptions(flat); + expect(areAllSelected([enabled[0]], enabled)).toBe(false); + }); + + it('returns false when target is empty', () => { + expect(areAllSelected([{ value: 'a', label: 'A' }], [])).toBe(false); + }); + }); + + describe('isIndeterminate', () => { + it('returns true when some — but not all — enabled options are selected', () => { + const enabled = getEnabledOptions(flat); + expect(isIndeterminate([enabled[0]], enabled)).toBe(true); + }); + + it('returns false when none are selected', () => { + expect(isIndeterminate([], getEnabledOptions(flat))).toBe(false); + }); + + it('returns false when all are selected', () => { + const enabled = getEnabledOptions(flat); + expect(isIndeterminate(enabled, enabled)).toBe(false); + }); + + it('returns false for empty target', () => { + expect(isIndeterminate([], [])).toBe(false); + }); + }); + + describe('toggleBulkSelection', () => { + it('removes the target options when all are selected', () => { + const enabled = getEnabledOptions(flat); + const result = toggleBulkSelection(enabled, enabled); + expect(result).toEqual([]); + }); + + it('preserves selections outside the target group when removing', () => { + const target: ISelectOption[] = [{ value: 'a', label: 'A' }]; + const selected: ISelectOption[] = [ + { value: 'a', label: 'A' }, + { value: 'extra', label: 'Extra' }, + ]; + const result = toggleBulkSelection(selected, target); + expect(result.map((o) => o.value)).toEqual(['extra']); + }); + + it('adds missing target options to the selection', () => { + const enabled = getEnabledOptions(flat); + const result = toggleBulkSelection([], enabled); + expect(result.map((o) => o.value)).toEqual(['a', 'b']); + }); + + it('does not duplicate already-selected items when adding', () => { + const enabled = getEnabledOptions(flat); + const result = toggleBulkSelection([enabled[0]], enabled); + expect(result.map((o) => o.value)).toEqual(['a', 'b']); + }); + }); +}); diff --git a/src/tedi/components/form/select/components/select-bulk-helpers.ts b/src/tedi/components/form/select/components/select-bulk-helpers.ts new file mode 100644 index 000000000..4f2c7c023 --- /dev/null +++ b/src/tedi/components/form/select/components/select-bulk-helpers.ts @@ -0,0 +1,111 @@ +import { GroupBase, OptionsOrGroups } from 'react-select'; + +import { ISelectOption } from '../select'; + +/** + * Sentinel value used by the "Select all" option when it is injected into + * react-select's option list. The sentinel is stripped from the value before + * it is exposed to consumers via onChange — it never leaks outside the + * component. + */ +export const SELECT_ALL_VALUE = '__tedi_select_all__'; + +export const isSelectAllSentinel = (option: { value?: string } | null | undefined): boolean => + !!option && option.value === SELECT_ALL_VALUE; + +/** + * Prefix for group sentinel options. When `selectableGroups + multiple` is on, + * each group is flattened into the option list with a sentinel option at the + * top of its run; toggling the sentinel toggles every enabled child of that + * group. The label after the prefix is the original group's label. + */ +export const GROUP_OPTION_PREFIX = '__tedi_select_group__:'; + +export const isGroupSentinel = (option: { value?: string } | null | undefined): boolean => + !!option && typeof option.value === 'string' && option.value.startsWith(GROUP_OPTION_PREFIX); + +/** + * Returns true when `options` is a grouped tree (i.e. each top-level entry + * has its own `options` array). + */ +export const isGroupedOptions = ( + options: OptionsOrGroups> +): options is ReadonlyArray> => + options.length > 0 && Array.isArray((options[0] as GroupBase).options); + +/** + * Flattens grouped/non-grouped options into a single list of enabled + * `ISelectOption`s. Used by Select All and group toggles to decide which + * options to flip on/off. + * + * Handles a mixed input where a flat option (e.g. the injected Select-all + * sentinel) sits alongside groups in the same top-level array, by checking + * each item individually rather than only inspecting `options[0]`. + */ +export const getEnabledOptions = ( + options: OptionsOrGroups> +): ISelectOption[] => { + if (!options || options.length === 0) return []; + const flat: ISelectOption[] = []; + for (const item of options) { + if (item && typeof item === 'object' && Array.isArray((item as GroupBase).options)) { + for (const opt of (item as GroupBase).options) { + if (!opt.isDisabled) flat.push(opt); + } + } else { + const opt = item as ISelectOption; + if (opt && !opt.isDisabled) flat.push(opt); + } + } + return flat; +}; + +/** + * Returns the enabled options of a specific group. Pass the group object + * directly (e.g. `GroupHeadingProps.data` from react-select) — looking groups + * up by label is unsafe because duplicate labels would always resolve to the + * first match, mutating the wrong group. + */ +export const getGroupEnabledOptions = (group: GroupBase | null | undefined): ISelectOption[] => { + if (!group || !Array.isArray(group.options)) return []; + return group.options.filter((o) => !o.isDisabled); +}; + +/** True iff every enabled option is currently in the selection. */ +export const areAllSelected = ( + selected: ReadonlyArray, + enabled: ReadonlyArray +): boolean => { + if (enabled.length === 0) return false; + return enabled.every((opt) => selected.some((s) => s.value === opt.value)); +}; + +/** True when some — but not all — enabled options are selected. */ +export const isIndeterminate = ( + selected: ReadonlyArray, + enabled: ReadonlyArray +): boolean => { + if (enabled.length === 0) return false; + const count = enabled.filter((opt) => selected.some((s) => s.value === opt.value)).length; + return count > 0 && count < enabled.length; +}; + +/** + * Toggle behaviour for both Select All and group toggle: when every enabled + * option in `target` is selected, remove them all; otherwise add the missing + * ones to the existing selection. Other selected values (e.g. options + * outside `target`) are preserved. + */ +export const toggleBulkSelection = ( + selected: ReadonlyArray, + target: ReadonlyArray +): ISelectOption[] => { + if (areAllSelected(selected, target)) { + return selected.filter((s) => !target.some((t) => t.value === s.value)); + } + const next = [...selected]; + for (const opt of target) { + if (!next.some((s) => s.value === opt.value)) next.push(opt); + } + return next; +}; diff --git a/src/tedi/components/form/select/components/select-group-bulk-context.ts b/src/tedi/components/form/select/components/select-group-bulk-context.ts new file mode 100644 index 000000000..137632369 --- /dev/null +++ b/src/tedi/components/form/select/components/select-group-bulk-context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react'; +import { SetValueAction } from 'react-select'; + +import { ISelectOption } from '../select'; + +/** + * Exposes react-select's `getValue` / `setValue` helpers from the `Group` + * component down to `GroupHeading`. react-select only forwards `selectProps` + * + theme/styles to the heading at runtime, so the heading can't read these + * helpers from its own props — it has to grab them from this context. + * + * Using `selectProps.value` / `selectProps.onChange` instead would only work + * in fully controlled mode: in uncontrolled mode `value` is undefined and + * `onChange` bypasses react-select's internal state. + */ +export interface SelectGroupBulkApi { + getValue: () => ReadonlyArray; + setValue: (value: ReadonlyArray, action: SetValueAction) => void; +} + +export const SelectGroupBulkContext = createContext(null); + +export const useSelectGroupBulkApi = () => useContext(SelectGroupBulkContext); diff --git a/src/tedi/components/form/select/components/select-group-heading.tsx b/src/tedi/components/form/select/components/select-group-heading.tsx index eb857ea19..133fa304a 100644 --- a/src/tedi/components/form/select/components/select-group-heading.tsx +++ b/src/tedi/components/form/select/components/select-group-heading.tsx @@ -1,21 +1,77 @@ import cn from 'classnames'; -import { ReactElement } from 'react'; +import { ReactElement, useId } from 'react'; import { components as ReactSelectComponents, GroupHeadingProps } from 'react-select'; import { Text, TextProps } from '../../../base/typography/text/text'; +import { Checkbox } from '../../checkbox/checkbox'; import { IGroupedOptions, ISelectOption } from '../select'; import styles from '../select.module.scss'; +import { areAllSelected, getGroupEnabledOptions, isIndeterminate, toggleBulkSelection } from './select-bulk-helpers'; +import { useSelectGroupBulkApi } from './select-group-bulk-context'; type GroupHeadingType = GroupHeadingProps> & { optionGroupHeadingText?: Pick; }; export const SelectGroupHeading = ({ optionGroupHeadingText, ...props }: GroupHeadingType): ReactElement => { + const groupHeadingId = useId(); const textSettings = props.data.text || optionGroupHeadingText; + // Forwarded from handleInputChange(value)} value={inputValue} {...args} /> + ); expect(container.firstChild).not.toHaveClass('custom-container-class'); }); + + it('forwards classNames map to react-select subcomponents', () => { + const { container } = render( + ); + expect(container.querySelector('[class*="search"]')).toBeInTheDocument(); + }); + + it('renders message list footer when renderMessageListFooter is provided', async () => { + render( + {opt.label} (custom)} + /> + ); + expect(screen.getByTestId('custom-value')).toHaveTextContent('Apple (custom)'); + }); + + it('applies the grid class when dropdownType is "grid"', async () => { + render(); + const input = screen.getByRole('combobox'); + + await act(async () => { + input.focus(); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + await new Promise((r) => setTimeout(r, 0)); + }); + + const list = await waitFor(() => { + const el = document.querySelector('[class*="menu-list--keyboard"]'); + expect(el).toBeInTheDocument(); + return el!; + }); + + // Walk up to the wrapper that owns onMouseMove, then fire it. + const wrapper = list.parentElement!; + await act(async () => { + fireEvent.mouseMove(wrapper); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(document.querySelector('[class*="menu-list--keyboard"]')).not.toBeInTheDocument(); + }); + + // The Checkbox label resolves to the i18n key when no LabelProvider is + // present (LabelContext default returns the key in test env), so tests + // assert against `'select.select-all'` rather than the localised string. + const SELECT_ALL_KEY = 'select.select-all'; + + it('renders a Select All toggle when showSelectAll is true in multi mode', async () => { + render(); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + expect(screen.queryByText(SELECT_ALL_KEY)).not.toBeInTheDocument(); + }); + + it('deselects every option when Select All is clicked while all are selected', async () => { + const handleChange = jest.fn(); + render( + ); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + const selectAllOption = (await screen.findAllByRole('option')).find((el) => + el.textContent?.includes(SELECT_ALL_KEY) + )!; + await act(async () => { + await userEvent.click(selectAllOption); + }); + + expect(handleChange).toHaveBeenCalled(); + const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0] as ISelectOption[]; + expect(lastCall.map((o) => o.value).sort()).toEqual(['apple', 'banana']); + }); + + it('renders selectable group headings as checkboxes', async () => { + render( + ); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + const heading = await screen.findByLabelText('Fruits'); + await act(async () => { + await userEvent.click(heading); + }); + + expect(handleChange).toHaveBeenCalled(); + const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0] as ISelectOption[]; + expect(lastCall.map((o) => o.value).sort()).toEqual(['apple', 'banana']); + }); + + it('falls back to plain group headings when selectableGroups is off', async () => { + render( + ); + + const closeButtons = screen.getAllByRole('button', { name: /close/i }); + await act(async () => { + await userEvent.click(closeButtons[0]); + }); + + expect(handleChange).toHaveBeenCalled(); + const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0] as ISelectOption[]; + expect(lastCall.map((o) => o.value)).toEqual(['banana']); + }); + + it('opens the menu on focus when openMenuOnFocus is true', async () => { + render(); + expect(container.querySelector('.tedi-select')).toHaveClass('tedi-select--invalid'); + }); + + it('renders valid helper styling when helper.type is "valid"', () => { + const { container } = render(); + // Goal is branch coverage of the inputIsHidden ?? props.isHidden ternary. + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('keeps keyboard mode active across multiple arrow keys', async () => { + render( + ); + + await waitFor(() => { + expect(screen.getByText(/^\+\d+$/)).toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/tedi/components/form/select/select.stories.tsx b/src/tedi/components/form/select/select.stories.tsx index 342faca6c..6e8ec537b 100644 --- a/src/tedi/components/form/select/select.stories.tsx +++ b/src/tedi/components/form/select/select.stories.tsx @@ -1,13 +1,14 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'; -import { OptionsOrGroups } from 'react-select'; +import { OptionProps, OptionsOrGroups } from 'react-select'; +import { Icon } from '../../base/icon/icon'; import { Text } from '../../base/typography/text/text'; import { Col, Row } from '../../layout/grid'; import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { Checkbox } from '../checkbox/checkbox'; import { AsyncSelectTemplate } from './examples/async'; -import { CustomOptionSelectTemplate } from './examples/custom-option'; import { EditableSelectTemplate } from './examples/editable'; -import { colourOptions, MultipleHandledTemplate } from './examples/multiple-handled'; +import { MultipleHandledTemplate } from './examples/multiple-handled'; import Select, { IGroupedOptions, ISelectOption } from './select'; /** @@ -51,20 +52,20 @@ const groupedOptions: OptionsOrGroups ( - + - + Default - + @@ -89,6 +90,24 @@ export const Sizes: StoryObj = { }, }; +export const Type: Story = { + args: { + options: options, + label: 'Label', + }, + render: (args) => ( + + + + ), +}; + export const States: Story = { args: { options: options, @@ -97,7 +116,7 @@ export const States: Story = { render: (args) => ( - + Default @@ -105,7 +124,7 @@ export const States: Story = { - + Hover @@ -119,7 +138,7 @@ export const States: Story = { - + Focus @@ -133,7 +152,7 @@ export const States: Story = { - + Active @@ -147,7 +166,7 @@ export const States: Story = { - + Error @@ -155,7 +174,7 @@ export const States: Story = { - + Success @@ -163,7 +182,7 @@ export const States: Story = { - + Disabled @@ -174,63 +193,466 @@ export const States: Story = { ), }; -export const MultipleSmall: Story = { - args: { - ...Default.args, - id: 'example-multiple-small', - size: 'small', - multiple: true, - defaultValue: undefined, - placeholder: 'Placeholder', - }, +/** + * Mirrors the Angular `ValueType` frame: no value, default, placeholder, + * multi-row multiselect, and **single-row multiselect** with overflow `+N` + * counter (the `tagsDirection="row"` handling). + */ +const longTagOptions: ISelectOption[] = [ + { value: 'longer-text', label: 'Longer text' }, + { value: 'longer-text-on-one-row', label: 'Longer text on one row' }, + { value: 'third-option', label: 'Third option' }, + { value: 'fourth-option', label: 'Fourth option' }, + { value: 'fifth-option', label: 'Fifth option' }, +]; + +const multiTagOptions: ISelectOption[] = Array.from({ length: 10 }, (_, i) => ({ + value: `tag-${i + 1}`, + label: `Tag ${i + 1}`, +})); + +interface ColorData { + name: string; + color: string; +} + +const colorPickerOptions: ISelectOption[] = ( + [ + { name: 'Transparent', color: 'transparent' }, + { name: 'White', color: '#ffffff' }, + { name: 'Red', color: '#f42a25' }, + { name: 'Magenta', color: '#e81e63' }, + { name: 'Purple', color: '#b21f7e' }, + { name: 'Violet', color: '#673ab7' }, + { name: 'Indigo', color: '#3f51b5' }, + { name: 'Blue', color: '#3f88c5' }, + { name: 'Light blue', color: '#03a9f3' }, + { name: 'Cyan', color: '#00bcd3' }, + { name: 'Teal', color: '#009688' }, + { name: 'Green', color: '#4caf50' }, + { name: 'Light green', color: '#8bc24a' }, + { name: 'Lime', color: '#ccdb39' }, + { name: 'Yellow', color: '#f2d611' }, + { name: 'Amber', color: '#ffc107' }, + { name: 'Orange', color: '#ff9800' }, + { name: 'Deep orange', color: '#ff5722' }, + { name: 'Grey', color: '#9e9e9e' }, + { name: 'Blue grey', color: '#607d8b' }, + { name: 'Brown', color: '#795548' }, + { name: 'Black', color: '#0d0d0d' }, + ] satisfies ColorData[] +).map((c, i) => ({ value: String(i + 1), label: c.name, customData: c })); + +const colorSwatchStyle = (data: ColorData): React.CSSProperties => ({ + width: '100%', + height: '100%', + borderRadius: 4, + background: + data.color === 'transparent' + ? 'linear-gradient(to top right, #fff calc(50% - 1px), #e53935 calc(50% - 1px), #e53935 calc(50% + 1px), #fff calc(50% + 1px))' + : data.color, + border: + data.color === 'transparent' || data.color === '#ffffff' ? '1px solid var(--form-input-border-default)' : 'none', +}); + +const triggerSwatchStyle = (data: ColorData): React.CSSProperties => ({ + ...colorSwatchStyle(data), + width: 24, + height: 24, +}); + +interface IconData { + name: string; + icon: string; +} + +const iconPickerOptions: ISelectOption[] = ( + [ + { name: 'Desktop', icon: 'computer' }, + { name: 'Phone', icon: 'smartphone' }, + { name: 'Tablet', icon: 'tablet_mac' }, + { name: 'Watch', icon: 'watch' }, + { name: 'TV', icon: 'tv' }, + ] satisfies IconData[] +).map((d, i) => ({ value: String(i + 1), label: d.name, customData: d })); + +export const ValueType: Story = { + render: () => ( + + + + ( +
+ )} + renderOption={(optionProps) => ( +
+ )} + /> +
+
+ + + + + + +