diff --git a/packages/components/src/spectrum/collections.ts b/packages/components/src/spectrum/collections.ts index ee8f046742..fbddbb3ebd 100644 --- a/packages/components/src/spectrum/collections.ts +++ b/packages/components/src/spectrum/collections.ts @@ -1,10 +1,16 @@ export { ActionBar, type SpectrumActionBarProps as ActionBarProps, + // ComboBox is exported from ComboBox.tsx as a custom DH component. Re-exporting + // the Spectrum props type for upstream consumers that need to compose prop types. + type SpectrumComboBoxProps, // ListBox - we aren't planning to support this component MenuTrigger, type SpectrumMenuTriggerProps as MenuTriggerProps, // TableView - we aren't planning to support this component + // Picker is exported from Picker.tsx as a custom DH component. Re-exporting + // the Spectrum props type for upstream consumers that need to compose prop types. + type SpectrumPickerProps, TagGroup, type SpectrumTagGroupProps as TagGroupProps, } from '@adobe/react-spectrum'; diff --git a/packages/jsapi-components/src/spectrum/ComboBox.tsx b/packages/jsapi-components/src/spectrum/ComboBox.tsx new file mode 100644 index 0000000000..ba99199d02 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/ComboBox.tsx @@ -0,0 +1,19 @@ +import { ComboBoxNormalized } from '@deephaven/components'; +import { dh as DhType } from '@deephaven/jsapi-types'; +import { usePickerProps } from './utils'; + +export interface ComboBoxProps { + table: DhType.Table; +} + +export function ComboBox(props: ComboBoxProps): JSX.Element { + const pickerProps = usePickerProps(props); + return ( + + ); +} + +export default ComboBox; diff --git a/packages/jsapi-components/src/spectrum/Picker.tsx b/packages/jsapi-components/src/spectrum/Picker.tsx index 2e9872d042..854d4bc2a7 100644 --- a/packages/jsapi-components/src/spectrum/Picker.tsx +++ b/packages/jsapi-components/src/spectrum/Picker.tsx @@ -1,152 +1,14 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - ItemKey, - NormalizedItem, - NormalizedItemData, - NormalizedSection, - NormalizedSectionData, - PickerNormalized, - PickerProps as PickerBaseProps, - useSpectrumThemeProvider, -} from '@deephaven/components'; -import { dh as DhType } from '@deephaven/jsapi-types'; -import { Settings } from '@deephaven/jsapi-utils'; -import Log from '@deephaven/log'; -import { PICKER_ITEM_HEIGHTS, PICKER_TOP_OFFSET } from '@deephaven/utils'; -import useFormatter from '../useFormatter'; -import useGetItemIndexByValue from '../useGetItemIndexByValue'; -import { useViewportData } from '../useViewportData'; -import { getItemKeyColumn } from './utils/itemUtils'; -import { useItemRowDeserializer } from './utils/useItemRowDeserializer'; +import { PickerNormalized } from '@deephaven/components'; +import { PickerProps } from './PickerProps'; +import { usePickerProps } from './utils'; -const log = Log.module('jsapi-components.Picker'); - -export interface PickerProps extends Omit { - 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; -} - -export function Picker({ - table, - keyColumn: keyColumnName, - labelColumn: labelColumnName, - iconColumn: iconColumnName, - settings, - onChange, - onSelectionChange, - ...props -}: PickerProps): JSX.Element { - const { scale } = useSpectrumThemeProvider(); - const itemHeight = PICKER_ITEM_HEIGHTS[scale]; - - const { getFormattedString: formatValue } = useFormatter(settings); - - // `null` is a valid value for `selectedKey` in controlled mode, so we check - // for explicit `undefined` to identify uncontrolled mode. - const isUncontrolled = props.selectedKey === undefined; - const [uncontrolledSelectedKey, setUncontrolledSelectedKey] = useState< - ItemKey | null | undefined - >(props.defaultSelectedKey); - - const keyColumn = useMemo( - () => getItemKeyColumn(table, keyColumnName), - [keyColumnName, table] - ); - - const deserializeRow = useItemRowDeserializer({ - table, - iconColumnName, - keyColumnName, - labelColumnName, - formatValue, - }); - - const getItemIndexByValue = useGetItemIndexByValue({ - table, - columnName: keyColumn.name, - value: isUncontrolled ? uncontrolledSelectedKey : props.selectedKey, - }); - - const getInitialScrollPosition = useCallback(async () => { - const index = await getItemIndexByValue(); - - if (index == null) { - return null; - } - - return index * itemHeight + PICKER_TOP_OFFSET; - }, [getItemIndexByValue, itemHeight]); - - const { viewportData, onScroll, setViewport } = useViewportData< - NormalizedItemData | NormalizedSectionData, - DhType.Table - >({ - reuseItemsOnTableResize: true, - table, - itemHeight, - deserializeRow, - }); - - const normalizedItems = viewportData.items as ( - | NormalizedItem - | NormalizedSection - )[]; - - useEffect( - // Set viewport to include the selected item so that its data will load and - // the real `key` will be available to show the selection in the UI. - function setViewportFromSelectedKey() { - let isCanceled = false; - - getItemIndexByValue() - .then(index => { - if (index == null || isCanceled) { - return; - } - - setViewport(index); - }) - .catch(err => { - log.error('Error setting viewport from selected key', err); - }); - - return () => { - isCanceled = true; - }; - }, - [getItemIndexByValue, settings, setViewport] - ); - - const onSelectionChangeInternal = useCallback( - (key: ItemKey | null): void => { - // If our component is uncontrolled, track the selected key internally - // so that we can scroll to the selected item if the user re-opens - if (isUncontrolled) { - setUncontrolledSelectedKey(key); - } - - (onChange ?? onSelectionChange)?.(key); - }, - [isUncontrolled, onChange, onSelectionChange] - ); +export function Picker(props: PickerProps): JSX.Element { + const pickerProps = usePickerProps(props); return ( ); } diff --git a/packages/jsapi-components/src/spectrum/PickerProps.ts b/packages/jsapi-components/src/spectrum/PickerProps.ts new file mode 100644 index 0000000000..7112e36468 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/PickerProps.ts @@ -0,0 +1,27 @@ +import { + NormalizedItem, + PickerPropsT, + SpectrumPickerProps, +} from '@deephaven/components'; +import { dh as DhType } from '@deephaven/jsapi-types'; +import { Settings } from '@deephaven/jsapi-utils'; + +export type PickerWithTableProps = Omit< + PickerPropsT, + 'children' +> & { + 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; +}; + +export type PickerProps = PickerWithTableProps< + SpectrumPickerProps +>; diff --git a/packages/jsapi-components/src/spectrum/index.ts b/packages/jsapi-components/src/spectrum/index.ts index 49fd5a7e25..8ee1093c2b 100644 --- a/packages/jsapi-components/src/spectrum/index.ts +++ b/packages/jsapi-components/src/spectrum/index.ts @@ -1,2 +1,4 @@ +export * from './ComboBox'; export * from './ListView'; 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 fe396f86c0..17dc2f5cf7 100644 --- a/packages/jsapi-components/src/spectrum/utils/index.ts +++ b/packages/jsapi-components/src/spectrum/utils/index.ts @@ -1,2 +1,3 @@ export * from './itemUtils'; export * from './useItemRowDeserializer'; +export * from './usePickerProps'; diff --git a/packages/jsapi-components/src/spectrum/utils/usePickerProps.ts b/packages/jsapi-components/src/spectrum/utils/usePickerProps.ts new file mode 100644 index 0000000000..2f4d09fc59 --- /dev/null +++ b/packages/jsapi-components/src/spectrum/utils/usePickerProps.ts @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ItemKey, + NormalizedItem, + NormalizedItemData, + NormalizedSection, + NormalizedSectionData, + usePickerItemScale, +} from '@deephaven/components'; +import { dh as DhType } from '@deephaven/jsapi-types'; +import Log from '@deephaven/log'; +import { PICKER_TOP_OFFSET } from '@deephaven/utils'; +import useFormatter from '../../useFormatter'; +import type { PickerWithTableProps } from '../PickerProps'; +import { getItemKeyColumn } from './itemUtils'; +import useItemRowDeserializer from './useItemRowDeserializer'; +import useGetItemIndexByValue from '../../useGetItemIndexByValue'; +import useViewportData from '../../useViewportData'; + +const log = Log.module('jsapi-components.usePickerProps'); + +/** Props that are derived by `usePickerProps`. */ +export type UsePickerDerivedProps = { + normalizedItems: (NormalizedItem | NormalizedSection)[]; + showItemIcons: boolean; + getInitialScrollPosition: () => Promise; + onChange: (key: ItemKey | null) => void; + onScroll: (event: Event) => void; +}; + +/** + * Props that are passed through untouched. (should exclude all of the + * destructured props passed into `usePickerProps` that are not in the spread + * ...props) +) */ +export type UsePickerPassthroughProps = Omit< + PickerWithTableProps, + | 'table' + | 'keyColumn' + | 'labelColumn' + | 'iconColumn' + | 'settings' + | 'onChange' + | 'onSelectionChange' +>; + +/** Props returned by `usePickerProps` hook. */ +export type UsePickerProps = UsePickerDerivedProps & + UsePickerPassthroughProps; + +export function usePickerProps({ + table, + keyColumn: keyColumnName, + labelColumn: labelColumnName, + iconColumn: iconColumnName, + settings, + onChange, + onSelectionChange, + ...props +}: PickerWithTableProps): UsePickerProps { + const { itemHeight } = usePickerItemScale(); + + const { getFormattedString: formatValue } = useFormatter(settings); + + // `null` is a valid value for `selectedKey` in controlled mode, so we check + // for explicit `undefined` to identify uncontrolled mode. + const isUncontrolled = props.selectedKey === undefined; + const [uncontrolledSelectedKey, setUncontrolledSelectedKey] = useState< + ItemKey | null | undefined + >(props.defaultSelectedKey); + + const keyColumn = useMemo( + () => getItemKeyColumn(table, keyColumnName), + [keyColumnName, table] + ); + + const deserializeRow = useItemRowDeserializer({ + table, + iconColumnName, + keyColumnName, + labelColumnName, + formatValue, + }); + + const getItemIndexByValue = useGetItemIndexByValue({ + table, + columnName: keyColumn.name, + value: isUncontrolled ? uncontrolledSelectedKey : props.selectedKey, + }); + + const getInitialScrollPosition = useCallback(async () => { + const index = await getItemIndexByValue(); + + if (index == null) { + return null; + } + + return index * itemHeight + PICKER_TOP_OFFSET; + }, [getItemIndexByValue, itemHeight]); + + const { viewportData, onScroll, setViewport } = useViewportData< + NormalizedItemData | NormalizedSectionData, + DhType.Table + >({ + reuseItemsOnTableResize: true, + table, + itemHeight, + deserializeRow, + }); + + const normalizedItems = viewportData.items as ( + | NormalizedItem + | NormalizedSection + )[]; + + useEffect( + // Set viewport to include the selected item so that its data will load and + // the real `key` will be available to show the selection in the UI. + function setViewportFromSelectedKey() { + let isCanceled = false; + + getItemIndexByValue() + .then(index => { + if (index == null || isCanceled) { + return; + } + + setViewport(index); + }) + .catch(err => { + log.error('Error setting viewport from selected key', err); + }); + + return () => { + isCanceled = true; + }; + }, + [getItemIndexByValue, settings, setViewport] + ); + + const onSelectionChangeInternal = useCallback( + (key: ItemKey | null): void => { + // If our component is uncontrolled, track the selected key internally + // so that we can scroll to the selected item if the user re-opens + if (isUncontrolled) { + setUncontrolledSelectedKey(key); + } + + (onChange ?? onSelectionChange)?.(key); + }, + [isUncontrolled, onChange, onSelectionChange] + ); + + return { + ...props, + normalizedItems, + showItemIcons: iconColumnName != null, + getInitialScrollPosition, + onChange: onSelectionChangeInternal, + onScroll, + }; +} + +export default usePickerProps;