diff --git a/packages/code-studio/src/styleguide/ListViews.tsx b/packages/code-studio/src/styleguide/ListViews.tsx index 40b24e034d..e651580721 100644 --- a/packages/code-studio/src/styleguide/ListViews.tsx +++ b/packages/code-studio/src/styleguide/ListViews.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useState } from 'react'; +import React, { ReactNode, useCallback, useState } from 'react'; +import type { StyleProps } from '@react-types/shared'; import { Grid, Icon, @@ -7,6 +8,8 @@ import { ListViewNormalized, ItemKey, Text, + Flex, + Checkbox, } from '@deephaven/components'; import { vsAccount, vsPerson } from '@deephaven/icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -14,6 +17,10 @@ import { generateNormalizedItems, sampleSectionIdAndClasses } from './utils'; // Generate enough items to require scrolling const itemsSimple = [...generateNormalizedItems(52)]; +const itemsWithIcons = [...generateNormalizedItems(52, { icons: true })]; +const itemsWithIconsAndDescriptions = [ + ...generateNormalizedItems(52, { icons: true, descriptions: true }), +]; function AccountIllustration(): JSX.Element { return ( @@ -26,11 +33,29 @@ function AccountIllustration(): JSX.Element { ); } +interface LabeledProps extends StyleProps { + label: string; + children: ReactNode; +} + +function LabeledFlexColumn({ label, children, ...styleProps }: LabeledProps) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {label} + {children} + + ); +} + export function ListViews(): JSX.Element { const [selectedKeys, setSelectedKeys] = useState<'all' | Iterable>( [] ); + const [showIcons, setShowIcons] = useState(true); + const [showDescriptions, setShowDescriptions] = useState(true); + const onChange = useCallback((keys: 'all' | Iterable): void => { setSelectedKeys(keys); }, []); @@ -40,81 +65,137 @@ export function ListViews(): JSX.Element {

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 - - - - - + + + + Aaa + + + + + + + + Item with icon A + + + + Item with icon B + + + + Item with icon C + + + + Item with icon D with overflowing content + + + + Item with icon E + + + + + + + {/* 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 + + Item with Description + Description + + + + + + Complex Ccc with text that should be truncated + + + + + + Complex Ccc with text that should be truncated + Description + + + + + + setShowIcons(e.currentTarget.checked)} + > + Show Ions + + setShowDescriptions(e.currentTarget.checked)} + > + Show Descriptions + + + + + + + + + + + + + +
); diff --git a/packages/code-studio/src/styleguide/Pickers.tsx b/packages/code-studio/src/styleguide/Pickers.tsx index f6a035a6fc..10ecc711ed 100644 --- a/packages/code-studio/src/styleguide/Pickers.tsx +++ b/packages/code-studio/src/styleguide/Pickers.tsx @@ -7,6 +7,7 @@ import { Section, Text, PickerNormalized, + Checkbox, } from '@deephaven/components'; import { vsPerson } from '@deephaven/icons'; import { Icon } from '@adobe/react-spectrum'; @@ -21,6 +22,7 @@ import { // Generate enough items to require scrolling const items = [...generateNormalizedItems(52)]; +const itemsWithIcons = [...generateNormalizedItems(52, { icons: true })]; const itemElementsA = [...generateItemElements(0, 51)]; const itemElementsB = [...generateItemElements(52, 103)]; const itemElementsC = [...generateItemElements(104, 155)]; @@ -63,6 +65,8 @@ function PersonIcon(): JSX.Element { export function Pickers(): JSX.Element { const [selectedKey, setSelectedKey] = useState(null); + const [showIcons, setShowIcons] = useState(true); + const getInitialScrollPosition = useCallback( async () => getPositionOfSelectedItem({ @@ -83,60 +87,72 @@ export function Pickers(): JSX.Element {

Pickers

- - - Aaa - + + + + Aaa + + + + {mixedItemsWithIconsNoDescriptions} + - - {mixedItemsWithIconsNoDescriptions} - + + {/* eslint-disable react/jsx-curly-brace-presence */} + {'String 1'} + {'String 2'} + {'String 3'} +
+ Item Aaa + Item Bbb + + + Complex Ccc + +
+
+ Item Ddd + Item Eee + + + Complex Fff + + + + Label + Description + + + + Label that causes overflow + Description that causes overflow + +
+
{itemElementsA}
+
{itemElementsB}
+
{itemElementsC}
+
{itemElementsD}
+
{itemElementsE}
+
+
- - {/* eslint-disable react/jsx-curly-brace-presence */} - {'String 1'} - {'String 2'} - {'String 3'} -
- Item Aaa - Item Bbb - - - Complex Ccc - -
-
- Item Ddd - Item Eee - - - Complex Fff - - - - Label - Description - - - - Label that causes overflow - Description that causes overflow - -
-
{itemElementsA}
-
{itemElementsB}
-
{itemElementsC}
-
{itemElementsD}
-
{itemElementsE}
-
+ setShowIcons(e.currentTarget.checked)} + > + Show Ions + - + + +
); diff --git a/packages/code-studio/src/styleguide/utils.ts b/packages/code-studio/src/styleguide/utils.ts index 8707ce5826..e05e16fac4 100644 --- a/packages/code-studio/src/styleguide/utils.ts +++ b/packages/code-studio/src/styleguide/utils.ts @@ -1,6 +1,7 @@ -import cl from 'classnames'; import { createElement, useCallback, useState } from 'react'; +import cl from 'classnames'; import { Item, ItemElement, NormalizedItem } from '@deephaven/components'; +import { dh as dhIcons } from '@deephaven/icons'; export const HIDE_FROM_E2E_TESTS_CLASS = 'hide-from-e2e-tests'; export const SAMPLE_SECTION_CLASS = 'sample-section'; @@ -39,11 +40,14 @@ export function* generateItemElements( * @param count The number of items to generate */ export function* generateNormalizedItems( - count: number + count: number, + include: { descriptions?: boolean; icons?: boolean } = {} ): Generator { const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const len = letters.length; + const iconKeys = Object.keys(dhIcons); + for (let i = 0; i < count; i += 1) { const charI = i % len; let suffix = String(Math.floor(i / len)); @@ -52,7 +56,14 @@ export function* generateNormalizedItems( } const letter = letters[charI]; const key = `${letter}${suffix}`; - const content = `${letter}${letter}${letter}${suffix}`; + + const icon = + include.icons === true ? iconKeys[i % iconKeys.length] : undefined; + + const description = + include.descriptions === true ? `Description ${key}` : undefined; + + const content = icon ?? `${letter}${letter}${letter}${suffix}`; yield { key, @@ -60,6 +71,8 @@ export function* generateNormalizedItems( key: (i + 1) * 100, content, textValue: content, + description, + icon, }, }; } diff --git a/packages/components/src/spectrum/listView/ListViewNormalized.tsx b/packages/components/src/spectrum/listView/ListViewNormalized.tsx index 80a0277ee7..0c3a33ed45 100644 --- a/packages/components/src/spectrum/listView/ListViewNormalized.tsx +++ b/packages/components/src/spectrum/listView/ListViewNormalized.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import cl from 'classnames'; import { NormalizedItem, normalizeTooltipOptions, @@ -11,6 +12,8 @@ import { ListViewWrapper } from './ListViewWrapper'; export interface ListViewNormalizedProps extends Omit { normalizedItems: NormalizedItem[]; + showItemDescriptions: boolean; + showItemIcons: boolean; } export function ListViewNormalized({ @@ -19,6 +22,9 @@ export function ListViewNormalized({ selectedKeys, defaultSelectedKeys, disabledKeys, + showItemDescriptions, + showItemIcons, + UNSAFE_className, onChange, onSelectionChange, ...props @@ -28,7 +34,17 @@ export function ListViewNormalized({ [tooltip] ); - const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions); + const renderNormalizedItem = useRenderNormalizedItem({ + itemIconSlot: 'illustration', + showItemDescriptions, + showItemIcons, + tooltipOptions, + }); + + // Spectrum doesn't re-render if only the `renderNormalizedItems` function + // changes, so we create a key from its dependencies that can be used to force + // re-render. + const forceRerenderKey = `${showItemIcons}-${showItemDescriptions}-${tooltipOptions?.placement}`; const { selectedStringKeys, @@ -47,6 +63,8 @@ export function ListViewNormalized({ { normalizedItems: (NormalizedItem | NormalizedSection)[]; + showItemIcons: boolean; getInitialScrollPosition?: () => Promise; onScroll?: (event: Event) => void; } @@ -35,6 +36,7 @@ export function PickerNormalized({ selectedKey, defaultSelectedKey, disabledKeys, + showItemIcons, UNSAFE_className, getInitialScrollPosition, onChange, @@ -48,7 +50,20 @@ export function PickerNormalized({ [tooltip] ); - const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions); + const renderNormalizedItem = useRenderNormalizedItem({ + itemIconSlot: 'icon', + // Descriptions introduce variable item heights which throws off calculation + // of initial scroll position. For now not going to implement description + // support in Picker. + showItemDescriptions: false, + showItemIcons, + tooltipOptions, + }); + + // Spectrum doesn't re-render if only the `renderNormalizedItems` function + // changes, so we create a key from its dependencies that can be used to force + // re-render. + const forceRerenderKey = `${showItemIcons}-${tooltipOptions?.placement}`; const { ref: scrollRef, onOpenChange: onOpenChangeInternal } = usePickerScrollOnOpen({ @@ -77,6 +92,7 @@ export function PickerNormalized({ } UNSAFE_className={cl( 'dh-picker', diff --git a/packages/components/src/spectrum/utils/itemUtils.ts b/packages/components/src/spectrum/utils/itemUtils.ts index 9eac80deb3..3682440939 100644 --- a/packages/components/src/spectrum/utils/itemUtils.ts +++ b/packages/components/src/spectrum/utils/itemUtils.ts @@ -29,6 +29,11 @@ export type SectionElement = ReactElement>; export type ItemElementOrPrimitive = number | string | boolean | ItemElement; export type ItemOrSection = ItemElementOrPrimitive | SectionElement; +// 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 +export type ItemIconSlot = 'icon' | 'image' | 'illustration'; + /** * Augment the Spectrum selection key type to include boolean values. * Spectrum collection components already supports this, but the built in types @@ -48,6 +53,8 @@ export type ItemSelectionChangeHandler = (key: ItemKey) => void; export interface NormalizedItemData { key?: ItemKey; content: ReactNode; + description?: ReactNode; + icon?: ReactNode; textValue: string | undefined; } diff --git a/packages/components/src/spectrum/utils/itemWrapperUtils.tsx b/packages/components/src/spectrum/utils/itemWrapperUtils.tsx index 47d8ae75bf..efed25cbeb 100644 --- a/packages/components/src/spectrum/utils/itemWrapperUtils.tsx +++ b/packages/components/src/spectrum/utils/itemWrapperUtils.tsx @@ -1,17 +1,49 @@ -import { cloneElement, ReactElement } from 'react'; +import { cloneElement, ReactElement, ReactNode } from 'react'; import { Item } from '@adobe/react-spectrum'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { dh as dhIcons } from '@deephaven/icons'; import { isElementOfType } from '@deephaven/react-hooks'; +import { NON_BREAKING_SPACE } from '@deephaven/utils'; import { isItemElement, isSectionElement, ItemElement, + ItemIconSlot, ItemOrSection, ITEM_EMPTY_STRING_TEXT_VALUE, SectionElement, TooltipOptions, } from './itemUtils'; import { ItemProps } from '../shared'; -import ItemContent from '../ItemContent'; +import { ItemContent } from '../ItemContent'; +import { Icon } from '../icons'; +import { Text } from '../Text'; + +/** + * 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 ( + + + + ); +} /** * Ensure all primitive children are wrapped in `Item` elements and that all @@ -88,4 +120,26 @@ export function wrapItemChildren( }); } -export default wrapItemChildren; +/** + * 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 ( + + {content === '' ? NON_BREAKING_SPACE : String(content)} + + ); + } + + return content; +} diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx index 1f199653d7..025417e119 100644 --- a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx +++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx @@ -3,10 +3,19 @@ import { ItemContent } from '../ItemContent'; import { Item } from '../shared'; import { getItemKey, + ItemIconSlot, ITEM_EMPTY_STRING_TEXT_VALUE, NormalizedItem, TooltipOptions, } from './itemUtils'; +import { wrapIcon, wrapPrimitiveWithText } from './itemWrapperUtils'; + +export interface UseRenderNormalizedItemOptions { + itemIconSlot: ItemIconSlot; + showItemDescriptions: boolean; + showItemIcons: boolean; + tooltipOptions: TooltipOptions | null; +} /** * Returns a render function that can be used to render a normalized item in @@ -14,15 +23,28 @@ import { * @param tooltipOptions Tooltip options to use when rendering the item * @returns Render function for normalized items */ -export function useRenderNormalizedItem( - tooltipOptions: TooltipOptions | null -): (normalizedItem: NormalizedItem) => JSX.Element { +export function useRenderNormalizedItem({ + itemIconSlot, + showItemDescriptions, + showItemIcons, + tooltipOptions, +}: UseRenderNormalizedItemOptions): ( + normalizedItem: NormalizedItem +) => JSX.Element { return useCallback( (normalizedItem: NormalizedItem) => { const key = getItemKey(normalizedItem); - const content = normalizedItem.item?.content ?? ''; + const content = wrapPrimitiveWithText(normalizedItem.item?.content); const textValue = normalizedItem.item?.textValue ?? ''; + const description = showItemDescriptions + ? wrapPrimitiveWithText(normalizedItem.item?.description, 'description') + : null; + + const icon = showItemIcons + ? wrapIcon(normalizedItem.item?.icon, itemIconSlot) + : null; + return ( - {content} + + {icon} + {content} + {description} + ); }, - [tooltipOptions] + [itemIconSlot, showItemDescriptions, showItemIcons, tooltipOptions] ); } diff --git a/packages/jsapi-components/src/spectrum/ListView.tsx b/packages/jsapi-components/src/spectrum/ListView.tsx index 994fed84e8..b48848eefc 100644 --- a/packages/jsapi-components/src/spectrum/ListView.tsx +++ b/packages/jsapi-components/src/spectrum/ListView.tsx @@ -6,7 +6,10 @@ import { } from '@deephaven/components'; import { dh as DhType } from '@deephaven/jsapi-types'; import { Settings } from '@deephaven/jsapi-utils'; -import { LIST_VIEW_ROW_HEIGHTS } from '@deephaven/utils'; +import { + LIST_VIEW_ROW_HEIGHTS, + LIST_VIEW_ROW_HEIGHTS_WITH_DESCRIPTIONS, +} from '@deephaven/utils'; import useFormatter from '../useFormatter'; import useViewportData from '../useViewportData'; import { useItemRowDeserializer } from './utils'; @@ -18,7 +21,11 @@ export interface ListViewProps extends ListViewNormalizedProps { /* The column of values to display as primary text. Defaults to the `keyColumn` value. */ labelColumn?: string; - // TODO #1890 : descriptionColumn, iconColumn + /* The column of values to display as descriptions. */ + descriptionColumn?: string; + + /* The column of values to map to icons. */ + iconColumn?: string; settings?: Settings; } @@ -27,16 +34,24 @@ export function ListView({ table, keyColumn: keyColumnName, labelColumn: labelColumnName, + descriptionColumn: descriptionColumnName, + iconColumn: iconColumnName, settings, ...props }: ListViewProps): JSX.Element { const { scale } = useSpectrumThemeProvider(); - const itemHeight = LIST_VIEW_ROW_HEIGHTS[props.density ?? 'regular'][scale]; + const itemHeight = ( + descriptionColumnName == null + ? LIST_VIEW_ROW_HEIGHTS + : LIST_VIEW_ROW_HEIGHTS_WITH_DESCRIPTIONS + )[props.density ?? 'regular'][scale]; const { getFormattedString: formatValue } = useFormatter(settings); const deserializeRow = useItemRowDeserializer({ table, + descriptionColumnName, + iconColumnName, keyColumnName, labelColumnName, formatValue, @@ -57,6 +72,8 @@ export function ListView({ // eslint-disable-next-line react/jsx-props-no-spreading {...props} normalizedItems={viewportData.items} + showItemDescriptions={descriptionColumnName != null} + showItemIcons={iconColumnName != null} onScroll={onScroll} /> ); diff --git a/packages/jsapi-components/src/spectrum/Picker.tsx b/packages/jsapi-components/src/spectrum/Picker.tsx index 838aa08e7a..7eff83737c 100644 --- a/packages/jsapi-components/src/spectrum/Picker.tsx +++ b/packages/jsapi-components/src/spectrum/Picker.tsx @@ -28,7 +28,11 @@ export interface PickerProps extends Omit { /* The column of values to display as primary text. Defaults to the `keyColumn` value. */ labelColumn?: string; - // TODO #1890 : descriptionColumn, iconColumn + /* The column of values to display as descriptions. */ + descriptionColumn?: string; + + /* The column of values to map to icons. */ + iconColumn?: string; settings?: Settings; } @@ -37,6 +41,7 @@ export function Picker({ table, keyColumn: keyColumnName, labelColumn: labelColumnName, + iconColumn: iconColumnName, settings, onChange, onSelectionChange, @@ -61,6 +66,7 @@ export function Picker({ const deserializeRow = useItemRowDeserializer({ table, + iconColumnName, keyColumnName, labelColumnName, formatValue, @@ -140,6 +146,7 @@ export function Picker({ // eslint-disable-next-line react/jsx-props-no-spreading {...props} normalizedItems={normalizedItems} + showItemIcons={iconColumnName != null} getInitialScrollPosition={getInitialScrollPosition} onChange={onSelectionChangeInternal} onScroll={onScroll} diff --git a/packages/jsapi-components/src/spectrum/utils/useItemRowDeserializer.ts b/packages/jsapi-components/src/spectrum/utils/useItemRowDeserializer.ts index c09526d10e..d168547277 100644 --- a/packages/jsapi-components/src/spectrum/utils/useItemRowDeserializer.ts +++ b/packages/jsapi-components/src/spectrum/utils/useItemRowDeserializer.ts @@ -22,6 +22,8 @@ function defaultFormatValue(value: unknown, _columnType: string): string { /** * Returns a function that deserializes a row into a normalized item data object. * @param table The table to get the key and label columns from + * @param descriptionColumnName The name of the column to use for description data + * @param iconColumnName The name of the column to use for icon data * @param keyColumnName The name of the column to use for key data * @param labelColumnName The name of the column to use for label data * @param formatValue Optional function to format the label value @@ -29,11 +31,15 @@ function defaultFormatValue(value: unknown, _columnType: string): string { */ export function useItemRowDeserializer({ table, + descriptionColumnName, + iconColumnName, keyColumnName, labelColumnName, formatValue = defaultFormatValue, }: { table: dh.Table; + descriptionColumnName?: string; + iconColumnName?: string; keyColumnName?: string; labelColumnName?: string; formatValue?: (value: unknown, columnType: string) => string; @@ -48,18 +54,40 @@ export function useItemRowDeserializer({ [keyColumn, labelColumnName, table] ); + const descriptionColumn = useMemo( + () => + descriptionColumnName == null + ? null + : table.findColumn(descriptionColumnName), + [descriptionColumnName, table] + ); + + const iconColumn = useMemo( + () => (iconColumnName == null ? null : table.findColumn(iconColumnName)), + [iconColumnName, table] + ); + const deserializeRow = useCallback( (row: dh.Row): NormalizedItemData => { const key = defaultFormatKey(row.get(keyColumn)); const content = formatValue(row.get(labelColumn), labelColumn.type); + const description = + descriptionColumn == null + ? undefined + : formatValue(row.get(descriptionColumn), descriptionColumn.type); + + const icon = iconColumn == null ? undefined : row.get(iconColumn); + return { key, content, textValue: content, + description, + icon, }; }, - [formatValue, keyColumn, labelColumn] + [descriptionColumn, formatValue, iconColumn, keyColumn, labelColumn] ); return deserializeRow; diff --git a/packages/utils/src/UIConstants.ts b/packages/utils/src/UIConstants.ts index 2a5d7b962a..98ce05e307 100644 --- a/packages/utils/src/UIConstants.ts +++ b/packages/utils/src/UIConstants.ts @@ -31,3 +31,18 @@ export const LIST_VIEW_ROW_HEIGHTS = { large: 60, }, } as const; + +export const LIST_VIEW_ROW_HEIGHTS_WITH_DESCRIPTIONS = { + compact: { + medium: 48, + large: 59, + }, + regular: { + medium: 54, + large: 67, + }, + spacious: { + medium: 56, + large: 69, + }, +} as const;