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/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/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; diff --git a/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx new file mode 100644 index 0000000000..cb0909ac20 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectListBox.tsx @@ -0,0 +1,62 @@ +import { type ReactElement } from 'react'; +import { ListBox } from '@adobe/react-spectrum'; +import type { DOMRef, LoadingState, Selection } from '@react-types/shared'; + +export interface MultiSelectListBoxProps { + /** DOMRef forwarded to the inner Spectrum ``. */ + listBoxRef: DOMRef; + /** 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: Iterable | 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({ + listBoxRef, + 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/MultiSelectNormalized.tsx b/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx new file mode 100644 index 0000000000..c4d764cdd3 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/MultiSelectNormalized.tsx @@ -0,0 +1,29 @@ +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. + */ +export function MultiSelectNormalized({ + UNSAFE_className, + ...props +}: MultiSelectNormalizedProps): JSX.Element { + const { forceRerenderKey, children, ...multiSelectProps } = + useMultiSelectNormalizedProps(props); + + return ( + + {children} + + ); +} + +export default MultiSelectNormalized; 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; +}; 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; diff --git a/packages/components/src/spectrum/multiSelect/index.ts b/packages/components/src/spectrum/multiSelect/index.ts new file mode 100644 index 0000000000..2ed78e5568 --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/index.ts @@ -0,0 +1,3 @@ +export * from './MultiSelect'; +export * from './MultiSelectNormalized'; +export * from './MultiSelectProps'; 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..307b7aaefd --- /dev/null +++ b/packages/components/src/spectrum/multiSelect/useMultiSelectKeyboard.ts @@ -0,0 +1,278 @@ +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) { + inputRef.current?.removeAttribute('aria-activedescendant'); + 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; 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;