From b94c8650b140511d341d807042e1cf2f9350c89a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 2 Apr 2024 14:02:31 -0500 Subject: [PATCH] Basic ListView implementation (#1909) --- .../code-studio/src/styleguide/ListViews.tsx | 119 ++++++++++++++++++ .../code-studio/src/styleguide/Pickers.tsx | 2 +- .../code-studio/src/styleguide/StyleGuide.tsx | 2 + .../components/src/spectrum/collections.ts | 2 - packages/components/src/spectrum/index.ts | 1 + .../src/spectrum/listView/ListView.tsx | 103 +++++++++++++++ .../components/src/spectrum/listView/index.ts | 1 + .../components/src/spectrum/picker/Picker.tsx | 47 ++----- .../components/src/spectrum/utils/index.ts | 2 + .../utils/useRenderNormalizedItem.tsx | 39 ++++++ .../utils/useStringifiedMultiSelection.ts | 104 +++++++++++++++ 11 files changed, 384 insertions(+), 38 deletions(-) create mode 100644 packages/code-studio/src/styleguide/ListViews.tsx create mode 100644 packages/components/src/spectrum/listView/ListView.tsx create mode 100644 packages/components/src/spectrum/listView/index.ts create mode 100644 packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx create mode 100644 packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts 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;