diff --git a/packages/code-studio/src/styleguide/ListViews.tsx b/packages/code-studio/src/styleguide/ListViews.tsx
new file mode 100644
index 0000000000..f521321c5f
--- /dev/null
+++ b/packages/code-studio/src/styleguide/ListViews.tsx
@@ -0,0 +1,119 @@
+import React, { useCallback, useState } from 'react';
+import { Grid, Item, ListView, ItemKey, Text } from '@deephaven/components';
+import { vsAccount, vsPerson } from '@deephaven/icons';
+import { Icon } from '@adobe/react-spectrum';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { sampleSectionIdAndClasses } from './utils';
+
+// Generate enough items to require scrolling
+const itemsSimple = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
+ .split('')
+ .map((key, i) => ({
+ key,
+ item: { key: (i + 1) * 100, content: `${key}${key}${key}` },
+ }));
+
+function AccountIcon({
+ slot,
+}: {
+ slot?: 'illustration' | 'image';
+}): JSX.Element {
+ return (
+ // Images in ListView items require a slot of 'image' or 'illustration' to
+ // be set in order to be positioned correctly:
+ // https://github.com/adobe/react-spectrum/blob/784737effd44b9d5e2b1316e690da44555eafd7e/packages/%40react-spectrum/list/src/ListViewItem.tsx#L266-L267
+
+
+
+ );
+}
+
+export function ListViews(): JSX.Element {
+ const [selectedKeys, setSelectedKeys] = useState<'all' | Iterable>(
+ []
+ );
+
+ const onChange = useCallback((keys: 'all' | Iterable): void => {
+ setSelectedKeys(keys);
+ }, []);
+
+ return (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+
List View
+
+
+ Single Child
+
+ - Aaa
+
+
+
+
+ -
+
+ Item with icon A
+
+ -
+
+ Item with icon B
+
+ -
+
+ Item with icon C
+
+ -
+
+ Item with icon D with overflowing content
+
+
+
+
+
+ {/* eslint-disable react/jsx-curly-brace-presence */}
+ {'String 1'}
+ {'String 2'}
+ {'String 3'}
+ {''}
+ {'Some really long text that should get truncated'}
+ {/* eslint-enable react/jsx-curly-brace-presence */}
+ {444}
+ {999}
+ {true}
+ {false}
+ - Item Aaa
+ - Item Bbb
+ -
+
+
+
+ Complex Ccc with text that should be truncated
+
+
+
+
+
+ {itemsSimple}
+
+
+
+ );
+}
+
+export default ListViews;
diff --git a/packages/code-studio/src/styleguide/Pickers.tsx b/packages/code-studio/src/styleguide/Pickers.tsx
index 18dc678c10..bee79b1d0b 100644
--- a/packages/code-studio/src/styleguide/Pickers.tsx
+++ b/packages/code-studio/src/styleguide/Pickers.tsx
@@ -29,7 +29,7 @@ function PersonIcon(): JSX.Element {
}
export function Pickers(): JSX.Element {
- const [selectedKey, setSelectedKey] = useState();
+ const [selectedKey, setSelectedKey] = useState(null);
const onChange = useCallback((key: ItemKey): void => {
setSelectedKey(key);
diff --git a/packages/code-studio/src/styleguide/StyleGuide.tsx b/packages/code-studio/src/styleguide/StyleGuide.tsx
index 051a58917f..72c3689777 100644
--- a/packages/code-studio/src/styleguide/StyleGuide.tsx
+++ b/packages/code-studio/src/styleguide/StyleGuide.tsx
@@ -36,6 +36,7 @@ import { GoldenLayout } from './GoldenLayout';
import { RandomAreaPlotAnimation } from './RandomAreaPlotAnimation';
import SpectrumComparison from './SpectrumComparison';
import Pickers from './Pickers';
+import ListViews from './ListViews';
const stickyProps = {
position: 'sticky',
@@ -109,6 +110,7 @@ function StyleGuide(): React.ReactElement {
+
diff --git a/packages/components/src/spectrum/collections.ts b/packages/components/src/spectrum/collections.ts
index 619027a946..a1d502975c 100644
--- a/packages/components/src/spectrum/collections.ts
+++ b/packages/components/src/spectrum/collections.ts
@@ -3,8 +3,6 @@ export {
type SpectrumActionBarProps as ActionBarProps,
ActionMenu,
type SpectrumActionMenuProps as ActionMenuProps,
- ListView,
- type SpectrumListViewProps as ListViewProps,
MenuTrigger,
type SpectrumMenuTriggerProps as MenuTriggerProps,
TagGroup,
diff --git a/packages/components/src/spectrum/index.ts b/packages/components/src/spectrum/index.ts
index 8db2ca0cb3..02f1d4df7a 100644
--- a/packages/components/src/spectrum/index.ts
+++ b/packages/components/src/spectrum/index.ts
@@ -16,6 +16,7 @@ export * from './status';
/**
* Custom DH components wrapping React Spectrum components.
*/
+export * from './listView';
export * from './picker';
export * from './Heading';
export * from './Text';
diff --git a/packages/components/src/spectrum/listView/ListView.tsx b/packages/components/src/spectrum/listView/ListView.tsx
new file mode 100644
index 0000000000..ff9cb04b7f
--- /dev/null
+++ b/packages/components/src/spectrum/listView/ListView.tsx
@@ -0,0 +1,103 @@
+import { useMemo } from 'react';
+import {
+ ListView as SpectrumListView,
+ SpectrumListViewProps,
+} from '@adobe/react-spectrum';
+import cl from 'classnames';
+import {
+ ItemElementOrPrimitive,
+ ItemKey,
+ NormalizedItem,
+ normalizeItemList,
+ normalizeTooltipOptions,
+ TooltipOptions,
+ useRenderNormalizedItem,
+ useStringifiedMultiSelection,
+} from '../utils';
+
+export type ListViewProps = {
+ children:
+ | ItemElementOrPrimitive
+ | ItemElementOrPrimitive[]
+ | NormalizedItem[];
+ /** Can be set to true or a TooltipOptions to enable item tooltips */
+ tooltip?: boolean | TooltipOptions;
+ selectedKeys?: 'all' | Iterable;
+ defaultSelectedKeys?: 'all' | Iterable;
+ disabledKeys?: Iterable;
+ /**
+ * Handler that is called when the selection change.
+ * Note that under the hood, this is just an alias for Spectrum's
+ * `onSelectionChange`. We are renaming for better consistency with other
+ * components.
+ */
+ onChange?: (keys: 'all' | Set) => void;
+
+ /**
+ * Handler that is called when the selection changes.
+ * @deprecated Use `onChange` instead
+ */
+ onSelectionChange?: (keys: 'all' | Set) => void;
+} & Omit<
+ SpectrumListViewProps,
+ | 'children'
+ | 'items'
+ | 'selectedKeys'
+ | 'defaultSelectedKeys'
+ | 'disabledKeys'
+ | 'onSelectionChange'
+>;
+
+export function ListView({
+ children,
+ tooltip = true,
+ selectedKeys,
+ defaultSelectedKeys,
+ disabledKeys,
+ UNSAFE_className,
+ onChange,
+ onSelectionChange,
+ ...spectrumListViewProps
+}: ListViewProps): JSX.Element {
+ const normalizedItems = useMemo(
+ () => normalizeItemList(children),
+ [children]
+ );
+
+ const tooltipOptions = useMemo(
+ () => normalizeTooltipOptions(tooltip, 'bottom'),
+ [tooltip]
+ );
+
+ const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions);
+
+ const {
+ selectedStringKeys,
+ defaultSelectedStringKeys,
+ disabledStringKeys,
+ onStringSelectionChange,
+ } = useStringifiedMultiSelection({
+ normalizedItems,
+ selectedKeys,
+ defaultSelectedKeys,
+ disabledKeys,
+ onChange: onChange ?? onSelectionChange,
+ });
+
+ return (
+
+ {renderNormalizedItem}
+
+ );
+}
+
+export default ListView;
diff --git a/packages/components/src/spectrum/listView/index.ts b/packages/components/src/spectrum/listView/index.ts
new file mode 100644
index 0000000000..e1e4de2f28
--- /dev/null
+++ b/packages/components/src/spectrum/listView/index.ts
@@ -0,0 +1 @@
+export * from './ListView';
diff --git a/packages/components/src/spectrum/picker/Picker.tsx b/packages/components/src/spectrum/picker/Picker.tsx
index e1ac6cae2e..415f3051cb 100644
--- a/packages/components/src/spectrum/picker/Picker.tsx
+++ b/packages/components/src/spectrum/picker/Picker.tsx
@@ -1,4 +1,4 @@
-import { Key, useCallback, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { DOMRef } from '@react-types/shared';
import { Picker as SpectrumPicker } from '@adobe/react-spectrum';
import {
@@ -23,8 +23,8 @@ import {
ItemKey,
getItemKey,
} from '../utils/itemUtils';
-import { ItemContent } from '../ItemContent';
-import { Item, Section } from '../shared';
+import { Section } from '../shared';
+import { useRenderNormalizedItem } from '../utils';
export type PickerProps = {
children: ItemOrSection | ItemOrSection[] | NormalizedItem[];
@@ -97,34 +97,7 @@ export function Picker({
[tooltip]
);
- const renderItem = useCallback(
- (normalizedItem: NormalizedItem) => {
- const key = getItemKey(normalizedItem);
- const content = normalizedItem.item?.content ?? '';
- const textValue = normalizedItem.item?.textValue ?? '';
-
- return (
- - `
- // elements that back the Spectrum Picker. These are not visible in the UI,
- // but are used for accessibility purposes, so we set to an arbitrary
- // 'Empty' value so that they are not empty strings.
- textValue={textValue === '' ? 'Empty' : textValue}
- >
- {content}
-
- );
- },
- [tooltipOptions]
- );
+ const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions);
const getInitialScrollPositionInternal = useCallback(
() =>
@@ -187,8 +160,12 @@ export function Picker({
// set on `Item` elements. Since we do this in `renderItem`, we need to
// ensure that `selectedKey` and `defaultSelectedKey` are strings in order
// for selection to work.
- selectedKey={selectedKey?.toString()}
- defaultSelectedKey={defaultSelectedKey?.toString()}
+ selectedKey={selectedKey == null ? selectedKey : selectedKey.toString()}
+ defaultSelectedKey={
+ defaultSelectedKey == null
+ ? defaultSelectedKey
+ : defaultSelectedKey.toString()
+ }
// `onChange` is just an alias for `onSelectionChange`
onSelectionChange={
onSelectionChangeInternal as NormalizedSpectrumPickerProps['onSelectionChange']
@@ -202,12 +179,12 @@ export function Picker({
title={itemOrSection.item?.title}
items={itemOrSection.item?.items}
>
- {renderItem}
+ {renderNormalizedItem}
);
}
- return renderItem(itemOrSection);
+ return renderNormalizedItem(itemOrSection);
}}
);
diff --git a/packages/components/src/spectrum/utils/index.ts b/packages/components/src/spectrum/utils/index.ts
index ab03442699..ef406aba98 100644
--- a/packages/components/src/spectrum/utils/index.ts
+++ b/packages/components/src/spectrum/utils/index.ts
@@ -1,2 +1,4 @@
export * from './itemUtils';
export * from './themeUtils';
+export * from './useRenderNormalizedItem';
+export * from './useStringifiedMultiSelection';
diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx
new file mode 100644
index 0000000000..52a70f62dc
--- /dev/null
+++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx
@@ -0,0 +1,39 @@
+import { Key, useCallback } from 'react';
+import { ItemContent } from '../ItemContent';
+import { Item } from '../shared';
+import { getItemKey, NormalizedItem, TooltipOptions } from './itemUtils';
+
+export function useRenderNormalizedItem(
+ tooltipOptions: TooltipOptions | null
+): (normalizedItem: NormalizedItem) => JSX.Element {
+ return useCallback(
+ (normalizedItem: NormalizedItem) => {
+ const key = getItemKey(normalizedItem);
+ const content = normalizedItem.item?.content ?? '';
+ const textValue = normalizedItem.item?.textValue ?? '';
+
+ return (
+ - `
+ // elements that back the Spectrum Picker. These are not visible in the UI,
+ // but are used for accessibility purposes, so we set to an arbitrary
+ // 'Empty' value so that they are not empty strings.
+ textValue={textValue === '' ? 'Empty' : textValue}
+ >
+ {content}
+
+ );
+ },
+ [tooltipOptions]
+ );
+}
+
+export default useRenderNormalizedItem;
diff --git a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts
new file mode 100644
index 0000000000..cadb8a4888
--- /dev/null
+++ b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts
@@ -0,0 +1,104 @@
+import { Key, useCallback, useMemo } from 'react';
+import { getItemKey, ItemKey, NormalizedItem } from '.';
+
+function toStringKeySet(
+ keys?: 'all' | Iterable
+): undefined | 'all' | Set {
+ if (keys == null || keys === 'all') {
+ return keys as undefined | 'all';
+ }
+
+ return new Set([...keys].map(String));
+}
+
+export interface UseStringifiedMultiSelectionOptions {
+ normalizedItems: NormalizedItem[];
+ selectedKeys?: 'all' | Iterable;
+ defaultSelectedKeys?: 'all' | Iterable;
+ disabledKeys?: Iterable;
+ /**
+ * Handler that is called when the selection change.
+ * Note that under the hood, this is just an alias for Spectrum's
+ * `onSelectionChange`. We are renaming for better consistency with other
+ * components.
+ */
+ onChange?: (keys: 'all' | Set) => void;
+}
+
+export interface UseStringifiedMultiSelectionResult {
+ /** Stringified selection keys */
+ selectedStringKeys?: 'all' | Set;
+ /** Stringified default selection keys */
+ defaultSelectedStringKeys?: 'all' | Set;
+ /** Stringified disabled keys */
+ disabledStringKeys?: 'all' | Set;
+ /** Handler that is called when the string key selections change */
+ onStringSelectionChange: (keys: 'all' | Set) => void;
+}
+
+/**
+ * Spectrum collection components treat keys as strings if the `key` prop is
+ * explicitly set on `Item` elements. Since we do this in `useRenderNormalizedItem`,
+ * we need to ensure that keys are strings in order for selection to work. We
+ * then need to convert back to the original key types in the onChange handler.
+ * This hook encapsulates converting to and from strings so that keys can match
+ * the original key type.
+ * @param normalizedItems The normalized items to select from.
+ * @param selectedKeys The currently selected keys in the collection.
+ * @param defaultSelectedKeys The initial selected keys in the collection.
+ * @param disabledKeys The currently disabled keys in the collection.
+ * @param onChange Handler that is called when the selection changes.
+ * @returns UseStringifiedMultiSelectionResult with stringified key sets and
+ * string key selection change handler.
+ */
+export function useStringifiedMultiSelection({
+ normalizedItems,
+ defaultSelectedKeys,
+ disabledKeys,
+ selectedKeys,
+ onChange,
+}: UseStringifiedMultiSelectionOptions): UseStringifiedMultiSelectionResult {
+ const selectedStringKeys = useMemo(
+ () => toStringKeySet(selectedKeys),
+ [selectedKeys]
+ );
+
+ const defaultSelectedStringKeys = useMemo(
+ () => toStringKeySet(defaultSelectedKeys),
+ [defaultSelectedKeys]
+ );
+
+ const disabledStringKeys = useMemo(
+ () => toStringKeySet(disabledKeys),
+ [disabledKeys]
+ );
+
+ const onStringSelectionChange = useCallback(
+ (keys: 'all' | Set) => {
+ if (keys === 'all') {
+ onChange?.('all');
+ return;
+ }
+
+ const actualKeys = new Set();
+
+ normalizedItems.forEach(item => {
+ if (keys.has(String(getItemKey(item)))) {
+ actualKeys.add(getItemKey(item));
+ }
+ });
+
+ onChange?.(actualKeys);
+ },
+ [normalizedItems, onChange]
+ );
+
+ return {
+ selectedStringKeys,
+ defaultSelectedStringKeys,
+ disabledStringKeys,
+ onStringSelectionChange,
+ };
+}
+
+export default useStringifiedMultiSelection;