Skip to content

Commit

Permalink
Split out usePickerProps hook and wired up ComboBox (deephaven#2074)
Browse files Browse the repository at this point in the history
  • Loading branch information
bmingles committed Jun 27, 2024
1 parent 938fb0d commit 72dfe89
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 144 deletions.
6 changes: 6 additions & 0 deletions packages/components/src/spectrum/collections.ts
Original file line number Diff line number Diff line change
@@ -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';
19 changes: 19 additions & 0 deletions packages/jsapi-components/src/spectrum/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ComboBoxNormalized
// eslint-disable-next-line react/jsx-props-no-spreading
{...pickerProps}
/>
);
}

export default ComboBox;
150 changes: 6 additions & 144 deletions packages/jsapi-components/src/spectrum/Picker.tsx
Original file line number Diff line number Diff line change
@@ -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<PickerBaseProps, '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 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 (
<PickerNormalized
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
normalizedItems={normalizedItems}
showItemIcons={iconColumnName != null}
getInitialScrollPosition={getInitialScrollPosition}
onChange={onSelectionChangeInternal}
onScroll={onScroll}
{...pickerProps}
/>
);
}
Expand Down
27 changes: 27 additions & 0 deletions packages/jsapi-components/src/spectrum/PickerProps.ts
Original file line number Diff line number Diff line change
@@ -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<TProps> = Omit<
PickerPropsT<TProps>,
'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<NormalizedItem>
>;
2 changes: 2 additions & 0 deletions packages/jsapi-components/src/spectrum/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './ComboBox';
export * from './ListView';
export * from './Picker';
export * from './PickerProps';
1 change: 1 addition & 0 deletions packages/jsapi-components/src/spectrum/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './itemUtils';
export * from './useItemRowDeserializer';
export * from './usePickerProps';
164 changes: 164 additions & 0 deletions packages/jsapi-components/src/spectrum/utils/usePickerProps.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>;
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<TProps> = Omit<
PickerWithTableProps<TProps>,
| 'table'
| 'keyColumn'
| 'labelColumn'
| 'iconColumn'
| 'settings'
| 'onChange'
| 'onSelectionChange'
>;

/** Props returned by `usePickerProps` hook. */
export type UsePickerProps<TProps> = UsePickerDerivedProps &
UsePickerPassthroughProps<TProps>;

export function usePickerProps<TProps>({
table,
keyColumn: keyColumnName,
labelColumn: labelColumnName,
iconColumn: iconColumnName,
settings,
onChange,
onSelectionChange,
...props
}: PickerWithTableProps<TProps>): UsePickerProps<TProps> {
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;

0 comments on commit 72dfe89

Please sign in to comment.