Skip to content

Commit

Permalink
Basic ListView implementation (deephaven#1909)
Browse files Browse the repository at this point in the history
  • Loading branch information
bmingles committed Apr 2, 2024
1 parent 7ec919b commit c8423cb
Show file tree
Hide file tree
Showing 11 changed files with 384 additions and 38 deletions.
119 changes: 119 additions & 0 deletions packages/code-studio/src/styleguide/ListViews.tsx
Original file line number Diff line number Diff line change
@@ -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
<Icon slot={slot}>
<FontAwesomeIcon icon={vsAccount} />
</Icon>
);
}

export function ListViews(): JSX.Element {
const [selectedKeys, setSelectedKeys] = useState<'all' | Iterable<ItemKey>>(
[]
);

const onChange = useCallback((keys: 'all' | Iterable<ItemKey>): void => {
setSelectedKeys(keys);
}, []);

return (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...sampleSectionIdAndClasses('list-views')}>
<h2 className="ui-title">List View</h2>

<Grid columnGap={14} height="size-4600">
<Text>Single Child</Text>
<ListView
gridRow="2"
aria-label="Single Child"
selectionMode="multiple"
>
<Item>Aaa</Item>
</ListView>

<label>Icons</label>
<ListView gridRow="2" aria-label="Icon" selectionMode="multiple">
<Item textValue="Item with icon A">
<AccountIcon slot="image" />
<Text>Item with icon A</Text>
</Item>
<Item textValue="Item with icon B">
<AccountIcon slot="image" />
<Text>Item with icon B</Text>
</Item>
<Item textValue="Item with icon C">
<AccountIcon slot="image" />
<Text>Item with icon C</Text>
</Item>
<Item textValue="Item with icon D">
<AccountIcon slot="image" />
<Text>Item with icon D with overflowing content</Text>
</Item>
</ListView>

<label>Mixed Children Types</label>
<ListView
gridRow="2"
aria-label="Mixed Children Types"
maxWidth="size-2400"
selectionMode="multiple"
defaultSelectedKeys={[999, 444]}
>
{/* 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>Item Aaa</Item>
<Item>Item Bbb</Item>
<Item textValue="Complex Ccc">
<Icon slot="image">
<FontAwesomeIcon icon={vsPerson} />
</Icon>
<Text>Complex Ccc with text that should be truncated</Text>
</Item>
</ListView>

<label>Controlled</label>
<ListView
gridRow="2"
aria-label="Controlled"
selectionMode="multiple"
selectedKeys={selectedKeys}
onChange={onChange}
>
{itemsSimple}
</ListView>
</Grid>
</div>
);
}

export default ListViews;
2 changes: 1 addition & 1 deletion packages/code-studio/src/styleguide/Pickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function PersonIcon(): JSX.Element {
}

export function Pickers(): JSX.Element {
const [selectedKey, setSelectedKey] = useState<ItemKey>();
const [selectedKey, setSelectedKey] = useState<ItemKey | null>(null);

const onChange = useCallback((key: ItemKey): void => {
setSelectedKey(key);
Expand Down
2 changes: 2 additions & 0 deletions packages/code-studio/src/styleguide/StyleGuide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -109,6 +110,7 @@ function StyleGuide(): React.ReactElement {
<Buttons />
<Progress />
<Inputs />
<ListViews />
<Pickers />
<ItemListInputs />
<DraggableLists />
Expand Down
2 changes: 0 additions & 2 deletions packages/components/src/spectrum/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/spectrum/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
103 changes: 103 additions & 0 deletions packages/components/src/spectrum/listView/ListView.tsx
Original file line number Diff line number Diff line change
@@ -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<ItemKey>;
defaultSelectedKeys?: 'all' | Iterable<ItemKey>;
disabledKeys?: Iterable<ItemKey>;
/**
* 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<ItemKey>) => void;

/**
* Handler that is called when the selection changes.
* @deprecated Use `onChange` instead
*/
onSelectionChange?: (keys: 'all' | Set<ItemKey>) => void;
} & Omit<
SpectrumListViewProps<NormalizedItem>,
| '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 (
<SpectrumListView
// eslint-disable-next-line react/jsx-props-no-spreading
{...spectrumListViewProps}
UNSAFE_className={cl('dh-list-view', UNSAFE_className)}
items={normalizedItems}
selectedKeys={selectedStringKeys}
defaultSelectedKeys={defaultSelectedStringKeys}
disabledKeys={disabledStringKeys}
onSelectionChange={onStringSelectionChange}
>
{renderNormalizedItem}
</SpectrumListView>
);
}

export default ListView;
1 change: 1 addition & 0 deletions packages/components/src/spectrum/listView/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ListView';
47 changes: 12 additions & 35 deletions packages/components/src/spectrum/picker/Picker.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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[];
Expand Down Expand Up @@ -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 (
<Item
// Note that setting the `key` prop explicitly on `Item` elements
// causes the picker to expect `selectedKey` and `defaultSelectedKey`
// to be strings. It also passes the stringified value of the key to
// `onSelectionChange` handlers` regardless of the actual type of the
// key. We can't really get around setting in order to support Windowed
// data, so we'll need to do some manual conversion of keys to strings
// in other places of this component.
key={key as Key}
// The `textValue` prop gets used to provide the content of `<option>`
// 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}
>
<ItemContent tooltipOptions={tooltipOptions}>{content}</ItemContent>
</Item>
);
},
[tooltipOptions]
);
const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions);

const getInitialScrollPositionInternal = useCallback(
() =>
Expand Down Expand Up @@ -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']
Expand All @@ -202,12 +179,12 @@ export function Picker({
title={itemOrSection.item?.title}
items={itemOrSection.item?.items}
>
{renderItem}
{renderNormalizedItem}
</Section>
);
}

return renderItem(itemOrSection);
return renderNormalizedItem(itemOrSection);
}}
</SpectrumPicker>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/spectrum/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './itemUtils';
export * from './themeUtils';
export * from './useRenderNormalizedItem';
export * from './useStringifiedMultiSelection';
39 changes: 39 additions & 0 deletions packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Item
// Note that setting the `key` prop explicitly on `Item` elements
// causes the picker to expect `selectedKey` and `defaultSelectedKey`
// to be strings. It also passes the stringified value of the key to
// `onSelectionChange` handlers` regardless of the actual type of the
// key. We can't really get around setting in order to support Windowed
// data, so we'll need to do some manual conversion of keys to strings
// in other places of this component.
key={key as Key}
// The `textValue` prop gets used to provide the content of `<option>`
// 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}
>
<ItemContent tooltipOptions={tooltipOptions}>{content}</ItemContent>
</Item>
);
},
[tooltipOptions]
);
}

export default useRenderNormalizedItem;
Loading

0 comments on commit c8423cb

Please sign in to comment.