From 412506584ff847b987613d029e9249fb8011b9de Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:49:31 +0300 Subject: [PATCH 1/5] fix(select): multiselect tag handling improvements, story updates, visual fixes #587 --- .../components/select-bulk-helpers.spec.ts | 142 +++++ .../select/components/select-bulk-helpers.ts | 75 +++ .../components/select-group-heading.tsx | 43 +- .../select/components/select-menu-list.tsx | 74 ++- .../components/select-multi-value.spec.ts | 72 +++ .../select/components/select-multi-value.tsx | 40 +- .../select/components/select-single-value.tsx | 16 + .../select/components/select-tags-context.ts | 18 + .../components/select-value-container.tsx | 112 +++- .../components/form/select/select.module.scss | 111 +++- .../components/form/select/select.spec.tsx | 349 ++++++++++++ .../components/form/select/select.stories.tsx | 528 +++++++++++++++--- src/tedi/components/form/select/select.tsx | 283 ++++++++++ .../providers/label-provider/labels-map.ts | 7 + 14 files changed, 1747 insertions(+), 123 deletions(-) create mode 100644 src/tedi/components/form/select/components/select-bulk-helpers.spec.ts create mode 100644 src/tedi/components/form/select/components/select-bulk-helpers.ts create mode 100644 src/tedi/components/form/select/components/select-multi-value.spec.ts create mode 100644 src/tedi/components/form/select/components/select-single-value.tsx create mode 100644 src/tedi/components/form/select/components/select-tags-context.ts diff --git a/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts b/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts new file mode 100644 index 000000000..a15dd8fda --- /dev/null +++ b/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts @@ -0,0 +1,142 @@ +import { IGroupedOptions, ISelectOption } from '../select'; +import { + areAllSelected, + getEnabledOptions, + getGroupEnabledOptions, + isGroupedOptions, + isIndeterminate, + toggleBulkSelection, +} from './select-bulk-helpers'; + +const flat: ISelectOption[] = [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'c', label: 'C', isDisabled: true }, +]; + +const grouped: IGroupedOptions[] = [ + { + 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 for the matching group label', () => { + expect(getGroupEnabledOptions(grouped, 'Numbers').map((o) => o.value)).toEqual(['1', '2']); + }); + + it('returns [] when group label is not found', () => { + expect(getGroupEnabledOptions(grouped, 'Missing')).toEqual([]); + }); + + it('returns [] when called on flat options', () => { + expect(getGroupEnabledOptions(flat, 'whatever')).toEqual([]); + }); + }); + + 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..d2704a27b --- /dev/null +++ b/src/tedi/components/form/select/components/select-bulk-helpers.ts @@ -0,0 +1,75 @@ +import { GroupBase, OptionsOrGroups } from 'react-select'; + +import { ISelectOption } from '../select'; + +/** + * 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. + */ +export const getEnabledOptions = ( + options: OptionsOrGroups> +): ISelectOption[] => { + if (!options || options.length === 0) return []; + if (isGroupedOptions(options)) { + return options.flatMap((group) => group.options.filter((o) => !o.isDisabled)); + } + return (options as ISelectOption[]).filter((o) => !o.isDisabled); +}; + +export const getGroupEnabledOptions = ( + options: OptionsOrGroups>, + groupLabel: string +): ISelectOption[] => { + if (!isGroupedOptions(options)) return []; + const group = options.find((g) => g.label === groupLabel); + return group ? 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-heading.tsx b/src/tedi/components/form/select/components/select-group-heading.tsx index eb857ea19..000a94878 100644 --- a/src/tedi/components/form/select/components/select-group-heading.tsx +++ b/src/tedi/components/form/select/components/select-group-heading.tsx @@ -3,8 +3,10 @@ import { ReactElement } 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'; type GroupHeadingType = GroupHeadingProps> & { optionGroupHeadingText?: Pick; @@ -13,9 +15,48 @@ type GroupHeadingType = GroupHeadingProps { const textSettings = props.data.text || optionGroupHeadingText; + // Forwarded from via selectProps. Cast through unknown + // to read them without polluting react-select's public types. + const { showSelectAll, selectAllLabel, keyboardMode, exitKeyboardMode, dropdownType } = + props.selectProps as unknown as { + showSelectAll?: boolean; + selectAllLabel?: string; + keyboardMode?: boolean; + exitKeyboardMode?: () => void; + dropdownType?: 'menu' | 'grid'; + }; + const isMulti = !!props.isMulti; + const enabled = getEnabledOptions(props.options); + const selected = (props.getValue() as ReadonlyArray) ?? []; + const allSelected = areAllSelected(selected, enabled); + const indeterminate = isIndeterminate(selected, enabled); + + const handleSelectAll = () => { + const next = toggleBulkSelection(selected, enabled); + props.setValue(next, allSelected ? 'deselect-option' : 'select-option'); + }; + + const renderSelectAll = isMulti && showSelectAll && enabled.length > 0; + + return ( +
+ + {renderSelectAll && ( +
e.preventDefault()} + onClick={handleSelectAll} + role="option" + aria-selected={allSelected} + > + +
+ )} + {props.children} +
+ {renderMessageListFooter && ( +
{renderMessageListFooter(props)}
+ )} +
+ ); +}; diff --git a/src/tedi/components/form/select/components/select-multi-value.spec.ts b/src/tedi/components/form/select/components/select-multi-value.spec.ts new file mode 100644 index 000000000..ef48c7061 --- /dev/null +++ b/src/tedi/components/form/select/components/select-multi-value.spec.ts @@ -0,0 +1,72 @@ +import { createMultiValueCloseHandler } from './select-multi-value'; + +type AnyEvent = { + type: string; + key?: string; + preventDefault?: jest.Mock; + stopPropagation?: jest.Mock; +}; + +const makeEvent = (type: string, key?: string): AnyEvent => ({ + type, + key, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), +}); + +describe('createMultiValueCloseHandler', () => { + it('forwards click events to removeProps.onClick', () => { + const onClick = jest.fn(); + const handler = createMultiValueCloseHandler({ onClick } as never); + const event = makeEvent('click'); + handler(event as never); + expect(onClick).toHaveBeenCalledWith(event); + }); + + it('does nothing on click when removeProps.onClick is missing', () => { + const handler = createMultiValueCloseHandler({} as never); + const event = makeEvent('click'); + expect(() => handler(event as never)).not.toThrow(); + }); + + it('treats Enter as activation and prevents the default + bubbling', () => { + const onClick = jest.fn(); + const handler = createMultiValueCloseHandler({ onClick } as never); + const event = makeEvent('keydown', 'Enter'); + handler(event as never); + expect(onClick).toHaveBeenCalledWith(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('treats Space as activation', () => { + const onClick = jest.fn(); + const handler = createMultiValueCloseHandler({ onClick } as never); + const event = makeEvent('keydown', ' '); + handler(event as never); + expect(onClick).toHaveBeenCalled(); + }); + + it('ignores other keys', () => { + const onClick = jest.fn(); + const handler = createMultiValueCloseHandler({ onClick } as never); + const event = makeEvent('keydown', 'a'); + handler(event as never); + expect(onClick).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('ignores keydown when removeProps.onClick is missing', () => { + const handler = createMultiValueCloseHandler({} as never); + const event = makeEvent('keydown', 'Enter'); + expect(() => handler(event as never)).not.toThrow(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('ignores unrelated event types', () => { + const onClick = jest.fn(); + const handler = createMultiValueCloseHandler({ onClick } as never); + handler(makeEvent('mousedown') as never); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tedi/components/form/select/components/select-multi-value.tsx b/src/tedi/components/form/select/components/select-multi-value.tsx index b02cc7770..eb189259f 100644 --- a/src/tedi/components/form/select/components/select-multi-value.tsx +++ b/src/tedi/components/form/select/components/select-multi-value.tsx @@ -2,15 +2,29 @@ import { MultiValueProps } from 'react-select'; import { Tag } from '../../../tags/tag/tag'; import { ISelectOption } from '../select'; +import { useSelectTagsContext } from './select-tags-context'; type MultiValueType = MultiValueProps & { isTagRemovable?: boolean }; -export const SelectMultiValue = ({ isTagRemovable, children, removeProps }: MultiValueType): JSX.Element => { - const handleClose: React.MouseEventHandler & React.KeyboardEventHandler = ( - event - ) => { +type RemoveProps = MultiValueProps['removeProps']; + +/** + * Build the close handler that the rendered Tag passes to its inner button. + * + * Click activations forward straight to react-select's `removeProps.onClick`. + * Keyboard activations (Enter / Space) are also accepted as a defensive + * fallback for consumers who wire the handler directly to a custom keyboard + * trigger — Tag itself only invokes `onClose` from its click handler, so the + * keyboard branch only fires when the handler is reused outside Tag. + */ +export const createMultiValueCloseHandler = + ( + removeProps: RemoveProps + ): React.MouseEventHandler & React.KeyboardEventHandler => + (event) => { if (event.type === 'click' && removeProps.onClick) { removeProps.onClick(event as unknown as React.MouseEvent); + return; } if (event.type === 'keydown') { @@ -23,8 +37,24 @@ export const SelectMultiValue = ({ isTagRemovable, children, removeProps }: Mult } }; +export const SelectMultiValue = ({ + isTagRemovable, + children, + removeProps, + ...props +}: MultiValueType): JSX.Element | null => { + const { isSingleRow, visibleCount } = useSelectTagsContext(); + + const selected = (props.selectProps.value as ReadonlyArray | null) ?? []; + const index = Array.isArray(selected) ? selected.findIndex((opt) => opt.value === props.data.value) : -1; + const isHidden = isSingleRow && visibleCount !== null && index !== -1 && index >= visibleCount; + + const handleClose = createMultiValueCloseHandler(removeProps); + + if (isHidden) return null; + return ( -
event.stopPropagation()}> +
event.stopPropagation()} data-tedi-tag-index={index}> {children} diff --git a/src/tedi/components/form/select/components/select-single-value.tsx b/src/tedi/components/form/select/components/select-single-value.tsx new file mode 100644 index 000000000..620ac44a4 --- /dev/null +++ b/src/tedi/components/form/select/components/select-single-value.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import { components as ReactSelectComponents, SingleValueProps } from 'react-select'; + +import { ISelectOption } from '../select'; + +export const SelectSingleValue = (props: SingleValueProps): JSX.Element => { + const { renderValue } = props.selectProps as unknown as { + renderValue?: (option: ISelectOption) => ReactNode; + }; + + if (renderValue) { + return {renderValue(props.data)}; + } + + return {props.children}; +}; diff --git a/src/tedi/components/form/select/components/select-tags-context.ts b/src/tedi/components/form/select/components/select-tags-context.ts new file mode 100644 index 000000000..336b6fb36 --- /dev/null +++ b/src/tedi/components/form/select/components/select-tags-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +export interface SelectTagsContextValue { + /** True when the parent Select is in `tagsDirection="row"` multi mode. */ + isSingleRow: boolean; + /** + * Number of tags that fit on one row. `null` means "not yet measured" — render + * every tag so widths can be read; subsequent renders use the computed value. + */ + visibleCount: number | null; +} + +export const SelectTagsContext = createContext({ + isSingleRow: false, + visibleCount: null, +}); + +export const useSelectTagsContext = () => useContext(SelectTagsContext); diff --git a/src/tedi/components/form/select/components/select-value-container.tsx b/src/tedi/components/form/select/components/select-value-container.tsx index a68974188..6cf00cd88 100644 --- a/src/tedi/components/form/select/components/select-value-container.tsx +++ b/src/tedi/components/form/select/components/select-value-container.tsx @@ -1,16 +1,110 @@ import cn from 'classnames'; -import { components as ReactSelectComponents } from 'react-select'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { components as ReactSelectComponents, ValueContainerProps } from 'react-select'; -import { UnknownType } from '../../../../types/commonTypes'; +import { Tag } from '../../../tags/tag/tag'; +import { ISelectOption } from '../select'; import styles from '../select.module.scss'; +import { SelectTagsContext } from './select-tags-context'; + +const TAG_GAP_PX = 8; +const COUNTER_TAG_WIDTH_PX = 40; + +type Props = ValueContainerProps & { + selectProps: ValueContainerProps['selectProps'] & { tagsDirection?: 'row' | 'stack' }; +}; + +export const SelectValueContainer = ({ children, ...props }: Props) => { + const tagsDirection = props.selectProps.tagsDirection; + const isMulti = props.isMulti; + const isSingleRow = !!isMulti && tagsDirection === 'row'; + + const selected = (props.selectProps.value as ReadonlyArray | null) ?? []; + const totalCount = Array.isArray(selected) ? selected.length : 0; + + const containerRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(null); + + // Reset measurement whenever the selection count changes — that gives the + // child MultiValues a render with `visibleCount = null`, which forces them + // all to render so we can read their widths. + useLayoutEffect(() => { + if (!isSingleRow) { + if (visibleCount !== null) setVisibleCount(null); + return; + } + setVisibleCount(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSingleRow, totalCount]); + + // Measure rendered tags after the reset render and compute how many fit. + useLayoutEffect(() => { + if (!isSingleRow || visibleCount !== null) return; + const container = containerRef.current; + if (!container) return; + + const containerWidth = container.clientWidth; + if (containerWidth === 0) return; + + const tags = container.querySelectorAll('[data-tedi-tag-index]'); + if (tags.length === 0) { + setVisibleCount(0); + return; + } + + const inputEl = container.querySelector('.select__input-container'); + const inputMin = inputEl ? parseFloat(getComputedStyle(inputEl).minWidth) || 0 : 0; + const available = containerWidth - inputMin; + + let usedWidth = 0; + let visible = 0; + for (let i = 0; i < tags.length; i++) { + const tagWidth = tags[i].offsetWidth; + const hasMore = i < tags.length - 1; + const reserved = hasMore ? COUNTER_TAG_WIDTH_PX + TAG_GAP_PX : 0; + const needed = usedWidth + tagWidth + (visible > 0 ? TAG_GAP_PX : 0); + if (needed + reserved <= available) { + usedWidth = needed; + visible++; + } else { + break; + } + } + if (visible === 0) visible = 1; + setVisibleCount(visible); + }, [isSingleRow, visibleCount, totalCount]); + + const hiddenCount = isSingleRow && visibleCount !== null ? Math.max(0, totalCount - visibleCount) : 0; + + const ctxValue = useMemo(() => ({ isSingleRow, visibleCount }), [isSingleRow, visibleCount]); + + // react-select renders children as [...MultiValues, Input]. Inject the + // `+N` Tag before the input so the visual order is: tags → counter → input. + const childrenArray = React.Children.toArray(children); + const lastIndex = childrenArray.length > 0 ? childrenArray.length - 1 : -1; + const beforeInput = lastIndex >= 0 ? childrenArray.slice(0, lastIndex) : []; + const inputChild = lastIndex >= 0 ? childrenArray[lastIndex] : null; -export const SelectValueContainer = ({ children, ...props }: UnknownType) => { return ( - - {children} - + + { + containerRef.current = el; + }, + }} + className={cn(styles['tedi-select__value-container'], props.className)} + > + {beforeInput} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} + {inputChild} + + ); }; diff --git a/src/tedi/components/form/select/select.module.scss b/src/tedi/components/form/select/select.module.scss index ac483d21a..f5662da5e 100644 --- a/src/tedi/components/form/select/select.module.scss +++ b/src/tedi/components/form/select/select.module.scss @@ -78,9 +78,16 @@ div .tedi-select__menu { border-radius: var(--form-field-radius); @include mixins.print-grayscale; + + // In grid mode the menu shouldn't stretch to match the trigger — let it + // shrink to the grid's natural width. + &:has(.tedi-select__menu-list--grid) { + width: fit-content; + min-width: 0; + } } -.tedi-select__menu .tedi-select__menu-list, +.tedi-select__menu .tedi-select__menu-list:not(.tedi-select__menu-list--grid), .tedi-select__menu .tedi-select__menu-list-inner { padding: 0; } @@ -99,10 +106,14 @@ div .tedi-select__menu { background-color: var(--dropdown-item-hover-background); } + // react-select's `isFocused` flag is set on both keyboard-arrow nav AND mouse + // hover (and on whichever option is auto-active when the menu first opens). + // Background tint is fine to share, but the focus ring must only appear during + // real keyboard navigation — that's gated by the `:has(:focus-visible)` rule + // below. &--focused { background-color: var(--form-input-background-default); border-radius: var(--dropdown-item-radius); - box-shadow: inset 0 0 0 2px var(--form-input-border-hover); } &:active:not(.tedi-select__option--disabled), @@ -204,18 +215,18 @@ div.tedi-select__multi-value-item { .tedi-select--tags-row { :global .select__control > .select__value-container--is-multi { flex-wrap: nowrap; + overflow: hidden; - &::after { - position: absolute; - inset: 0; - pointer-events: none; - content: ''; - box-shadow: inset 0.5rem 0 0.25rem -0.25rem var(--tedi-alpha-100); + > * { + flex-shrink: 0; } - } - &.tedi-select--searchable :global .tedi-select__control > .tedi-select__value-container--is-multi { - justify-content: flex-end; + /* Push react-select's input container to the very end so the + overflow `+N` Tag visually sits between the last visible tag + and the search input. */ + :global .select__input-container { + order: 99; + } } :global .tedi-select__control--is-focused .tedi-select__input { @@ -223,6 +234,84 @@ div.tedi-select__multi-value-item { } } +.tedi-select__overflow-tag { + flex-shrink: 0; +} + +// Focus ring on the active option ONLY while the user is keyboard-driving +// the menu. `--keyboard` is toggled by Select itself: arrow / Home / End / +// PageUp / PageDown / Tab keydown turns it on; the next mousemove inside the +// menu list turns it off. This avoids the limitation of `:focus-visible`, +// which stays on after a Tab and would otherwise re-paint the ring on every +// mouse hover. +.tedi-select__menu-list--keyboard .tedi-select__option--focused { + box-shadow: inset 0 0 0 2px var(--form-input-border-hover); +} + +.tedi-select__select-all { + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + cursor: pointer; + + &:hover { + background-color: var(--dropdown-item-hover-background); + } +} + +.tedi-select__group-heading-toggle { + cursor: pointer; +} + +// Grid layout for color/icon pickers (Angular `dropdownType="grid"`). +// Each option becomes a fixed-size square; the menu auto-sizes to its +// natural width up to `--tedi-swatch-columns` columns. Customise via the +// three CSS variables on the menu list. +.tedi-select__menu-list--grid { + --tedi-swatch-size: 24px; + --tedi-swatch-gap: 4px; + --tedi-swatch-columns: 11; + --tedi-swatch-grid-padding: 8px; + + display: grid; + grid-template-columns: repeat(auto-fit, var(--tedi-swatch-size)); + gap: var(--tedi-swatch-gap); + width: fit-content; + max-width: calc( + var(--tedi-swatch-columns) * (var(--tedi-swatch-size) + var(--tedi-swatch-gap)) + + (2 * var(--tedi-swatch-grid-padding)) + ); + padding: var(--tedi-swatch-grid-padding); + + .tedi-select__option { + display: flex; + align-items: center; + justify-content: center; + width: var(--tedi-swatch-size); + height: var(--tedi-swatch-size); + padding: 2px; + color: inherit; + background: transparent; + border-radius: var(--card-radius-rounded); + + // Override the global `--selected` rule (white-on-blue), which would + // otherwise paint icon options invisible inside their transparent cell. + &--selected, + &[aria-selected='true'] { + color: inherit; + background: transparent; + border: 2px solid var(--card-border-selected); + + p, + span { + color: inherit; + } + } + + &:hover:not(.tedi-select__option--disabled) { + background: transparent; + } + } +} + .tedi-select--tags-stack { :global .select__input-container { grid-template-columns: 0 min-content; diff --git a/src/tedi/components/form/select/select.spec.tsx b/src/tedi/components/form/select/select.spec.tsx index deea2affd..f515bf084 100644 --- a/src/tedi/components/form/select/select.spec.tsx +++ b/src/tedi/components/form/select/select.spec.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { UnknownType } from '../../../types/commonTypes'; +import { SelectMultiValueRemove } from './components/select-multi-value-remove'; import { ISelectOption, Select, SelectProps } from './select'; async function openSelectWithKeyboard(selectElement: HTMLElement) { @@ -326,4 +327,352 @@ describe('Select component', () => { const { container } = render( 'my-menu' }} /> + ); + expect(container.querySelector('.my-control')).toBeInTheDocument(); + }); + + it('renders search icon when iconName is "search"', () => { + const { container } = render(
Footer content
} /> + ); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + expect(await screen.findByTestId('list-footer')).toBeInTheDocument(); + }); + + it('uses renderValue to customise the selected single-value display', () => { + render( + ); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + await waitFor(() => { + const list = document.querySelector('[class*="menu-list--grid"]'); + expect(list).toBeInTheDocument(); + }); + }); + + it('toggles keyboard mode on arrow keys and clears it on mousemove', async () => { + render(); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + expect(await screen.findByText(SELECT_ALL_KEY)).toBeInTheDocument(); + }); + + it('does not render Select All toggle outside multi mode', async () => { + render( + ); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + const selectAll = await screen.findByText(SELECT_ALL_KEY); + await act(async () => { + await userEvent.click(selectAll); + }); + + expect(handleChange).toHaveBeenCalled(); + const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0] as ISelectOption[]; + expect(lastCall).toEqual([]); + }); + + it('selects every enabled option when Select All is clicked', async () => { + const handleChange = jest.fn(); + render(); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + expect(await screen.findByLabelText('Fruits')).toBeInTheDocument(); + expect(screen.getByLabelText('Vegetables')).toBeInTheDocument(); + }); + + it('toggles every option in a group when its heading checkbox is clicked', async () => { + const handleChange = jest.fn(); + render( + ); + + await act(async () => { + await userEvent.click(screen.getByRole('combobox')); + }); + + // Group label is shown but not as a labelled checkbox. + expect(await screen.findByText('Fruits')).toBeInTheDocument(); + expect(screen.queryByLabelText('Fruits')).not.toBeInTheDocument(); + }); + + it('removes a tag when its close button is clicked (multi-select)', async () => { + const handleChange = jest.fn(); + render( + ); + await act(async () => { + screen.getByRole('combobox').focus(); + await new Promise((r) => setTimeout(r, 0)); + }); + await waitFor(() => { + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + }); + + it('renders error helper styling when helper.type is "error"', () => { + const { container } = render(); + expect(container.querySelector('.tedi-select')).toHaveClass('tedi-select--valid'); + }); + + describe('SelectMultiValueRemove', () => { + const renderRemove = (onClick: jest.Mock) => + render( + )} + /> + ); + + it('forwards click events to innerProps.onClick', () => { + const onClick = jest.fn(); + const { container } = renderRemove(onClick); + fireEvent.click(container.querySelector('button')!); + expect(onClick).toHaveBeenCalled(); + }); + + it('treats Enter as activation', () => { + const onClick = jest.fn(); + const { container } = renderRemove(onClick); + fireEvent.keyDown(container.querySelector('button')!, { key: 'Enter' }); + expect(onClick).toHaveBeenCalled(); + }); + + it('treats Space as activation', () => { + const onClick = jest.fn(); + const { container } = renderRemove(onClick); + fireEvent.keyDown(container.querySelector('button')!, { key: ' ' }); + expect(onClick).toHaveBeenCalled(); + }); + + it('ignores other keys', () => { + const onClick = jest.fn(); + const { container } = renderRemove(onClick); + fireEvent.keyDown(container.querySelector('button')!, { key: 'a' }); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('stops propagation on mousedown so the menu does not close', () => { + const onClick = jest.fn(); + const stopSpy = jest.spyOn(Event.prototype, 'stopPropagation'); + const { container } = renderRemove(onClick); + fireEvent.mouseDown(container.querySelector('button')!); + expect(stopSpy).toHaveBeenCalled(); + stopSpy.mockRestore(); + }); + }); + + it('honours the inputIsHidden prop on the underlying input', () => { + render(); + const input = screen.getByRole('combobox'); + await act(async () => { + input.focus(); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + await new Promise((r) => setTimeout(r, 0)); + }); + await waitFor(() => { + expect(document.querySelector('[class*="menu-list--keyboard"]')).toBeInTheDocument(); + }); + }); + + describe('tagsDirection="row" overflow handling', () => { + let originalClientWidth: PropertyDescriptor | undefined; + let originalOffsetWidth: PropertyDescriptor | undefined; + + beforeAll(() => { + originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 100 }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 50 }); + }); + + afterAll(() => { + if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth); + else delete (HTMLElement.prototype as unknown as Record).clientWidth; + if (originalOffsetWidth) Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth); + else delete (HTMLElement.prototype as unknown as Record).offsetWidth; + }); + + it('renders a +N counter when tags overflow on a single row', async () => { + const manyOptions: ISelectOption[] = Array.from({ length: 5 }, (_, i) => ({ + value: `v${i}`, + label: `Option ${i}`, + })); + render( + + + + +
+ { + const data = option.customData as IconData; + return ; + }} + renderOption={(optionProps) => { + const data = optionProps.data.customData as IconData; + return ; + }} + /> +
+ + ), }; -export const WithHint: Story = { - args: { - ...Default.args, - id: 'with-hint-example', - defaultValue: undefined, - placeholder: 'Placeholder', - helper: { - text: 'Text hint', - type: 'hint', - }, +const departmentOptions: ISelectOption[] = [ + 'Emergency department', + 'Internal medicine', + 'Cardiology', + 'Neurology', + 'Orthopedics', + 'Pediatrics', + 'Psychiatry', + 'Radiology', + 'Surgery', + 'Urology', + 'Dermatology', + 'Oncology', + 'Gastroenterology', + 'Pulmonology', + 'Nephrology', + 'Endocrinology', + 'Rheumatology', + 'Infectious diseases', + 'Hematology', + 'Allergy and immunology', + 'Geriatrics', + 'Neonatology', + 'Palliative care', + 'Physical medicine', + 'Anesthesiology', + 'Pathology', + 'Nuclear medicine', + 'Ophthalmology', + 'Otolaryngology', + 'Plastic surgery', +].map((label) => ({ value: label.toLowerCase().replace(/\s+/g, '-'), label })); + +const groupedDepartments: OptionsOrGroups> = [ + { + label: 'Emergency', + options: [ + { value: 'emergency-department', label: 'Emergency department' }, + { value: 'urgent-care', label: 'Urgent care' }, + ], }, -}; + { + label: 'Internal', + options: [ + { value: 'internal-medicine', label: 'Internal medicine' }, + { value: 'cardiology', label: 'Cardiology' }, + { value: 'neurology', label: 'Neurology' }, + ], + }, + { + label: 'Surgery', + options: [ + { value: 'general-surgery', label: 'General surgery' }, + { value: 'orthopedic-surgery', label: 'Orthopedic surgery' }, + { value: 'neurosurgery', label: 'Neurosurgery' }, + ], + }, +]; -export const MultipleClosesOnSelect: Story = { - args: { - ...Default.args, - id: 'example-multiple-closes-on-select', - multiple: true, - closeMenuOnSelect: true, - blurInputOnSelect: true, - defaultValue: undefined, - placeholder: 'Placeholder', +interface DescriptionData { + title: string; + description: string; +} + +const accessOptions: ISelectOption[] = [ + { + value: '1', + label: 'Access to health data', + customData: { title: 'Access to health data', description: 'Doctors will be able to see your health data' }, }, -}; + { + value: '2', + label: 'Access to medications and health data', + customData: { + title: 'Access to medications and health data', + description: 'Doctors will be able to see your medications and health data', + }, + }, + { + value: '3', + label: 'Access to all', + customData: { title: 'Access to all', description: 'Doctors will be able to see all your information' }, + }, +]; -export const ClearIndicatorVisible: Story = { - args: { - ...Default.args, - isClearIndicatorVisible: true, +const permissionOptions: ISelectOption[] = [ + { + value: '1', + label: 'Read permissions', + customData: { title: 'Read permissions', description: 'Can view documents and files' }, + }, + { + value: '2', + label: 'Write permissions', + customData: { title: 'Write permissions', description: 'Can create and edit documents' }, }, + { + value: '3', + label: 'Admin permissions', + customData: { title: 'Admin permissions', description: 'Full access to all features' }, + }, +]; + +interface MetaData { + name: string; + slots: number; +} + +const locationMetaOptions: ISelectOption[] = [ + { value: '1', label: 'Tallinn', customData: { name: 'Tallinn', slots: 3 } }, + { value: '2', label: 'Tartu', customData: { name: 'Tartu', slots: 4 } }, + { value: '3', label: 'Elva', customData: { name: 'Elva', slots: 7 } }, + { value: '4', label: 'Pärnu', customData: { name: 'Pärnu', slots: 2 } }, + { value: '5', label: 'Narva', customData: { name: 'Narva', slots: 5 } }, +]; + +const renderDescriptionOption = (props: OptionProps) => { + const { title, description } = props.data.customData as DescriptionData; + return ( + + + {title} + + {description} + + + + ); }; -export const Loading: Story = { - args: { - ...Default.args, - isLoading: true, - }, +const renderHorizontalMetaOption = (props: OptionProps) => { + const { name, slots } = props.data.customData as MetaData; + return ( + + + {name} + + + + {slots} timeslots available + + + + ); }; -export const Required: Story = { - args: { - ...Default.args, - required: true, - }, +const selectAllOptions: ISelectOption[] = [ + { value: '1', label: 'Locations' }, + { value: '2', label: 'Doctors' }, + { value: '3', label: 'Hospitals' }, +]; + +export const Examples: Story = { + render: () => ( + + + + + + + + + ), }; +/** + * Demonstrates the controlled-value pattern: parent owns selection in + * `useState`, passes `value` and `onChange`. Use this shape when selection + * needs to be lifted (form integration, programmatic updates, etc.). + */ export const MultipleHandled: Story = { render: MultipleHandledTemplate, args: { @@ -241,38 +635,6 @@ export const MultipleHandled: Story = { }, }; -export const StackingTags: Story = { - render: MultipleHandledTemplate, - args: { - id: 'stacking-tags-example', - label: 'Stacking Tags', - defaultValue: colourOptions.filter((option) => !option.isDisabled), - multiple: true, - tagsDirection: 'stack', - isTagRemovable: true, - }, -}; - -export const NonRemovableTags: Story = { - render: MultipleHandledTemplate, - args: { - id: 'removable-tags-example', - label: 'Removable Tags', - defaultValue: colourOptions.filter((option) => !option.isDisabled), - multiple: true, - tagsDirection: 'stack', - isTagRemovable: false, - }, -}; - -export const WithDescription: Story = { - render: CustomOptionSelectTemplate, - args: { - label: 'With description', - id: 'description-select', - }, -}; - export const AsyncSelect: Story = { render: AsyncSelectTemplate, args: { @@ -289,11 +651,3 @@ export const EditableSelect: Story = { label: 'Editable label', }, }; - -export const SelectWithGroupedOptions: Story = { - args: { - id: 'grouped-options-example', - label: 'Grouped options label', - options: groupedOptions, - }, -}; diff --git a/src/tedi/components/form/select/select.tsx b/src/tedi/components/form/select/select.tsx index 6cb7f12d5..53c87a433 100644 --- a/src/tedi/components/form/select/select.tsx +++ b/src/tedi/components/form/select/select.tsx @@ -30,6 +30,7 @@ import { SelectMenuPortal } from './components/select-menu-portal'; import { SelectMultiValue } from './components/select-multi-value'; import { SelectMultiValueRemove } from './components/select-multi-value-remove'; import { SelectOption } from './components/select-option'; +import { SelectSingleValue } from './components/select-single-value'; import { SelectValueContainer } from './components/select-value-container'; import styles from './select.module.scss'; @@ -40,53 +41,279 @@ declare module 'react-select/dist/declarations/src/Select' { } export interface SelectProps extends FormLabelProps { + /** Unique HTML id for the input. Also used as react-select's `instanceId` for SSR-stable internal IDs. */ id: string; + /** + * The list of selectable options. Pass a flat `ISelectOption[]` for a simple + * list, or an array of `IGroupedOptions` (each with its own `options` array) + * for a grouped menu. + */ options?: OptionsOrGroups>; + /** + * Used in async mode (`async: true`). + * - `true` — call `loadOptions` once on mount with an empty input string + * and use the result as the initial option list. + * - An array — show these options before the user types anything. + * - Omit — start with no options until the user types. + */ defaultOptions?: OptionsOrGroups> | boolean; + /** Text shown in the field when no value is selected. */ placeholder?: string; + /** Extra class on the root wrapper. Use `classNames` for per-subcomponent overrides. */ className?: string; + /** + * Icon shown on the right of the field as the dropdown indicator. + * - `"arrow_drop_down"` (default) — standard select chevron. + * - `"search"` — magnifier; useful for combobox-style search fields. + * @default 'arrow_drop_down' + */ iconName?: 'arrow_drop_down' | 'search'; + /** + * Fires whenever the selection changes. Receives the new value: + * a single `ISelectOption` (single-select), an array (multi-select), + * or `null` when cleared. + */ onChange?: (value: TSelectValue) => void; + /** + * Fires whenever the user types in the search input. Receives the new input + * string and a `react-select` action descriptor (e.g. `'input-change'`, + * `'menu-close'`). Use this to drive controlled search. + */ onInputChange?: (value: string, actionMeta: InputActionMeta) => void; + /** Controlled search input string. Pair with `onInputChange` to manage it from the parent. */ inputValue?: string; + /** + * Async option loader. Called with the current search string; resolve the + * `callback` with the matching options. Only invoked when `async: true`. + */ loadOptions?: ( inputValue: string, callback: (options: OptionsOrGroups>) => void ) => void; + /** When `true`, shows a loading spinner in the indicator area. Useful while async results are pending. */ isLoading?: boolean; + /** Uncontrolled initial selection. Ignored when `value` is provided. */ defaultValue?: TSelectValue; + /** + * Controlled selection. When set, the parent owns the value and must update + * it via `onChange`. Use `defaultValue` for uncontrolled usage. + */ value?: TSelectValue; + /** Disables interaction and applies disabled styling. */ disabled?: boolean; + /** Form field name; rendered onto the underlying hidden input for form submission. */ name?: string; + /** + * Forces error styling. Also set automatically when `helper.type === 'error'`. + * @default false + */ invalid?: boolean; + /** + * Forces valid (success) styling. Also set automatically when `helper.type === 'valid'`. + * @default false + */ valid?: boolean; + /** + * Helper / feedback text rendered below the field. Set `type` to `'hint'`, + * `'error'`, or `'valid'` — the latter two also drive the field's invalid / + * valid visual state. + */ helper?: FeedbackTextProps; + /** + * Visual size variant. + * - omit — default (40px tall). + * - `"small"` — compact (32px tall) for dense layouts. + */ size?: 'small'; + /** + * Switches the underlying component to `react-select`'s `AsyncSelect`. Pair + * with `loadOptions` (and optionally `defaultOptions`) to fetch options on + * the fly. + * @default false + */ async?: boolean; + /** + * Custom renderer for the content of each option in the dropdown. Receives + * the full option props from `react-select`; return any React node. Use + * `renderValue` if you also want to customise how the selected value + * appears in the trigger. + */ renderOption?: (props: OptionProps) => JSX.Element; + /** + * Message shown when the option list is empty (no matches for the search, + * or no options at all). Defaults to the localised `select.no-options` + * label from `LabelProvider`. + */ noOptionsMessage?: (obj: { inputValue: string }) => React.ReactNode; + /** + * Message shown while async options are loading. Defaults to the localised + * `select.loading` label from `LabelProvider`. + */ loadingMessage?: (obj: { inputValue: string }) => React.ReactNode; + /** + * Renders custom content underneath the option list, inside the dropdown + * (e.g. a "Show more" button or a "powered by" footer). + */ renderMessageListFooter?: (props: MenuListProps) => JSX.Element; + /** + * Enables multi-select mode: the field renders selections as removable + * tags and `onChange` receives an array. + * @default false + */ multiple?: boolean; + /** + * Layout for selected tags in multi-select mode. + * - `"row"` (default) — tags stay on one row; overflow tags collapse into a + * `+N` counter, just like the Angular `multiRow=false` mode. + * - `"stack"` — tags wrap onto multiple rows. + * @default 'row' + */ tagsDirection?: 'stack' | 'row'; + /** + * Layout for the dropdown menu. + * - `"menu"` (default): vertical list of options. + * - `"grid"`: swatch grid for color / icon pickers and similar compact + * pickers. Grid sizing is customizable via the `--tedi-swatch-size`, + * `--tedi-swatch-gap`, and `--tedi-swatch-columns` CSS variables on the + * menu list element. + * @default 'menu' + */ + dropdownType?: 'menu' | 'grid'; + /** + * Custom renderer for the trigger value (single-select). Receives the + * currently selected option and may return any React node — useful for + * color swatches, icons, or any non-text representation in the field. + * Ignored in multi-select mode (use `renderOption` for tag rendering). + */ + renderValue?: (option: ISelectOption) => React.ReactNode; + /** + * In multi-select mode, prepends a "Select all" toggle to the menu list. + * Toggles every enabled option (or, when filtering, every visible enabled + * option) on/off. Indeterminate when only some are selected. Ignored when + * `multiple` is false. + * @default false + */ + showSelectAll?: boolean; + /** + * In multi-select mode with grouped options, makes each group heading a + * checkbox that toggles the whole group. Indeterminate when only some + * options in the group are selected. Ignored when `multiple` is false or + * `options` is not grouped. + * @default false + */ + selectableGroups?: boolean; + /** + * Open the menu automatically when the input first receives focus. + * @default false + */ openMenuOnFocus?: boolean; + /** + * Open the menu when the trigger area is clicked. + * @default true + */ openMenuOnClick?: boolean; + /** + * Treat the Tab key as a confirm-and-move-on for the currently focused + * option (otherwise Tab simply moves focus out of the menu without + * selecting). + * @default false + */ tabSelectsValue?: boolean; + /** + * Close the menu after each successful selection. Default depends on + * `multiple`: `true` for single-select, `false` for multi-select so the + * user can pick several options without re-opening. + */ closeMenuOnSelect?: boolean; + /** + * Blur the search input after each selection. Useful if you want to + * collapse the cursor immediately on pick. + * @default false + */ blurInputOnSelect?: boolean; + /** + * Focus the input on initial mount. + * @default false + */ autoFocus?: boolean; + /** + * Whether the value can be cleared. With the current default, Backspace + * also clears — but the visible "×" button only appears if + * `isClearIndicatorVisible` is also `true` (that prop is now deprecated; + * see its docstring for migration plans). + * @default true + */ isClearable?: boolean; + /** + * @deprecated This prop will be removed in a future major version. + * + * `isClearable` and `isClearIndicatorVisible` overlap: `isClearable` already + * controls whether the value can be cleared, and `isClearIndicatorVisible` + * only adds an extra gate on whether the visible "×" button renders. The + * default combination (`isClearable: true`, `isClearIndicatorVisible: false`) + * leaves consumers in a state where Backspace clears the value but no + * affordance is shown — a hidden interaction. + * + * In a future major version the prop will be removed and `isClearable` + * alone will control both behaviour and visibility (matching `react-select` + * and the Angular implementation). For new code, prefer `isClearable` and + * leave this prop unset. + */ isClearIndicatorVisible?: boolean; + /** + * Allow filtering the option list by typing. Set to `false` for a pure + * dropdown with no search input (e.g. color/icon pickers). + * @default true + */ isSearchable?: boolean; + /** + * In multi-select mode, render an "×" remove button on each selected tag + * so the user can deselect single options without re-opening the menu. + * @default false + */ isTagRemovable?: boolean; + /** + * Controlled menu open state. When set, the parent owns whether the menu + * is showing — pair with `onMenuOpen` / `onMenuClose`. + */ menuIsOpen?: boolean; + /** Fires when the menu opens (uncontrolled or controlled). */ onMenuOpen?: () => void; + /** Fires when the menu closes (uncontrolled or controlled). */ onMenuClose?: () => void; + /** Fires when the input loses focus. */ onBlur?: () => void; + /** + * Hide the underlying text input (its width collapses to 0). Useful when + * the field is a pure picker with no typing — the value display still + * shows, but the cursor caret area is removed. + */ inputIsHidden?: boolean; + /** + * Typography overrides applied to group headings (when `options` is + * grouped). `text` on an individual `IGroupedOptions` entry takes + * precedence over this default. + * @default { modifiers: 'small', color: 'tertiary' } + */ optionGroupHeadingText?: Pick; + /** + * In async mode, cache the result of each `loadOptions` call by input + * string so the same query isn't re-fetched. + * @default false + */ cacheOptions?: boolean; + /** + * In single-select mode, render each option with a leading radio button + * for a more explicit "pick one" UI. Has no effect in multi-select mode. + * @default false + */ showRadioButtons?: boolean; + /** + * Per-subcomponent class overrides forwarded to react-select's `classNames` + * map. Each entry adds an extra class onto the corresponding internal + * subcomponent; use this for one-off styling without losing the default + * `tedi-select__*` BEM classes. + */ classNames?: { clearIndicator?: string; container?: string; @@ -113,17 +340,35 @@ export interface SelectProps extends FormLabelProps { }; } +/** + * One option in the select list. `customData` is a typed escape hatch for + * carrying domain data alongside the display label — `renderOption` / + * `renderValue` can read it back via `props.data.customData`. + */ export interface ISelectOption { + /** The string written into the form value when this option is picked. */ value: string; + /** Display label. Strings are searchable; React nodes render as-is and aren't filtered by search. */ label: string | React.ReactNode | React.ReactNode[]; + /** When `true`, the option appears greyed out and can't be picked. */ isDisabled?: boolean; + /** Arbitrary data attached to the option, accessible inside custom renderers. */ customData?: CustomData; } +/** + * A group of options in a grouped select. Extends react-select's `GroupBase` + * with a `text` field that overrides typography for this specific heading. + */ export interface IGroupedOptions extends GroupBase { + /** Typography override for this group heading. Falls back to `optionGroupHeadingText`. */ text?: Pick; } +/** + * Shape of the Select's value: a single option (single-select), an array + * (multi-select), or `null` when empty. + */ export type TSelectValue = | ISelectOption | ReadonlyArray> @@ -164,6 +409,10 @@ export const Select = forwardRef { + if ( + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Home' || + e.key === 'End' || + e.key === 'PageUp' || + e.key === 'PageDown' || + e.key === 'Tab' + ) { + if (!keyboardMode) setKeyboardMode(true); + } + }; + const exitKeyboardMode = React.useCallback(() => { + setKeyboardMode((prev) => (prev ? false : prev)); + }, []); + const SelectMenuListMemo = React.useCallback( (menuProps: MenuListProps) => ( @@ -220,6 +491,7 @@ export const Select = forwardRef SelectMultiValue({ isTagRemovable, ...props }), MultiValueRemove: SelectMultiValueRemove, + SingleValue: SelectSingleValue, Group: SelectGroup, GroupHeading: (props) => SelectGroupHeading({ optionGroupHeadingText, ...props }), IndicatorsContainer: SelectIndicatorsContainer, @@ -234,6 +506,17 @@ export const Select = forwardRef> + // Forwarded to `selectProps` so children (ValueContainer, MultiValue, + // MenuList, GroupHeading) can read these without a separate context. + // @ts-expect-error custom prop preserved on selectProps + tagsDirection={tagsDirection} + showSelectAll={showSelectAll} + selectableGroups={selectableGroups} + dropdownType={dropdownType} + renderValue={renderValue} + keyboardMode={keyboardMode} + exitKeyboardMode={exitKeyboardMode} + onKeyDown={handleSelectKeyDown} id={id} aria-describedby={helperId} autoFocus={autoFocus} diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 8483ae1b2..ee3e1fc96 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -313,6 +313,13 @@ export const labelsMap = validateDefaultLabels({ en: 'No options', ru: 'Нет вариантов', }, + 'select.select-all': { + description: 'Label for the "Select all" toggle inside multi-select dropdown', + components: ['select'], + et: 'Vali kõik', + en: 'Select all', + ru: 'Выбрать все', + }, 'stepper.completed': { description: 'Label for screen-reader that this step is completed (visually hidden)', components: ['StepperNav'], From 2cc1036d621859cc3fea088769a1e6b64e1f11f8 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:44:59 +0300 Subject: [PATCH 2/5] fix(select): code rabbit fixes #587 --- .../components/select-bulk-helpers.spec.ts | 37 ++++++++++++++++--- .../select/components/select-bulk-helpers.ts | 16 ++++---- .../components/select-group-bulk-context.ts | 23 ++++++++++++ .../components/select-group-heading.tsx | 37 ++++++++++++------- .../form/select/components/select-group.tsx | 24 ++++++++++-- .../select/components/select-menu-list.tsx | 3 +- .../components/select-single-option.tsx | 6 +++ .../components/select-value-container.tsx | 2 + .../components/form/select/select.module.scss | 11 +++++- 9 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 src/tedi/components/form/select/components/select-group-bulk-context.ts diff --git a/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts b/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts index a15dd8fda..110f76463 100644 --- a/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts +++ b/src/tedi/components/form/select/components/select-bulk-helpers.spec.ts @@ -61,16 +61,41 @@ describe('select-bulk-helpers', () => { }); describe('getGroupEnabledOptions', () => { - it('returns enabled options for the matching group label', () => { - expect(getGroupEnabledOptions(grouped, 'Numbers').map((o) => o.value)).toEqual(['1', '2']); + it('returns enabled options of the passed group', () => { + expect(getGroupEnabledOptions(grouped[1]).map((o) => o.value)).toEqual(['1', '2']); }); - it('returns [] when group label is not found', () => { - expect(getGroupEnabledOptions(grouped, 'Missing')).toEqual([]); + 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 called on flat options', () => { - expect(getGroupEnabledOptions(flat, 'whatever')).toEqual([]); + 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']); }); }); diff --git a/src/tedi/components/form/select/components/select-bulk-helpers.ts b/src/tedi/components/form/select/components/select-bulk-helpers.ts index d2704a27b..a6b90685c 100644 --- a/src/tedi/components/form/select/components/select-bulk-helpers.ts +++ b/src/tedi/components/form/select/components/select-bulk-helpers.ts @@ -26,13 +26,15 @@ export const getEnabledOptions = ( return (options as ISelectOption[]).filter((o) => !o.isDisabled); }; -export const getGroupEnabledOptions = ( - options: OptionsOrGroups>, - groupLabel: string -): ISelectOption[] => { - if (!isGroupedOptions(options)) return []; - const group = options.find((g) => g.label === groupLabel); - return group ? group.options.filter((o) => !o.isDisabled) : []; +/** + * 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. */ 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 000a94878..ebde317b6 100644 --- a/src/tedi/components/form/select/components/select-group-heading.tsx +++ b/src/tedi/components/form/select/components/select-group-heading.tsx @@ -7,6 +7,7 @@ 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; @@ -16,34 +17,40 @@ export const SelectGroupHeading = ({ optionGroupHeadingText, ...props }: GroupHe const textSettings = props.data.text || optionGroupHeadingText; // Forwarded from ; cast to read without polluting react-select types. @@ -52,7 +53,7 @@ export const SelectGroupHeading = ({ optionGroupHeadingText, ...props }: GroupHe // mousedown that would otherwise close react-select's menu.
e.preventDefault()}> {props.data.label}} From ec070d82cb82fadabb5a4dd9f6e58db66a89b71c Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:40:02 +0300 Subject: [PATCH 4/5] fix(select): single row multitag handling on window resize, layout shift etc #587 --- .../components/select-value-container.tsx | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/tedi/components/form/select/components/select-value-container.tsx b/src/tedi/components/form/select/components/select-value-container.tsx index fcdad2be9..eb9c9ad1a 100644 --- a/src/tedi/components/form/select/components/select-value-container.tsx +++ b/src/tedi/components/form/select/components/select-value-container.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { components as ReactSelectComponents, ValueContainerProps } from 'react-select'; import { Tag } from '../../../tags/tag/tag'; @@ -23,6 +23,10 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { const totalCount = Array.isArray(selected) ? selected.length : 0; const containerRef = useRef(null); + // Width seen by the most recent measurement pass. We compare against this + // inside the ResizeObserver so contents-only reflows (e.g. tags coming + // back when we reset to null) don't ping-pong us into a re-measure loop. + const lastMeasuredWidthRef = useRef(0); const [visibleCount, setVisibleCount] = useState(null); // Reset measurement whenever the selection count changes — that gives the @@ -37,6 +41,30 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSingleRow, totalCount]); + // Re-measure when the container actually changes width (browser resize, + // parent layout shift, etc.). Without this the `+N` counter only matches + // the layout that existed at first paint — narrowing the window after + // load would leave overflow tags visibly clipping or push more rows in + // than fit. We compare the new contentRect width against the last value + // we measured at, so reflows triggered by our own setState don't loop. + useEffect(() => { + if (!isSingleRow) return; + const container = containerRef.current; + if (!container) return; + if (typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver((entries) => { + const newWidth = entries[0]?.contentRect.width ?? 0; + if (newWidth > 0 && newWidth !== lastMeasuredWidthRef.current) { + // Triggers the measurement pass below via the visibleCount === null + // branch; that pass updates `lastMeasuredWidthRef`. + setVisibleCount(null); + } + }); + observer.observe(container); + return () => observer.disconnect(); + }, [isSingleRow]); + // Measure rendered tags after the reset render and compute how many fit. useLayoutEffect(() => { if (!isSingleRow || visibleCount !== null) return; @@ -48,6 +76,7 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { const tags = container.querySelectorAll('[data-tedi-tag-index]'); if (tags.length === 0) { + lastMeasuredWidthRef.current = containerWidth; setVisibleCount(0); return; } @@ -71,6 +100,7 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { } } if (visible === 0) visible = 1; + lastMeasuredWidthRef.current = containerWidth; setVisibleCount(visible); }, [isSingleRow, visibleCount, totalCount]); From a0ee45986c0e4b554bb60113b223bd9a494caba0 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 6 May 2026 11:48:47 +0300 Subject: [PATCH 5/5] fix(select): code review fixes #587 --- .../select/components/select-bulk-helpers.ts | 29 +++++- .../components/select-group-heading.tsx | 7 +- .../select/components/select-menu-list.tsx | 51 ++-------- .../select/components/select-multi-option.tsx | 23 ++++- .../select/components/select-multi-value.tsx | 34 ++++++- .../components/select-single-option.tsx | 7 +- .../components/select-value-container.tsx | 38 ++++++-- .../form/select/examples/multiple-handled.tsx | 8 +- .../components/form/select/select.module.scss | 14 +++ .../components/form/select/select.spec.tsx | 18 +++- .../components/form/select/select.stories.tsx | 78 ++++++++++++--- src/tedi/components/form/select/select.tsx | 97 ++++++++++++++++++- src/tedi/components/tags/tag/tag.module.scss | 6 ++ src/tedi/components/tags/tag/tag.tsx | 12 ++- 14 files changed, 333 insertions(+), 89 deletions(-) diff --git a/src/tedi/components/form/select/components/select-bulk-helpers.ts b/src/tedi/components/form/select/components/select-bulk-helpers.ts index a6b90685c..bd1cb2a8d 100644 --- a/src/tedi/components/form/select/components/select-bulk-helpers.ts +++ b/src/tedi/components/form/select/components/select-bulk-helpers.ts @@ -2,6 +2,17 @@ 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; + /** * Returns true when `options` is a grouped tree (i.e. each top-level entry * has its own `options` array). @@ -15,15 +26,27 @@ export const isGroupedOptions = ( * 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 []; - if (isGroupedOptions(options)) { - return options.flatMap((group) => group.options.filter((o) => !o.isDisabled)); + 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 (options as ISelectOption[]).filter((o) => !o.isDisabled); + return flat; }; /** 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 660f3d4e9..133fa304a 100644 --- a/src/tedi/components/form/select/components/select-group-heading.tsx +++ b/src/tedi/components/form/select/components/select-group-heading.tsx @@ -45,7 +45,12 @@ export const SelectGroupHeading = ({ optionGroupHeadingText, ...props }: GroupHe }; return ( - + {interactive ? ( // The Checkbox owns the toggle path through its own `onChange`; // attaching another handler to the wrapper would fire `handleToggle` diff --git a/src/tedi/components/form/select/components/select-menu-list.tsx b/src/tedi/components/form/select/components/select-menu-list.tsx index 44b043242..d3486ac90 100644 --- a/src/tedi/components/form/select/components/select-menu-list.tsx +++ b/src/tedi/components/form/select/components/select-menu-list.tsx @@ -1,41 +1,24 @@ import cn from 'classnames'; import { components as ReactSelectComponents, MenuListProps } from 'react-select'; -import { useLabels } from '../../../../providers/label-provider'; -import { Checkbox } from '../../checkbox/checkbox'; import { ISelectOption } from '../select'; import styles from '../select.module.scss'; -import { areAllSelected, getEnabledOptions, isIndeterminate, toggleBulkSelection } from './select-bulk-helpers'; type MenuListType = MenuListProps & { renderMessageListFooter?: (props: MenuListProps) => JSX.Element; }; export const SelectMenuList = ({ renderMessageListFooter, ...props }: MenuListType) => { - const { getLabel } = useLabels(); - // Custom props forwarded from ) so it participates in keyboard navigation and default focus. + // The menu list itself only handles keyboard / mouse mode tracking and the + // optional message list footer. + const { keyboardMode, exitKeyboardMode, dropdownType } = props.selectProps as unknown as { + keyboardMode?: boolean; + exitKeyboardMode?: () => void; + dropdownType?: 'menu' | 'grid'; }; - const renderSelectAll = isMulti && showSelectAll && enabled.length > 0; - return (
- {renderSelectAll && ( -
e.preventDefault()} - onClick={handleSelectAll} - role="option" - aria-selected={allSelected} - > - -
- )} {props.children}
{renderMessageListFooter && ( diff --git a/src/tedi/components/form/select/components/select-multi-option.tsx b/src/tedi/components/form/select/components/select-multi-option.tsx index 2ae887225..535a5583d 100644 --- a/src/tedi/components/form/select/components/select-multi-option.tsx +++ b/src/tedi/components/form/select/components/select-multi-option.tsx @@ -4,16 +4,30 @@ import { components as ReactSelectComponents, OptionProps } from 'react-select'; import Checkbox from '../../checkbox/checkbox'; import { ISelectOption } from '../select'; import styles from '../select.module.scss'; +import { areAllSelected, getEnabledOptions, isIndeterminate, SELECT_ALL_VALUE } from './select-bulk-helpers'; type MultiOptionType = OptionProps & { renderOption?: (props: OptionProps) => JSX.Element; }; export const SelectMultiOption = ({ renderOption, ...props }: MultiOptionType): JSX.Element => { + const isSelectAll = props.data.value === SELECT_ALL_VALUE; + + let displayChecked = props.isSelected; + let displayIndeterminate = false; + if (isSelectAll) { + const enabled = getEnabledOptions(props.options).filter((o) => o.value !== SELECT_ALL_VALUE); + const selected = (props.getValue() as ReadonlyArray) ?? []; + const realSelected = selected.filter((o) => o.value !== SELECT_ALL_VALUE); + displayChecked = areAllSelected(realSelected, enabled); + displayIndeterminate = isIndeterminate(realSelected, enabled); + } + const OptionBEM = cn( styles['tedi-select__option'], { [styles['tedi-select__option--disabled']]: props.isDisabled }, - { [styles['tedi-select__option--focused']]: props.isFocused } + { [styles['tedi-select__option--focused']]: props.isFocused }, + { [styles['tedi-select__option--select-all']]: isSelectAll } ); const { tabIndex, ...innerProps } = props.innerProps; @@ -21,10 +35,10 @@ export const SelectMultiOption = ({ renderOption, ...props }: MultiOptionType): return ( - {renderOption ? ( + {renderOption && !isSelectAll ? ( renderOption(props) ) : ( <> @@ -36,7 +50,8 @@ export const SelectMultiOption = ({ renderOption, ...props }: MultiOptionType): className={styles['tedi-select__checkbox']} value={props.data.value} name={props.data.value} - checked={props.isSelected} + checked={displayChecked} + indeterminate={displayIndeterminate} onChange={() => null} disabled={props.isDisabled} hover={props.isFocused} diff --git a/src/tedi/components/form/select/components/select-multi-value.tsx b/src/tedi/components/form/select/components/select-multi-value.tsx index eb189259f..e2066bc38 100644 --- a/src/tedi/components/form/select/components/select-multi-value.tsx +++ b/src/tedi/components/form/select/components/select-multi-value.tsx @@ -51,11 +51,43 @@ export const SelectMultiValue = ({ const handleClose = createMultiValueCloseHandler(removeProps); + // Stop the click from bubbling to react-select's control (which would + // toggle the menu) before forwarding to the remove handler. The wrapping + // div's onMouseDown also stops propagation, but we keep the click guard on + // the button itself in case keyboard activation synthesises a click event. + const handleCloseClick: React.MouseEventHandler = (event) => { + event.stopPropagation(); + handleClose(event); + }; + + // Enter/Space activate the close button. preventDefault keeps Space from + // scrolling the menu list, stopPropagation keeps the activation event from + // re-opening the menu after the tag is removed. + const handleCloseKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + handleClose(event); + } + }; + if (isHidden) return null; return (
event.stopPropagation()} data-tedi-tag-index={index}> - + event.stopPropagation(), + onKeyDown: handleCloseKeyDown, + } + : undefined + } + > {children}
diff --git a/src/tedi/components/form/select/components/select-single-option.tsx b/src/tedi/components/form/select/components/select-single-option.tsx index d349dfc41..eb54d7fac 100644 --- a/src/tedi/components/form/select/components/select-single-option.tsx +++ b/src/tedi/components/form/select/components/select-single-option.tsx @@ -14,7 +14,7 @@ export const SelectSingleOption = ({ showRadioButtons, renderOption, ...props }: const OptionBEM = cn( styles['tedi-select__option'], { [styles['tedi-select__option--disabled']]: props.isDisabled }, - { [styles['tedi-select__option--selected']]: props.isSelected }, + { [styles['tedi-select__option--selected']]: props.isSelected && !showRadioButtons }, { [styles['tedi-select__option--focused']]: props.isFocused } ); @@ -40,11 +40,6 @@ export const SelectSingleOption = ({ showRadioButtons, renderOption, ...props }: value={props.data.value} checked={props.isSelected} disabled={props.isDisabled} - // Required: without an onChange, Radio falls back to its internal - // `innerChecked` state and ignores the controlled `checked` prop, - // so the radio dot would never visually update from react-select's - // selection. Click is handled at the option level — this is just - // a no-op marker so Radio respects the prop. onChange={() => null} /> diff --git a/src/tedi/components/form/select/components/select-value-container.tsx b/src/tedi/components/form/select/components/select-value-container.tsx index eb9c9ad1a..290dc64d5 100644 --- a/src/tedi/components/form/select/components/select-value-container.tsx +++ b/src/tedi/components/form/select/components/select-value-container.tsx @@ -18,6 +18,17 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { const tagsDirection = props.selectProps.tagsDirection; const isMulti = props.isMulti; const isSingleRow = !!isMulti && tagsDirection === 'row'; + // Re-measure whenever the focus state flips — react-select inflates the + // input's min-width from ~2px to 5rem on focus (see select.module.scss). + // Without this trigger the visibleCount calculated in the unfocused state + // would still be in effect after focus, causing the now-too-wide input to + // push the last visible tag and the +N counter past the container's right + // edge. + const isFocused = !!props.selectProps.menuIsOpen || !!(props.selectProps as { isFocused?: boolean }).isFocused; + // Re-measure when the typed input string changes — the input grows with + // content past its min-width, so a static measurement taken when the input + // was empty is no longer accurate once the user types. + const inputValue = props.selectProps.inputValue ?? ''; const selected = (props.selectProps.value as ReadonlyArray | null) ?? []; const totalCount = Array.isArray(selected) ? selected.length : 0; @@ -29,9 +40,11 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { const lastMeasuredWidthRef = useRef(0); const [visibleCount, setVisibleCount] = useState(null); - // Reset measurement whenever the selection count changes — that gives the - // child MultiValues a render with `visibleCount = null`, which forces them - // all to render so we can read their widths. + // Reset measurement whenever inputs that affect available space change: + // selection count (more / fewer tags to lay out), focus state (input min + // jumps between ~2px and 5rem), or typed input string (input grows with + // content). Each of these can change the layout enough that a previously + // computed visibleCount no longer fits. useLayoutEffect(() => { if (!isSingleRow) { if (visibleCount !== null) setVisibleCount(null); @@ -39,7 +52,7 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { } setVisibleCount(null); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSingleRow, totalCount]); + }, [isSingleRow, totalCount, isFocused, inputValue]); // Re-measure when the container actually changes width (browser resize, // parent layout shift, etc.). Without this the `+N` counter only matches @@ -81,9 +94,22 @@ export const SelectValueContainer = ({ children, ...props }: Props) => { return; } + // Reserve the input's actual rendered width (not just its min-width) so + // we account for content the user may have typed. Fall back to min-width + // when the input hasn't been laid out yet. const inputEl = container.querySelector('.select__input-container'); - const inputMin = inputEl ? parseFloat(getComputedStyle(inputEl).minWidth) || 0 : 0; - const available = containerWidth - inputMin; + let inputReserve = 0; + if (inputEl) { + const rendered = inputEl.offsetWidth; + const minWidth = parseFloat(getComputedStyle(inputEl).minWidth) || 0; + inputReserve = Math.max(rendered, minWidth); + } + // Reserve the gap between the last-visible-tag-or-counter and the input + // too — without this we underestimate by TAG_GAP_PX, letting the layout + // overflow by exactly one gap (visible as the rightmost tag clipping + // into the input area, which from a user's vantage looks like the first + // tags being pushed under the input). + const available = containerWidth - inputReserve - TAG_GAP_PX; let usedWidth = 0; let visible = 0; diff --git a/src/tedi/components/form/select/examples/multiple-handled.tsx b/src/tedi/components/form/select/examples/multiple-handled.tsx index 685879de8..dff8b8d2b 100644 --- a/src/tedi/components/form/select/examples/multiple-handled.tsx +++ b/src/tedi/components/form/select/examples/multiple-handled.tsx @@ -31,7 +31,13 @@ export const MultipleHandledTemplate: StoryFn = (args) => { return ( - handleInputChange(value)} + value={inputValue} + {...args} + /> ); diff --git a/src/tedi/components/form/select/select.module.scss b/src/tedi/components/form/select/select.module.scss index c17f10610..9a221904f 100644 --- a/src/tedi/components/form/select/select.module.scss +++ b/src/tedi/components/form/select/select.module.scss @@ -203,6 +203,20 @@ div.tedi-select__multi-value-item { margin-bottom: 0; } +// When the group heading itself is a selectable checkbox (selectableGroups +// in multi-select), indent the options that follow it so the visual +// hierarchy reads as "group toggle" → "child option toggles". Uses the same +// padding values as ChoiceGroup's indented inner row (the design-system +// reference for this pattern) so a child option's checkbox aligns under the +// parent's label glyph instead of 5px too far right on desktop. +.tedi-select__group-heading--selectable ~ .tedi-select__option { + padding-left: calc(var(--form-checkbox-radio-subitem-padding-left) - 5px); + + @include breakpoints.media-breakpoint-down(md) { + padding-left: var(--form-checkbox-radio-subitem-padding-left); + } +} + .tedi-select__multi-value-clear { margin: -0.125rem -0.25rem -0.125rem 0; } diff --git a/src/tedi/components/form/select/select.spec.tsx b/src/tedi/components/form/select/select.spec.tsx index f515bf084..678f5bac3 100644 --- a/src/tedi/components/form/select/select.spec.tsx +++ b/src/tedi/components/form/select/select.spec.tsx @@ -414,7 +414,9 @@ describe('Select component', () => { await userEvent.click(screen.getByRole('combobox')); }); - expect(await screen.findByText(SELECT_ALL_KEY)).toBeInTheDocument(); + // The select-all option renders the label twice (sr-only span + Checkbox + // label), so use findAllByText and assert at least one match. + expect((await screen.findAllByText(SELECT_ALL_KEY)).length).toBeGreaterThan(0); }); it('does not render Select All toggle outside multi mode', async () => { @@ -444,9 +446,13 @@ describe('Select component', () => { await userEvent.click(screen.getByRole('combobox')); }); - const selectAll = await screen.findByText(SELECT_ALL_KEY); + // The select-all option renders the label twice; click the option element + // itself (matched by role) to trigger react-select's selection handler. + const selectAllOption = (await screen.findAllByRole('option')).find((el) => + el.textContent?.includes(SELECT_ALL_KEY) + )!; await act(async () => { - await userEvent.click(selectAll); + await userEvent.click(selectAllOption); }); expect(handleChange).toHaveBeenCalled(); @@ -462,9 +468,11 @@ describe('Select component', () => { await userEvent.click(screen.getByRole('combobox')); }); - const selectAll = await screen.findByText(SELECT_ALL_KEY); + const selectAllOption = (await screen.findAllByRole('option')).find((el) => + el.textContent?.includes(SELECT_ALL_KEY) + )!; await act(async () => { - await userEvent.click(selectAll); + await userEvent.click(selectAllOption); }); expect(handleChange).toHaveBeenCalled(); diff --git a/src/tedi/components/form/select/select.stories.tsx b/src/tedi/components/form/select/select.stories.tsx index f43f0bf57..a02ef49d9 100644 --- a/src/tedi/components/form/select/select.stories.tsx +++ b/src/tedi/components/form/select/select.stories.tsx @@ -5,6 +5,7 @@ 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 { EditableSelectTemplate } from './examples/editable'; import { MultipleHandledTemplate } from './examples/multiple-handled'; @@ -51,20 +52,20 @@ const groupedOptions: OptionsOrGroups ( - + - + Default - + @@ -115,7 +116,7 @@ export const States: Story = { render: (args) => ( - + Default @@ -123,7 +124,7 @@ export const States: Story = { - + Hover @@ -137,7 +138,7 @@ export const States: Story = { - + Focus @@ -151,7 +152,7 @@ export const States: Story = { - + Active @@ -165,7 +166,7 @@ export const States: Story = { - + Error @@ -173,7 +174,7 @@ export const States: Story = { - + Success @@ -181,7 +182,7 @@ export const States: Story = { - + Disabled @@ -485,6 +486,33 @@ const renderDescriptionOption = (props: OptionProps) => ); }; +const renderDescriptionOptionWithCheckbox = (props: OptionProps) => { + const { title, description } = props.data.customData as DescriptionData; + return ( + + + null} + disabled={props.isDisabled} + /> + + + {props.label} + {title} + + {description} + + + + ); +}; + const renderHorizontalMetaOption = (props: OptionProps) => { const { name, slots } = props.data.customData as MetaData; return ( @@ -611,7 +639,7 @@ export const Examples: Story = { label="Multiselect with custom templates" placeholder="Select permissions..." options={permissionOptions} - renderOption={renderDescriptionOption} + renderOption={renderDescriptionOptionWithCheckbox} multiple isClearable isTagRemovable @@ -635,6 +663,18 @@ export const MultipleHandled: Story = { }, }; +/** + * Demonstrates the `async` mode with a `loadOptions` callback that fetches + * matches on demand instead of receiving a static `options` array. Use this + * shape when the option list lives on the server, is too large to ship up + * front, or needs to be filtered by the backend (typeahead search, remote + * lookup, etc.). + * + * In this example `loadOptions` simulates a 1s network delay before resolving + * with locally filtered colour options, and the user's input is sanitised + * (non-word characters stripped) before it's used as the search term — the + * sanitised value is shown above the select. + */ export const AsyncSelect: Story = { render: AsyncSelectTemplate, args: { @@ -644,6 +684,18 @@ export const AsyncSelect: Story = { }, }; +/** + * Demonstrates a fully controlled combobox where the parent owns both the + * selected `value` AND the visible `inputValue`. Use this shape when the + * input text needs to be edited freely (rather than only filter the menu) + * and stay in sync with the selected option — e.g. an editable autocomplete + * field. + * + * Selecting an option from the menu overwrites the input text with that + * option's label. Typing into the field updates `inputValue` only, leaving + * the previously selected value intact until a new option is picked. The + * underlying input is rendered visibly (`inputIsHidden={false}`). + */ export const EditableSelect: Story = { render: EditableSelectTemplate, args: { diff --git a/src/tedi/components/form/select/select.tsx b/src/tedi/components/form/select/select.tsx index 53c87a433..36dfc7953 100644 --- a/src/tedi/components/form/select/select.tsx +++ b/src/tedi/components/form/select/select.tsx @@ -1,6 +1,7 @@ import cn from 'classnames'; import React, { forwardRef } from 'react'; import ReactSelect, { + ActionMeta, GroupBase, InputActionMeta, MenuListProps, @@ -16,6 +17,7 @@ import { FeedbackText, FeedbackTextProps } from '../../../../tedi/components/for import { FormLabel, FormLabelProps } from '../../../../tedi/components/form/form-label/form-label'; import { useLabels } from '../../../../tedi/providers/label-provider'; import { TextProps } from '../../base/typography/text/text'; +import { areAllSelected, getEnabledOptions, SELECT_ALL_VALUE } from './components/select-bulk-helpers'; import { SelectClearIndicator } from './components/select-clear-indicator'; import { SelectControl } from './components/select-control'; import { SelectDropDownIndicator } from './components/select-dropdown-indicator'; @@ -441,14 +443,100 @@ export const Select = forwardRef element.current as SelectInstance> ); - const onChangeHandler = (option: OnChangeValue) => { - onChange?.(option); + // "Select all" is injected into react-select's option list as a sentinel + // option so that keyboard navigation and default focus naturally include + // it (rather than rendering it as a sibling div outside react-select's + // option system, which left it unreachable via arrow keys). + // + // Toggling the sentinel is intercepted in onChangeHandler and translated + // into a bulk select / deselect of all enabled options. The sentinel is + // stripped from the value before it reaches the consumer's onChange — it + // never leaks outside this component. + const showSelectAllMode = !!showSelectAll && multiple; + + // Internal value tracking is needed when showSelectAllMode is on because + // we need the latest selection in multiple places (filterOption, value + // injection) regardless of whether the consumer uses controlled or + // uncontrolled mode. For uncontrolled usage we track the value here so we + // can decide when to inject the sentinel into the value passed down. + const [internalValue, setInternalValue] = React.useState(defaultValue ?? null); + const isControlled = value !== undefined; + const currentValue = isControlled ? value : internalValue; + const currentValueArray: ReadonlyArray = React.useMemo(() => { + if (Array.isArray(currentValue)) return currentValue; + if (currentValue) return [currentValue as ISelectOption]; + return []; + }, [currentValue]); + + const selectAllSentinel = React.useMemo( + () => ({ value: SELECT_ALL_VALUE, label: getLabel('select.select-all') }), + [getLabel] + ); + + const optionsForReactSelect = React.useMemo(() => { + if (!showSelectAllMode || !options || options.length === 0) return options; + return [selectAllSentinel, ...options] as typeof options; + }, [options, showSelectAllMode, selectAllSentinel]); + + const valueForReactSelect = React.useMemo(() => { + if (!showSelectAllMode) return currentValue; + const enabled = getEnabledOptions(options ?? []); + if (enabled.length > 0 && areAllSelected(currentValueArray, enabled)) { + return [...currentValueArray, selectAllSentinel]; + } + return currentValue; + }, [currentValue, currentValueArray, options, showSelectAllMode, selectAllSentinel]); + + const onChangeHandler = (option: OnChangeValue, actionMeta: ActionMeta) => { + let resolved: OnChangeValue = option; + + if (showSelectAllMode) { + const enabled = getEnabledOptions(options ?? []); + const toggledOption = (actionMeta as { option?: ISelectOption }).option; + const toggledSentinel = toggledOption?.value === SELECT_ALL_VALUE; + + if (toggledSentinel && actionMeta.action === 'select-option') { + // Sentinel was just selected → select every enabled option, but + // keep any disabled options that were already in the selection. + const previouslyDisabled = currentValueArray.filter( + (s) => s.value !== SELECT_ALL_VALUE && !enabled.some((e) => e.value === s.value) + ); + resolved = [...previouslyDisabled, ...enabled]; + } else if (toggledSentinel && actionMeta.action === 'deselect-option') { + // Sentinel was just deselected → drop every enabled option from + // the current selection while preserving disabled ones. + resolved = currentValueArray.filter( + (s) => s.value !== SELECT_ALL_VALUE && !enabled.some((e) => e.value === s.value) + ); + } else if (Array.isArray(option)) { + // Strip the sentinel from any other action's payload so it never + // leaks outside this component. + resolved = (option as ISelectOption[]).filter((o) => o.value !== SELECT_ALL_VALUE); + } + } + + if (!isControlled) { + setInternalValue(resolved as TSelectValue); + } + + onChange?.(resolved); if (!blurInputOnSelect && element.current) { setTimeout(() => element.current?.inputRef?.focus(), 0); } }; + // Keep the sentinel visible regardless of the search input — filtering + // it out would hide a meta-action while the user is narrowing the list. + const filterOption = React.useCallback( + (candidate: { value: string; label: string; data: ISelectOption }, input: string) => { + if (candidate.data.value === SELECT_ALL_VALUE) return true; + if (!input) return true; + return String(candidate.label).toLowerCase().includes(input.toLowerCase()); + }, + [] + ); + // Keyboard-vs-mouse mode for the option focus ring. react-select's // `isFocused` flag is shared between hover and arrow-key nav, so we can't // use it directly. Arrow / Home / End / PageUp / PageDown keys flip the @@ -524,12 +612,13 @@ export const Select = forwardRef { * If provided, a close button will be rendered inside the Tag. */ onClose?: MouseEventHandler; + /** + * Extra props forwarded to the inner close button (when `onClose` is set). + * Lets consumers wire up keyboard handlers, tab focus, or event isolation + * without reaching past the Tag API. `onClick` and `iconSize` are owned by + * Tag and can't be overridden here. + */ + closeButtonProps?: Omit; /** * Determines whether the Tag is in a loading state * @default false @@ -46,6 +53,7 @@ export const Tag = (props: TagProps): JSX.Element => { children, className, onClose, + closeButtonProps, isLoading = false, color = 'primary', ...rest @@ -71,7 +79,7 @@ export const Tag = (props: TagProps): JSX.Element => {
)} - {!isLoading && onClose && } + {!isLoading && onClose && }
); };