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