From ecc627933c7ee3226cd4c90b58755a2dfd434abb Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Wed, 22 Apr 2026 12:58:36 -0400 Subject: [PATCH 01/15] ms scaffolding for table backed sources --- packages/components/src/spectrum/index.ts | 1 + .../src/spectrum/multiSelect/index.ts | 2 + .../utils/useStringifiedMultiSelection.ts | 13 +- .../src/spectrum/MultiPickerProps.ts | 21 ++ .../src/spectrum/MultiSelect.tsx | 66 ++++ .../jsapi-components/src/spectrum/index.ts | 2 + .../src/spectrum/utils/index.ts | 1 + .../src/spectrum/utils/useMultiPickerProps.ts | 308 ++++++++++++++++++ 8 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/spectrum/multiSelect/index.ts create mode 100644 packages/jsapi-components/src/spectrum/MultiPickerProps.ts create mode 100644 packages/jsapi-components/src/spectrum/MultiSelect.tsx create mode 100644 packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts diff --git a/packages/components/src/spectrum/index.ts b/packages/components/src/spectrum/index.ts index 6b4f5a84f2..2250978f8b 100644 --- a/packages/components/src/spectrum/index.ts +++ b/packages/components/src/spectrum/index.ts @@ -22,6 +22,7 @@ export * from './comboBox'; export * from './ListActionGroup'; export * from './ListActionMenu'; export * from './listView'; +export * from './multiSelect'; export * from './picker'; export * from './Heading'; export * from './Text'; diff --git a/packages/components/src/spectrum/multiSelect/index.ts b/packages/components/src/spectrum/multiSelect/index.ts new file mode 100644 index 0000000000..39e8883eeb --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/index.ts @@ -0,0 +1,2 @@ +export * from './MultiSelect'; +export * from './MultiSelectNormalized'; diff --git a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts index 112743c697..feea0f4cc4 100644 --- a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts +++ b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts @@ -82,10 +82,21 @@ export function useStringifiedMultiSelection({ } const actualKeys = new Set(); + const foundStringKeys = new Set(); normalizedItems.forEach(item => { - if (keys.has(String(getItemKey(item)))) { + const stringKey = String(getItemKey(item)); + if (keys.has(stringKey)) { actualKeys.add(getItemKey(item)); + foundStringKeys.add(stringKey); + } + }); + + // Preserve keys not found in normalizedItems (i.e., filtered out by server-side search). + // Pass them through as-is since they are already valid ItemKey values. + keys.forEach(key => { + if (!foundStringKeys.has(String(key))) { + actualKeys.add(key); } }); diff --git a/packages/jsapi-components/src/spectrum/MultiPickerProps.ts b/packages/jsapi-components/src/spectrum/MultiPickerProps.ts new file mode 100644 index 0000000000..05931dfd80 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/MultiPickerProps.ts @@ -0,0 +1,21 @@ +import { type MultiSelectNormalizedProps } from '@deephaven/components'; +import { type dh as DhType } from '@deephaven/jsapi-types'; +import { type Settings } from '@deephaven/jsapi-utils'; + +export type MultiPickerWithTableProps = Omit< + MultiSelectNormalizedProps, + 'normalizedItems' | 'showItemIcons' | 'selectedItemLabels' +> & { + table: DhType.Table; + + /** The column of values to use as item keys. Defaults to the first column. */ + keyColumn?: string; + + /** The column of values to display as primary text. Defaults to the `keyColumn` value. */ + labelColumn?: string; + + /** The column of values to map to icons. */ + iconColumn?: string; + + settings?: Settings; +}; diff --git a/packages/jsapi-components/src/spectrum/MultiSelect.tsx b/packages/jsapi-components/src/spectrum/MultiSelect.tsx new file mode 100644 index 0000000000..d04599ea53 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/MultiSelect.tsx @@ -0,0 +1,66 @@ +import { MultiSelectNormalized } from '@deephaven/components'; +import { useCallback, useRef } from 'react'; +import { type MultiPickerWithTableProps } from './MultiPickerProps'; +import { useMultiPickerProps } from './utils'; + +export type MultiSelectProps = MultiPickerWithTableProps; + +export function MultiSelect(props: MultiSelectProps): JSX.Element { + const { + onInputChange: onInputChangeInternal, + onOpenChange: onOpenChangeOriginal, + onSearchTextChange, + ...restPickerProps + } = useMultiPickerProps(props); + + const isOpenRef = useRef(false); + const inputValueRef = useRef(''); + + const onInputChange = useCallback( + (value: string) => { + onInputChangeInternal?.(value); + + // Only apply search text if MultiSelect is open. + if (isOpenRef.current) { + onSearchTextChange(value); + } + // When closed, clear the search text and store the value so we can + // re-apply it in `onOpenChange` if opened by user input. + else { + onSearchTextChange(''); + inputValueRef.current = value; + } + }, + [onInputChangeInternal, onSearchTextChange] + ); + + const onOpenChange = useCallback( + (isOpen: boolean) => { + onOpenChangeOriginal?.(isOpen); + + // Reset the search text when closed. + if (!isOpen) { + onSearchTextChange(''); + } + // Restore search text when opened by user input. + else if (inputValueRef.current !== '') { + onSearchTextChange(inputValueRef.current); + } + + isOpenRef.current = isOpen; + }, + [onSearchTextChange, onOpenChangeOriginal] + ); + + return ( + + ); +} + +export default MultiSelect; diff --git a/packages/jsapi-components/src/spectrum/index.ts b/packages/jsapi-components/src/spectrum/index.ts index 8ee1093c2b..d7b1fe76c9 100644 --- a/packages/jsapi-components/src/spectrum/index.ts +++ b/packages/jsapi-components/src/spectrum/index.ts @@ -1,4 +1,6 @@ export * from './ComboBox'; export * from './ListView'; +export * from './MultiSelect'; +export * from './MultiPickerProps'; export * from './Picker'; export * from './PickerProps'; diff --git a/packages/jsapi-components/src/spectrum/utils/index.ts b/packages/jsapi-components/src/spectrum/utils/index.ts index 17dc2f5cf7..2cff79c464 100644 --- a/packages/jsapi-components/src/spectrum/utils/index.ts +++ b/packages/jsapi-components/src/spectrum/utils/index.ts @@ -1,3 +1,4 @@ export * from './itemUtils'; export * from './useItemRowDeserializer'; +export * from './useMultiPickerProps'; export * from './usePickerProps'; diff --git a/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts new file mode 100644 index 0000000000..e87a2d01f3 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type ItemSelection, + type NormalizedItem, + type NormalizedItemData, + type NormalizedSection, + usePickerItemScale, +} from '@deephaven/components'; +import { createKeyedItemKey, TableUtils } from '@deephaven/jsapi-utils'; +import { useApi } from '@deephaven/jsapi-bootstrap'; +import Log from '@deephaven/log'; +import { usePromiseFactory } from '@deephaven/react-hooks'; +import { type KeyedItem } from '@deephaven/utils'; +import useFormatter from '../../useFormatter'; +import useTableUtils from '../../useTableUtils'; +import type { MultiPickerWithTableProps } from '../MultiPickerProps'; +import { getItemKeyColumn, getItemLabelColumn } from './itemUtils'; +import { useItemRowDeserializer } from './useItemRowDeserializer'; +import useSearchableViewportData from '../../useSearchableViewportData'; +import useWidgetClose from '../../useWidgetClose'; + +const log = Log.module('jsapi-components.useMultiPickerProps'); + +/** Props that are derived by `useMultiPickerProps`. */ +export type UseMultiPickerDerivedProps = { + normalizedItems: (NormalizedItem | NormalizedSection)[]; + showItemIcons: boolean; + selectedItemLabels: Map; + onChange: (keys: ItemSelection) => void; + onScroll: (event: Event) => void; + onSearchTextChange: (searchText: string) => void; +}; + +/** + * Props that are passed through untouched. (should exclude all of the + * destructured props passed into `useMultiPickerProps` that are not in the + * spread ...props) + */ +export type UseMultiPickerPassthroughProps = Omit< + MultiPickerWithTableProps, + | 'table' + | 'keyColumn' + | 'labelColumn' + | 'iconColumn' + | 'settings' + | 'onChange' + | 'onSelectionChange' +>; + +/** Props returned by `useMultiPickerProps` hook. */ +export type UseMultiPickerProps = UseMultiPickerDerivedProps & + UseMultiPickerPassthroughProps; + +export function useMultiPickerProps({ + table: tableSource, + keyColumn: keyColumnName, + labelColumn: labelColumnName, + iconColumn: iconColumnName, + settings, + onChange, + onSelectionChange, + ...props +}: MultiPickerWithTableProps): UseMultiPickerProps { + const { itemHeight } = usePickerItemScale(); + + const { getFormattedString: formatValue, timeZone } = useFormatter(settings); + + // Copy table so we can apply filters without affecting the original table. + const { data: tableCopy } = usePromiseFactory( + TableUtils.copyTableAndApplyFilters, + [tableSource] + ); + + useWidgetClose(tableCopy); + + const keyColumn = useMemo( + () => + tableCopy == null ? null : getItemKeyColumn(tableCopy, keyColumnName), + [keyColumnName, tableCopy] + ); + + const labelColumn = useMemo( + () => + tableCopy == null || keyColumn == null + ? null + : getItemLabelColumn(tableCopy, keyColumn, labelColumnName), + [keyColumn, labelColumnName, tableCopy] + ); + + const searchColumnNames = useMemo( + () => (labelColumn == null ? [] : [labelColumn.name]), + [labelColumn] + ); + + const deserializeRow = useItemRowDeserializer({ + table: tableCopy, + iconColumnName, + keyColumnName, + labelColumnName, + formatValue, + }); + + const { onScroll, onSearchTextChange, viewportData } = + useSearchableViewportData({ + reuseItemsOnTableResize: true, + table: tableCopy, + itemHeight, + deserializeRow, + searchColumnNames, + timeZone, + }); + + const dh = useApi(); + const tableUtils = useTableUtils(); + + // When selected keys point to rows outside the initial viewport, we take a table snapshot of + // just those rows to load their real label data. This avoids moving the viewport and ensures all + // selected items display correct labels. + const selectedKeysForSnapshot = + props.selectedKeys ?? props.defaultSelectedKeys; + const hasLoadedSnapshotRef = useRef(false); + const [snapshotItemsByIndex, setSnapshotItemsByIndex] = useState< + Map> + >(new Map()); + + // Reset when table changes so we re-snapshot. + useEffect(() => { + hasLoadedSnapshotRef.current = false; + setSnapshotItemsByIndex(new Map()); + }, [tableCopy]); + + useEffect( + function snapshotSelectedKeyLabels() { + if ( + hasLoadedSnapshotRef.current || + tableCopy == null || + keyColumn == null || + selectedKeysForSnapshot == null || + selectedKeysForSnapshot === 'all' + ) { + return; + } + + let isCanceled = false; + hasLoadedSnapshotRef.current = true; + + const column = tableCopy.findColumn(keyColumn.name); + const columnValueType = tableUtils.getValueType(column.type); + + (async () => { + try { + // Seek row indices for all selected keys + const rowIndices: number[] = []; + await Promise.all( + [...selectedKeysForSnapshot].map(async key => { + if (isCanceled) { + return; + } + const index = await tableCopy.seekRow( + 0, + column, + columnValueType, + key + ); + if (index !== -1) { + rowIndices.push(index); + } + }) + ); + + rowIndices.sort((a, b) => a - b); + + if (isCanceled || rowIndices.length === 0) { + return; + } + + // Take a snapshot of just those rows + const rangeSet = dh.RangeSet.ofItems(rowIndices); + const tableData = await tableCopy.createSnapshot({ + rows: rangeSet, + columns: tableCopy.columns, + }); + + if (isCanceled) { + return; + } + + const itemMap = new Map>(); + tableData.rows.forEach((row, i) => { + const rowIndex = rowIndices[i]; + const item = deserializeRow(row); + itemMap.set(rowIndex, { + key: createKeyedItemKey(rowIndex), + item, + }); + }); + + if (!isCanceled) { + setSnapshotItemsByIndex(itemMap); + } + } catch (err) { + log.error('Error loading labels for selected keys', err); + } + })(); + + return () => { + isCanceled = true; + }; + }, + [ + selectedKeysForSnapshot, + tableCopy, + keyColumn, + tableUtils, + dh, + deserializeRow, + ] + ); + + // Label cache for every selected item resolved (from viewport or snapshot). This ensures that + // when search filtering narrows the table, tags for selected items that are no longer in the + // filtered results still display their correct labels. + // NOTE: This is passed using a separate `selectedItemLabels` prop so it only affects tag + // rendering. + const labelCacheRef = useRef>(new Map()); + const [selectedItemLabels, setSelectedItemLabels] = useState< + Map + >(new Map()); + + // Merge snapshot data into viewport items (needed for initial load of labels, otherwise they + // display key values). + const normalizedItems = useMemo(() => { + if (snapshotItemsByIndex.size === 0) { + return viewportData.items; + } + + return viewportData.items.map((item, index) => { + if (item.item == null) { + const snapshotItem = snapshotItemsByIndex.get(index); + if (snapshotItem != null) { + return snapshotItem; + } + } + return item; + }); + }, [viewportData.items, snapshotItemsByIndex]); + + // Update the label cache whenever normalizedItems or selected keys change. + useEffect( + function updateLabelCache() { + const currentSelectedKeys = + props.selectedKeys ?? props.defaultSelectedKeys; + + let selectedKeySet: Set | null; + if (currentSelectedKeys instanceof Array) { + selectedKeySet = new Set(currentSelectedKeys.map(String)); + } else if (currentSelectedKeys === 'all') { + selectedKeySet = null; + } else { + selectedKeySet = new Set(); + } + + let cacheUpdated = false; + + // Cache labels from all resolved items that are currently selected. The normalizedItems + // merge already includes snapshot data, so this single pass should cover both viewport + // and snapshot sources. + normalizedItems.forEach(item => { + if (item.item != null) { + const key = String(item.item.key); + const label = item.item.textValue; + if ( + label != null && + (selectedKeySet == null || selectedKeySet.has(key)) && + labelCacheRef.current.get(key) !== label + ) { + labelCacheRef.current.set(key, label); + cacheUpdated = true; + } + } + }); + + if (cacheUpdated) { + setSelectedItemLabels(new Map(labelCacheRef.current)); + } + }, + [normalizedItems, props.selectedKeys, props.defaultSelectedKeys] + ); + + const onSelectionChangeInternal = useCallback( + (keys: ItemSelection): void => { + (onChange ?? onSelectionChange)?.(keys); + }, + [onChange, onSelectionChange] + ); + + return { + ...props, + normalizedItems, + selectedItemLabels, + showItemIcons: iconColumnName != null, + onChange: onSelectionChangeInternal, + onScroll, + onSearchTextChange, + }; +} + +export default useMultiPickerProps; From e297575daeaa631c43b435e4961d248101db74a4 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Fri, 24 Apr 2026 17:27:02 -0400 Subject: [PATCH 02/15] cleanup normalized props --- .../multiSelect/MultiSelectNormalized.tsx | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx new file mode 100644 index 0000000000..dc33713cd8 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx @@ -0,0 +1,155 @@ +import { useCallback, useMemo } from 'react'; +import { Item } from '@adobe/react-spectrum'; +import { + getItemKey, + isNormalizedSection, + type NormalizedItem, + type NormalizedSection, + normalizeTooltipOptions, + type TooltipOptions, + useRenderNormalizedItem, + useStringifiedMultiSelection, +} from '../utils'; +import { Section } from '../shared'; +import { + MultiSelect, + type MultiSelectEntry, + type MultiSelectItem, + type MultiSelectProps, + isMultiSelectSection, +} from './MultiSelect'; + +export type MultiSelectNormalizedProps = Omit< + MultiSelectProps, + 'children' | 'items' +> & { + normalizedItems: (NormalizedItem | NormalizedSection)[]; + showItemIcons: boolean; + tooltip?: boolean | TooltipOptions; +}; + +/** + * MultiSelect that takes an array of `NormalizedItem` or `NormalizedSection` + * items as children. Handles converting selection keys and uses `useRenderNormalizedItem` to + * render items. + */ +export function MultiSelectNormalized({ + normalizedItems, + showItemIcons, + tooltip = true, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onChange, + onSelectionChange, + selectedItemLabels, + ...props +}: MultiSelectNormalizedProps): JSX.Element { + const tooltipOptions = useMemo( + () => normalizeTooltipOptions(tooltip), + [tooltip] + ); + + const renderNormalizedItem = useRenderNormalizedItem({ + itemIconSlot: 'icon', + showItemDescriptions: false, + showItemIcons, + tooltipOptions, + }); + + // Flatten all items (including those inside sections) so they're all visible to the key + // conversion logic + const flatItems = useMemo( + () => + normalizedItems.flatMap(item => + isNormalizedSection(item) ? item.item?.items ?? [] : [item] + ), + [normalizedItems] + ); + + const { + selectedStringKeys, + defaultSelectedStringKeys, + disabledStringKeys, + onStringSelectionChange, + } = useStringifiedMultiSelection({ + normalizedItems: flatItems, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onChange: onChange ?? onSelectionChange, + }); + + const items: MultiSelectEntry[] = useMemo( + () => + normalizedItems.map(itemOrSection => { + if (isNormalizedSection(itemOrSection)) { + return { + key: String(getItemKey(itemOrSection)), + title: itemOrSection.item?.title, + items: (itemOrSection.item?.items ?? []).map( + (ni: NormalizedItem) => ({ + key: String(getItemKey(ni)), + label: ni.item?.textValue ?? String(getItemKey(ni)), + renderedChild: renderNormalizedItem(ni), + }) + ), + }; + } + return { + key: String(getItemKey(itemOrSection)), + label: + itemOrSection.item?.textValue ?? String(getItemKey(itemOrSection)), + renderedChild: renderNormalizedItem(itemOrSection), + }; + }), + [normalizedItems, renderNormalizedItem] + ); + + const renderEntry = useCallback((entry: MultiSelectEntry): JSX.Element => { + if (isMultiSelectSection(entry)) { + return ( +
+ {(item: MultiSelectItem): JSX.Element => + (item.renderedChild as JSX.Element) ?? ( + + {item.label} + + ) + } +
+ ); + } + return ( + (entry.renderedChild as JSX.Element) ?? ( + + {entry.label} + + ) + ); + }, []); + + return ( + + {renderEntry} + + ); +} + +export default MultiSelectNormalized; From 274619814617f7525f1d5acfdac25bc0868a9722 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Sun, 26 Apr 2026 13:37:18 -0400 Subject: [PATCH 03/15] some more normalization refactors --- .../multiSelect/MultiSelectNormalized.tsx | 152 ++---------------- 1 file changed, 13 insertions(+), 139 deletions(-) diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx index dc33713cd8..c4d764cdd3 100644 --- a/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx +++ b/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx @@ -1,153 +1,27 @@ -import { useCallback, useMemo } from 'react'; -import { Item } from '@adobe/react-spectrum'; -import { - getItemKey, - isNormalizedSection, - type NormalizedItem, - type NormalizedSection, - normalizeTooltipOptions, - type TooltipOptions, - useRenderNormalizedItem, - useStringifiedMultiSelection, -} from '../utils'; -import { Section } from '../shared'; -import { - MultiSelect, - type MultiSelectEntry, - type MultiSelectItem, - type MultiSelectProps, - isMultiSelectSection, -} from './MultiSelect'; - -export type MultiSelectNormalizedProps = Omit< - MultiSelectProps, - 'children' | 'items' -> & { - normalizedItems: (NormalizedItem | NormalizedSection)[]; - showItemIcons: boolean; - tooltip?: boolean | TooltipOptions; -}; +import cl from 'classnames'; +import { MultiSelect } from './MultiSelect'; +import { type MultiSelectNormalizedProps } from './MultiSelectProps'; +import { useMultiSelectNormalizedProps } from './useMultiSelectNormalizedProps'; /** - * MultiSelect that takes an array of `NormalizedItem` or `NormalizedSection` - * items as children. Handles converting selection keys and uses `useRenderNormalizedItem` to - * render items. + * MultiSelect that takes an array of `NormalizedItem` or `NormalizedSection` items as children. + * Handles converting selection keys and uses `useRenderNormalizedItem` to render items. */ export function MultiSelectNormalized({ - normalizedItems, - showItemIcons, - tooltip = true, - selectedKeys, - defaultSelectedKeys, - disabledKeys, - onChange, - onSelectionChange, - selectedItemLabels, + UNSAFE_className, ...props }: MultiSelectNormalizedProps): JSX.Element { - const tooltipOptions = useMemo( - () => normalizeTooltipOptions(tooltip), - [tooltip] - ); - - const renderNormalizedItem = useRenderNormalizedItem({ - itemIconSlot: 'icon', - showItemDescriptions: false, - showItemIcons, - tooltipOptions, - }); - - // Flatten all items (including those inside sections) so they're all visible to the key - // conversion logic - const flatItems = useMemo( - () => - normalizedItems.flatMap(item => - isNormalizedSection(item) ? item.item?.items ?? [] : [item] - ), - [normalizedItems] - ); - - const { - selectedStringKeys, - defaultSelectedStringKeys, - disabledStringKeys, - onStringSelectionChange, - } = useStringifiedMultiSelection({ - normalizedItems: flatItems, - selectedKeys, - defaultSelectedKeys, - disabledKeys, - onChange: onChange ?? onSelectionChange, - }); - - const items: MultiSelectEntry[] = useMemo( - () => - normalizedItems.map(itemOrSection => { - if (isNormalizedSection(itemOrSection)) { - return { - key: String(getItemKey(itemOrSection)), - title: itemOrSection.item?.title, - items: (itemOrSection.item?.items ?? []).map( - (ni: NormalizedItem) => ({ - key: String(getItemKey(ni)), - label: ni.item?.textValue ?? String(getItemKey(ni)), - renderedChild: renderNormalizedItem(ni), - }) - ), - }; - } - return { - key: String(getItemKey(itemOrSection)), - label: - itemOrSection.item?.textValue ?? String(getItemKey(itemOrSection)), - renderedChild: renderNormalizedItem(itemOrSection), - }; - }), - [normalizedItems, renderNormalizedItem] - ); - - const renderEntry = useCallback((entry: MultiSelectEntry): JSX.Element => { - if (isMultiSelectSection(entry)) { - return ( -
- {(item: MultiSelectItem): JSX.Element => - (item.renderedChild as JSX.Element) ?? ( - - {item.label} - - ) - } -
- ); - } - return ( - (entry.renderedChild as JSX.Element) ?? ( - - {entry.label} - - ) - ); - }, []); + const { forceRerenderKey, children, ...multiSelectProps } = + useMultiSelectNormalizedProps(props); return ( - {renderEntry} + {children} ); } From a2174531c829461b9cf833885098b309687a8804 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Sun, 26 Apr 2026 17:42:48 -0400 Subject: [PATCH 04/15] cleanup subcomponents --- .../multiSelect/MultiSelectListBox.tsx | 62 +++++++++++++++++++ .../spectrum/multiSelect/MultiSelectTag.tsx | 44 +++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelectTag.tsx diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx new file mode 100644 index 0000000000..a033f542bf --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx @@ -0,0 +1,62 @@ +import { type ReactElement, type RefObject } from 'react'; +import { ListBox } from '@adobe/react-spectrum'; +import type { Key, LoadingState, Selection } from '@react-types/shared'; + +export interface MultiSelectListBoxProps { + /** Container ref used by the keyboard hook for virtual focus DOM ops. */ + containerRef: RefObject; + /** ID applied to the inner Spectrum ``. */ + listBoxId: string; + /** Spectrum `LoadingState` for the items collection. */ + loadingState: LoadingState | undefined; + /** JSX children to render inside ``. */ + filteredJsxChildren: ReactElement[]; + /** Selected keys */ + selectedKeys: Iterable; + /** Disabled keys for ``. */ + disabledKeys: Key[] | undefined; + /** Selection change handler from ``. */ + onSelectionChange: (selection: Selection) => void; + /** ARIA label applied to the ``. */ + ariaLabel: string; + /** When provided, the ListBox is replaced with this empty-state message. */ + emptyMessage?: string; +} + +/** + * Popover content for `MultiSelect`. Renders either an empty-state message (text-only) + * or the Spectrum ``. Private subcomponent of `MultiSelect`. + */ +export function MultiSelectListBox({ + containerRef, + listBoxId, + loadingState, + filteredJsxChildren, + selectedKeys, + disabledKeys, + onSelectionChange, + ariaLabel, + emptyMessage, +}: MultiSelectListBoxProps): JSX.Element { + if (emptyMessage != null) { + return
{emptyMessage}
; + } + + return ( +
+ + {filteredJsxChildren} + +
+ ); +} + +export default MultiSelectListBox; diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectTag.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectTag.tsx new file mode 100644 index 0000000000..c2c63315d1 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectTag.tsx @@ -0,0 +1,44 @@ +import CrossSmall from '@spectrum-icons/ui/CrossSmall'; + +export interface MultiSelectTagProps { + tagKey: string; + label: string; + isDisabled: boolean; + isReadOnly: boolean; + onRemove: (key: string) => void; +} + +/** + * A tag rendered inside the `MultiSelect` trigger area. + * Private subcomponent of `MultiSelect`. + */ +export function MultiSelectTag({ + tagKey, + label, + isDisabled, + isReadOnly, + onRemove, +}: MultiSelectTagProps): JSX.Element { + return ( + + {label} + {!isDisabled && !isReadOnly && ( + + )} + + ); +} + +export default MultiSelectTag; From df5c8d487c1054d4590f102e9f871ce932714023 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Sun, 26 Apr 2026 18:04:55 -0400 Subject: [PATCH 05/15] WIP remove wrapper div --- .../multiSelect/MultiSelectListBox.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx index a033f542bf..031ab21b81 100644 --- a/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx +++ b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx @@ -1,10 +1,10 @@ -import { type ReactElement, type RefObject } from 'react'; +import { type ReactElement } from 'react'; import { ListBox } from '@adobe/react-spectrum'; -import type { Key, LoadingState, Selection } from '@react-types/shared'; +import type { DOMRef, Key, LoadingState, Selection } from '@react-types/shared'; export interface MultiSelectListBoxProps { - /** Container ref used by the keyboard hook for virtual focus DOM ops. */ - containerRef: RefObject; + /** DOMRef forwarded to the inner Spectrum ``. */ + listBoxRef: DOMRef; /** ID applied to the inner Spectrum ``. */ listBoxId: string; /** Spectrum `LoadingState` for the items collection. */ @@ -28,7 +28,7 @@ export interface MultiSelectListBoxProps { * or the Spectrum ``. Private subcomponent of `MultiSelect`. */ export function MultiSelectListBox({ - containerRef, + listBoxRef, listBoxId, loadingState, filteredJsxChildren, @@ -43,19 +43,19 @@ export function MultiSelectListBox({ } return ( -
- - {filteredJsxChildren} - -
+ + {filteredJsxChildren} + ); } From 0f10d771de5c75d66c3f4676a7cce7a53071d6ce Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Sun, 26 Apr 2026 18:05:44 -0400 Subject: [PATCH 06/15] WIP export props --- packages/components/src/spectrum/multiSelect/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/spectrum/multiSelect/index.ts b/packages/components/src/spectrum/multiSelect/index.ts index 39e8883eeb..2ed78e5568 100644 --- a/packages/components/src/spectrum/multiSelect/index.ts +++ b/packages/components/src/spectrum/multiSelect/index.ts @@ -1,2 +1,3 @@ export * from './MultiSelect'; export * from './MultiSelectNormalized'; +export * from './MultiSelectProps'; From f9821637e42c7c183bf53efa41d5d2c5aaa0fd16 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Sun, 26 Apr 2026 22:18:12 -0400 Subject: [PATCH 07/15] WIP finalize props --- .../spectrum/multiSelect/MultiSelectProps.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelectProps.ts diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectProps.ts b/packages/components/src/spectrum/multiSelect/MultiSelectProps.ts new file mode 100644 index 0000000000..cf89af5f42 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectProps.ts @@ -0,0 +1,142 @@ +import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'; +import type { + AriaLabelingProps, + LoadingState, + StyleProps, + ValidationState, +} from '@react-types/shared'; +import type { MenuTriggerAction } from '../comboBox'; +import type { + ItemOrSection, + MultipleItemSelectionProps, + NormalizedItem, + NormalizedSection, + TooltipOptions, +} from '../utils'; + +/** + * Public props for the `@deephaven/components` `MultiSelect`. + * + * The MultiSelect is a hand-built composite (custom popover + search input + + * tag row + ListBox), not a thin wrapper around a Spectrum collection + * component. As such it owns its own prop surface rather than extending a + * Spectrum type. Reuse `StyleProps`, `AriaLabelingProps`, `ValidationState`, + * and `LoadingState` from `@react-types/shared` to stay consistent with the + * Spectrum vocabulary. + * + * Children follow the same shape as `PickerProps.children`: declarative + * `Item` / `Section` JSX (no render-function `items` pattern). + */ +export interface MultiSelectProps + extends StyleProps, + AriaLabelingProps, + MultipleItemSelectionProps { + /** Item or Section elements to render in the dropdown. */ + children: ItemOrSection | ItemOrSection[]; + /** Can be set to true or a TooltipOptions to enable item tooltips. */ + tooltip?: boolean | TooltipOptions; + /** The content to display as the field label. */ + label?: ReactNode; + /** A description for the field. */ + description?: ReactNode; + /** An error message for the field. */ + errorMessage?: ReactNode; + /** Whether user input is required on the field before form submission. */ + isRequired?: boolean; + /** Whether the input is disabled. */ + isDisabled?: boolean; + /** Whether the input can be selected but not changed by the user. */ + isReadOnly?: boolean; + /** Whether the input should display its "valid" or "invalid" visual styling. */ + validationState?: ValidationState; + /** Whether the MultiSelect should be displayed with a quiet style. */ + isQuiet?: boolean; + /** The label's overall position relative to the element it is labeling. */ + labelPosition?: 'top' | 'side'; + /** The label's horizontal alignment relative to the element it is labeling. */ + labelAlign?: 'start' | 'end'; + /** Whether the required state should be shown as an icon or text. */ + necessityIndicator?: 'icon' | 'label'; + /** A ContextualHelp element to place next to the label. */ + contextualHelp?: ReactNode; + + /** Controlled value of the search input. */ + inputValue?: string; + /** Default (uncontrolled) value of the search input. */ + defaultInputValue?: string; + /** Handler called when the search input value changes. */ + onInputChange?: (value: string) => void; + + /** Whether keyboard navigation is circular. */ + shouldFocusWrap?: boolean; + /** The current loading state of the items. */ + loadingState?: LoadingState; + /** The interaction required to display the menu. */ + menuTrigger?: 'focus' | 'input' | 'manual'; + /** Alignment of the menu relative to the input target. */ + align?: 'start' | 'end'; + /** Direction the menu will render relative to the input. */ + direction?: 'bottom' | 'top'; + /** Whether the menu should automatically flip direction when there isn't enough space. */ + shouldFlip?: boolean; + /** Width of the menu. */ + menuWidth?: string | number; + /** Whether the MultiSelect allows a non-item matching input value to be selected. */ + allowsCustomValue?: boolean; + /** Whether the form value of the field is the selected key(s) or text(s). */ + formValue?: 'key' | 'text'; + /** Whether to use native HTML form validation, ARIA validation, or both. */ + validationBehavior?: 'native' | 'aria'; + /** Whether the element should receive focus on render. */ + autoFocus?: boolean; + /** The name of the input element, used when submitting an HTML form. */ + name?: string; + /** The element's unique identifier. */ + id?: string; + /** Whether the field is hidden. */ + isHidden?: boolean; + /** Handler called when the input receives focus. */ + onFocus?: (e: FocusEvent) => void; + /** Handler called when the input loses focus. */ + onBlur?: (e: FocusEvent) => void; + /** Handler called when the focus state changes. */ + onFocusChange?: (isFocused: boolean) => void; + /** Handler called when a key is pressed. */ + onKeyDown?: (e: KeyboardEvent) => void; + /** Handler called when a key is released. */ + onKeyUp?: (e: KeyboardEvent) => void; + + /** + * Method that is called when the open state of the menu changes. The + * `menuTrigger` argument indicates the action that caused the change + * (`undefined` on close). + */ + onOpenChange?: (isOpen: boolean, menuTrigger?: MenuTriggerAction) => void; + + /** Handler called when the dropdown list is scrolled. */ + onScroll?: (event: Event) => void; + + /** + * Handler called when search text changes. When provided, client-side + * filtering is skipped (server-side filtering is assumed by the consumer). + */ + onSearchTextChange?: (text: string) => void; + + /** + * External label map for selected items whose data may not be present in + * `children` (e.g. filtered out by server-side search). Used as a fallback + * when rendering tags. + */ + selectedItemLabels?: Map; +} + +/** + * Props consumed by `MultiSelectNormalized`. Built on top of `MultiSelectProps` + * so adding a prop to the base type automatically surfaces it on the + * normalized variant. Replaces declarative JSX `children` with a flat + * normalized item list (used by table-backed flows in `@deephaven/jsapi-components`). + */ +export type MultiSelectNormalizedProps = Omit & { + normalizedItems: (NormalizedItem | NormalizedSection)[]; + showItemIcons: boolean; +}; From a0d36d091d381bab3c23f67a3a5d663565437f90 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Mon, 27 Apr 2026 10:37:16 -0400 Subject: [PATCH 08/15] WIP decouple hooks/state from single ms file --- .../multiSelect/MultiSelectListBox.tsx | 4 +- .../spectrum/multiSelect/multiSelectUtils.tsx | 222 ++++++++++++++ .../multiSelect/useMultiSelectFilter.ts | 91 ++++++ .../multiSelect/useMultiSelectKeyboard.ts | 277 ++++++++++++++++++ .../useMultiSelectLoadingSpinner.ts | 64 ++++ .../useMultiSelectNormalizedProps.tsx | 128 ++++++++ .../useMultiSelectScrollListener.ts | 69 +++++ .../multiSelect/useMultiSelectState.ts | 128 ++++++++ 8 files changed, 981 insertions(+), 2 deletions(-) create mode 100644 packages/components/src/spectrum/multiSelect/multiSelectUtils.tsx create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectFilter.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectNormalizedProps.tsx create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectState.ts diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx index 031ab21b81..cb0909ac20 100644 --- a/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx +++ b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx @@ -1,6 +1,6 @@ import { type ReactElement } from 'react'; import { ListBox } from '@adobe/react-spectrum'; -import type { DOMRef, Key, LoadingState, Selection } from '@react-types/shared'; +import type { DOMRef, LoadingState, Selection } from '@react-types/shared'; export interface MultiSelectListBoxProps { /** DOMRef forwarded to the inner Spectrum ``. */ @@ -14,7 +14,7 @@ export interface MultiSelectListBoxProps { /** Selected keys */ selectedKeys: Iterable; /** Disabled keys for ``. */ - disabledKeys: Key[] | undefined; + disabledKeys: Iterable | undefined; /** Selection change handler from ``. */ onSelectionChange: (selection: Selection) => void; /** ARIA label applied to the ``. */ diff --git a/packages/components/src/spectrum/multiSelect/multiSelectUtils.tsx b/packages/components/src/spectrum/multiSelect/multiSelectUtils.tsx new file mode 100644 index 0000000000..7cf64c3cb4 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/multiSelectUtils.tsx @@ -0,0 +1,222 @@ +import { cloneElement, type ReactElement, type ReactNode } from 'react'; +import { ensureArray } from '@deephaven/utils'; +import { + getItemTextValue, + isItemElement, + isSectionElement, + type ItemElement, + type ItemKey, + type SectionElement, +} from '../utils'; + +/** + * Flat shape used internally by the `MultiSelect` keyboard / filter / state + * hooks. Not part of the public API. + */ +export interface MultiSelectFlatItem { + kind: 'item'; + key: string; + label: string; +} + +/** + * Section-preserving shape used internally for client-side filtering. Not + * part of the public API. + */ +export interface MultiSelectFlatSection { + kind: 'section'; + key: string; + title: ReactNode; + items: MultiSelectFlatItem[]; +} + +export type MultiSelectFlatEntry = MultiSelectFlatItem | MultiSelectFlatSection; + +/** + * TODO: this is pretty fragile + */ +function cleanReactKey(rawKey: string): string { + return rawKey.replace(/^\.\$/, ''); +} + +/** + * Convert a JSX `` element into a flat `{key, label}` record. Returns + * `null` for items without a key. + */ +function itemElementToFlat( + element: ItemElement +): MultiSelectFlatItem | null { + if (element.key == null) { + return null; + } + const key = cleanReactKey(String(element.key)); + const label = + getItemTextValue(element) ?? + (typeof element.props.children === 'string' ? element.props.children : key); + return { kind: 'item', key, label }; +} + +/** + * Type guard that checks whether a flat entry is a section. + */ +export function isFlatSection( + entry: MultiSelectFlatEntry +): entry is MultiSelectFlatSection { + return entry.kind === 'section'; +} + +/** + * Walk a JSX /
children array and return a + * section-preserving flat representation suitable for the internal + * filter / keyboard / state hooks. Children are expected to have already + * been normalized via `wrapItemChildren` so primitives have been wrapped + * into `` elements. + */ +export function flattenJsxChildren( + children: readonly ReactElement[] +): MultiSelectFlatEntry[] { + const entries: MultiSelectFlatEntry[] = []; + + children.forEach(child => { + if (isSectionElement(child)) { + const section = child as SectionElement; + const sectionKey = + section.key != null + ? cleanReactKey(String(section.key)) + : String(entries.length); + + const sectionItems: MultiSelectFlatItem[] = []; + ensureArray(section.props.children).forEach(sectionChild => { + if (isItemElement(sectionChild)) { + const item = itemElementToFlat(sectionChild as ItemElement); + if (item != null) { + sectionItems.push(item); + } + } + }); + + entries.push({ + kind: 'section', + key: sectionKey, + title: section.props.title, + items: sectionItems, + }); + return; + } + + if (isItemElement(child)) { + const item = itemElementToFlat(child as ItemElement); + if (item != null) { + entries.push(item); + } + } + }); + + return entries; +} + +/** + * Flatten a section-preserving entry list into a plain MultiSelectFlatItem[], + * extracting items out of any sections. + */ +export function flattenEntriesToItems( + entries: readonly MultiSelectFlatEntry[] +): MultiSelectFlatItem[] { + return entries.flatMap(entry => + isFlatSection(entry) ? entry.items : [entry] + ); +} + +/** + * Filter entries by search text. Items within sections are filtered + * individually; sections with no matching items are removed. + */ +export function filterEntries( + entries: readonly MultiSelectFlatEntry[], + text: string, + contains: (string: string, substring: string) => boolean +): MultiSelectFlatEntry[] { + const result: MultiSelectFlatEntry[] = []; + entries.forEach(entry => { + if (isFlatSection(entry)) { + const filtered = entry.items.filter(item => contains(item.label, text)); + if (filtered.length > 0) { + result.push({ ...entry, items: filtered }); + } + } else if (contains(entry.label, text)) { + result.push(entry); + } + }); + return result; +} + +/** + * Collect the set of all surviving item keys from a list of flat entries. + * Used to drive filterJsxChildrenByKeys so we don't re-walk JSX with the + * search text predicate. + */ +export function collectEntryItemKeys( + entries: readonly MultiSelectFlatEntry[] +): Set { + const keys = new Set(); + entries.forEach(entry => { + if (isFlatSection(entry)) { + entry.items.forEach(item => keys.add(item.key)); + } else { + keys.add(entry.key); + } + }); + return keys; +} + +export function filterJsxChildrenByKeys( + children: readonly ReactElement[], + survivingKeys: ReadonlySet +): ReactElement[] { + const result: ReactElement[] = []; + + children.forEach(child => { + if (isSectionElement(child)) { + const section = child as SectionElement; + const filteredChildren = filterJsxChildrenByKeys( + ensureArray(section.props.children).filter( + (c): c is ReactElement => c != null + ), + survivingKeys + ); + if (filteredChildren.length > 0) { + result.push( + cloneElement(section as ReactElement, { + children: filteredChildren, + }) + ); + } + return; + } + + if (isItemElement(child)) { + const item = child as ItemElement; + if ( + item.key != null && + survivingKeys.has(cleanReactKey(String(item.key))) + ) { + result.push(child); + } + } + }); + + return result; +} + +export function resolveSelection( + selection: 'all' | Iterable | undefined, + allKeys: string[] +): Set { + if (selection == null) { + return new Set(); + } + if (selection === 'all') { + return new Set(allKeys); + } + return new Set([...selection].map(String)); +} diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectFilter.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectFilter.ts new file mode 100644 index 0000000000..d52b04260a --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectFilter.ts @@ -0,0 +1,91 @@ +import { type ReactElement, useCallback, useMemo } from 'react'; +import { useFilter } from '@react-aria/i18n'; +import { useControlledState } from '@react-stately/utils'; +import { + collectEntryItemKeys, + filterEntries, + filterJsxChildrenByKeys, + flattenEntriesToItems, + type MultiSelectFlatEntry, + type MultiSelectFlatItem, +} from './multiSelectUtils'; + +export interface UseMultiSelectFilterOptions { + allEntries: readonly MultiSelectFlatEntry[]; + wrappedChildren: readonly ReactElement[]; + inputValue: string | undefined; + defaultInputValue: string; + onInputChange: ((value: string) => void) | undefined; + onSearchTextChange: ((text: string) => void) | undefined; +} + +export interface UseMultiSelectFilterResult { + /** Current search text (controlled or uncontrolled). */ + searchText: string; + /** Set the search text and forward to onSearchTextChange if provided. */ + setSearchText: (value: string) => void; + /** Flat list of items surviving the current filter (sections expanded). */ + filteredItems: MultiSelectFlatItem[]; + /** Filtered JSX children for ``. */ + filteredJsxChildren: ReactElement[]; +} + +/** + * Owns the search/filter state for `MultiSelect`. Supports controlled and + * uncontrolled `inputValue`. + */ +export function useMultiSelectFilter({ + allEntries, + wrappedChildren, + inputValue, + defaultInputValue, + onInputChange, + onSearchTextChange, +}: UseMultiSelectFilterOptions): UseMultiSelectFilterResult { + const [searchText, setSearchTextInternal] = useControlledState( + inputValue, + defaultInputValue, + onInputChange + ); + + const setSearchText = useCallback( + (value: string) => { + setSearchTextInternal(value); + onSearchTextChange?.(value); + }, + [setSearchTextInternal, onSearchTextChange] + ); + + const { contains } = useFilter({ sensitivity: 'base' }); + + const shouldSkipFiltering = onSearchTextChange != null || searchText === ''; + + const filteredEntries = useMemo(() => { + if (shouldSkipFiltering) { + return allEntries; + } + return filterEntries(allEntries, searchText, contains); + }, [allEntries, searchText, contains, shouldSkipFiltering]); + + const filteredItems = useMemo( + () => flattenEntriesToItems(filteredEntries), + [filteredEntries] + ); + + const filteredJsxChildren = useMemo(() => { + if (shouldSkipFiltering) { + return wrappedChildren as ReactElement[]; + } + const survivingKeys = collectEntryItemKeys(filteredEntries); + return filterJsxChildrenByKeys(wrappedChildren, survivingKeys); + }, [wrappedChildren, filteredEntries, shouldSkipFiltering]); + + return { + searchText, + setSearchText, + filteredItems, + filteredJsxChildren, + }; +} + +export default useMultiSelectFilter; diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts new file mode 100644 index 0000000000..5d4a31ce81 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts @@ -0,0 +1,277 @@ +import { + type KeyboardEvent, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import type { OverlayTriggerState } from '@react-stately/overlays'; +import type { MenuTriggerAction } from '../comboBox'; +import { type MultiSelectFlatItem } from './multiSelectUtils'; + +export interface UseMultiSelectKeyboardOptions { + filteredItems: MultiSelectFlatItem[]; + allItems: MultiSelectFlatItem[]; + shouldFocusWrap: boolean; + overlayState: OverlayTriggerState; + openOverlay: (reason: MenuTriggerAction) => void; + closeOverlay: () => void; + isReadOnly: boolean; + isDisabled: boolean; + searchText: string; + setSearchText: (value: string) => void; + selectedKeys: Set; + toggleKey: (key: string) => void; + allowsCustomValue: boolean; + menuTrigger: 'focus' | 'input' | 'manual'; + onKeyDown: ((e: KeyboardEvent) => void) | undefined; + listBoxContainerRef: RefObject; + inputRef: RefObject; +} + +export interface UseMultiSelectKeyboardResult { + /** `onKeyDown` handler for the inline input. */ + handleInputKeyDown: (e: KeyboardEvent) => void; +} + +/** + * TODO: fragile, see if there is a better way to link focused state + * Replicates the key-normalization Spectrum applies to listbox option ids + * (see `@react-aria/listbox/src/utils.ts`). Whitespace is stripped so that + * `-option-` matches the actual rendered DOM `id`. + */ +function normalizeKey(key: string): string { + return key.replace(/\s*/g, ''); +} + +/** + * Owns virtual-focus tracking, keyboard handling, and the DOM/scroll + * side-effects for option highlighting in MultiSelect. Keyboard navigation + * is handled outside Spectrum's ListBox because the input retains real + * focus while options are visually highlighted via `data-dh-focused`. + * + * Focus is tracked by item key, so it survives filtering, virtualization, etc + * where the underlying filteredItems array shifts independently of the user's + * intended focus target. + */ +export function useMultiSelectKeyboard({ + filteredItems, + allItems, + shouldFocusWrap, + overlayState, + openOverlay, + closeOverlay, + isReadOnly, + isDisabled, + searchText, + setSearchText, + selectedKeys, + toggleKey, + allowsCustomValue, + menuTrigger, + onKeyDown, + listBoxContainerRef, + inputRef, +}: UseMultiSelectKeyboardOptions): UseMultiSelectKeyboardResult { + const [focusedKey, setFocusedKey] = useState(null); + + const moveFocus = useCallback( + (dir: 'down' | 'up') => { + const len = filteredItems.length; + if (len === 0) { + return; + } + setFocusedKey(prev => { + const currentIdx = + prev == null ? -1 : filteredItems.findIndex(i => i.key === prev); + let nextIdx: number; + if (dir === 'down') { + if (currentIdx === -1) { + nextIdx = 0; + } else if (shouldFocusWrap) { + nextIdx = (currentIdx + 1) % len; + } else { + nextIdx = Math.min(currentIdx + 1, len - 1); + } + } else if (currentIdx === -1) { + nextIdx = len - 1; + } else if (shouldFocusWrap) { + nextIdx = (currentIdx - 1 + len) % len; + } else { + nextIdx = Math.max(currentIdx - 1, 0); + } + return filteredItems[nextIdx].key; + }); + }, + [filteredItems, shouldFocusWrap] + ); + + const handleInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isDisabled) { + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (!overlayState.isOpen) { + openOverlay('manual'); + } else { + moveFocus('down'); + } + break; + + case 'ArrowUp': + e.preventDefault(); + if (overlayState.isOpen) { + moveFocus('up'); + } + break; + + case 'Enter': { + if (!overlayState.isOpen) { + e.preventDefault(); + openOverlay('manual'); + break; + } + const focusedItemStillExists = + focusedKey != null && filteredItems.some(i => i.key === focusedKey); + if (!isReadOnly && focusedItemStillExists) { + e.preventDefault(); + toggleKey(focusedKey); + } else if ( + !isReadOnly && + allowsCustomValue && + searchText.trim() !== '' + ) { + e.preventDefault(); + const trimmed = searchText.trim(); + // Check if typed text exactly matches an existing item label + const matchingItem = allItems.find( + item => item.label.toLowerCase() === trimmed.toLowerCase() + ); + toggleKey(matchingItem != null ? matchingItem.key : trimmed); + setSearchText(''); + } else { + // No focused item, no custom value — clear search and close + setSearchText(''); + closeOverlay(); + } + break; + } + + case 'Escape': + if (overlayState.isOpen) { + e.preventDefault(); + closeOverlay(); + } + break; + + case 'Tab': + if (overlayState.isOpen) { + closeOverlay(); + } + break; + + case 'Backspace': + if (searchText === '' && !isReadOnly && selectedKeys.size > 0) { + const keys = [...selectedKeys]; + const lastKey = keys[keys.length - 1]; + if (lastKey != null) { + toggleKey(lastKey); + } + } + break; + + default: + break; + } + + onKeyDown?.(e); + }, + [ + isDisabled, + overlayState, + openOverlay, + closeOverlay, + moveFocus, + isReadOnly, + focusedKey, + filteredItems, + allItems, + allowsCustomValue, + toggleKey, + searchText, + setSearchText, + selectedKeys, + onKeyDown, + ] + ); + + // Reset state only when the dropdown closes (not every render). This avoids clearing the input + // on each keystroke, especially in menuTrigger='manual' mode. + const wasOpenRef = useRef(false); + useEffect(() => { + if (wasOpenRef.current && !overlayState.isOpen) { + if (!allowsCustomValue) { + setSearchText(''); + } + setFocusedKey(null); + } + wasOpenRef.current = overlayState.isOpen; + }, [overlayState.isOpen, setSearchText, allowsCustomValue]); + + // Open dropdown when user starts typing (unless menuTrigger is 'manual'). + // Intentionally watches only searchText: including overlayState.isOpen would + // re-fire on close and auto-reopen if the input still has text. + useEffect(() => { + if ( + menuTrigger !== 'manual' && + searchText !== '' && + !overlayState.isOpen && + !isDisabled + ) { + openOverlay('input'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchText]); + + // Spectrum's doesn't expose its own focus management, so we mark the focused option + // with data-dh-focused for SCSS styling and copy the option's id onto + // the input's aria-activedescendant. + useEffect(() => { + const container = listBoxContainerRef.current; + if (container == null) { + return; + } + const options = container.querySelectorAll('[role="option"]'); + const focusedSuffix = + focusedKey != null ? `-option-${normalizeKey(focusedKey)}` : null; + let focusedOptionId: string | undefined; + options.forEach(el => { + const matches = focusedSuffix != null && el.id.endsWith(focusedSuffix); + if (matches) { + el.setAttribute('data-dh-focused', 'true'); + el.scrollIntoView({ block: 'nearest' }); + focusedOptionId = el.id; + } else { + el.removeAttribute('data-dh-focused'); + } + }); + + const input = inputRef.current; + if (input != null) { + if (focusedOptionId != null) { + input.setAttribute('aria-activedescendant', focusedOptionId); + } else { + input.removeAttribute('aria-activedescendant'); + } + } + }, [focusedKey, filteredItems, inputRef, listBoxContainerRef]); + + return { handleInputKeyDown }; +} + +export default useMultiSelectKeyboard; diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.ts new file mode 100644 index 0000000000..8ad612d235 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react'; +import { type LoadingState } from '@react-types/shared'; +import { type MenuTriggerAction } from '../comboBox/ComboBox'; + +const LOADING_DEBOUNCE_MS = 500; + +export interface UseMultiSelectLoadingSpinnerOptions { + loadingState: LoadingState | undefined; + searchText: string; + isOpen: boolean; + menuTrigger: MenuTriggerAction; +} + +export function useMultiSelectLoadingSpinner({ + loadingState, + searchText, + isOpen, + menuTrigger, +}: UseMultiSelectLoadingSpinnerOptions): boolean { + const [showLoading, setShowLoading] = useState(false); + const loadingTimeoutRef = useRef | null>(null); + const isLoadingForSpinner = + loadingState === 'loading' || loadingState === 'filtering'; + const lastSearchTextRef = useRef(searchText); + + useEffect(() => { + if (isLoadingForSpinner && !showLoading) { + const searchChanged = searchText !== lastSearchTextRef.current; + if (loadingTimeoutRef.current !== null && searchChanged) { + clearTimeout(loadingTimeoutRef.current); + loadingTimeoutRef.current = null; + } + if (loadingTimeoutRef.current === null) { + loadingTimeoutRef.current = setTimeout(() => { + setShowLoading(true); + }, LOADING_DEBOUNCE_MS); + } + } else if (!isLoadingForSpinner) { + setShowLoading(false); + if (loadingTimeoutRef.current != null) { + clearTimeout(loadingTimeoutRef.current); + loadingTimeoutRef.current = null; + } + } + lastSearchTextRef.current = searchText; + }, [isLoadingForSpinner, showLoading, searchText]); + + useEffect( + () => () => { + if (loadingTimeoutRef.current != null) { + clearTimeout(loadingTimeoutRef.current); + loadingTimeoutRef.current = null; + } + }, + [] + ); + + return ( + showLoading && + (isOpen || menuTrigger === 'manual' || loadingState === 'loading') + ); +} + +export default useMultiSelectLoadingSpinner; diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectNormalizedProps.tsx b/packages/components/src/spectrum/multiSelect/useMultiSelectNormalizedProps.tsx new file mode 100644 index 0000000000..d306b27003 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectNormalizedProps.tsx @@ -0,0 +1,128 @@ +import { useMemo } from 'react'; +import { Section } from '@adobe/react-spectrum'; +import { + getItemKey, + isNormalizedSection, + normalizeTooltipOptions, + useRenderNormalizedItem, + useStringifiedMultiSelection, +} from '../utils'; +import { + type MultiSelectNormalizedProps, + type MultiSelectProps, +} from './MultiSelectProps'; + +/** Props that are derived by `useMultiSelectNormalizedProps` */ +export type UseMultiSelectNormalizedDerivedProps = { + children: JSX.Element[]; + forceRerenderKey: string; + selectedKeys: MultiSelectProps['selectedKeys']; + defaultSelectedKeys: MultiSelectProps['defaultSelectedKeys']; + disabledKeys: MultiSelectProps['disabledKeys']; + onChange: MultiSelectProps['onChange']; +}; + +/** + * Props that are passed through untouched. Should exclude all of the + * destructured props passed into `useMultiSelectNormalizedProps` that are not + * in the spread `...props`. + */ +export type UseMultiSelectNormalizedPassthroughProps = Omit< + MultiSelectNormalizedProps, + | 'normalizedItems' + | 'showItemIcons' + | 'tooltip' + | 'selectedKeys' + | 'defaultSelectedKeys' + | 'disabledKeys' + | 'onChange' + | 'onSelectionChange' +>; + +/** Props returned from `useMultiSelectNormalizedProps` hook. */ +export type UseMultiSelectNormalizedResult = + UseMultiSelectNormalizedDerivedProps & + UseMultiSelectNormalizedPassthroughProps; + +export function useMultiSelectNormalizedProps({ + normalizedItems, + showItemIcons, + tooltip = true, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onChange, + onSelectionChange, + ...props +}: MultiSelectNormalizedProps): UseMultiSelectNormalizedResult { + const tooltipOptions = useMemo( + () => normalizeTooltipOptions(tooltip), + [tooltip] + ); + + const renderNormalizedItem = useRenderNormalizedItem({ + itemIconSlot: 'icon', + showItemDescriptions: false, + showItemIcons, + tooltipOptions, + }); + + // Spectrum doesn't re-render if only the render function identity changes, + // so we expose a key that the parent can use to force a re-render. + const forceRerenderKey = `${showItemIcons}-${tooltipOptions?.placement}`; + + // Stringification operates on the flat item list so selection works for + // items inside sections too. + const flatItems = useMemo( + () => + normalizedItems.flatMap(item => + isNormalizedSection(item) ? item.item?.items ?? [] : [item] + ), + [normalizedItems] + ); + + const { + selectedStringKeys, + defaultSelectedStringKeys, + disabledStringKeys, + onStringSelectionChange, + } = useStringifiedMultiSelection({ + normalizedItems: flatItems, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onChange: onChange ?? onSelectionChange, + }); + + const children = useMemo( + () => + normalizedItems.map(itemOrSection => { + if (isNormalizedSection(itemOrSection)) { + return ( +
+ {renderNormalizedItem} +
+ ); + } + return renderNormalizedItem(itemOrSection); + }), + [normalizedItems, renderNormalizedItem] + ); + + return { + ...props, + children, + forceRerenderKey, + selectedKeys: selectedStringKeys as MultiSelectProps['selectedKeys'], + defaultSelectedKeys: + defaultSelectedStringKeys as MultiSelectProps['defaultSelectedKeys'], + disabledKeys: disabledStringKeys as MultiSelectProps['disabledKeys'], + onChange: onStringSelectionChange as MultiSelectProps['onChange'], + }; +} + +export default useMultiSelectNormalizedProps; diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.ts new file mode 100644 index 0000000000..a6cfab0c2c --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.ts @@ -0,0 +1,69 @@ +import { type RefObject, useEffect, useRef, useState } from 'react'; + +export interface UseMultiSelectScrollListenerOptions { + /** Ref to the DOM container that wraps the popover's ``. */ + containerRef: RefObject; + /** Whether the popover is currently open. */ + isOpen: boolean; + /** Scroll event listener attached to the inner scroll area. */ + onScroll: (event: Event) => void; +} + +/** + * Resolves the scrollable element inside the popover (the listbox, or the + * container if the listbox isn't present) and attaches a scroll listener + * to it. The listener is detached when the popover closes. + */ +export function useMultiSelectScrollListener({ + containerRef, + isOpen, + onScroll, +}: UseMultiSelectScrollListenerOptions): void { + const [scrollAreaEl, setScrollAreaEl] = useState(null); + + // Mirror onScroll into a ref so the listener is attached once per + // scrollAreaEl lifetime, regardless of caller memoization. + const onScrollRef = useRef(onScroll); + onScrollRef.current = onScroll; + + useEffect(() => { + if (!isOpen) { + setScrollAreaEl(null); + return undefined; + } + + // The ListBox mounts asynchronously inside the popover, so defer one + // animation frame to give Spectrum time to attach. + const handle = window.requestAnimationFrame(() => { + const container = containerRef.current; + if (container == null) { + return; + } + const listBox = container.querySelector('[role="listbox"]'); + setScrollAreaEl(listBox ?? container); + }); + + return () => { + window.cancelAnimationFrame(handle); + }; + }, [isOpen, containerRef]); + + // Attach scroll listener when the scroll area becomes available. + useEffect(() => { + if (scrollAreaEl == null) { + return undefined; + } + + const handler = (event: Event): void => { + onScrollRef.current(event); + }; + + scrollAreaEl.addEventListener('scroll', handler); + + return () => { + scrollAreaEl.removeEventListener('scroll', handler); + }; + }, [scrollAreaEl]); +} + +export default useMultiSelectScrollListener; diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectState.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectState.ts new file mode 100644 index 0000000000..acd95b9e52 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectState.ts @@ -0,0 +1,128 @@ +import { useCallback, useMemo, useRef } from 'react'; +import type { Key, Selection } from '@react-types/shared'; +import { useControlledState } from '@react-stately/utils'; +import { + type ItemKey, + type ItemSelection, + itemSelectionToStringSet, +} from '../utils'; +import { resolveSelection, type MultiSelectFlatItem } from './multiSelectUtils'; + +export interface UseMultiSelectStateOptions { + selectedKeys: 'all' | Iterable | undefined; + defaultSelectedKeys: 'all' | Iterable | undefined; + disabledKeys: Iterable | undefined; + onChange: ((keys: ItemSelection) => void) | undefined; + onSelectionChange: ((keys: ItemSelection) => void) | undefined; + allKeys: string[]; +} + +export interface UseMultiSelectStateResult { + /** Resolved selection set (controlled or uncontrolled). */ + selectedKeys: Set; + /** Selected keys as an array (memoized for stable rendering). */ + selectedKeyArray: string[]; + /** Disabled keys, ready to pass to ``. */ + listBoxDisabledKeys: Iterable | undefined; + /** Toggle a single key in the selection. */ + toggleKey: (key: string) => void; + /** Apply a `Selection` from the underlying ``. */ + applyListBoxSelection: ( + selection: Selection, + filteredItems: MultiSelectFlatItem[] + ) => void; +} + +export function useMultiSelectState({ + selectedKeys: propSelectedKeys, + defaultSelectedKeys, + disabledKeys: propDisabledKeys, + onChange: propOnChange, + onSelectionChange: propOnSelectionChange, + allKeys, +}: UseMultiSelectStateOptions): UseMultiSelectStateResult { + const controlledKeys = useMemo | undefined>( + () => + propSelectedKeys !== undefined + ? resolveSelection(propSelectedKeys, allKeys) + : undefined, + [propSelectedKeys, allKeys] + ); + + const handleChange = useCallback( + (next: Set) => { + const callback = propOnChange ?? propOnSelectionChange; + callback?.(next as ItemSelection); + }, + [propOnChange, propOnSelectionChange] + ); + + // Resolve the initial default once. useControlledState only reads this on + // first render in uncontrolled mode and ignores it in controlled mode, so + // recomputing on every render would just allocate a throwaway Set. + const initialDefaultRef = useRef | undefined>(undefined); + if (initialDefaultRef.current === undefined) { + initialDefaultRef.current = resolveSelection(defaultSelectedKeys, allKeys); + } + + const [selectedKeys, setSelectedKeys] = useControlledState>( + controlledKeys, + initialDefaultRef.current, + handleChange + ); + + // Mirror selectedKeys into a ref so toggleKey/applyListBoxSelection can + // read the latest value without re-creating on every change. + const selectedKeysRef = useRef(selectedKeys); + selectedKeysRef.current = selectedKeys; + + const listBoxDisabledKeys = useMemo | undefined>( + () => itemSelectionToStringSet(propDisabledKeys), + [propDisabledKeys] + ); + + const toggleKey = useCallback( + (key: string) => { + const next = new Set(selectedKeysRef.current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setSelectedKeys(next); + }, + [setSelectedKeys] + ); + + const applyListBoxSelection = useCallback( + (selection: Selection, filteredItems: MultiSelectFlatItem[]) => { + if (selection === 'all') { + setSelectedKeys(new Set(allKeys)); + return; + } + + // Preserve selected keys for items not in the current filtered list. + // The ListBox only knows about rendered (filtered) items, so it can't + // manage selection state for items hidden by search filtering. + const filteredKeySet = new Set(filteredItems.map(i => i.key)); + const next = new Set( + [...selectedKeysRef.current].filter(k => !filteredKeySet.has(k)) + ); + selection.forEach((k: Key) => next.add(String(k))); + setSelectedKeys(next); + }, + [allKeys, setSelectedKeys] + ); + + const selectedKeyArray = useMemo(() => [...selectedKeys], [selectedKeys]); + + return { + selectedKeys, + selectedKeyArray, + listBoxDisabledKeys, + toggleKey, + applyListBoxSelection, + }; +} + +export default useMultiSelectState; From f85af59f0d255c34d73747a141767a00ed7ec9ba Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Mon, 27 Apr 2026 10:42:40 -0400 Subject: [PATCH 09/15] WIP fix stale aria-activedescendant --- .../src/spectrum/multiSelect/useMultiSelectKeyboard.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts index 5d4a31ce81..307b7aaefd 100644 --- a/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts @@ -244,6 +244,7 @@ export function useMultiSelectKeyboard({ useEffect(() => { const container = listBoxContainerRef.current; if (container == null) { + inputRef.current?.removeAttribute('aria-activedescendant'); return; } const options = container.querySelectorAll('[role="option"]'); From 625fce6a2e8fa2caf4edea079ff32c5801f9e5a4 Mon Sep 17 00:00:00 2001 From: gzh2003 Date: Mon, 27 Apr 2026 11:03:55 -0400 Subject: [PATCH 10/15] WIP changes --- packages/components/package.json | 7 + .../src/spectrum/multiSelect/MultiSelect.scss | 592 ++++++++++++++++++ .../src/spectrum/multiSelect/MultiSelect.tsx | 481 ++++++++++++++ 3 files changed, 1080 insertions(+) create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelect.scss create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelect.tsx diff --git a/packages/components/package.json b/packages/components/package.json index 63a02e869c..3e946a1a4e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -35,13 +35,20 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@hello-pangea/dnd": "^18.0.1", "@internationalized/date": "^3.5.5", + "@react-aria/focus": "^3.21.0", + "@react-aria/i18n": "^3.12.11", + "@react-spectrum/label": "^3.16.17", + "@react-spectrum/overlays": "^5.8.0", "@react-spectrum/theme-default": "^3.5.1", "@react-spectrum/toast": "^3.0.0-beta.16", "@react-spectrum/utils": "^3.11.5", + "@react-stately/overlays": "^3.6.18", + "@react-stately/utils": "^3.10.8", "@react-types/combobox": "3.13.1", "@react-types/radio": "^3.8.1", "@react-types/shared": "^3.22.1", "@react-types/textfield": "^3.9.1", + "@spectrum-icons/ui": "^3.6.18", "bootstrap": "4.6.2", "classnames": "^2.3.1", "event-target-shim": "^6.0.2", diff --git a/packages/components/src/spectrum/multiSelect/MultiSelect.scss b/packages/components/src/spectrum/multiSelect/MultiSelect.scss new file mode 100644 index 0000000000..bc1b43ff7a --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelect.scss @@ -0,0 +1,592 @@ +/* stylelint-disable no-descending-specificity */ + +// Wrapper for Spectrum's . Zero intrinsic width prevents the trigger +// from growing with tag content. !important required to override Spectrum's +// width rules on .spectrum-Field--positionTop and .spectrum-Form > *. +.dh-multi-select { + inline-size: 0 !important; + min-inline-size: var( + --spectrum-combobox-default-width, + var(--spectrum-global-dimension-size-2400, 240px) + ) !important; + max-inline-size: 100% !important; +} + +.dh-multi-select-trigger { + display: flex; + align-items: stretch; + border: none; + border-radius: var(--spectrum-alias-border-radius-regular, 4px); + background-color: var(--dh-color-input-bg); + color: var(--dh-color-input-fg); + cursor: pointer; + outline: none; + position: relative; + min-height: var( + --spectrum-alias-single-line-height, + var(--spectrum-global-dimension-size-400, 32px) + ); + + // Visual border via ::after: paints on top of all children (including chevron) + &::after { + content: ''; + position: absolute; + inset: 0; + border: 1px solid var(--dh-color-input-border); + border-radius: var(--spectrum-alias-border-radius-regular, 4px); + pointer-events: none; + transition: border-color 130ms ease-in-out; + } + + // Hover state: :not(.is-focused) ensures focus styles take precedence + &:hover:not(.is-disabled):not(.is-read-only):not(.is-focused) { + &::after { + border-color: var(--dh-color-input-hover-border); + } + + .dh-multi-select-chevron { + color: var(--dh-color-selector-hover-fg); + background-color: var(--dh-color-selector-hover-bg); + + &::before { + border-left-color: var(--dh-color-input-hover-border); + } + } + } + + // Focus state: applied by FocusRing `focusClass` + &.is-focused { + &::after { + border-color: var(--dh-color-input-focus-border); + } + + .dh-multi-select-chevron:not(.is-open) { + background-color: var(--dh-color-selector-hover-bg); + + &::before { + border-left-color: var(--dh-color-input-focus-border); + } + } + } + + // Focus ring: applied by FocusRing `focusRingClass` + &.focus-ring { + box-shadow: 0 0 0 1px var(--dh-color-input-focus-border); + } + + // Invalid state: outer border + chevron divider red. + &.is-invalid { + &::after { + border-color: var( + --spectrum-textfield-border-color-error, + var(--spectrum-red-900) + ); + } + + .dh-multi-select-chevron::before { + border-left-color: var( + --spectrum-textfield-border-color-error, + var(--spectrum-red-900) + ); + } + + &:hover:not(.is-disabled):not(.is-read-only):not(.is-focused) { + &::after { + border-color: var( + --spectrum-textfield-border-color-error-hover, + var(--spectrum-red-1000) + ); + } + + .dh-multi-select-chevron::before { + border-left-color: var( + --spectrum-textfield-border-color-error-hover, + var(--spectrum-red-1000) + ); + } + } + + &:active:not(.is-disabled):not(.is-read-only) { + &::after { + border-color: var( + --spectrum-textfield-border-color-error-down, + var(--spectrum-red-1100) + ); + } + + .dh-multi-select-chevron::before { + border-left-color: var( + --spectrum-textfield-border-color-error-down, + var(--spectrum-red-1100) + ); + } + } + + &.is-focused { + &::after { + border-color: var(--dh-color-input-focus-border); + } + + .dh-multi-select-chevron::before { + border-left-color: var(--dh-color-input-focus-border); + } + } + } + + // Disabled state + &.is-disabled { + background-color: var(--dh-color-input-disabled-bg); + color: var(--dh-color-input-disabled-fg); + cursor: default; + + &::after { + border-color: var(--dh-color-input-disabled-border); + } + + .dh-multi-select-chevron { + color: var(--dh-color-selector-disabled-fg); + background-color: var(--dh-color-input-disabled-bg); + + &::before { + border-left-color: var(--dh-color-input-disabled-border); + } + } + } + + // Read-only state + &.is-read-only { + cursor: default; + } + + // Quiet variant: matches Spectrum ComboBox quiet + &.is-quiet { + background: none; + border-radius: 0; + min-width: calc( + 2 * + var( + --spectrum-dropdown-height, + var(--spectrum-global-dimension-size-400, 32px) + ) + ); + + &::after { + border: none; + border-bottom: var(--spectrum-alias-input-border-size, 1px) solid + var(--dh-color-input-border); + border-radius: 0; + } + + &:hover:not(.is-disabled):not(.is-read-only):not(.is-focused) { + &::after { + border-bottom-color: var(--dh-color-input-hover-border); + } + + .dh-multi-select-chevron { + background-color: transparent; + + &::before { + border-bottom-color: var(--dh-color-input-hover-border); + } + } + } + + &.is-focused { + &::after { + border-bottom-color: var(--dh-color-input-focus-border); + } + + .dh-multi-select-chevron:not(.is-open) { + background-color: transparent; + } + + .dh-multi-select-chevron::before { + border-bottom-color: var(--dh-color-input-focus-border); + } + } + + // Quiet focus ring for bottom-only line (not full outline) + &.focus-ring { + box-shadow: 0 2px 0 0 var(--dh-color-input-focus-border); + } + + .dh-multi-select-chevron { + inline-size: auto; + padding-inline-start: var(--spectrum-global-dimension-size-130, 10px); + padding-inline-end: 0; + background-color: transparent; + border-radius: 0; + + // Bottom border continuation instead of left separator + &::before { + border-left: none; + border-bottom: var(--spectrum-alias-input-border-size, 1px) solid + var(--dh-color-input-border); + border-radius: 0; + top: auto; + bottom: 0; + left: 0; + right: 0; + width: auto; + height: 0; + } + + &.is-open { + background-color: var( + --spectrum-alias-background-color-transparent, + transparent + ); + } + } + + &.is-disabled .dh-multi-select-chevron { + border-radius: 0; + + &::before { + border-bottom-color: var(--dh-color-input-disabled-border); + } + } + + // Invalid quiet variant: bottom borders red. + &.is-invalid { + &::after { + border-bottom-color: var( + --spectrum-textfield-border-color-error, + var(--spectrum-red-900) + ); + } + + .dh-multi-select-chevron::before { + border-bottom-color: var( + --spectrum-textfield-border-color-error, + var(--spectrum-red-900) + ); + } + + &:hover:not(.is-disabled):not(.is-read-only):not(.is-focused) { + &::after { + border-bottom-color: var( + --spectrum-textfield-border-color-error-hover, + var(--spectrum-red-1000) + ); + } + + .dh-multi-select-chevron::before { + border-bottom-color: var( + --spectrum-textfield-border-color-error-hover, + var(--spectrum-red-1000) + ); + } + } + + &:active:not(.is-disabled):not(.is-read-only) { + &::after { + border-bottom-color: var( + --spectrum-textfield-border-color-error-down, + var(--spectrum-red-1100) + ); + } + + .dh-multi-select-chevron::before { + border-bottom-color: var( + --spectrum-textfield-border-color-error-down, + var(--spectrum-red-1100) + ); + } + } + + // Focus overrides red (specificity boost via &.is-invalid). + &.is-focused { + &::after { + border-bottom-color: var(--dh-color-input-focus-border); + } + + .dh-multi-select-chevron::before { + border-bottom-color: var(--dh-color-input-focus-border); + } + } + } + } +} + +// Content area tags + input wrapping flow. +.dh-multi-select-content { + display: flex; + flex-wrap: wrap; + align-items: center; + flex: 1; + min-width: 0; + // Half Spectrum's tag-group gap tokens: container gap is single-spaced, while + // Spectrum's per-tag margins double up between adjacent items. + gap: calc( + var( + --spectrum-taggroup-tag-gap-y, + var(--spectrum-global-dimension-size-100) + ) / 2 + ) + calc( + var( + --spectrum-taggroup-tag-gap-x, + var(--spectrum-global-dimension-size-100) + ) / 2 + ); + padding: 3px 0 3px 4px; +} + +// Custom tag chip: matches Spectrum .spectrum-Tag exactly. +.dh-multi-select-tag { + display: inline-grid; + grid-template-columns: 1fr auto; + align-items: center; + box-sizing: border-box; + max-inline-size: 100%; + cursor: default; + user-select: none; + block-size: var( + --spectrum-tag-height, + var(--spectrum-global-dimension-size-300) + ); + padding-inline-start: calc( + var(--spectrum-tag-padding-x, var(--spectrum-global-dimension-size-125)) - + var(--spectrum-tag-border-size, var(--spectrum-alias-border-size-thin)) + ); + border-style: solid; + border-width: var( + --spectrum-tag-border-size, + var(--spectrum-alias-border-size-thin) + ); + border-radius: var( + --spectrum-tag-border-radius, + var(--spectrum-alias-border-radius-regular) + ); + color: var(--spectrum-tag-text-color, var(--spectrum-global-color-gray-700)); + background-color: var( + --spectrum-tag-background-color, + var(--spectrum-global-color-gray-75) + ); + border-color: var( + --spectrum-tag-border-color, + var(--spectrum-global-color-gray-600) + ); + font-size: var( + --spectrum-tag-text-size, + var(--spectrum-global-dimension-font-size-75) + ); + transition: + border-color 0.13s ease-in-out, + color 0.13s ease-in-out, + box-shadow 0.13s ease-in-out, + background-color 0.13s ease-in-out; + + &:hover { + background-color: var( + --spectrum-tag-background-color-hover, + var(--spectrum-global-color-gray-75) + ); + color: var( + --spectrum-tag-text-color-hover, + var(--spectrum-global-color-gray-900) + ); + border-color: var( + --spectrum-tag-border-color-hover, + var(--spectrum-global-color-gray-900) + ); + } + + .is-disabled & { + color: var( + --spectrum-tag-text-color-disabled, + var(--spectrum-global-color-gray-500) + ); + background-color: var( + --spectrum-tag-background-color-disabled, + var(--spectrum-global-color-gray-200) + ); + border-color: var( + --spectrum-tag-border-color-disabled, + var(--spectrum-global-color-gray-200) + ); + pointer-events: none; + } +} + +// Tag label: matches Spectrum .spectrum-Tag-content +.dh-multi-select-tag-label { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + line-height: calc( + var(--spectrum-tag-height, var(--spectrum-global-dimension-size-300)) - + calc( + var(--spectrum-tag-border-size, var(--spectrum-alias-border-size-thin)) * + 2 + ) + ); + margin-inline-end: 0; + + // When there's no remove button, add end padding to match Spectrum. + &:last-child { + margin-inline-end: var( + --spectrum-tag-padding-x, + var(--spectrum-global-dimension-size-125) + ); + } +} + +// Tag remove button: matches Spectrum .spectrum-Tag-removeButton dimensions +.dh-multi-select-tag-remove { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + color: inherit; + height: calc( + var(--spectrum-tag-height, var(--spectrum-global-dimension-size-300)) - + ( + 2 * + var( + --spectrum-tag-border-size, + var(--spectrum-alias-border-size-thin) + ) + ) + ); + width: var(--spectrum-global-dimension-size-300); + + &:hover { + color: var( + --spectrum-tag-removable-button-icon-color-hover, + var(--spectrum-global-color-gray-900) + ); + } + + // Scale down the CrossSmall icon to fit the tag + svg { + width: 8px; + height: 8px; + } +} + +// Inline filter input: fills remaining space on the last row of tags. +.dh-multi-select-input { + border: none; + outline: none; + background: transparent; + color: var(--dh-color-input-fg); + font-family: inherit; + font-size: inherit; + line-height: inherit; + flex: 1; + min-width: 40px; + padding: 2px 0 2px 4px; + // Match tag height so input aligns with tags on the same row + height: var(--spectrum-tag-height, var(--spectrum-global-dimension-size-300)); + + &:disabled { + color: var(--dh-color-input-disabled-fg); + cursor: default; + } +} + +.dh-multi-select-chevron { + display: flex; + align-items: center; + justify-content: center; + inline-size: var(--spectrum-global-dimension-size-400, 32px); + flex-shrink: 0; + align-self: stretch; + box-sizing: border-box; + position: relative; + padding: var(--spectrum-combobox-fieldbutton-inset, 0); + background-color: var(--dh-color-selector-bg); + background-clip: content-box; + color: var(--dh-color-selector-fg); + border-radius: 0 calc(var(--spectrum-alias-border-radius-regular, 4px) - 1px) + calc(var(--spectrum-alias-border-radius-regular, 4px) - 1px) 0; + transition: + background-color 130ms ease-in-out, + color 130ms ease-in-out; + + // Left separator line via pseudo-element (matches Spectrum FieldButton pattern) + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 0; + border-left: 1px solid var(--dh-color-input-border); + pointer-events: none; + } + + // Active/open state: matches Spectrum FieldButton is-active. + &.is-open { + background-color: var( + --spectrum-fieldbutton-background-color-down, + var(--spectrum-global-color-gray-200) + ); + color: var( + --spectrum-fieldbutton-icon-color-down, + var(--spectrum-alias-icon-color-down) + ); + + &::before { + border-left-color: var( + --spectrum-fieldbutton-border-color-down, + var(--spectrum-alias-border-color-down) + ); + } + } +} + +// Validation icon shown left of chevron when validationState="invalid". +.dh-multi-select-invalid-icon { + display: flex; + align-items: center; + padding-inline-start: var(--spectrum-global-dimension-size-100, 8px); + padding-inline-end: var(--spectrum-global-dimension-size-100, 8px); + color: var(--spectrum-semantic-negative-color-icon); + flex-shrink: 0; +} + +// Mirrors Spectrum ComboBox .no-results rule. +.dh-multi-select-empty { + display: block; + padding-top: var(--spectrum-selectlist-option-padding-height); + padding-inline-start: var( + --spectrum-selectlist-option-padding, + var(--spectrum-global-dimension-static-size-150) + ); + font-size: var( + --spectrum-selectlist-option-text-size, + var(--spectrum-alias-font-size-default) + ); + font-weight: var( + --spectrum-selectlist-option-text-font-weight, + var(--spectrum-global-font-weight-regular) + ); +} + +// Inline loading spinner. +.dh-multi-select-loading-circle { + display: flex; + align-items: center; + justify-content: center; + // Inset to align with single-select spinner position. + padding-inline-end: 8px; + padding-inline-start: 4px; +} + +// Virtual focus highlight (keyboard nav). Popover lives in a portal, +// so the selector is unscoped. +[role='option'][data-dh-focused='true'] { + background-color: var(--dh-color-item-list-hover-bg); + border-inline-start-color: var( + --spectrum-selectlist-option-focus-indicator-color, + var(--spectrum-alias-border-color-focus) + ); +} diff --git a/packages/components/src/spectrum/multiSelect/MultiSelect.tsx b/packages/components/src/spectrum/multiSelect/MultiSelect.tsx new file mode 100644 index 0000000000..39eb426a77 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelect.tsx @@ -0,0 +1,481 @@ +import React, { useCallback, useId, useMemo, useRef } from 'react'; +import type { DOMRefValue } from '@react-types/shared'; +import type { Placement } from '@react-types/overlays'; +import { Field } from '@react-spectrum/label'; +import { FocusRing } from '@react-aria/focus'; +import { Popover } from '@react-spectrum/overlays'; +import { useUnwrapDOMRef } from '@react-spectrum/utils'; +import { useOverlayTriggerState } from '@react-stately/overlays'; +import ChevronDownMedium from '@spectrum-icons/ui/ChevronDownMedium'; +import AlertMedium from '@spectrum-icons/ui/AlertMedium'; +import { ProgressCircle } from '@adobe/react-spectrum'; +import cl from 'classnames'; +import { EMPTY_FUNCTION, ensureArray } from '@deephaven/utils'; +import { useMergeRef } from '@deephaven/react-hooks'; +import { normalizeTooltipOptions, wrapItemChildren } from '../utils'; +import type { MenuTriggerAction } from '../comboBox'; +import { type MultiSelectProps } from './MultiSelectProps'; +import { + flattenJsxChildren, + flattenEntriesToItems, + type MultiSelectFlatEntry, +} from './multiSelectUtils'; +import { useMultiSelectState } from './useMultiSelectState'; +import { useMultiSelectFilter } from './useMultiSelectFilter'; +import { useMultiSelectKeyboard } from './useMultiSelectKeyboard'; +import { useMultiSelectLoadingSpinner } from './useMultiSelectLoadingSpinner'; +import { useMultiSelectScrollListener } from './useMultiSelectScrollListener'; +import { MultiSelectTag } from './MultiSelectTag'; +import { MultiSelectListBox } from './MultiSelectListBox'; +import './MultiSelect.scss'; + +/** + * Multi-select styled to match Spectrum ComboBox. Renders selected items as + * tags inside the trigger area alongside a filter input. Accepts the same + * `Item` / `Section` JSX children as `Picker`. + */ +function MultiSelectInner( + props: MultiSelectProps, + forwardedRef: React.Ref +): JSX.Element { + const { + children, + tooltip = true, + selectedKeys: propSelectedKeys, + defaultSelectedKeys, + disabledKeys: propDisabledKeys, + onChange: propOnChange, + onSelectionChange: propOnSelectionChange, + onOpenChange, + onScroll = EMPTY_FUNCTION, + label, + description, + errorMessage, + isRequired = false, + isDisabled = false, + isReadOnly = false, + validationState, + isQuiet = false, + labelPosition = 'top', + labelAlign, + necessityIndicator, + contextualHelp, + inputValue: controlledInputValue, + defaultInputValue = '', + onInputChange, + shouldFocusWrap = false, + loadingState, + menuTrigger = 'input', + align = 'start', + direction = 'bottom', + shouldFlip = true, + menuWidth, + allowsCustomValue = false, + formValue = 'key', + validationBehavior = 'aria', + autoFocus = false, + name, + id, + isHidden = false, + onFocus, + onBlur, + onFocusChange, + onKeyDown, + onKeyUp, + onSearchTextChange, + selectedItemLabels, + UNSAFE_className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + 'aria-details': ariaDetails, + ...styleProps + } = props; + + // Spectrum's onOpenChange omits the trigger reason; relay it via ref. + const lastTriggerReasonRef = useRef(undefined); + + const handleOverlayOpenChange = useCallback( + (isOpen: boolean) => { + onOpenChange?.(isOpen, lastTriggerReasonRef.current); + }, + [onOpenChange] + ); + + const overlayState = useOverlayTriggerState({ + onOpenChange: handleOverlayOpenChange, + }); + + const openOverlay = useCallback( + (reason: MenuTriggerAction) => { + lastTriggerReasonRef.current = reason; + overlayState.open(); + }, + [overlayState] + ); + const closeOverlay = useCallback(() => { + lastTriggerReasonRef.current = undefined; + overlayState.close(); + }, [overlayState]); + + const listBoxId = useId(); + + const placement = `${direction} ${align}` as Placement; + + const triggerRef = useRef(null); + const inputRef = useRef(null); + const popoverRef = useRef>(null); + const unwrappedPopoverRef = useUnwrapDOMRef(popoverRef); + const listBoxRef = useRef>(null); + const unwrappedListBoxRef = useUnwrapDOMRef(listBoxRef); + const isFocusedRef = useRef(false); + const mergedTriggerRef = useMergeRef(triggerRef, forwardedRef); + + // Ensures Item/ItemContent wrapping for tooltips/overflow. + const tooltipOptions = useMemo( + () => normalizeTooltipOptions(tooltip), + [tooltip] + ); + const wrappedChildren = useMemo( + () => ensureArray(wrapItemChildren(children, tooltipOptions)), + [children, tooltipOptions] + ); + + // Flat {key,label} entries for filter/keyboard hooks. ListBox renders JSX directly. + const allEntries: MultiSelectFlatEntry[] = useMemo( + () => flattenJsxChildren(wrappedChildren), + [wrappedChildren] + ); + + const allItems = useMemo( + () => flattenEntriesToItems(allEntries), + [allEntries] + ); + const allKeys = useMemo(() => allItems.map(i => i.key), [allItems]); + const itemLabelMap = useMemo(() => { + const m = new Map(); + allItems.forEach(i => m.set(i.key, i.label)); + return m; + }, [allItems]); + + const getLabelFor = useCallback( + (key: string): string => + itemLabelMap.get(key) ?? selectedItemLabels?.get(key) ?? key, + [itemLabelMap, selectedItemLabels] + ); + + const { searchText, setSearchText, filteredItems, filteredJsxChildren } = + useMultiSelectFilter({ + allEntries, + wrappedChildren, + inputValue: controlledInputValue, + defaultInputValue, + onInputChange, + onSearchTextChange, + }); + + const emptyMessage: string | undefined = useMemo(() => { + if (filteredItems.length > 0) { + return undefined; + } + if (loadingState === 'loading') { + return 'Loading...'; + } + // loadingMore + empty: defer to ListBox's loader pill instead of "No results". + if (loadingState === 'loadingMore') { + return undefined; + } + return 'No results'; + }, [filteredItems.length, loadingState]); + + const { + selectedKeys, + selectedKeyArray, + listBoxDisabledKeys, + toggleKey, + applyListBoxSelection, + } = useMultiSelectState({ + selectedKeys: propSelectedKeys, + defaultSelectedKeys, + disabledKeys: propDisabledKeys, + onChange: propOnChange, + onSelectionChange: propOnSelectionChange, + allKeys, + }); + + const { handleInputKeyDown } = useMultiSelectKeyboard({ + filteredItems, + allItems, + shouldFocusWrap, + overlayState, + openOverlay, + closeOverlay, + isReadOnly, + isDisabled, + searchText, + setSearchText, + selectedKeys, + toggleKey, + allowsCustomValue, + menuTrigger, + onKeyDown, + listBoxContainerRef: unwrappedListBoxRef, + inputRef, + }); + + useMultiSelectScrollListener({ + containerRef: unwrappedListBoxRef, + isOpen: overlayState.isOpen, + onScroll, + }); + + const shouldShowInlineSpinner = useMultiSelectLoadingSpinner({ + loadingState, + searchText, + isOpen: overlayState.isOpen, + menuTrigger, + }); + + const refocusInput = useCallback(() => { + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }, []); + + const handleTagRemove = useCallback( + (key: string) => { + toggleKey(key); + refocusInput(); + }, + [toggleKey, refocusInput] + ); + + const handleListBoxSelectionChange = useCallback( + (selection: Parameters[0]) => { + if (isReadOnly) { + return; + } + applyListBoxSelection(selection, filteredItems); + refocusInput(); + }, + [isReadOnly, applyListBoxSelection, filteredItems, refocusInput] + ); + + const handleInputFocus = useCallback( + (e: React.FocusEvent) => { + if (isFocusedRef.current) { + return; + } + isFocusedRef.current = true; + if (menuTrigger === 'focus' && !overlayState.isOpen && !isDisabled) { + openOverlay('focus'); + } + onFocus?.(e); + onFocusChange?.(true); + }, + [onFocus, onFocusChange, menuTrigger, overlayState, isDisabled, openOverlay] + ); + + const handleInputBlur = useCallback( + (e: React.FocusEvent) => { + const related = e.relatedTarget as HTMLElement | null; + // Ignore null relatedTarget (DOM churn during re-render). + // Real dismisses carry a relatedTarget or are handled by Spectrum. + if (related == null) { + return; + } + if (triggerRef.current != null && triggerRef.current.contains(related)) { + return; + } + if ( + unwrappedPopoverRef.current != null && + unwrappedPopoverRef.current.contains(related) + ) { + return; + } + + isFocusedRef.current = false; + if (overlayState.isOpen) { + closeOverlay(); + } + onBlur?.(e); + onFocusChange?.(false); + }, + [onBlur, onFocusChange, overlayState, closeOverlay, unwrappedPopoverRef] + ); + + const handleTriggerAreaClick = useCallback(() => { + if (isDisabled) { + return; + } + if (!overlayState.isOpen) { + openOverlay('manual'); + } + inputRef.current?.focus(); + }, [isDisabled, overlayState, openOverlay]); + + const handleChevronClick = useCallback( + (e: React.MouseEvent) => { + // Stop trigger-area handler from re-opening the popover on close. + e.stopPropagation(); + if (isDisabled) { + return; + } + if (overlayState.isOpen) { + closeOverlay(); + } else { + openOverlay('manual'); + } + inputRef.current?.focus(); + }, + [isDisabled, overlayState, openOverlay, closeOverlay] + ); + + return ( + +
+ + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+
+ {selectedKeyArray.map(key => ( + + ))} + setSearchText(e.target.value)} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onKeyDown={handleInputKeyDown} + onKeyUp={onKeyUp} + disabled={isDisabled} + readOnly={isReadOnly} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={autoFocus} + role="combobox" + aria-haspopup="listbox" + aria-expanded={overlayState.isOpen} + aria-controls={overlayState.isOpen ? listBoxId : undefined} + aria-autocomplete="list" + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} + aria-describedby={ariaDescribedby} + aria-details={ariaDetails} + /> +
+ + {shouldShowInlineSpinner && ( +
+ +
+ )} + + {validationState === 'invalid' && !isDisabled && ( + + )} + +
e.preventDefault()} + role="button" + tabIndex={-1} + aria-label="Toggle dropdown" + > + +
+
+
+ + {name != null && ( + + )} + + {overlayState.isOpen && !isDisabled && ( + + triggerRef.current?.contains(target) !== true + } + UNSAFE_style={{ + width: menuWidth ?? triggerRef.current?.offsetWidth ?? undefined, + }} + > + + + )} +
+
+ ); +} + +/** Forwarded-ref wrapper. Trigger is a
, matching Picker's DOMRef shape. */ +export const MultiSelect = React.forwardRef(MultiSelectInner); +MultiSelect.displayName = 'MultiSelect'; + +export default MultiSelect; From 36b788659fb5da6464086cb7679f21690bac5f87 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Thu, 14 May 2026 13:23:49 -0500 Subject: [PATCH 11/15] adding tests --- .../multiSelect/MultiSelectTag.test.tsx | 60 ++++ .../multiSelect/multiSelectUtils.test.tsx | 267 ++++++++++++++++++ .../multiSelect/useMultiSelectFilter.test.ts | 127 +++++++++ .../useMultiSelectKeyboard.test.ts | 259 +++++++++++++++++ .../useMultiSelectLoadingSpinner.test.ts | 149 ++++++++++ .../useMultiSelectScrollListener.test.ts | 130 +++++++++ .../multiSelect/useMultiSelectState.test.ts | 197 +++++++++++++ .../src/spectrum/MultiSelect.test.tsx | 104 +++++++ 8 files changed, 1293 insertions(+) create mode 100644 packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx create mode 100644 packages/components/src/spectrum/multiSelect/multiSelectUtils.test.tsx create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectFilter.test.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.test.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts create mode 100644 packages/components/src/spectrum/multiSelect/useMultiSelectState.test.ts create mode 100644 packages/jsapi-components/src/spectrum/MultiSelect.test.tsx diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx new file mode 100644 index 0000000000..69d0c3e0d5 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MultiSelectTag } from './MultiSelectTag'; + +// CrossSmall icon is imported from @spectrum-icons/ui +jest.mock('@spectrum-icons/ui/CrossSmall', () => { + function MockCrossSmall(): JSX.Element { + return ; + } + MockCrossSmall.displayName = 'CrossSmall'; + return { __esModule: true, default: MockCrossSmall }; +}); + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe('MultiSelectTag', () => { + const defaultProps = { + tagKey: 'key1', + label: 'Tag Label', + isDisabled: false, + isReadOnly: false, + onRemove: jest.fn(), + }; + + it('renders the label text', () => { + render(); + expect(screen.getByText('Tag Label')).toBeInTheDocument(); + }); + + it('renders remove button when not disabled and not read-only', () => { + render(); + expect( + screen.getByRole('button', { name: 'Remove Tag Label' }) + ).toBeInTheDocument(); + }); + + it('does not render remove button when isDisabled', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('does not render remove button when isReadOnly', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('calls onRemove with the tag key when remove button is clicked', async () => { + const onRemove = jest.fn(); + render(); + + const removeBtn = screen.getByRole('button', { name: 'Remove Tag Label' }); + await userEvent.click(removeBtn); + + expect(onRemove).toHaveBeenCalledWith('key1'); + }); +}); diff --git a/packages/components/src/spectrum/multiSelect/multiSelectUtils.test.tsx b/packages/components/src/spectrum/multiSelect/multiSelectUtils.test.tsx new file mode 100644 index 0000000000..b4cdaaaee6 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/multiSelectUtils.test.tsx @@ -0,0 +1,267 @@ +import React from 'react'; +import { Item, Section } from '../shared'; +import { + flattenJsxChildren, + flattenEntriesToItems, + filterEntries, + collectEntryItemKeys, + filterJsxChildrenByKeys, + resolveSelection, + isFlatSection, + type MultiSelectFlatEntry, + type MultiSelectFlatItem, + type MultiSelectFlatSection, +} from './multiSelectUtils'; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe('isFlatSection', () => { + it('returns true for section entries', () => { + const section: MultiSelectFlatSection = { + kind: 'section', + key: 'sec', + title: 'Section', + items: [], + }; + expect(isFlatSection(section)).toBe(true); + }); + + it('returns false for item entries', () => { + const item: MultiSelectFlatItem = { kind: 'item', key: 'a', label: 'A' }; + expect(isFlatSection(item)).toBe(false); + }); +}); + +describe('flattenJsxChildren', () => { + it('flattens plain Item elements', () => { + const children = [ + + Alpha + , + + Beta + , + ]; + + const result = flattenJsxChildren(children); + expect(result).toEqual([ + { kind: 'item', key: 'a', label: 'Alpha' }, + { kind: 'item', key: 'b', label: 'Beta' }, + ]); + }); + + it('flattens Section elements with nested Items', () => { + const children = [ +
+ + X + + + Y + +
, + ]; + + const result = flattenJsxChildren(children); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'section', + title: 'Group', + items: [ + { kind: 'item', key: 'x', label: 'X' }, + { kind: 'item', key: 'y', label: 'Y' }, + ], + }); + }); + + it('handles a mix of Items and Sections', () => { + const children = [ + + A + , +
+ + B + +
, + ]; + + const result = flattenJsxChildren(children); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ kind: 'item', key: 'a' }); + expect(result[1]).toMatchObject({ kind: 'section', key: 's1' }); + }); + + it('skips items without keys', () => { + // eslint-disable-next-line react/jsx-key + const children = [NoKey]; + const result = flattenJsxChildren(children); + expect(result).toEqual([]); + }); +}); + +describe('flattenEntriesToItems', () => { + it('returns items directly and extracts items from sections', () => { + const entries: MultiSelectFlatEntry[] = [ + { kind: 'item', key: 'a', label: 'A' }, + { + kind: 'section', + key: 's', + title: 'S', + items: [ + { kind: 'item', key: 'b', label: 'B' }, + { kind: 'item', key: 'c', label: 'C' }, + ], + }, + ]; + const result = flattenEntriesToItems(entries); + expect(result).toEqual([ + { kind: 'item', key: 'a', label: 'A' }, + { kind: 'item', key: 'b', label: 'B' }, + { kind: 'item', key: 'c', label: 'C' }, + ]); + }); + + it('returns empty array for empty entries', () => { + expect(flattenEntriesToItems([])).toEqual([]); + }); +}); + +describe('filterEntries', () => { + const contains = (str: string, sub: string): boolean => + str.toLowerCase().includes(sub.toLowerCase()); + + const entries: MultiSelectFlatEntry[] = [ + { kind: 'item', key: 'apple', label: 'Apple' }, + { kind: 'item', key: 'banana', label: 'Banana' }, + { + kind: 'section', + key: 'citrus', + title: 'Citrus', + items: [ + { kind: 'item', key: 'orange', label: 'Orange' }, + { kind: 'item', key: 'lemon', label: 'Lemon' }, + ], + }, + ]; + + it('filters top-level items by text', () => { + const result = filterEntries(entries, 'app', contains); + expect(result).toEqual([{ kind: 'item', key: 'apple', label: 'Apple' }]); + }); + + it('filters items within sections', () => { + const result = filterEntries(entries, 'lem', contains); + expect(result).toEqual([ + { + kind: 'section', + key: 'citrus', + title: 'Citrus', + items: [{ kind: 'item', key: 'lemon', label: 'Lemon' }], + }, + ]); + }); + + it('removes sections with no matching items', () => { + const result = filterEntries(entries, 'xyz', contains); + expect(result).toEqual([]); + }); + + it('returns all matching entries across items and sections', () => { + const result = filterEntries(entries, 'a', contains); + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ key: 'apple' }); + expect(result[1]).toMatchObject({ key: 'banana' }); + expect(result[2]).toMatchObject({ + kind: 'section', + items: [{ key: 'orange' }], + }); + }); +}); + +describe('collectEntryItemKeys', () => { + it('collects keys from items and sections', () => { + const entries: MultiSelectFlatEntry[] = [ + { kind: 'item', key: 'a', label: 'A' }, + { + kind: 'section', + key: 's', + title: 'S', + items: [{ kind: 'item', key: 'b', label: 'B' }], + }, + ]; + expect(collectEntryItemKeys(entries)).toEqual(new Set(['a', 'b'])); + }); +}); + +describe('filterJsxChildrenByKeys', () => { + it('keeps only items whose keys are in the surviving set', () => { + const children = [ + + A + , + + B + , + + C + , + ]; + const result = filterJsxChildrenByKeys(children, new Set(['a', 'c'])); + expect(result).toHaveLength(2); + expect(result[0].key).toBe('a'); + expect(result[1].key).toBe('c'); + }); + + it('filters items within sections and removes empty sections', () => { + const children = [ +
+ + X + + + Y + +
, + ]; + const result = filterJsxChildrenByKeys(children, new Set(['x'])); + expect(result).toHaveLength(1); + }); + + it('removes sections when no children survive', () => { + const children = [ +
+ + X + +
, + ]; + const result = filterJsxChildrenByKeys(children, new Set(['nope'])); + expect(result).toHaveLength(0); + }); +}); + +describe('resolveSelection', () => { + const allKeys = ['a', 'b', 'c']; + + it('returns empty set for null/undefined', () => { + expect(resolveSelection(undefined, allKeys)).toEqual(new Set()); + }); + + it('returns all keys for "all"', () => { + expect(resolveSelection('all', allKeys)).toEqual(new Set(['a', 'b', 'c'])); + }); + + it('converts an iterable to a string set', () => { + expect(resolveSelection([1, 2] as Iterable, allKeys)).toEqual( + new Set(['1', '2']) + ); + }); + + it('converts string iterables as-is', () => { + expect(resolveSelection(['a', 'b'], allKeys)).toEqual(new Set(['a', 'b'])); + }); +}); diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectFilter.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectFilter.test.ts new file mode 100644 index 0000000000..72ab534c6b --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectFilter.test.ts @@ -0,0 +1,127 @@ +import { renderHook, act } from '@testing-library/react'; +import { useMultiSelectFilter } from './useMultiSelectFilter'; +import type { MultiSelectFlatEntry } from './multiSelectUtils'; + +// Mock @react-aria/i18n useFilter to provide a simple case-insensitive contains +jest.mock('@react-aria/i18n', () => ({ + useFilter: () => ({ + contains: (str: string, sub: string) => + str.toLowerCase().includes(sub.toLowerCase()), + }), +})); + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +const ENTRIES: MultiSelectFlatEntry[] = [ + { kind: 'item', key: 'apple', label: 'Apple' }, + { kind: 'item', key: 'banana', label: 'Banana' }, + { kind: 'item', key: 'cherry', label: 'Cherry' }, +]; + +// wrappedChildren is not exercised here since JSX filtering is tested +// in multiSelectUtils.test.tsx; we pass an empty array. +const EMPTY_CHILDREN: React.ReactElement[] = []; + +describe('useMultiSelectFilter', () => { + it('returns all items when searchText is empty', () => { + const { result } = renderHook(() => + useMultiSelectFilter({ + allEntries: ENTRIES, + wrappedChildren: EMPTY_CHILDREN, + inputValue: undefined, + defaultInputValue: '', + onInputChange: undefined, + onSearchTextChange: undefined, + }) + ); + + expect(result.current.searchText).toBe(''); + expect(result.current.filteredItems).toHaveLength(3); + }); + + it('filters items by search text', () => { + const { result } = renderHook(() => + useMultiSelectFilter({ + allEntries: ENTRIES, + wrappedChildren: EMPTY_CHILDREN, + inputValue: undefined, + defaultInputValue: '', + onInputChange: undefined, + onSearchTextChange: undefined, + }) + ); + + act(() => { + result.current.setSearchText('ban'); + }); + + expect(result.current.searchText).toBe('ban'); + expect(result.current.filteredItems).toEqual([ + { kind: 'item', key: 'banana', label: 'Banana' }, + ]); + }); + + it('skips client-side filtering when onSearchTextChange is provided', () => { + const onSearchTextChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectFilter({ + allEntries: ENTRIES, + wrappedChildren: EMPTY_CHILDREN, + inputValue: undefined, + defaultInputValue: '', + onInputChange: undefined, + onSearchTextChange, + }) + ); + + act(() => { + result.current.setSearchText('ban'); + }); + + // Filtering is skipped — all items returned + expect(result.current.filteredItems).toHaveLength(3); + expect(onSearchTextChange).toHaveBeenCalledWith('ban'); + }); + + it('uses controlled inputValue', () => { + const onInputChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectFilter({ + allEntries: ENTRIES, + wrappedChildren: EMPTY_CHILDREN, + inputValue: 'ch', + defaultInputValue: '', + onInputChange, + onSearchTextChange: undefined, + }) + ); + + expect(result.current.searchText).toBe('ch'); + expect(result.current.filteredItems).toEqual([ + { kind: 'item', key: 'cherry', label: 'Cherry' }, + ]); + }); + + it('calls onInputChange when setSearchText is called', () => { + const onInputChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectFilter({ + allEntries: ENTRIES, + wrappedChildren: EMPTY_CHILDREN, + inputValue: undefined, + defaultInputValue: '', + onInputChange, + onSearchTextChange: undefined, + }) + ); + + act(() => { + result.current.setSearchText('xyz'); + }); + + expect(onInputChange).toHaveBeenCalledWith('xyz'); + }); +}); diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.test.ts new file mode 100644 index 0000000000..62d57bc0c4 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.test.ts @@ -0,0 +1,259 @@ +import { type KeyboardEvent, createRef } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import type { OverlayTriggerState } from '@react-stately/overlays'; +import { useMultiSelectKeyboard } from './useMultiSelectKeyboard'; +import type { MultiSelectFlatItem } from './multiSelectUtils'; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +const ITEMS: MultiSelectFlatItem[] = [ + { kind: 'item', key: 'a', label: 'A' }, + { kind: 'item', key: 'b', label: 'B' }, + { kind: 'item', key: 'c', label: 'C' }, +]; + +function createMockOverlayState(isOpen = false): OverlayTriggerState { + return { + isOpen, + open: jest.fn(), + close: jest.fn(), + toggle: jest.fn(), + setOpen: jest.fn(), + }; +} + +function createKeyEvent( + key: string, + overrides: Partial = {} +): KeyboardEvent { + return { + key, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + ...overrides, + } as unknown as KeyboardEvent; +} + +function createDefaultOptions( + overrides: Record = {} +): Parameters[0] { + return { + filteredItems: ITEMS, + allItems: ITEMS, + shouldFocusWrap: false, + overlayState: createMockOverlayState(false), + openOverlay: jest.fn(), + closeOverlay: jest.fn(), + isReadOnly: false, + isDisabled: false, + searchText: '', + setSearchText: jest.fn(), + selectedKeys: new Set(), + toggleKey: jest.fn(), + allowsCustomValue: false, + menuTrigger: 'input' as const, + onKeyDown: undefined, + listBoxContainerRef: createRef(), + inputRef: createRef(), + ...overrides, + }; +} + +describe('useMultiSelectKeyboard', () => { + describe('ArrowDown', () => { + it('opens the overlay when closed', () => { + const openOverlay = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + openOverlay, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('ArrowDown')); + }); + + expect(openOverlay).toHaveBeenCalledWith('manual'); + }); + + it('does nothing when disabled', () => { + const openOverlay = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + isDisabled: true, + openOverlay, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('ArrowDown')); + }); + + expect(openOverlay).not.toHaveBeenCalled(); + }); + }); + + describe('Escape', () => { + it('closes the overlay when open', () => { + const closeOverlay = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + overlayState: createMockOverlayState(true), + closeOverlay, + }) + ) + ); + + const event = createKeyEvent('Escape'); + act(() => { + result.current.handleInputKeyDown(event); + }); + + expect(closeOverlay).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does not call closeOverlay when already closed', () => { + const closeOverlay = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + overlayState: createMockOverlayState(false), + closeOverlay, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('Escape')); + }); + + expect(closeOverlay).not.toHaveBeenCalled(); + }); + }); + + describe('Enter', () => { + it('opens the overlay when closed', () => { + const openOverlay = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + openOverlay, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('Enter')); + }); + + expect(openOverlay).toHaveBeenCalledWith('manual'); + }); + }); + + describe('Tab', () => { + it('closes the overlay when open', () => { + const closeOverlay = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + overlayState: createMockOverlayState(true), + closeOverlay, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('Tab')); + }); + + expect(closeOverlay).toHaveBeenCalled(); + }); + }); + + describe('Backspace', () => { + it('removes the last selected key when searchText is empty', () => { + const toggleKey = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + searchText: '', + selectedKeys: new Set(['a', 'b']), + toggleKey, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('Backspace')); + }); + + expect(toggleKey).toHaveBeenCalledWith('b'); + }); + + it('does not remove when searchText is not empty', () => { + const toggleKey = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + searchText: 'foo', + selectedKeys: new Set(['a']), + toggleKey, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('Backspace')); + }); + + expect(toggleKey).not.toHaveBeenCalled(); + }); + + it('does not remove when isReadOnly', () => { + const toggleKey = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + searchText: '', + selectedKeys: new Set(['a']), + isReadOnly: true, + toggleKey, + }) + ) + ); + + act(() => { + result.current.handleInputKeyDown(createKeyEvent('Backspace')); + }); + + expect(toggleKey).not.toHaveBeenCalled(); + }); + }); + + it('forwards to onKeyDown callback', () => { + const onKeyDown = jest.fn(); + const { result } = renderHook(() => + useMultiSelectKeyboard( + createDefaultOptions({ + onKeyDown, + }) + ) + ); + + const event = createKeyEvent('x'); + act(() => { + result.current.handleInputKeyDown(event); + }); + + expect(onKeyDown).toHaveBeenCalledWith(event); + }); +}); diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts new file mode 100644 index 0000000000..3ce4c75629 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts @@ -0,0 +1,149 @@ +import { renderHook, act } from '@testing-library/react'; +import { useMultiSelectLoadingSpinner } from './useMultiSelectLoadingSpinner'; + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + expect.hasAssertions(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const LOADING_DEBOUNCE_MS = 500; + +describe('useMultiSelectLoadingSpinner', () => { + it('does not show spinner when loadingState is idle', () => { + const { result } = renderHook(() => + useMultiSelectLoadingSpinner({ + loadingState: 'idle', + searchText: '', + isOpen: true, + menuTrigger: 'input', + }) + ); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS + 100); + }); + + expect(result.current).toBe(false); + }); + + it('shows spinner after debounce when loading and isOpen', () => { + const { result } = renderHook(() => + useMultiSelectLoadingSpinner({ + loadingState: 'loading', + searchText: '', + isOpen: true, + menuTrigger: 'input', + }) + ); + + // Before debounce + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS); + }); + + expect(result.current).toBe(true); + }); + + it('does not show spinner before debounce elapses', () => { + const { result } = renderHook(() => + useMultiSelectLoadingSpinner({ + loadingState: 'filtering', + searchText: 'test', + isOpen: true, + menuTrigger: 'input', + }) + ); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS - 100); + }); + + expect(result.current).toBe(false); + }); + + it('hides spinner when loadingState transitions to idle', () => { + const { result, rerender } = renderHook( + ({ loadingState }) => + useMultiSelectLoadingSpinner({ + loadingState, + searchText: '', + isOpen: true, + menuTrigger: 'input', + }), + { initialProps: { loadingState: 'loading' as const } } + ); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS); + }); + + expect(result.current).toBe(true); + + rerender({ loadingState: 'idle' as const }); + + expect(result.current).toBe(false); + }); + + it('does not show spinner when not open and menuTrigger is input', () => { + const { result } = renderHook(() => + useMultiSelectLoadingSpinner({ + loadingState: 'loading', + searchText: '', + isOpen: false, + menuTrigger: 'input', + }) + ); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS); + }); + + // isOpen is false and menuTrigger is 'input', loadingState is 'loading' + // The function returns: showLoading && (isOpen || menuTrigger === 'manual' || loadingState === 'loading') + // showLoading=true, isOpen=false, menuTrigger='input', loadingState='loading' => true + expect(result.current).toBe(true); + }); + + it('shows spinner when closed and menuTrigger is manual', () => { + const { result } = renderHook(() => + useMultiSelectLoadingSpinner({ + loadingState: 'filtering', + searchText: '', + isOpen: false, + menuTrigger: 'manual', + }) + ); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS); + }); + + expect(result.current).toBe(true); + }); + + it('does not show spinner for filtering when closed and menuTrigger is input', () => { + const { result } = renderHook(() => + useMultiSelectLoadingSpinner({ + loadingState: 'filtering', + searchText: '', + isOpen: false, + menuTrigger: 'input', + }) + ); + + act(() => { + jest.advanceTimersByTime(LOADING_DEBOUNCE_MS); + }); + + // showLoading=true, isOpen=false, menuTrigger='input', loadingState='filtering' + // => showLoading && (false || false || false) => false + expect(result.current).toBe(false); + }); +}); diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts new file mode 100644 index 0000000000..c2f755dc95 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts @@ -0,0 +1,130 @@ +import { createRef } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useMultiSelectScrollListener } from './useMultiSelectScrollListener'; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { + cb(0); + return 0; + }); + jest + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => undefined); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('useMultiSelectScrollListener', () => { + it('does not attach listener when closed', () => { + const onScroll = jest.fn(); + const containerRef = createRef(); + + renderHook(() => + useMultiSelectScrollListener({ + containerRef, + isOpen: false, + onScroll, + }) + ); + + expect(onScroll).not.toHaveBeenCalled(); + }); + + it('attaches scroll listener to listbox element when open', () => { + const onScroll = jest.fn(); + + const listBox = document.createElement('div'); + listBox.setAttribute('role', 'listbox'); + const addSpy = jest.spyOn(listBox, 'addEventListener'); + + const container = document.createElement('div'); + container.appendChild(listBox); + + const containerRef = { current: container }; + + renderHook(() => + useMultiSelectScrollListener({ + containerRef, + isOpen: true, + onScroll, + }) + ); + + expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); + + it('falls back to container when no listbox found', () => { + const onScroll = jest.fn(); + + const container = document.createElement('div'); + const addSpy = jest.spyOn(container, 'addEventListener'); + + const containerRef = { current: container }; + + renderHook(() => + useMultiSelectScrollListener({ + containerRef, + isOpen: true, + onScroll, + }) + ); + + expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); + + it('invokes onScroll when the element scrolls', () => { + const onScroll = jest.fn(); + + const listBox = document.createElement('div'); + listBox.setAttribute('role', 'listbox'); + + const container = document.createElement('div'); + container.appendChild(listBox); + + const containerRef = { current: container }; + + renderHook(() => + useMultiSelectScrollListener({ + containerRef, + isOpen: true, + onScroll, + }) + ); + + const scrollEvent = new Event('scroll'); + listBox.dispatchEvent(scrollEvent); + + expect(onScroll).toHaveBeenCalledWith(scrollEvent); + }); + + it('removes listener when popover closes', () => { + const onScroll = jest.fn(); + + const listBox = document.createElement('div'); + listBox.setAttribute('role', 'listbox'); + const removeSpy = jest.spyOn(listBox, 'removeEventListener'); + + const container = document.createElement('div'); + container.appendChild(listBox); + + const containerRef = { current: container }; + + const { rerender } = renderHook( + ({ isOpen }) => + useMultiSelectScrollListener({ + containerRef, + isOpen, + onScroll, + }), + { initialProps: { isOpen: true } } + ); + + rerender({ isOpen: false }); + + expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); +}); diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectState.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectState.test.ts new file mode 100644 index 0000000000..7c817d8931 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectState.test.ts @@ -0,0 +1,197 @@ +import { renderHook, act } from '@testing-library/react'; +import { useMultiSelectState } from './useMultiSelectState'; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +const ALL_KEYS = ['a', 'b', 'c', 'd']; + +describe('useMultiSelectState', () => { + it('initializes with defaultSelectedKeys (uncontrolled)', () => { + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: ['a', 'b'], + disabledKeys: undefined, + onChange: undefined, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + expect(result.current.selectedKeyArray).toEqual(['a', 'b']); + expect(result.current.selectedKeys).toEqual(new Set(['a', 'b'])); + }); + + it('uses controlled selectedKeys', () => { + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: ['c'], + defaultSelectedKeys: undefined, + disabledKeys: undefined, + onChange: undefined, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + expect(result.current.selectedKeyArray).toEqual(['c']); + }); + + it('resolves "all" for selectedKeys', () => { + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: 'all', + defaultSelectedKeys: undefined, + disabledKeys: undefined, + onChange: undefined, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + expect(result.current.selectedKeyArray).toEqual(ALL_KEYS); + }); + + it('defaults to empty selection', () => { + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: undefined, + disabledKeys: undefined, + onChange: undefined, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + expect(result.current.selectedKeyArray).toEqual([]); + }); + + it('converts disabledKeys to listBoxDisabledKeys', () => { + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: undefined, + disabledKeys: ['b', 'c'], + onChange: undefined, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + expect(result.current.listBoxDisabledKeys).toEqual(new Set(['b', 'c'])); + }); + + describe('toggleKey', () => { + it('adds a key that is not selected', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: ['a'], + disabledKeys: undefined, + onChange, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + act(() => { + result.current.toggleKey('b'); + }); + + expect(onChange).toHaveBeenCalledWith(new Set(['a', 'b'])); + }); + + it('removes a key that is already selected', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: ['a', 'b'], + disabledKeys: undefined, + onChange, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + act(() => { + result.current.toggleKey('a'); + }); + + expect(onChange).toHaveBeenCalledWith(new Set(['b'])); + }); + + it('calls onSelectionChange when onChange is not provided', () => { + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: [], + disabledKeys: undefined, + onChange: undefined, + onSelectionChange, + allKeys: ALL_KEYS, + }) + ); + + act(() => { + result.current.toggleKey('d'); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(new Set(['d'])); + }); + }); + + describe('applyListBoxSelection', () => { + it('applies "all" selection', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: [], + disabledKeys: undefined, + onChange, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + act(() => { + result.current.applyListBoxSelection('all', []); + }); + + expect(onChange).toHaveBeenCalledWith(new Set(ALL_KEYS)); + }); + + it('preserves selected keys not in the filtered list', () => { + const onChange = jest.fn(); + const filteredItems = [ + { kind: 'item' as const, key: 'b', label: 'B' }, + { kind: 'item' as const, key: 'c', label: 'C' }, + ]; + + const { result } = renderHook(() => + useMultiSelectState({ + selectedKeys: undefined, + defaultSelectedKeys: ['a'], + disabledKeys: undefined, + onChange, + onSelectionChange: undefined, + allKeys: ALL_KEYS, + }) + ); + + act(() => { + result.current.applyListBoxSelection(new Set(['b']), filteredItems); + }); + + // 'a' is preserved because it wasn't in filteredItems, 'b' is new + expect(onChange).toHaveBeenCalledWith(new Set(['a', 'b'])); + }); + }); +}); diff --git a/packages/jsapi-components/src/spectrum/MultiSelect.test.tsx b/packages/jsapi-components/src/spectrum/MultiSelect.test.tsx new file mode 100644 index 0000000000..37d046c640 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/MultiSelect.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MultiSelect } from './MultiSelect'; + +// Track the props passed to MultiSelectNormalized. +let capturedProps: Record = {}; + +const mockOnSearchTextChange = jest.fn(); +const mockOnOpenChange = jest.fn(); +const mockOnInputChange = jest.fn(); + +jest.mock('./utils', () => ({ + useMultiPickerProps: jest.fn((props: Record) => ({ + ...props, + normalizedItems: [], + showItemIcons: false, + selectedItemLabels: new Map(), + onChange: jest.fn(), + onScroll: jest.fn(), + onSearchTextChange: mockOnSearchTextChange, + onInputChange: mockOnInputChange, + onOpenChange: mockOnOpenChange, + })), +})); + +jest.mock('@deephaven/components', () => ({ + MultiSelectNormalized: jest.fn((props: Record) => { + capturedProps = props; + return
; + }), +})); + +beforeEach(() => { + jest.clearAllMocks(); + capturedProps = {}; + expect.hasAssertions(); +}); + +describe('jsapi-components MultiSelect', () => { + it('renders MultiSelectNormalized', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('multi-select-normalized')).toBeInTheDocument(); + }); + + it('clears search text on input change when closed', () => { + render(); + const onInputChange = capturedProps.onInputChange as (v: string) => void; + + onInputChange('hello'); + + // When closed, search text should be cleared + expect(mockOnSearchTextChange).toHaveBeenCalledWith(''); + }); + + it('applies search text on input change when open', () => { + render(); + const onInputChange = capturedProps.onInputChange as (v: string) => void; + const onOpenChange = capturedProps.onOpenChange as ( + isOpen: boolean + ) => void; + + // Open the dropdown + onOpenChange(true); + mockOnSearchTextChange.mockClear(); + + // Simulate typing while open + onInputChange('hello'); + + expect(mockOnSearchTextChange).toHaveBeenCalledWith('hello'); + }); + + it('clears search text on close', () => { + render(); + const onOpenChange = capturedProps.onOpenChange as ( + isOpen: boolean + ) => void; + + // Open then close + onOpenChange(true); + mockOnSearchTextChange.mockClear(); + onOpenChange(false); + + expect(mockOnSearchTextChange).toHaveBeenCalledWith(''); + }); + + it('restores search text when opened by input', () => { + render(); + const onInputChange = capturedProps.onInputChange as (v: string) => void; + const onOpenChange = capturedProps.onOpenChange as ( + isOpen: boolean + ) => void; + + // Type while closed (stores input value) + onInputChange('test'); + mockOnSearchTextChange.mockClear(); + + // Open — should restore the stored input value + onOpenChange(true); + + expect(mockOnSearchTextChange).toHaveBeenCalledWith('test'); + }); +}); From 72b6a408153f3edae84e7e1a7d2391293a63e0fd Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Thu, 14 May 2026 13:28:06 -0500 Subject: [PATCH 12/15] remove prop spreading tests --- .../multiSelect/MultiSelectTag.test.tsx | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx index 69d0c3e0d5..95f8a63212 100644 --- a/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx +++ b/packages/components/src/spectrum/multiSelect/MultiSelectTag.test.tsx @@ -18,39 +18,71 @@ beforeEach(() => { }); describe('MultiSelectTag', () => { - const defaultProps = { - tagKey: 'key1', - label: 'Tag Label', - isDisabled: false, - isReadOnly: false, - onRemove: jest.fn(), - }; - it('renders the label text', () => { - render(); + render( + + ); expect(screen.getByText('Tag Label')).toBeInTheDocument(); }); it('renders remove button when not disabled and not read-only', () => { - render(); + render( + + ); expect( screen.getByRole('button', { name: 'Remove Tag Label' }) ).toBeInTheDocument(); }); it('does not render remove button when isDisabled', () => { - render(); + render( + + ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); it('does not render remove button when isReadOnly', () => { - render(); + render( + + ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); it('calls onRemove with the tag key when remove button is clicked', async () => { const onRemove = jest.fn(); - render(); + render( + + ); const removeBtn = screen.getByRole('button', { name: 'Remove Tag Label' }); await userEvent.click(removeBtn); From e42e0e560e3bfb5b7efd6d5baf2007916e83fb35 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Thu, 14 May 2026 14:02:22 -0500 Subject: [PATCH 13/15] remove act --- .../spectrum/multiSelect/useMultiSelectScrollListener.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts index c2f755dc95..f9b4b0e7fe 100644 --- a/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectScrollListener.test.ts @@ -1,5 +1,5 @@ import { createRef } from 'react'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { useMultiSelectScrollListener } from './useMultiSelectScrollListener'; beforeEach(() => { From 160c5b15ea090c2d9e3e85e69ea52c31f09f9917 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 18 May 2026 13:19:26 -0500 Subject: [PATCH 14/15] comments --- .../useMultiSelectLoadingSpinner.test.ts | 2 +- .../utils/useMultiPickerProps.test.ts | 337 ++++++++++++++++++ .../src/spectrum/utils/useMultiPickerProps.ts | 27 +- 3 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts diff --git a/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts b/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts index 3ce4c75629..fbd176ec10 100644 --- a/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectLoadingSpinner.test.ts @@ -91,7 +91,7 @@ describe('useMultiSelectLoadingSpinner', () => { expect(result.current).toBe(false); }); - it('does not show spinner when not open and menuTrigger is input', () => { + it('shows spinner when closed and menuTrigger is input and loadingState is loading', () => { const { result } = renderHook(() => useMultiSelectLoadingSpinner({ loadingState: 'loading', diff --git a/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts new file mode 100644 index 0000000000..2d040a9d1c --- /dev/null +++ b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts @@ -0,0 +1,337 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { usePickerItemScale } from '@deephaven/components'; +import { TableUtils } from '@deephaven/jsapi-utils'; +import { usePromiseFactory } from '@deephaven/react-hooks'; +import { TestUtils } from '@deephaven/test-utils'; +import { useMultiPickerProps } from './useMultiPickerProps'; +import { getItemKeyColumn, getItemLabelColumn } from './itemUtils'; +import { useItemRowDeserializer } from './useItemRowDeserializer'; +import useSearchableViewportData from '../../useSearchableViewportData'; +import useFormatter from '../../useFormatter'; +import useTableUtils from '../../useTableUtils'; + +jest.mock('@deephaven/components', () => ({ + ...jest.requireActual('@deephaven/components'), + usePickerItemScale: jest.fn(), +})); +jest.mock('@deephaven/jsapi-bootstrap', () => ({ + useApi: jest.fn(), +})); +jest.mock('@deephaven/jsapi-utils', () => ({ + ...jest.requireActual('@deephaven/jsapi-utils'), + TableUtils: { copyTableAndApplyFilters: jest.fn() }, +})); +jest.mock('@deephaven/react-hooks', () => ({ + ...jest.requireActual('@deephaven/react-hooks'), + usePromiseFactory: jest.fn(), +})); +jest.mock('./itemUtils'); +jest.mock('./useItemRowDeserializer'); +jest.mock('../../useSearchableViewportData'); +jest.mock('../../useFormatter'); +jest.mock('../../useTableUtils'); +jest.mock('../../useWidgetClose'); + +const { asMock, createMockProxy } = TestUtils; + +// Re-import useApi so we can configure its return value +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { useApi } = require('@deephaven/jsapi-bootstrap'); + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe('useMultiPickerProps - snapshot selected key labels', () => { + const mockTable = createMockProxy({ + columns: [ + createMockProxy({ name: 'Key', type: 'String' }), + createMockProxy({ name: 'Label', type: 'String' }), + ], + }); + const mockKeyColumn = createMockProxy({ + name: 'Key', + type: 'String', + }); + const mockLabelColumn = createMockProxy({ + name: 'Label', + type: 'String', + }); + const mockTableUtils = createMockProxy(); + const mockRangeSetOfItems = jest.fn().mockReturnValue('mockRangeSet'); + const mockDh = { + RangeSet: { ofItems: mockRangeSetOfItems }, + } as unknown as typeof DhType; + + function setupMocks() { + asMock(usePickerItemScale).mockReturnValue({ itemHeight: 32 }); + asMock(usePromiseFactory).mockReturnValue({ data: mockTable }); + asMock(getItemKeyColumn).mockReturnValue(mockKeyColumn); + asMock(getItemLabelColumn).mockReturnValue(mockLabelColumn); + asMock(useFormatter).mockReturnValue({ + getFormattedString: jest.fn(), + timeZone: 'UTC', + }); + asMock(useTableUtils).mockReturnValue(mockTableUtils); + asMock(useApi).mockReturnValue(mockDh); + asMock(useItemRowDeserializer).mockReturnValue( + jest.fn(row => ({ + key: row.key, + content: row.label, + textValue: row.label, + })) + ); + asMock(useSearchableViewportData).mockReturnValue({ + onScroll: jest.fn(), + onSearchTextChange: jest.fn(), + viewportData: { items: [] }, + }); + + mockTable.findColumn.mockReturnValue(mockKeyColumn); + mockTableUtils.getValueType.mockReturnValue('String'); + } + + function makeProps( + overrides: { + selectedKeys?: 'all' | Iterable; + defaultSelectedKeys?: 'all' | Iterable; + } = {} + ) { + return { + table: createMockProxy(), + ...overrides, + }; + } + + /** + * Helper to configure seekRow and createSnapshot mocks for given key-label + * pairs. Each key maps to a synthetic row index (position in the array). + */ + function mockSnapshotForKeys( + entries: Array<{ key: string; label: string; index: number }> + ) { + mockTable.seekRow.mockImplementation(async (_start, _col, _type, key) => { + const entry = entries.find(e => e.key === String(key)); + return entry ? entry.index : -1; + }); + + const mockRows = entries.map(e => ({ key: e.key, label: e.label })); + + mockTable.createSnapshot.mockResolvedValue({ + rows: mockRows, + }); + + asMock(useItemRowDeserializer).mockReturnValue( + jest.fn(row => ({ + key: (row as { key: string }).key, + content: (row as { label: string }).label, + textValue: (row as { label: string }).label, + })) + ); + } + + it('should snapshot selected keys on mount', async () => { + setupMocks(); + mockSnapshotForKeys([ + { key: 'A', label: 'Label A', index: 5 }, + { key: 'B', label: 'Label B', index: 10 }, + ]); + + const { result } = renderHook(() => + useMultiPickerProps(makeProps({ selectedKeys: ['A', 'B'] })) + ); + + await waitFor(() => { + expect(mockTable.seekRow).toHaveBeenCalled(); + }); + + // Each of the 2 keys should have been sought + const seekedKeys = mockTable.seekRow.mock.calls.map( + (call: unknown[]) => call[3] + ); + expect(seekedKeys).toContain('A'); + expect(seekedKeys).toContain('B'); + + expect(mockTable.createSnapshot).toHaveBeenCalled(); + expect(result.current.normalizedItems).toBeDefined(); + }); + + it('should not snapshot when selectedKeys is null', async () => { + setupMocks(); + mockSnapshotForKeys([]); + + renderHook(() => useMultiPickerProps(makeProps())); + + // Give effects a chance to run + await waitFor(() => { + expect(mockTable.seekRow).not.toHaveBeenCalled(); + }); + + expect(mockTable.createSnapshot).not.toHaveBeenCalled(); + }); + + it('should not snapshot when selectedKeys is "all"', async () => { + setupMocks(); + mockSnapshotForKeys([]); + + renderHook(() => useMultiPickerProps(makeProps({ selectedKeys: 'all' }))); + + await waitFor(() => { + expect(mockTable.seekRow).not.toHaveBeenCalled(); + }); + + expect(mockTable.createSnapshot).not.toHaveBeenCalled(); + }); + + it('should not snapshot when selectedKeys is empty', async () => { + setupMocks(); + mockSnapshotForKeys([]); + + renderHook(() => useMultiPickerProps(makeProps({ selectedKeys: [] }))); + + await waitFor(() => { + expect(mockTable.seekRow).not.toHaveBeenCalled(); + }); + + expect(mockTable.createSnapshot).not.toHaveBeenCalled(); + }); + + it('should snapshot newly added keys when selection grows', async () => { + setupMocks(); + mockSnapshotForKeys([{ key: 'A', label: 'Label A', index: 5 }]); + + const { rerender } = renderHook( + ({ selectedKeys }) => useMultiPickerProps(makeProps({ selectedKeys })), + { initialProps: { selectedKeys: ['A'] as string[] } } + ); + + // Wait for first snapshot to complete + await waitFor(() => { + expect(mockTable.seekRow).toHaveBeenCalledTimes(1); + }); + expect(mockTable.createSnapshot).toHaveBeenCalledTimes(1); + + // Now add key 'B' to the selection + mockSnapshotForKeys([ + { key: 'A', label: 'Label A', index: 5 }, + { key: 'B', label: 'Label B', index: 10 }, + ]); + + await act(async () => { + rerender({ selectedKeys: ['A', 'B'] }); + }); + + // Should seek only the new key 'B' (key 'A' was already snapshotted) + await waitFor(() => { + // The second render's seekRow should only be for key 'B' + const seekCalls = mockTable.seekRow.mock.calls; + const lastKey = seekCalls[seekCalls.length - 1]?.[3]; + expect(lastKey).toBe('B'); + }); + }); + + it('should snapshot keys that arrive after initially empty selection', async () => { + setupMocks(); + mockSnapshotForKeys([]); + + const { rerender } = renderHook( + ({ selectedKeys }) => useMultiPickerProps(makeProps({ selectedKeys })), + { initialProps: { selectedKeys: [] as string[] } } + ); + + // No snapshot should happen with empty selection + await waitFor(() => { + expect(mockTable.seekRow).not.toHaveBeenCalled(); + }); + + // Now set selection to have keys + mockSnapshotForKeys([{ key: 'X', label: 'Label X', index: 3 }]); + + await act(async () => { + rerender({ selectedKeys: ['X'] }); + }); + + // Should now snapshot the new key + await waitFor(() => { + expect(mockTable.seekRow).toHaveBeenCalled(); + }); + + expect(mockTable.createSnapshot).toHaveBeenCalled(); + }); + + it('should reset and re-snapshot when table changes', async () => { + setupMocks(); + mockSnapshotForKeys([{ key: 'A', label: 'Label A', index: 5 }]); + + const { rerender } = renderHook( + ({ selectedKeys }) => useMultiPickerProps(makeProps({ selectedKeys })), + { initialProps: { selectedKeys: ['A'] as string[] } } + ); + + await waitFor(() => { + expect(mockTable.createSnapshot).toHaveBeenCalledTimes(1); + }); + + // Simulate table change by returning a new table from usePromiseFactory. + // The hook's reset effect clears the snapshotted keys set, so key 'A' + // should be snapshotted again against the new table. + const newMockTable = createMockProxy({ + columns: mockTable.columns, + }); + newMockTable.findColumn.mockReturnValue(mockKeyColumn); + newMockTable.seekRow.mockResolvedValue(2); + newMockTable.createSnapshot.mockResolvedValue({ + rows: [{ key: 'A', label: 'New Label A' }], + }); + asMock(usePromiseFactory).mockReturnValue({ data: newMockTable }); + asMock(getItemKeyColumn).mockReturnValue(mockKeyColumn); + asMock(getItemLabelColumn).mockReturnValue(mockLabelColumn); + + await act(async () => { + rerender({ selectedKeys: ['A'] }); + }); + + // After table change, key 'A' should be re-snapshotted from the new table + await waitFor(() => { + expect(newMockTable.seekRow).toHaveBeenCalled(); + }); + + expect(newMockTable.createSnapshot).toHaveBeenCalled(); + }); + + it('should merge snapshot data from incremental selections', async () => { + setupMocks(); + mockSnapshotForKeys([{ key: 'A', label: 'Label A', index: 5 }]); + + const { rerender } = renderHook( + ({ selectedKeys }) => useMultiPickerProps(makeProps({ selectedKeys })), + { initialProps: { selectedKeys: ['A'] as string[] } } + ); + + await waitFor(() => { + expect(mockTable.createSnapshot).toHaveBeenCalledTimes(1); + }); + + // Add key 'B' - snapshot data should merge, not replace + mockSnapshotForKeys([{ key: 'B', label: 'Label B', index: 10 }]); + // Reset seekRow to only return index for 'B' + mockTable.seekRow.mockImplementation(async (_start, _col, _type, key) => { + if (String(key) === 'B') return 10; + return -1; + }); + mockTable.createSnapshot.mockResolvedValue({ + rows: [{ key: 'B', label: 'Label B' }], + }); + + await act(async () => { + rerender({ selectedKeys: ['A', 'B'] }); + }); + + await waitFor(() => { + // createSnapshot should be called again for key 'B' + expect(mockTable.createSnapshot).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts index e87a2d01f3..e68b447944 100644 --- a/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts +++ b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.ts @@ -118,21 +118,20 @@ export function useMultiPickerProps({ // selected items display correct labels. const selectedKeysForSnapshot = props.selectedKeys ?? props.defaultSelectedKeys; - const hasLoadedSnapshotRef = useRef(false); + const snapshottedKeysRef = useRef>(new Set()); const [snapshotItemsByIndex, setSnapshotItemsByIndex] = useState< Map> >(new Map()); // Reset when table changes so we re-snapshot. useEffect(() => { - hasLoadedSnapshotRef.current = false; + snapshottedKeysRef.current = new Set(); setSnapshotItemsByIndex(new Map()); }, [tableCopy]); useEffect( function snapshotSelectedKeyLabels() { if ( - hasLoadedSnapshotRef.current || tableCopy == null || keyColumn == null || selectedKeysForSnapshot == null || @@ -141,8 +140,16 @@ export function useMultiPickerProps({ return; } + // Determine which keys still need snapshotting. + const keysToSnapshot = [...selectedKeysForSnapshot].filter( + key => !snapshottedKeysRef.current.has(String(key)) + ); + + if (keysToSnapshot.length === 0) { + return; + } + let isCanceled = false; - hasLoadedSnapshotRef.current = true; const column = tableCopy.findColumn(keyColumn.name); const columnValueType = tableUtils.getValueType(column.type); @@ -152,7 +159,7 @@ export function useMultiPickerProps({ // Seek row indices for all selected keys const rowIndices: number[] = []; await Promise.all( - [...selectedKeysForSnapshot].map(async key => { + keysToSnapshot.map(async key => { if (isCanceled) { return; } @@ -196,7 +203,15 @@ export function useMultiPickerProps({ }); if (!isCanceled) { - setSnapshotItemsByIndex(itemMap); + // Mark keys as successfully snapshotted so they are not re-fetched. + keysToSnapshot.forEach(key => + snapshottedKeysRef.current.add(String(key)) + ); + setSnapshotItemsByIndex(prev => { + const merged = new Map(prev); + itemMap.forEach((value, key) => merged.set(key, value)); + return merged; + }); } } catch (err) { log.error('Error loading labels for selected keys', err); From 55d6deae990d3b19ca418c0a4de0da0213deccd2 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 18 May 2026 14:05:48 -0500 Subject: [PATCH 15/15] fix tests --- .../src/spectrum/utils/useMultiPickerProps.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts index 2d040a9d1c..c628cb000b 100644 --- a/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts +++ b/packages/jsapi-components/src/spectrum/utils/useMultiPickerProps.test.ts @@ -1,9 +1,10 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import type { dh as DhType } from '@deephaven/jsapi-types'; import { usePickerItemScale } from '@deephaven/components'; -import { TableUtils } from '@deephaven/jsapi-utils'; +import type { TableUtils } from '@deephaven/jsapi-utils'; import { usePromiseFactory } from '@deephaven/react-hooks'; import { TestUtils } from '@deephaven/test-utils'; +import { useApi } from '@deephaven/jsapi-bootstrap'; import { useMultiPickerProps } from './useMultiPickerProps'; import { getItemKeyColumn, getItemLabelColumn } from './itemUtils'; import { useItemRowDeserializer } from './useItemRowDeserializer'; @@ -35,10 +36,6 @@ jest.mock('../../useWidgetClose'); const { asMock, createMockProxy } = TestUtils; -// Re-import useApi so we can configure its return value -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { useApi } = require('@deephaven/jsapi-bootstrap'); - beforeEach(() => { jest.clearAllMocks(); expect.hasAssertions();