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;