diff --git a/packages/code-studio/src/styleguide/Pickers.tsx b/packages/code-studio/src/styleguide/Pickers.tsx
new file mode 100644
index 0000000000..e698c57555
--- /dev/null
+++ b/packages/code-studio/src/styleguide/Pickers.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { Picker } from '@deephaven/components';
+import { vsPerson } from '@deephaven/icons';
+import { Flex, Icon, Item, Text } from '@adobe/react-spectrum';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { sampleSectionIdAndClasses } from './utils';
+
+function PersonIcon(): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+export function Pickers(): JSX.Element {
+ return (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+
Pickers
+
+
+
+ - Aaa
+
+
+
+ {/* 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
+
+
+
+
+ );
+}
+
+export default Pickers;
diff --git a/packages/code-studio/src/styleguide/StyleGuide.tsx b/packages/code-studio/src/styleguide/StyleGuide.tsx
index 7f35ae1674..e44f4a3b4d 100644
--- a/packages/code-studio/src/styleguide/StyleGuide.tsx
+++ b/packages/code-studio/src/styleguide/StyleGuide.tsx
@@ -30,6 +30,7 @@ import { HIDE_FROM_E2E_TESTS_CLASS } from './utils';
import { GoldenLayout } from './GoldenLayout';
import { RandomAreaPlotAnimation } from './RandomAreaPlotAnimation';
import SpectrumComparison from './SpectrumComparison';
+import Pickers from './Pickers';
const stickyProps = {
position: 'sticky',
@@ -111,6 +112,7 @@ function StyleGuide(): React.ReactElement {
+
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index ef7168e802..b3746db4af 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -48,6 +48,7 @@ export { default as SelectValueList } from './SelectValueList';
export * from './SelectValueList';
export * from './shortcuts';
export { default as SocketedButton } from './SocketedButton';
+export * from './spectrum';
export * from './SpectrumUtils';
export * from './TableViewEmptyState';
export * from './TextWithTooltip';
diff --git a/packages/components/src/spectrum/index.ts b/packages/components/src/spectrum/index.ts
new file mode 100644
index 0000000000..aea9fbd736
--- /dev/null
+++ b/packages/components/src/spectrum/index.ts
@@ -0,0 +1 @@
+export * from './picker';
diff --git a/packages/components/src/spectrum/picker/Picker.tsx b/packages/components/src/spectrum/picker/Picker.tsx
new file mode 100644
index 0000000000..68345b26f4
--- /dev/null
+++ b/packages/components/src/spectrum/picker/Picker.tsx
@@ -0,0 +1,104 @@
+import { useMemo } from 'react';
+import { Item, Picker as SpectrumPicker } from '@adobe/react-spectrum';
+import { Tooltip } from '../../popper';
+import {
+ NormalizedSpectrumPickerProps,
+ normalizePickerItemList,
+ normalizeTooltipOptions,
+ PickerItem,
+ PickerItemKey,
+ TooltipOptions,
+} from './PickerUtils';
+import { PickerItemContent } from './PickerItemContent';
+
+export type PickerProps = {
+ children: PickerItem | PickerItem[];
+ /** Can be set to true or a TooltipOptions to enable item tooltips */
+ tooltip?: boolean | TooltipOptions;
+ /** The currently selected key in the collection (controlled). */
+ selectedKey?: PickerItemKey | null;
+ /** The initial selected key in the collection (uncontrolled). */
+ defaultSelectedKey?: PickerItemKey;
+ /**
+ * 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?: (key: PickerItemKey) => void;
+ /**
+ * Handler that is called when the selection changes.
+ * @deprecated Use `onChange` instead
+ */
+ onSelectionChange?: (key: PickerItemKey) => void;
+} /*
+ * Support remaining SpectrumPickerProps.
+ * Note that `selectedKey`, `defaultSelectedKey`, and `onSelectionChange` are
+ * re-defined above to account for boolean types which aren't included in the
+ * React `Key` type, but are actually supported by the Spectrum Picker component.
+ */ & Omit<
+ NormalizedSpectrumPickerProps,
+ | 'children'
+ | 'items'
+ | 'onSelectionChange'
+ | 'selectedKey'
+ | 'defaultSelectedKey'
+>;
+
+/**
+ * Picker component for selecting items from a list of items. Items can be
+ * provided via the `items` prop or as children. Each item can be a string,
+ * number, boolean, or a Spectrum - element. The remaining props are just
+ * pass through props for the Spectrum Picker component.
+ * See https://react-spectrum.adobe.com/react-spectrum/Picker.html
+ */
+export function Picker({
+ children,
+ tooltip,
+ defaultSelectedKey,
+ selectedKey,
+ onChange,
+ onSelectionChange,
+ ...spectrumPickerProps
+}: PickerProps): JSX.Element {
+ const normalizedItems = useMemo(
+ () => normalizePickerItemList(children),
+ [children]
+ );
+
+ const tooltipOptions = useMemo(
+ () => normalizeTooltipOptions(tooltip),
+ [tooltip]
+ );
+
+ return (
+
+ {({ content, textValue }) => (
+
-
+ {content}
+ {tooltipOptions == null || content === '' ? null : (
+ {content}
+ )}
+
+ )}
+
+ );
+}
+
+export default Picker;
diff --git a/packages/components/src/spectrum/picker/PickerItemContent.tsx b/packages/components/src/spectrum/picker/PickerItemContent.tsx
new file mode 100644
index 0000000000..e5bcd8ae20
--- /dev/null
+++ b/packages/components/src/spectrum/picker/PickerItemContent.tsx
@@ -0,0 +1,31 @@
+import { isValidElement, ReactNode } from 'react';
+import { Text } from '@adobe/react-spectrum';
+import stylesCommon from '../../SpectrumComponent.module.scss';
+
+export interface PickerItemContentProps {
+ children: ReactNode;
+}
+
+/**
+ * Picker item content. Text content will be wrapped in a Spectrum Text
+ * component with ellipsis overflow handling.
+ */
+export function PickerItemContent({
+ children: content,
+}: PickerItemContentProps): JSX.Element {
+ if (isValidElement(content)) {
+ return content;
+ }
+
+ if (content === '') {
+ // Prevent the item height from collapsing when the content is empty
+ // eslint-disable-next-line no-param-reassign
+ content = <> >;
+ }
+
+ return (
+ {content}
+ );
+}
+
+export default PickerItemContent;
diff --git a/packages/components/src/spectrum/picker/PickerUtils.test.tsx b/packages/components/src/spectrum/picker/PickerUtils.test.tsx
new file mode 100644
index 0000000000..48dfe5f8ea
--- /dev/null
+++ b/packages/components/src/spectrum/picker/PickerUtils.test.tsx
@@ -0,0 +1,142 @@
+import React from 'react';
+import { Item, Text } from '@adobe/react-spectrum';
+import {
+ NormalizedPickerItem,
+ normalizeTooltipOptions,
+ normalizePickerItemList,
+ PickerItem,
+} from './PickerUtils';
+import type { PickerProps } from './Picker';
+
+beforeEach(() => {
+ expect.hasAssertions();
+});
+
+/* eslint-disable react/jsx-key */
+const expectedNormalizations = new Map([
+ [
+ 999,
+ {
+ content: '999',
+ key: 999,
+ textValue: '999',
+ },
+ ],
+ [
+ true,
+ {
+ content: 'true',
+ key: true,
+ textValue: 'true',
+ },
+ ],
+ [
+ false,
+ {
+ content: 'false',
+ key: false,
+ textValue: 'false',
+ },
+ ],
+ [
+ '',
+ {
+ content: '',
+ key: '',
+ textValue: '',
+ },
+ ],
+ [
+ 'String',
+ {
+ content: 'String',
+ key: 'String',
+ textValue: 'String',
+ },
+ ],
+ [
+ - Single string child no textValue
,
+ {
+ content: 'Single string child no textValue',
+ key: 'Single string child no textValue',
+ textValue: 'Single string child no textValue',
+ },
+ ],
+ [
+ -
+ No textValue
+
,
+ {
+ content: No textValue,
+ key: '',
+ textValue: '',
+ },
+ ],
+ [
+ - Single string
,
+ {
+ content: 'Single string',
+ key: 'Single string',
+ textValue: 'textValue',
+ },
+ ],
+ [
+ -
+ Explicit key
+
,
+ {
+ content: 'Explicit key',
+ key: 'explicit.key',
+ textValue: 'textValue',
+ },
+ ],
+ [
+ -
+ i
+ Complex
+
,
+ {
+ content: [i, Complex],
+ key: 'textValue',
+ textValue: 'textValue',
+ },
+ ],
+]);
+/* eslint-enable react/jsx-key */
+
+const mixedItems = [...expectedNormalizations.keys()];
+
+const children = {
+ empty: [] as PickerProps['children'],
+ single: mixedItems[0] as PickerProps['children'],
+ mixed: mixedItems as PickerProps['children'],
+};
+
+describe('normalizePickerItemList', () => {
+ it.each([children.empty, children.single, children.mixed])(
+ 'should return normalized picker items: %s',
+ given => {
+ const childrenArray = Array.isArray(given) ? given : [given];
+
+ const expected = childrenArray.map(item =>
+ expectedNormalizations.get(item)
+ );
+
+ const actual = normalizePickerItemList(given);
+ expect(actual).toEqual(expected);
+ }
+ );
+});
+
+describe('normalizeTooltipOptions', () => {
+ it.each([
+ [undefined, null],
+ [null, null],
+ [false, null],
+ [true, { placement: 'top-start' }],
+ [{ placement: 'bottom-end' }, { placement: 'bottom-end' }],
+ ] as const)('should return: %s', (options, expected) => {
+ const actual = normalizeTooltipOptions(options);
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/packages/components/src/spectrum/picker/PickerUtils.ts b/packages/components/src/spectrum/picker/PickerUtils.ts
new file mode 100644
index 0000000000..4aca535b44
--- /dev/null
+++ b/packages/components/src/spectrum/picker/PickerUtils.ts
@@ -0,0 +1,130 @@
+import { Key, ReactElement, ReactNode } from 'react';
+import type { SpectrumPickerProps } from '@adobe/react-spectrum';
+import type { ItemProps } from '@react-types/shared';
+import { PopperOptions } from '../../popper';
+
+export type ItemElement = ReactElement>;
+export type PickerItem = number | string | boolean | ItemElement;
+
+/**
+ * Augment the Spectrum selection key type to include boolean values.
+ * The Spectrum Picker already supports this, but the built in types don't
+ * reflect it.
+ */
+export type PickerItemKey = Key | boolean;
+
+/**
+ * Augment the Spectrum selection change handler type to include boolean keys.
+ * The Spectrum Picker already supports this, but the built in types don't
+ * reflect it.
+ */
+export type PickerSelectionChangeHandler = (key: PickerItemKey) => void;
+
+/**
+ * The Picker supports a variety of item types, including strings, numbers,
+ * booleans, and more complex React elements. This type represents a normalized
+ * form to make rendering items simpler and keep the logic of transformation
+ * in separate util methods.
+ */
+export interface NormalizedPickerItem {
+ key: PickerItemKey;
+ content: ReactNode;
+ textValue: string;
+}
+
+export type NormalizedSpectrumPickerProps =
+ SpectrumPickerProps;
+
+export type TooltipOptions = { placement: PopperOptions['placement'] };
+
+/**
+ * Determine the `key` of a picker item.
+ * @param item The picker item
+ * @returns A `PickerItemKey` for the picker item
+ */
+function normalizeItemKey(item: PickerItem): PickerItemKey {
+ // string, number, or boolean
+ if (typeof item !== 'object') {
+ return item;
+ }
+
+ // `ItemElement` with `key` prop set
+ if (item.key != null) {
+ return item.key;
+ }
+
+ if (typeof item.props.children === 'string') {
+ return item.props.children;
+ }
+
+ return item.props.textValue ?? '';
+}
+
+/**
+ * Get a normalized `textValue` for a picker item ensuring it is a string.
+ * @param item The picker item
+ * @returns A string `textValue` for the picker item
+ */
+function normalizeTextValue(item: PickerItem): string {
+ if (typeof item !== 'object') {
+ return String(item);
+ }
+
+ if (item.props.textValue != null) {
+ return item.props.textValue;
+ }
+
+ if (typeof item.props.children === 'string') {
+ return item.props.children;
+ }
+
+ return '';
+}
+
+/**
+ * Normalize a picker item to an object form.
+ * @param item item to normalize
+ * @returns NormalizedPickerItem object
+ */
+function normalizePickerItem(item: PickerItem): NormalizedPickerItem {
+ const key = normalizeItemKey(item);
+ const content = typeof item === 'object' ? item.props.children : String(item);
+ const textValue = normalizeTextValue(item);
+
+ return {
+ key,
+ content,
+ textValue,
+ };
+}
+
+/**
+ * Get normalized picker items from a picker item or array of picker items.
+ * @param items A picker item or array of picker items
+ * @returns An array of normalized picker items
+ */
+export function normalizePickerItemList(
+ items: PickerItem | PickerItem[]
+): NormalizedPickerItem[] {
+ const itemsArray = Array.isArray(items) ? items : [items];
+ return itemsArray.map(normalizePickerItem);
+}
+
+/**
+ * Returns a TooltipOptions object or null if options is false or null.
+ * @param options
+ * @returns TooltipOptions or null
+ */
+export function normalizeTooltipOptions(
+ options?: boolean | TooltipOptions | null
+): PopperOptions | null {
+ if (options == null || options === false) {
+ return null;
+ }
+
+ if (options === true) {
+ return { placement: 'top-start' };
+ }
+
+ return options;
+}
diff --git a/packages/components/src/spectrum/picker/index.ts b/packages/components/src/spectrum/picker/index.ts
new file mode 100644
index 0000000000..f8fadc724f
--- /dev/null
+++ b/packages/components/src/spectrum/picker/index.ts
@@ -0,0 +1,2 @@
+export * from './Picker';
+export * from './PickerUtils';
diff --git a/tests/styleguide.spec.ts b/tests/styleguide.spec.ts
index 1a64db2c05..fe2280bb84 100644
--- a/tests/styleguide.spec.ts
+++ b/tests/styleguide.spec.ts
@@ -26,6 +26,7 @@ const sampleSectionIds: string[] = [
'sample-section-context-menus',
'sample-section-dropdown-menus',
'sample-section-navigations',
+ 'sample-section-pickers',
'sample-section-tooltips',
'sample-section-icons',
'sample-section-editors',
diff --git a/tests/styleguide.spec.ts-snapshots/pickers-chromium-linux.png b/tests/styleguide.spec.ts-snapshots/pickers-chromium-linux.png
new file mode 100644
index 0000000000..e0a394040c
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/pickers-chromium-linux.png differ
diff --git a/tests/styleguide.spec.ts-snapshots/pickers-firefox-linux.png b/tests/styleguide.spec.ts-snapshots/pickers-firefox-linux.png
new file mode 100644
index 0000000000..9fe033008f
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/pickers-firefox-linux.png differ
diff --git a/tests/styleguide.spec.ts-snapshots/pickers-webkit-linux.png b/tests/styleguide.spec.ts-snapshots/pickers-webkit-linux.png
new file mode 100644
index 0000000000..82b031a601
Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/pickers-webkit-linux.png differ