Skip to content

Commit

Permalink
Item icon and description rendering (deephaven#1890)
Browse files Browse the repository at this point in the history
  • Loading branch information
bmingles committed Apr 12, 2024
1 parent 298a596 commit 5c07661
Show file tree
Hide file tree
Showing 14 changed files with 261 additions and 72 deletions.
4 changes: 2 additions & 2 deletions packages/code-studio/src/styleguide/Pickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ const itemsWithIconsAndDescriptions = [

function PersonIcon(): JSX.Element {
return (
<Icon>
<Icon slot="icon">
<FontAwesomeIcon icon={vsPerson} />
</Icon>
);
}

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

const onChange = useCallback((key: ItemKey): void => {
setSelectedKey(key);
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/spectrum/ItemContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from 'react';
import cl from 'classnames';
import { isElementOfType, useCheckOverflow } from '@deephaven/react-hooks';
import { NON_BREAKING_SPACE } from '@deephaven/utils';
import { Text } from './Text';
import { TooltipOptions } from './utils';
import ItemTooltip from './ItemTooltip';
Expand Down Expand Up @@ -46,7 +47,7 @@ export function ItemContent({
/* eslint-disable no-param-reassign */
if (content === '') {
// Prevent the item height from collapsing when the content is empty
content = '\xa0'; // Non-breaking space
content = NON_BREAKING_SPACE; // Non-breaking space
} else if (typeof content === 'boolean') {
// Boolean values need to be stringified to render
content = String(content);
Expand Down
28 changes: 27 additions & 1 deletion packages/components/src/spectrum/listView/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
normalizeItemList,
normalizeTooltipOptions,
TooltipOptions,
useRenderItemFlags,
useRenderNormalizedItem,
useStringifiedMultiSelection,
} from '../utils';
Expand All @@ -33,6 +34,18 @@ export type ListViewProps = {
selectedKeys?: 'all' | Iterable<ItemKey>;
defaultSelectedKeys?: 'all' | Iterable<ItemKey>;
disabledKeys?: Iterable<ItemKey>;
/**
* Whether to show item icons. If not provided, items will be checked for
* icons. If any are found, icons will be shown for all items. This should be
* explicitly set for windowed data.
*/
showItemIcons?: boolean;
/**
* Whether to show item descriptions. If not provided, items will be checked
* for descriptions. If any are found, descriptions will be shown for all
* items. This should be explicitly set for windowed data.
*/
showItemDescriptions?: boolean;
/**
* Handler that is called when the selection change.
* Note that under the hood, this is just an alias for Spectrum's
Expand Down Expand Up @@ -65,6 +78,8 @@ export function ListView({
selectedKeys,
defaultSelectedKeys,
disabledKeys,
showItemIcons: showItemIconsDefault,
showItemDescriptions: showItemDescriptionsDefault,
UNSAFE_className,
onChange,
onScroll = EMPTY_FUNCTION,
Expand All @@ -81,7 +96,18 @@ export function ListView({
[tooltip]
);

const renderNormalizedItem = useRenderNormalizedItem('image', tooltipOptions);
const { showItemIcons, showItemDescriptions } = useRenderItemFlags({
normalizedItems,
showItemIcons: showItemIconsDefault,
showItemDescriptions: showItemDescriptionsDefault,
});

const renderNormalizedItem = useRenderNormalizedItem({
itemIconSlot: 'image',
showItemDescriptions,
showItemIcons,
tooltipOptions,
});

const {
selectedStringKeys,
Expand Down
50 changes: 44 additions & 6 deletions packages/components/src/spectrum/picker/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@deephaven/react-hooks';
import {
EMPTY_FUNCTION,
PICKER_ITEM_HEIGHT,
PICKER_ITEM_HEIGHTS,
PICKER_TOP_OFFSET,
} from '@deephaven/utils';
import cl from 'classnames';
Expand All @@ -24,7 +24,7 @@ import {
getItemKey,
} from '../utils/itemUtils';
import { Section } from '../shared';
import { useRenderNormalizedItem } from '../utils';
import { useRenderItemFlags, useRenderNormalizedItem } from '../utils';

export type PickerProps = {
children: ItemOrSection | ItemOrSection[] | NormalizedItem[];
Expand All @@ -34,6 +34,21 @@ export type PickerProps = {
selectedKey?: ItemKey | null;
/** The initial selected key in the collection (uncontrolled). */
defaultSelectedKey?: ItemKey;
/**
* Whether to show item icons. If not provided, items will be checked for
* icons. If any are found, icons will be shown for all items. This is necessary
* to ensure all items have the same height which is needed for mapping the
* initial scroll position. This should be explicitly set for windowed data.
*/
showItemIcons?: boolean;
/**
* Whether to show item descriptions. If not provided, items will be checked
* for descriptions. If any are found, descriptions will be shown for all items.
* This is necessary to ensure all items have the same height which is needed
* for mapping scroll position to item indices. This should be explicitly set
* for windowed data.
*/
showItemDescriptions?: boolean;
/** Function to retrieve initial scroll position when opening the picker */
getInitialScrollPosition?: () => Promise<number | null>;
/**
Expand Down Expand Up @@ -78,6 +93,8 @@ export function Picker({
tooltip = true,
defaultSelectedKey,
selectedKey,
showItemIcons: showItemIconsDefault,
showItemDescriptions: showItemDescriptionsDefault,
getInitialScrollPosition,
onChange,
onOpenChange,
Expand All @@ -92,12 +109,27 @@ export function Picker({
[children]
);

const { showItemIcons, showItemDescriptions } = useRenderItemFlags({
normalizedItems,
showItemIcons: showItemIconsDefault,
showItemDescriptions: showItemDescriptionsDefault,
});

const itemHeight = showItemDescriptions
? PICKER_ITEM_HEIGHTS.withDescription
: PICKER_ITEM_HEIGHTS.noDescription;

const tooltipOptions = useMemo(
() => normalizeTooltipOptions(tooltip),
[tooltip]
);

const renderNormalizedItem = useRenderNormalizedItem('icon', tooltipOptions);
const renderNormalizedItem = useRenderNormalizedItem({
itemIconSlot: 'icon',
showItemDescriptions,
showItemIcons,
tooltipOptions,
});

const getInitialScrollPositionInternal = useCallback(
() =>
Expand All @@ -106,12 +138,18 @@ export function Picker({
keyedItems: normalizedItems,
// TODO: #1890 & deephaven-plugins#371 add support for sections and
// items with descriptions since they impact the height calculations
itemHeight: PICKER_ITEM_HEIGHT,
selectedKey,
itemHeight,
selectedKey: selectedKey ?? defaultSelectedKey,
topOffset: PICKER_TOP_OFFSET,
})
: getInitialScrollPosition(),
[getInitialScrollPosition, normalizedItems, selectedKey]
[
defaultSelectedKey,
getInitialScrollPosition,
itemHeight,
normalizedItems,
selectedKey,
]
);

const { ref: scrollRef, onOpenChange: popoverOnOpenChange } =
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,4 +1,6 @@
export * from './itemUtils';
export * from './itemWrapperUtils';
export * from './themeUtils';
export * from './useRenderItemFlags';
export * from './useRenderNormalizedItem';
export * from './useStringifiedMultiSelection';
53 changes: 32 additions & 21 deletions packages/components/src/spectrum/utils/itemUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Children, isValidElement, Key, ReactElement, ReactNode } from 'react';
import { isValidElement, Key, ReactElement, ReactNode } from 'react';
import { SpectrumPickerProps } from '@adobe/react-spectrum';
import type { ItemRenderer } from '@react-types/shared';
import Log from '@deephaven/log';
Expand Down Expand Up @@ -191,28 +191,20 @@ function normalizeItemContent(item: ItemElement): {
return { label: item.props.children };
}

let description: ReactNode | undefined;
let icon: ReactNode | undefined;

const label: ReactNode = Children.map(item.props.children, child => {
if (isElementOfType(child, Text) && child.props.slot === 'description') {
description = child;
return null;
}

// Picker uses `icon` slot. ListView can use `image` or `illustration` slots.
// https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/picker/src/Picker.tsx#L194
// https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/list/src/ListViewItem.tsx#L266-L267
if (
child.props.slot === 'icon' ||
child.props.slot === 'image' ||
child.props.slot === 'illustration'
) {
let icon: ReactNode;
let label: ReactNode;
let description: ReactNode;

item.props.children.forEach((child, i) => {
if (isElementOfType(child, Text)) {
if (child.props.slot === 'description') {
description = child;
} else {
label = child;
}
} else {
icon = child;
return null;
}

return child;
});

return {
Expand Down Expand Up @@ -317,6 +309,25 @@ function normalizeItem<TItemOrSection extends ItemOrSection>(
} as NormalizedItemOrSection<TItemOrSection>;
}

/**
* Check if any item in a normalized item list has a specific property on its
* `item` prop.
* @param normalizedItems The list of normalized items to check
* @param prop The property to check for
* @returns True if any item has the property
*/
export function doesAnyItemHaveProp<TItemOrSection extends ItemOrSection>(
normalizedItems: NormalizedItemOrSection<TItemOrSection>[],
prop: string
): boolean {
return normalizedItems.some(
item =>
item.item != null &&
prop in item.item &&
item.item[prop as keyof typeof item.item] != null
);
}

/**
* Normalize an item or section or a list of items or sections.
* @param itemsOrSections An item or section or array of items or sections
Expand Down
57 changes: 57 additions & 0 deletions packages/components/src/spectrum/utils/itemWrapperUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ReactNode } from 'react';
import { Icon } from '@adobe/react-spectrum';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { dh as dhIcons } from '@deephaven/icons';
import { NON_BREAKING_SPACE } from '@deephaven/utils';
import { Text } from '../Text';
import { ItemIconSlot } from './itemUtils';

/**
* If the given content is a primitive type, wrap it in a Text component.
* @param content The content to wrap
* @param slot The slot to use for the Text component
* @returns The wrapped content or original content if not a primitive type
*/
export function wrapPrimitiveWithText(
content?: ReactNode,
slot?: string
): ReactNode {
// eslint-disable-next-line no-param-reassign
content = content ?? '';

if (['string', 'boolean', 'number'].includes(typeof content)) {
return (
<Text slot={slot}>
{content === '' ? NON_BREAKING_SPACE : String(content)}
</Text>
);
}

return content;
}

/**
* If the given content is a string, wrap it in an Icon component. Otherwise,
* return the original content. If the key is not found in the dhIcons object,
* the vsBlank icon will be used.
* @param maybeIconKey The content to wrap
* @param slot The slot to use for the Icon component
* @returns The wrapped content or original content if not a string
*/
export function wrapIcon(
maybeIconKey: ReactNode,
slot: ItemIconSlot
): ReactNode {
// eslint-disable-next-line no-param-reassign
maybeIconKey = maybeIconKey ?? '';

if (typeof maybeIconKey !== 'string') {
return maybeIconKey;
}

return (
<Icon slot={slot}>
<FontAwesomeIcon icon={dhIcons[maybeIconKey] ?? dhIcons.vsBlank} />
</Icon>
);
}
49 changes: 49 additions & 0 deletions packages/components/src/spectrum/utils/useRenderItemFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import {
doesAnyItemHaveProp,
NormalizedItem,
NormalizedSection,
} from './itemUtils';

export interface UseRenderItemFlagsOptions {
normalizedItems: (NormalizedSection | NormalizedItem)[];
showItemIcons?: boolean;
showItemDescriptions?: boolean;
}

export interface RenderItemFlags {
showItemIcons: boolean;
showItemDescriptions: boolean;
}

/**
* Get flags for rendering items. Icons and descriptions need to be hidden or
* shown for all items in a list. If `showItemIcons` or `showItemDescriptions`
* are explicitly provided, use those values. Otherwise, if any item is found
* with an icon or description, set the respective flag to true.
* @param normalizedItems The normalized items to check for icons and descriptions
* @param showItemIcons Whether to show item icons by default
* @param showItemDescriptions Whether to show item descriptions by default
* @returns Flags for rendering items
*/
export function useRenderItemFlags({
normalizedItems,
showItemIcons: showItemIconsDefault,
showItemDescriptions: showItemDescriptionsDefault,
}: UseRenderItemFlagsOptions): RenderItemFlags {
const showItemIcons = useMemo(
() => showItemIconsDefault ?? doesAnyItemHaveProp(normalizedItems, 'icon'),
[normalizedItems, showItemIconsDefault]
);

const showItemDescriptions = useMemo(
() =>
showItemDescriptionsDefault ??
doesAnyItemHaveProp(normalizedItems, 'description'),
[normalizedItems, showItemDescriptionsDefault]
);

return { showItemIcons, showItemDescriptions };
}

export default useRenderItemFlags;
Loading

0 comments on commit 5c07661

Please sign in to comment.