diff --git a/jest.setup.ts b/jest.setup.ts index 4bb196107b..8db731f817 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -50,6 +50,21 @@ Object.defineProperty(window, 'ResizeObserver', { }, }); +Object.defineProperty(window, 'DOMRect', { + value: function (x: number = 0, y: number = 0, width = 0, height = 0) { + return TestUtils.createMockProxy<DOMRect>({ + x, + y, + width, + height, + top: y, + bottom: y + height, + left: x, + right: x + width, + }); + }, +}); + Object.defineProperty(window, 'TextDecoder', { value: TextDecoder, }); diff --git a/packages/code-studio/src/styleguide/ListViews.tsx b/packages/code-studio/src/styleguide/ListViews.tsx new file mode 100644 index 0000000000..d52fe3672e --- /dev/null +++ b/packages/code-studio/src/styleguide/ListViews.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useState } from 'react'; +import { + Grid, + Icon, + Item, + ListView, + ItemKey, + Text, +} from '@deephaven/components'; +import { vsAccount, vsPerson } from '@deephaven/icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { generateNormalizedItems, sampleSectionIdAndClasses } from './utils'; + +// Generate enough items to require scrolling +const itemsSimple = [...generateNormalizedItems(52)]; + +function AccountIllustration(): 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="illustration"> + <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-6000"> + <Text>Single Child</Text> + <ListView + density="compact" + gridRow="2" + aria-label="Single Child" + selectionMode="multiple" + > + <Item>Aaa</Item> + </ListView> + + <label>Icons</label> + <ListView + gridRow="2" + aria-label="Icon" + density="compact" + selectionMode="multiple" + > + <Item textValue="Item with icon A"> + <AccountIllustration /> + <Text>Item with icon A</Text> + </Item> + <Item textValue="Item with icon B"> + <AccountIllustration /> + <Text>Item with icon B</Text> + </Item> + <Item textValue="Item with icon C"> + <AccountIllustration /> + <Text>Item with icon C</Text> + </Item> + <Item textValue="Item with icon D"> + <AccountIllustration /> + <Text>Item with icon D with overflowing content</Text> + </Item> + </ListView> + + <label>Mixed Children Types</label> + <ListView + gridRow="2" + aria-label="Mixed Children Types" + density="compact" + 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; diff --git a/packages/code-studio/src/styleguide/Pickers.tsx b/packages/code-studio/src/styleguide/Pickers.tsx index 18dc678c10..89adb62356 100644 --- a/packages/code-studio/src/styleguide/Pickers.tsx +++ b/packages/code-studio/src/styleguide/Pickers.tsx @@ -10,15 +10,10 @@ import { import { vsPerson } from '@deephaven/icons'; import { Icon } from '@adobe/react-spectrum'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { sampleSectionIdAndClasses } from './utils'; +import { generateNormalizedItems, sampleSectionIdAndClasses } from './utils'; // Generate enough items to require scrolling -const items = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' - .split('') - .map((key, i) => ({ - key, - item: { key: (i + 1) * 100, content: `${key}${key}${key}` }, - })); +const items = [...generateNormalizedItems(52)]; function PersonIcon(): JSX.Element { return ( @@ -29,7 +24,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); diff --git a/packages/code-studio/src/styleguide/StyleGuide.test.tsx b/packages/code-studio/src/styleguide/StyleGuide.test.tsx index f86f510f5e..28355f2637 100644 --- a/packages/code-studio/src/styleguide/StyleGuide.test.tsx +++ b/packages/code-studio/src/styleguide/StyleGuide.test.tsx @@ -9,11 +9,37 @@ import StyleGuide from './StyleGuide'; window.HTMLElement.prototype.scroll = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn(); +/** + * Mock a dimension property of a ListView element. + */ +function mockListViewDimension(propName: keyof HTMLElement, value: number) { + jest + .spyOn(window.HTMLElement.prototype, propName, 'get') + .mockImplementation(function getDimension() { + const isSpectrumListView = + this instanceof HTMLElement && + this.className.includes('_react-spectrum-ListView'); + + // For non ListView, just return zero which is the default value anyway. + return isSpectrumListView === true ? value : 0; + }); +} + describe('<StyleGuide /> mounts', () => { test('h1 text of StyleGuide renders', () => { // Provide a non-null array to ThemeProvider to tell it to initialize const customThemes: ThemeData[] = []; + // React Spectrum `useVirtualizerItem` depends on `scrollWidth` and `scrollHeight`. + // Mocking these to avoid React "Maximum update depth exceeded" errors. + // https://github.com/adobe/react-spectrum/blob/0b2a838b36ad6d86eee13abaf68b7e4d2b4ada6c/packages/%40react-aria/virtualizer/src/useVirtualizerItem.ts#L49C3-L49C60 + // From preview docs: https://reactspectrum.blob.core.windows.net/reactspectrum/726a5e8f0ed50fc8d98e39c74bd6dfeb3660fbdf/docs/react-spectrum/testing.html#virtualized-components + // The virtualizer will now think it has a visible area of 1000px x 1000px and that the items within it are 40px x 40px + mockListViewDimension('clientWidth', 1000); + mockListViewDimension('clientHeight', 1000); + mockListViewDimension('scrollHeight', 40); + mockListViewDimension('scrollWidth', 40); + expect(() => render( <ApiContext.Provider value={dh}> 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 { <Buttons /> <Progress /> <Inputs /> + <ListViews /> <Pickers /> <ItemListInputs /> <DraggableLists /> diff --git a/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap b/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap new file mode 100644 index 0000000000..a9964ea190 --- /dev/null +++ b/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap @@ -0,0 +1,706 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateNormalizedItems should generate normalized items 1`] = ` +[ + { + "item": { + "content": "AAA", + "key": 100, + }, + "key": "A", + }, + { + "item": { + "content": "BBB", + "key": 200, + }, + "key": "B", + }, + { + "item": { + "content": "CCC", + "key": 300, + }, + "key": "C", + }, + { + "item": { + "content": "DDD", + "key": 400, + }, + "key": "D", + }, + { + "item": { + "content": "EEE", + "key": 500, + }, + "key": "E", + }, + { + "item": { + "content": "FFF", + "key": 600, + }, + "key": "F", + }, + { + "item": { + "content": "GGG", + "key": 700, + }, + "key": "G", + }, + { + "item": { + "content": "HHH", + "key": 800, + }, + "key": "H", + }, + { + "item": { + "content": "III", + "key": 900, + }, + "key": "I", + }, + { + "item": { + "content": "JJJ", + "key": 1000, + }, + "key": "J", + }, + { + "item": { + "content": "KKK", + "key": 1100, + }, + "key": "K", + }, + { + "item": { + "content": "LLL", + "key": 1200, + }, + "key": "L", + }, + { + "item": { + "content": "MMM", + "key": 1300, + }, + "key": "M", + }, + { + "item": { + "content": "NNN", + "key": 1400, + }, + "key": "N", + }, + { + "item": { + "content": "OOO", + "key": 1500, + }, + "key": "O", + }, + { + "item": { + "content": "PPP", + "key": 1600, + }, + "key": "P", + }, + { + "item": { + "content": "QQQ", + "key": 1700, + }, + "key": "Q", + }, + { + "item": { + "content": "RRR", + "key": 1800, + }, + "key": "R", + }, + { + "item": { + "content": "SSS", + "key": 1900, + }, + "key": "S", + }, + { + "item": { + "content": "TTT", + "key": 2000, + }, + "key": "T", + }, + { + "item": { + "content": "UUU", + "key": 2100, + }, + "key": "U", + }, + { + "item": { + "content": "VVV", + "key": 2200, + }, + "key": "V", + }, + { + "item": { + "content": "WWW", + "key": 2300, + }, + "key": "W", + }, + { + "item": { + "content": "XXX", + "key": 2400, + }, + "key": "X", + }, + { + "item": { + "content": "YYY", + "key": 2500, + }, + "key": "Y", + }, + { + "item": { + "content": "ZZZ", + "key": 2600, + }, + "key": "Z", + }, + { + "item": { + "content": "aaa", + "key": 2700, + }, + "key": "a", + }, + { + "item": { + "content": "bbb", + "key": 2800, + }, + "key": "b", + }, + { + "item": { + "content": "ccc", + "key": 2900, + }, + "key": "c", + }, + { + "item": { + "content": "ddd", + "key": 3000, + }, + "key": "d", + }, + { + "item": { + "content": "eee", + "key": 3100, + }, + "key": "e", + }, + { + "item": { + "content": "fff", + "key": 3200, + }, + "key": "f", + }, + { + "item": { + "content": "ggg", + "key": 3300, + }, + "key": "g", + }, + { + "item": { + "content": "hhh", + "key": 3400, + }, + "key": "h", + }, + { + "item": { + "content": "iii", + "key": 3500, + }, + "key": "i", + }, + { + "item": { + "content": "jjj", + "key": 3600, + }, + "key": "j", + }, + { + "item": { + "content": "kkk", + "key": 3700, + }, + "key": "k", + }, + { + "item": { + "content": "lll", + "key": 3800, + }, + "key": "l", + }, + { + "item": { + "content": "mmm", + "key": 3900, + }, + "key": "m", + }, + { + "item": { + "content": "nnn", + "key": 4000, + }, + "key": "n", + }, + { + "item": { + "content": "ooo", + "key": 4100, + }, + "key": "o", + }, + { + "item": { + "content": "ppp", + "key": 4200, + }, + "key": "p", + }, + { + "item": { + "content": "qqq", + "key": 4300, + }, + "key": "q", + }, + { + "item": { + "content": "rrr", + "key": 4400, + }, + "key": "r", + }, + { + "item": { + "content": "sss", + "key": 4500, + }, + "key": "s", + }, + { + "item": { + "content": "ttt", + "key": 4600, + }, + "key": "t", + }, + { + "item": { + "content": "uuu", + "key": 4700, + }, + "key": "u", + }, + { + "item": { + "content": "vvv", + "key": 4800, + }, + "key": "v", + }, + { + "item": { + "content": "www", + "key": 4900, + }, + "key": "w", + }, + { + "item": { + "content": "xxx", + "key": 5000, + }, + "key": "x", + }, + { + "item": { + "content": "yyy", + "key": 5100, + }, + "key": "y", + }, + { + "item": { + "content": "zzz", + "key": 5200, + }, + "key": "z", + }, + { + "item": { + "content": "AAA1", + "key": 5300, + }, + "key": "A1", + }, + { + "item": { + "content": "BBB1", + "key": 5400, + }, + "key": "B1", + }, + { + "item": { + "content": "CCC1", + "key": 5500, + }, + "key": "C1", + }, + { + "item": { + "content": "DDD1", + "key": 5600, + }, + "key": "D1", + }, + { + "item": { + "content": "EEE1", + "key": 5700, + }, + "key": "E1", + }, + { + "item": { + "content": "FFF1", + "key": 5800, + }, + "key": "F1", + }, + { + "item": { + "content": "GGG1", + "key": 5900, + }, + "key": "G1", + }, + { + "item": { + "content": "HHH1", + "key": 6000, + }, + "key": "H1", + }, + { + "item": { + "content": "III1", + "key": 6100, + }, + "key": "I1", + }, + { + "item": { + "content": "JJJ1", + "key": 6200, + }, + "key": "J1", + }, + { + "item": { + "content": "KKK1", + "key": 6300, + }, + "key": "K1", + }, + { + "item": { + "content": "LLL1", + "key": 6400, + }, + "key": "L1", + }, + { + "item": { + "content": "MMM1", + "key": 6500, + }, + "key": "M1", + }, + { + "item": { + "content": "NNN1", + "key": 6600, + }, + "key": "N1", + }, + { + "item": { + "content": "OOO1", + "key": 6700, + }, + "key": "O1", + }, + { + "item": { + "content": "PPP1", + "key": 6800, + }, + "key": "P1", + }, + { + "item": { + "content": "QQQ1", + "key": 6900, + }, + "key": "Q1", + }, + { + "item": { + "content": "RRR1", + "key": 7000, + }, + "key": "R1", + }, + { + "item": { + "content": "SSS1", + "key": 7100, + }, + "key": "S1", + }, + { + "item": { + "content": "TTT1", + "key": 7200, + }, + "key": "T1", + }, + { + "item": { + "content": "UUU1", + "key": 7300, + }, + "key": "U1", + }, + { + "item": { + "content": "VVV1", + "key": 7400, + }, + "key": "V1", + }, + { + "item": { + "content": "WWW1", + "key": 7500, + }, + "key": "W1", + }, + { + "item": { + "content": "XXX1", + "key": 7600, + }, + "key": "X1", + }, + { + "item": { + "content": "YYY1", + "key": 7700, + }, + "key": "Y1", + }, + { + "item": { + "content": "ZZZ1", + "key": 7800, + }, + "key": "Z1", + }, + { + "item": { + "content": "aaa1", + "key": 7900, + }, + "key": "a1", + }, + { + "item": { + "content": "bbb1", + "key": 8000, + }, + "key": "b1", + }, + { + "item": { + "content": "ccc1", + "key": 8100, + }, + "key": "c1", + }, + { + "item": { + "content": "ddd1", + "key": 8200, + }, + "key": "d1", + }, + { + "item": { + "content": "eee1", + "key": 8300, + }, + "key": "e1", + }, + { + "item": { + "content": "fff1", + "key": 8400, + }, + "key": "f1", + }, + { + "item": { + "content": "ggg1", + "key": 8500, + }, + "key": "g1", + }, + { + "item": { + "content": "hhh1", + "key": 8600, + }, + "key": "h1", + }, + { + "item": { + "content": "iii1", + "key": 8700, + }, + "key": "i1", + }, + { + "item": { + "content": "jjj1", + "key": 8800, + }, + "key": "j1", + }, + { + "item": { + "content": "kkk1", + "key": 8900, + }, + "key": "k1", + }, + { + "item": { + "content": "lll1", + "key": 9000, + }, + "key": "l1", + }, + { + "item": { + "content": "mmm1", + "key": 9100, + }, + "key": "m1", + }, + { + "item": { + "content": "nnn1", + "key": 9200, + }, + "key": "n1", + }, + { + "item": { + "content": "ooo1", + "key": 9300, + }, + "key": "o1", + }, + { + "item": { + "content": "ppp1", + "key": 9400, + }, + "key": "p1", + }, + { + "item": { + "content": "qqq1", + "key": 9500, + }, + "key": "q1", + }, + { + "item": { + "content": "rrr1", + "key": 9600, + }, + "key": "r1", + }, + { + "item": { + "content": "sss1", + "key": 9700, + }, + "key": "s1", + }, + { + "item": { + "content": "ttt1", + "key": 9800, + }, + "key": "t1", + }, + { + "item": { + "content": "uuu1", + "key": 9900, + }, + "key": "u1", + }, + { + "item": { + "content": "vvv1", + "key": 10000, + }, + "key": "v1", + }, +] +`; diff --git a/packages/code-studio/src/styleguide/utils.test.ts b/packages/code-studio/src/styleguide/utils.test.ts index fdc3f3ad85..0c3e973c7e 100644 --- a/packages/code-studio/src/styleguide/utils.test.ts +++ b/packages/code-studio/src/styleguide/utils.test.ts @@ -1,8 +1,16 @@ import { + generateNormalizedItems, sampleSectionIdAndClasses, sampleSectionIdAndClassesSpectrum, } from './utils'; +describe('generateNormalizedItems', () => { + it('should generate normalized items', () => { + const actual = [...generateNormalizedItems(100)]; + expect(actual).toMatchSnapshot(); + }); +}); + describe('sampleSectionIdAndClasses', () => { it('should return id and className', () => { const actual = sampleSectionIdAndClasses('some-id', [ diff --git a/packages/code-studio/src/styleguide/utils.ts b/packages/code-studio/src/styleguide/utils.ts index a256f79b4c..7e7f9087e3 100644 --- a/packages/code-studio/src/styleguide/utils.ts +++ b/packages/code-studio/src/styleguide/utils.ts @@ -1,9 +1,39 @@ import cl from 'classnames'; import { useCallback, useState } from 'react'; +import { NormalizedItem } from '@deephaven/components'; export const HIDE_FROM_E2E_TESTS_CLASS = 'hide-from-e2e-tests'; export const SAMPLE_SECTION_CLASS = 'sample-section'; +/** + * Generate a given number of NormalizedItems. + * @param count The number of items to generate + */ +export function* generateNormalizedItems( + count: number +): Generator<NormalizedItem> { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const len = letters.length; + + for (let i = 0; i < count; i += 1) { + const charI = i % len; + let suffix = String(Math.floor(i / len)); + if (suffix === '0') { + suffix = ''; + } + const letter = letters[charI]; + const key = `${letter}${suffix}`; + + yield { + key, + item: { + key: (i + 1) * 100, + content: `${letter}${letter}${letter}${suffix}`, + }, + }; + } +} + /** * Pseudo random number generator with seed so we get reproducible output. * This is necessary in order for e2e tests to work. diff --git a/packages/components/src/spectrum/Heading.tsx b/packages/components/src/spectrum/Heading.tsx index 38682e6ca7..f29e7b2729 100644 --- a/packages/components/src/spectrum/Heading.tsx +++ b/packages/components/src/spectrum/Heading.tsx @@ -1,9 +1,10 @@ /* eslint-disable react/jsx-props-no-spreading */ -import { useMemo } from 'react'; +import { forwardRef, useMemo } from 'react'; import { Heading as SpectrumHeading, type HeadingProps as SpectrumHeadingProps, } from '@adobe/react-spectrum'; +import type { DOMRefValue } from '@react-types/shared'; import { type ColorValue, colorValueStyle } from '../theme/colorUtils'; export type HeadingProps = SpectrumHeadingProps & { @@ -20,7 +21,10 @@ export type HeadingProps = SpectrumHeadingProps & { * */ -export function Heading(props: HeadingProps): JSX.Element { +export const Heading = forwardRef< + DOMRefValue<HTMLHeadingElement>, + HeadingProps +>((props, forwardedRef): JSX.Element => { const { color, UNSAFE_style, ...rest } = props; const style = useMemo( () => ({ @@ -30,7 +34,9 @@ export function Heading(props: HeadingProps): JSX.Element { [color, UNSAFE_style] ); - return <SpectrumHeading {...rest} UNSAFE_style={style} />; -} + return <SpectrumHeading {...rest} ref={forwardedRef} UNSAFE_style={style} />; +}); + +Heading.displayName = 'Heading'; export default Heading; diff --git a/packages/components/src/spectrum/ItemContent.tsx b/packages/components/src/spectrum/ItemContent.tsx new file mode 100644 index 0000000000..1526d3ed64 --- /dev/null +++ b/packages/components/src/spectrum/ItemContent.tsx @@ -0,0 +1,101 @@ +import { + Children, + cloneElement, + isValidElement, + ReactNode, + useState, +} from 'react'; +import cl from 'classnames'; +import { isElementOfType, useCheckOverflow } from '@deephaven/react-hooks'; +import { Text } from './Text'; +import { TooltipOptions } from './utils'; +import ItemTooltip from './ItemTooltip'; +import stylesCommon from '../SpectrumComponent.module.scss'; + +export interface ItemContentProps { + children: ReactNode; + tooltipOptions?: TooltipOptions | null; +} + +/** + * Item content. Text content will be wrapped in a Spectrum Text + * component with ellipsis overflow handling. If text content overflows and + * tooltipOptions are provided a tooltip will be displayed when hovering over + * the item content. + */ +export function ItemContent({ + children: content, + tooltipOptions, +}: ItemContentProps): JSX.Element | null { + const { checkOverflow, isOverflowing, resetIsOverflowing } = + useCheckOverflow(); + + const [previousContent, setPreviousContent] = useState(content); + + // Reset `isOverflowing` if content changes. It will get re-calculated as + // `Text` components render. + if (previousContent !== content) { + setPreviousContent(content); + resetIsOverflowing(); + } + + if (isValidElement(content)) { + return content; + } + + /* eslint-disable no-param-reassign */ + if (content === '') { + // Prevent the item height from collapsing when the content is empty + content = '\xa0'; // Non-breaking space + } else if (typeof content === 'boolean') { + // Boolean values need to be stringified to render + content = String(content); + } else if (Array.isArray(content)) { + // For cases where there are multiple `Text` children, add a css class to + // handle overflow. The primary use case for multiple text nodes is when a + // description is provided for an item. e.g. + // <Item textValue="Some Text"> + // <SomeIcon /> + // <Text>Some Label</Text> + // <Text slot="description">Some Description</Text> + // </Item> + content = Children.map(content, el => + isElementOfType(el, Text) + ? cloneElement(el, { + ...el.props, + ref: checkOverflow, + UNSAFE_className: cl( + el.props.UNSAFE_className, + stylesCommon.spectrumEllipsis + ), + }) + : el + ); + } + + if (typeof content === 'string' || typeof content === 'number') { + content = ( + <Text + ref={checkOverflow} + UNSAFE_className={stylesCommon.spectrumEllipsis} + > + {content} + </Text> + ); + } + /* eslint-enable no-param-reassign */ + + const tooltip = + tooltipOptions == null || !isOverflowing ? null : ( + <ItemTooltip options={tooltipOptions}>{content}</ItemTooltip> + ); + + return ( + <> + {content} + {tooltip} + </> + ); +} + +export default ItemContent; diff --git a/packages/components/src/spectrum/ItemTooltip.tsx b/packages/components/src/spectrum/ItemTooltip.tsx new file mode 100644 index 0000000000..a86588d2ff --- /dev/null +++ b/packages/components/src/spectrum/ItemTooltip.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react'; +import { isElementOfType } from '@deephaven/react-hooks'; +import { TooltipOptions } from './utils'; +import { Tooltip } from '../popper'; +import { Flex } from './layout'; +import { Text } from './Text'; + +export interface ItemTooltipProps { + children: ReactNode; + options: TooltipOptions; +} + +/** + * Tooltip for `<Item>` content. + */ +export function ItemTooltip({ + children, + options, +}: ItemTooltipProps): JSX.Element { + if (Array.isArray(children)) { + return ( + <Tooltip options={options}> + {/* Multiple children scenarios include a `<Text>` node for the label + and at least 1 of an optional icon or `<Text slot="description">` node. + In such cases we only show the label and description `<Text>` nodes. */} + <Flex direction="column" alignItems="start"> + {children.filter(node => isElementOfType(node, Text))} + </Flex> + </Tooltip> + ); + } + + return <Tooltip options={options}>{children}</Tooltip>; +} + +export default ItemTooltip; diff --git a/packages/components/src/spectrum/Text.tsx b/packages/components/src/spectrum/Text.tsx index 3164c2eafd..d0467f275d 100644 --- a/packages/components/src/spectrum/Text.tsx +++ b/packages/components/src/spectrum/Text.tsx @@ -1,9 +1,10 @@ /* eslint-disable react/jsx-props-no-spreading */ -import { useMemo } from 'react'; +import { forwardRef, useMemo } from 'react'; import { Text as SpectrumText, type TextProps as SpectrumTextProps, } from '@adobe/react-spectrum'; +import type { DOMRefValue } from '@react-types/shared'; import { type ColorValue, colorValueStyle } from '../theme/colorUtils'; export type TextProps = SpectrumTextProps & { @@ -19,18 +20,21 @@ export type TextProps = SpectrumTextProps & { * @returns The Text component * */ +export const Text = forwardRef<DOMRefValue<HTMLSpanElement>, TextProps>( + (props, forwardedRef): JSX.Element => { + const { color, UNSAFE_style, ...rest } = props; + const style = useMemo( + () => ({ + ...UNSAFE_style, + color: colorValueStyle(color), + }), + [color, UNSAFE_style] + ); -export function Text(props: TextProps): JSX.Element { - const { color, UNSAFE_style, ...rest } = props; - const style = useMemo( - () => ({ - ...UNSAFE_style, - color: colorValueStyle(color), - }), - [color, UNSAFE_style] - ); + return <SpectrumText {...rest} ref={forwardedRef} UNSAFE_style={style} />; + } +); - return <SpectrumText {...rest} UNSAFE_style={style} />; -} +Text.displayName = 'Text'; export default Text; diff --git a/packages/components/src/spectrum/View.tsx b/packages/components/src/spectrum/View.tsx index 847900541e..03c28a193e 100644 --- a/packages/components/src/spectrum/View.tsx +++ b/packages/components/src/spectrum/View.tsx @@ -1,9 +1,10 @@ /* eslint-disable react/jsx-props-no-spreading */ -import { useMemo } from 'react'; +import { forwardRef, useMemo } from 'react'; import { View as SpectrumView, type ViewProps as SpectrumViewProps, } from '@adobe/react-spectrum'; +import type { DOMRefValue } from '@react-types/shared'; import { type ColorValue, colorValueStyle } from '../theme/colorUtils'; export type ViewProps = Omit<SpectrumViewProps<6>, 'backgroundColor'> & { @@ -20,17 +21,21 @@ export type ViewProps = Omit<SpectrumViewProps<6>, 'backgroundColor'> & { * */ -export function View(props: ViewProps): JSX.Element { - const { backgroundColor, UNSAFE_style, ...rest } = props; - const style = useMemo( - () => ({ - ...UNSAFE_style, - backgroundColor: colorValueStyle(backgroundColor), - }), - [backgroundColor, UNSAFE_style] - ); +export const View = forwardRef<DOMRefValue<HTMLElement>, ViewProps>( + (props, forwardedRef): JSX.Element => { + const { backgroundColor, UNSAFE_style, ...rest } = props; + const style = useMemo( + () => ({ + ...UNSAFE_style, + backgroundColor: colorValueStyle(backgroundColor), + }), + [backgroundColor, UNSAFE_style] + ); - return <SpectrumView {...rest} UNSAFE_style={style} />; -} + return <SpectrumView {...rest} ref={forwardedRef} UNSAFE_style={style} />; + } +); + +View.displayName = 'View'; export default View; 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/icons.ts b/packages/components/src/spectrum/icons.ts new file mode 100644 index 0000000000..ac724b4335 --- /dev/null +++ b/packages/components/src/spectrum/icons.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { Icon } from '@adobe/react-spectrum'; diff --git a/packages/components/src/spectrum/index.ts b/packages/components/src/spectrum/index.ts index e001ef8beb..79dee1031d 100644 --- a/packages/components/src/spectrum/index.ts +++ b/packages/components/src/spectrum/index.ts @@ -6,6 +6,7 @@ export * from './collections'; export * from './content'; export * from './dateAndTime'; export * from './forms'; +export * from './icons'; export * from './layout'; export * from './navigation'; export * from './overlays'; @@ -16,6 +17,7 @@ export * from './status'; /** * Custom DH components wrapping React Spectrum components. */ +export * from './listView'; export * from './picker'; export * from './Heading'; export * from './Text'; @@ -24,4 +26,6 @@ export * from './View'; /** * Custom DH spectrum utils */ +export * from './ItemContent'; +export * from './ItemTooltip'; export * from './utils'; diff --git a/packages/components/src/spectrum/listView/ListView.tsx b/packages/components/src/spectrum/listView/ListView.tsx new file mode 100644 index 0000000000..5b748518b4 --- /dev/null +++ b/packages/components/src/spectrum/listView/ListView.tsx @@ -0,0 +1,147 @@ +import { useMemo } from 'react'; +import cl from 'classnames'; +import { + ListView as SpectrumListView, + SpectrumListViewProps, +} from '@adobe/react-spectrum'; +import { EMPTY_FUNCTION } from '@deephaven/utils'; +import { + extractSpectrumHTMLElement, + useContentRect, + useOnScrollRef, +} from '@deephaven/react-hooks'; +import { Flex } from '../layout'; +import { + ItemElementOrPrimitive, + ItemKey, + ItemSelection, + 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: ItemSelection) => void; + + /** Handler that is called when the picker is scrolled. */ + onScroll?: (event: Event) => void; + + /** + * Handler that is called when the selection changes. + * @deprecated Use `onChange` instead + */ + onSelectionChange?: (keys: ItemSelection) => void; +} & Omit< + SpectrumListViewProps<NormalizedItem>, + | 'children' + | 'items' + | 'selectedKeys' + | 'defaultSelectedKeys' + | 'disabledKeys' + | 'onSelectionChange' +>; + +export function ListView({ + children, + tooltip = true, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + UNSAFE_className, + onChange, + onScroll = EMPTY_FUNCTION, + onSelectionChange, + ...spectrumListViewProps +}: ListViewProps): JSX.Element | null { + 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, + }); + + const scrollRef = useOnScrollRef(onScroll, extractSpectrumHTMLElement); + + // Spectrum ListView crashes when it has zero height. Track the contentRect + // of the parent container and only render the ListView when it has a non-zero + // height. See https://github.com/adobe/react-spectrum/issues/6213 + const { ref: contentRectRef, contentRect } = useContentRect( + extractSpectrumHTMLElement + ); + + return ( + <Flex + ref={contentRectRef} + direction="column" + flex={spectrumListViewProps.flex ?? 1} + minHeight={0} + UNSAFE_className={cl('dh-list-view', UNSAFE_className)} + > + {contentRect.height === 0 ? ( + // Use to ensure content has a non-zero height. This ensures the + // container will also have a non-zero height unless its height is + // explicitly set to zero. Example use case: + // 1. Tab containing ListView is visible. Container height is non-zero. + // ListView is rendered. + // 2. Tab is hidden. Container height is explicitly constrained to zero. + // ListView is not rendered. + // 3. Tab is shown again. Height constraint is removed. Resize observer + // fires and shows non-zero height due to the (without this, + // the height would remain zero forever since ListView hasn't rendered yet) + // 4. ListView is rendered again. + <> </> + ) : ( + <SpectrumListView + // eslint-disable-next-line react/jsx-props-no-spreading + {...spectrumListViewProps} + ref={scrollRef} + items={normalizedItems} + selectedKeys={selectedStringKeys} + defaultSelectedKeys={defaultSelectedStringKeys} + disabledKeys={disabledStringKeys} + onSelectionChange={onStringSelectionChange} + > + {renderNormalizedItem} + </SpectrumListView> + )} + </Flex> + ); +} + +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 e706ede9d8..d4bba67aa8 100644 --- a/packages/components/src/spectrum/picker/Picker.tsx +++ b/packages/components/src/spectrum/picker/Picker.tsx @@ -1,10 +1,9 @@ -import { Key, ReactNode, useCallback, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { DOMRef } from '@react-types/shared'; -import { Flex, Picker as SpectrumPicker } from '@adobe/react-spectrum'; +import { Picker as SpectrumPicker } from '@adobe/react-spectrum'; import { getPositionOfSelectedItem, findSpectrumPickerScrollArea, - isElementOfType, usePopoverOnScrollRef, } from '@deephaven/react-hooks'; import { @@ -13,7 +12,6 @@ import { PICKER_TOP_OFFSET, } from '@deephaven/utils'; import cl from 'classnames'; -import { Tooltip } from '../../popper'; import { isNormalizedSection, NormalizedSpectrumPickerProps, @@ -25,9 +23,8 @@ import { ItemKey, getItemKey, } from '../utils/itemUtils'; -import { PickerItemContent } from './PickerItemContent'; -import { Item, Section } from '../shared'; -import { Text } from '../Text'; +import { Section } from '../shared'; +import { useRenderNormalizedItem } from '../utils'; export type PickerProps = { children: ItemOrSection | ItemOrSection[] | NormalizedItem[]; @@ -69,26 +66,6 @@ export type PickerProps = { | 'defaultSelectedKey' >; -/** - * Create tooltip content optionally wrapping with a Flex column for array - * content. This is needed for Items containing description `Text` elements. - */ -function createTooltipContent(content: ReactNode) { - if (typeof content === 'boolean') { - return String(content); - } - - if (Array.isArray(content)) { - return ( - <Flex direction="column" alignItems="start"> - {content.filter(node => isElementOfType(node, Text))} - </Flex> - ); - } - - return content; -} - /** * 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, @@ -120,41 +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} - > - <> - <PickerItemContent>{content}</PickerItemContent> - {tooltipOptions == null || content === '' ? null : ( - <Tooltip options={tooltipOptions}> - {createTooltipContent(content)} - </Tooltip> - )} - </> - </Item> - ); - }, - [tooltipOptions] - ); + const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions); const getInitialScrollPositionInternal = useCallback( () => @@ -208,7 +151,6 @@ export function Picker({ <SpectrumPicker // eslint-disable-next-line react/jsx-props-no-spreading {...spectrumPickerProps} - // The `ref` prop type defined by React Spectrum is incorrect here ref={scrollRef as unknown as DOMRef<HTMLDivElement>} onOpenChange={onOpenChangeInternal} UNSAFE_className={cl('dh-picker', UNSAFE_className)} @@ -217,8 +159,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'] @@ -232,12 +178,12 @@ export function Picker({ title={itemOrSection.item?.title} items={itemOrSection.item?.items} > - {renderItem} + {renderNormalizedItem} </Section> ); } - return renderItem(itemOrSection); + return renderNormalizedItem(itemOrSection); }} </SpectrumPicker> ); diff --git a/packages/components/src/spectrum/picker/PickerItemContent.tsx b/packages/components/src/spectrum/picker/PickerItemContent.tsx deleted file mode 100644 index d680c56ed2..0000000000 --- a/packages/components/src/spectrum/picker/PickerItemContent.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Children, cloneElement, isValidElement, ReactNode } from 'react'; -import { Text } from '@adobe/react-spectrum'; -import cl from 'classnames'; -import { isElementOfType } from '@deephaven/react-hooks'; -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 | null { - if (isValidElement(content)) { - return content; - } - - /* eslint-disable no-param-reassign */ - if (content === '') { - // Prevent the item height from collapsing when the content is empty - content = '\xa0'; // Non-breaking space - } else if (typeof content === 'boolean') { - // Boolean values need to be stringified to render - content = String(content); - } else if (Array.isArray(content)) { - // For cases where there are multiple `Text` children, add a css class to - // handle overflow. The primary use case for multiple text nodes is when a - // description is provided for an item. e.g. - // <Item textValue="Some Text"> - // <SomeIcon /> - // <Text>Some Label</Text> - // <Text slot="description">Some Description</Text> - // </Item> - content = Children.map(content, (el, i) => - isElementOfType(el, Text) - ? cloneElement(el, { - ...el.props, - UNSAFE_className: cl( - el.props.UNSAFE_className, - stylesCommon.spectrumEllipsis - ), - }) - : el - ); - } - /* eslint-enable no-param-reassign */ - - return typeof content === 'string' || typeof content === 'number' ? ( - <Text UNSAFE_className={stylesCommon.spectrumEllipsis}>{content}</Text> - ) : ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <>{content}</> - ); -} - -export default PickerItemContent; diff --git a/packages/components/src/spectrum/picker/index.ts b/packages/components/src/spectrum/picker/index.ts index b666d3021b..c434d5d810 100644 --- a/packages/components/src/spectrum/picker/index.ts +++ b/packages/components/src/spectrum/picker/index.ts @@ -1,2 +1 @@ export * from './Picker'; -export * from './PickerItemContent'; 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/itemUtils.test.tsx b/packages/components/src/spectrum/utils/itemUtils.test.tsx index e2cdad44c7..0585cfb8c8 100644 --- a/packages/components/src/spectrum/utils/itemUtils.test.tsx +++ b/packages/components/src/spectrum/utils/itemUtils.test.tsx @@ -14,6 +14,7 @@ import { ItemElementOrPrimitive, ItemOrSection, SectionElement, + itemSelectionToStringSet, } from './itemUtils'; import type { PickerProps } from '../picker/Picker'; import { Item, Section } from '../shared'; @@ -256,6 +257,19 @@ describe('isNormalizedSection', () => { }); }); +describe('itemSelectionToStringSet', () => { + it.each([ + ['all', 'all'], + [new Set([1, 2, 3]), new Set(['1', '2', '3'])], + ] as const)( + `should return 'all' or stringify the keys`, + (given, expected) => { + const actual = itemSelectionToStringSet(given); + expect(actual).toEqual(expected); + } + ); +}); + describe('normalizeItemList', () => { it.each([children.empty, children.single, children.mixed])( 'should return normalized items: %#: %s', @@ -289,4 +303,9 @@ describe('normalizeTooltipOptions', () => { const actual = normalizeTooltipOptions(options); expect(actual).toEqual(expected); }); + + it('should allow overriding default placement', () => { + const actual = normalizeTooltipOptions(true, 'top'); + expect(actual).toEqual({ placement: 'top' }); + }); }); diff --git a/packages/components/src/spectrum/utils/itemUtils.ts b/packages/components/src/spectrum/utils/itemUtils.ts index 06f5e852dc..351d13f94d 100644 --- a/packages/components/src/spectrum/utils/itemUtils.ts +++ b/packages/components/src/spectrum/utils/itemUtils.ts @@ -2,7 +2,7 @@ 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'; -import { KeyedItem } from '@deephaven/utils'; +import { KeyedItem, SelectionT } from '@deephaven/utils'; import { Item, ItemProps, Section, SectionProps } from '../shared'; import { PopperOptions } from '../../popper'; @@ -33,6 +33,8 @@ export type ItemOrSection = ItemElementOrPrimitive | SectionElement; */ export type ItemKey = Key | boolean; +export type ItemSelection = SelectionT<ItemKey>; + /** * Augment the Spectrum selection change handler type to include boolean keys. * Spectrum components already supports this, but the built in types don't @@ -67,6 +69,9 @@ export type NormalizedSection = KeyedItem< Key | undefined >; +export type NormalizedItemOrSection<TItemOrSection extends ItemOrSection> = + TItemOrSection extends SectionElement ? NormalizedSection : NormalizedItem; + export type NormalizedSpectrumPickerProps = SpectrumPickerProps<NormalizedItem>; export type TooltipOptions = { placement: PopperOptions['placement'] }; @@ -114,14 +119,19 @@ export function isItemElement<T>( } /** - * Determine if a node is an array containing normalized items with keys. - * Note that this only checks the first node in the array. + * Determine if a node is an array containing normalized items or sections with + * keys. Note that this only checks the first node in the array. * @param node The node to check - * @returns True if the node is a normalized item with keys array + * @returns True if the node is a normalized item or section with keys array */ -export function isNormalizedItemsWithKeysList( - node: ItemOrSection | ItemOrSection[] | (NormalizedItem | NormalizedSection)[] -): node is (NormalizedItem | NormalizedSection)[] { +export function isNormalizedItemsWithKeysList< + TItemOrSection extends ItemOrSection, +>( + node: + | TItemOrSection + | TItemOrSection[] + | NormalizedItemOrSection<TItemOrSection>[] +): node is NormalizedItemOrSection<TItemOrSection>[] { if (!Array.isArray(node)) { return false; } @@ -225,9 +235,9 @@ function normalizeTextValue(item: ItemElementOrPrimitive): string | undefined { * @param itemOrSection item to normalize * @returns NormalizedItem or NormalizedSection object */ -function normalizeItem( - itemOrSection: ItemOrSection -): NormalizedItem | NormalizedSection { +function normalizeItem<TItemOrSection extends ItemOrSection>( + itemOrSection: TItemOrSection +): NormalizedItemOrSection<TItemOrSection> { if (!isItemOrSection(itemOrSection)) { log.debug(INVALID_ITEM_ERROR_MESSAGE, itemOrSection); throw new Error(INVALID_ITEM_ERROR_MESSAGE); @@ -244,7 +254,7 @@ function normalizeItem( return { item: { key, title, items }, - }; + } as NormalizedItemOrSection<TItemOrSection>; } const key = normalizeItemKey(itemOrSection); @@ -255,23 +265,23 @@ function normalizeItem( return { item: { key, content, textValue }, - }; + } as NormalizedItemOrSection<TItemOrSection>; } /** - * Get normalized items from an item or array of items. - * @param itemsOrSections An item or array of items - * @returns An array of normalized items + * Normalize an item or section or a list of items or sections. + * @param itemsOrSections An item or section or array of items or sections + * @returns An array of normalized items or sections */ -export function normalizeItemList( - itemsOrSections: ItemOrSection | ItemOrSection[] | NormalizedItem[] -): (NormalizedItem | NormalizedSection)[] { +export function normalizeItemList<TItemOrSection extends ItemOrSection>( + itemsOrSections: TItemOrSection | TItemOrSection[] | NormalizedItem[] +): NormalizedItemOrSection<TItemOrSection>[] { // If already normalized, just return as-is if (isNormalizedItemsWithKeysList(itemsOrSections)) { - return itemsOrSections; + return itemsOrSections as NormalizedItemOrSection<TItemOrSection>[]; } - const itemsArray = Array.isArray(itemsOrSections) + const itemsArray: TItemOrSection[] = Array.isArray(itemsOrSections) ? itemsOrSections : [itemsOrSections]; @@ -280,19 +290,37 @@ export function normalizeItemList( /** * Returns a TooltipOptions object or null if options is false or null. - * @param options + * @param options Tooltip options + * @param placement Default placement for the tooltip if `options` is set + * explicitly to `true` * @returns TooltipOptions or null */ export function normalizeTooltipOptions( - options?: boolean | TooltipOptions | null + options?: boolean | TooltipOptions | null, + placement: TooltipOptions['placement'] = 'right' ): TooltipOptions | null { if (options == null || options === false) { return null; } if (options === true) { - return { placement: 'right' }; + return { placement }; } return options; } + +/** + * Convert a selection of `ItemKey`s to a selection of strings. + * @param itemKeys The selection of `ItemKey`s + * @returns The selection of strings + */ +export function itemSelectionToStringSet( + itemKeys?: 'all' | Iterable<ItemKey> +): undefined | 'all' | Set<string> { + if (itemKeys == null || itemKeys === 'all') { + return itemKeys as undefined | 'all'; + } + + return new Set([...itemKeys].map(String)); +} diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx new file mode 100644 index 0000000000..993524b71a --- /dev/null +++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx @@ -0,0 +1,41 @@ +import React, { Key } from 'react'; +import { Item } from '@adobe/react-spectrum'; +import { renderHook } from '@testing-library/react-hooks'; +import ItemContent from '../ItemContent'; +import { useRenderNormalizedItem } from './useRenderNormalizedItem'; +import { getItemKey } from './itemUtils'; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe.each([null, { placement: 'top' }] as const)( + 'useRenderNormalizedItem: %s', + tooltipOptions => { + it.each([ + [{}, 'Empty', ''], + [{ item: { content: 'mock.content' } }, 'Empty', 'mock.content'], + [ + { item: { textValue: 'mock.textValue', content: 'mock.content' } }, + 'mock.textValue', + 'mock.content', + ], + ])( + 'should return a render function that can be used to render a normalized item in collection components.', + (normalizedItem, textValue, content) => { + const { result } = renderHook(() => + useRenderNormalizedItem(tooltipOptions) + ); + + const actual = result.current(normalizedItem); + + expect(actual).toEqual( + <Item key={getItemKey(normalizedItem) as Key} textValue={textValue}> + <ItemContent tooltipOptions={tooltipOptions}>{content}</ItemContent> + </Item> + ); + } + ); + } +); diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx new file mode 100644 index 0000000000..2904bbb558 --- /dev/null +++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx @@ -0,0 +1,45 @@ +import { Key, useCallback } from 'react'; +import { ItemContent } from '../ItemContent'; +import { Item } from '../shared'; +import { getItemKey, NormalizedItem, TooltipOptions } from './itemUtils'; + +/** + * Returns a render function that can be used to render a normalized item in + * collection components. + * @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 { + 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 components that use this hook. + 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; diff --git a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.test.ts b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.test.ts new file mode 100644 index 0000000000..a429d9c1e7 --- /dev/null +++ b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.test.ts @@ -0,0 +1,67 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { NormalizedItem } from './itemUtils'; +import { useStringifiedMultiSelection } from './useStringifiedMultiSelection'; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe('useStringifiedMultiSelection', () => { + const normalizedItems: NormalizedItem[] = [1, 2, 3, 4, 5, 6, 7, 8, 9].map( + i => ({ + key: i, + item: { key: i, content: `Item ${i}` }, + }) + ); + + const selectedKeys = [1, 2, 3]; + const defaultSelectedKeys = [4, 5, 6]; + const disabledKeys = [7, 8, 9]; + + const selectedStringKeys = new Set(['1', '2', '3']); + const defaultSelectedStringKeys = new Set(['4', '5', '6']); + const disabledStringKeys = new Set(['7', '8', '9']); + + it('should stringify selections', () => { + const { result } = renderHook(() => + useStringifiedMultiSelection({ + normalizedItems, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + }) + ); + + expect(result.current).toEqual({ + selectedStringKeys, + defaultSelectedStringKeys, + disabledStringKeys, + onStringSelectionChange: expect.any(Function), + }); + }); + + it.each([ + ['all', 'all'], + [new Set(['1', '2', '3']), new Set([1, 2, 3])], + ] as const)( + `should call onChange with 'all' or actual keys`, + (given, expected) => { + const onChange = jest.fn().mockName('onChange'); + + const { result } = renderHook(() => + useStringifiedMultiSelection({ + normalizedItems, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onChange, + }) + ); + + result.current.onStringSelectionChange(given); + + expect(onChange).toHaveBeenCalledWith(expected); + } + ); +}); diff --git a/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts new file mode 100644 index 0000000000..04ec670168 --- /dev/null +++ b/packages/components/src/spectrum/utils/useStringifiedMultiSelection.ts @@ -0,0 +1,100 @@ +import { Key, useCallback, useMemo } from 'react'; +import { + getItemKey, + ItemKey, + ItemSelection, + itemSelectionToStringSet, + NormalizedItem, +} from './itemUtils'; + +export interface UseStringifiedMultiSelectionOptions { + normalizedItems: NormalizedItem[]; + 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: ItemSelection) => void; +} + +export interface UseStringifiedMultiSelectionResult { + /** Stringified selection keys */ + selectedStringKeys?: 'all' | Set<Key>; + /** Stringified default selection keys */ + defaultSelectedStringKeys?: 'all' | Set<Key>; + /** Stringified disabled keys */ + disabledStringKeys?: 'all' | Set<Key>; + /** Handler that is called when the string key selections change */ + onStringSelectionChange: (keys: 'all' | Set<Key>) => 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( + () => itemSelectionToStringSet(selectedKeys), + [selectedKeys] + ); + + const defaultSelectedStringKeys = useMemo( + () => itemSelectionToStringSet(defaultSelectedKeys), + [defaultSelectedKeys] + ); + + const disabledStringKeys = useMemo( + () => itemSelectionToStringSet(disabledKeys), + [disabledKeys] + ); + + const onStringSelectionChange = useCallback( + (keys: 'all' | Set<Key>) => { + if (keys === 'all') { + onChange?.('all'); + return; + } + + const actualKeys = new Set<ItemKey>(); + + 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; diff --git a/packages/components/src/theme/index.ts b/packages/components/src/theme/index.ts index 523fd23ddd..f58f84d25b 100644 --- a/packages/components/src/theme/index.ts +++ b/packages/components/src/theme/index.ts @@ -7,3 +7,4 @@ export * from './ThemeUtils'; export * from './useTheme'; export * from './Logo'; export * from './colorUtils'; +export * from './useSpectrumThemeProvider'; diff --git a/packages/components/src/theme/useSpectrumThemeProvider.ts b/packages/components/src/theme/useSpectrumThemeProvider.ts new file mode 100644 index 0000000000..ee6bb35952 --- /dev/null +++ b/packages/components/src/theme/useSpectrumThemeProvider.ts @@ -0,0 +1,5 @@ +import { useProvider } from '@adobe/react-spectrum'; + +export const useSpectrumThemeProvider = useProvider; + +export default useSpectrumThemeProvider; diff --git a/packages/jsapi-components/src/spectrum/ListView.tsx b/packages/jsapi-components/src/spectrum/ListView.tsx new file mode 100644 index 0000000000..fd363c896a --- /dev/null +++ b/packages/jsapi-components/src/spectrum/ListView.tsx @@ -0,0 +1,66 @@ +import { + ListView as ListViewBase, + ListViewProps as ListViewPropsBase, + NormalizedItemData, + useSpectrumThemeProvider, +} 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 useFormatter from '../useFormatter'; +import useViewportData from '../useViewportData'; +import { useItemRowDeserializer } from './utils'; + +export interface ListViewProps extends Omit<ListViewPropsBase, 'children'> { + table: DhType.Table; + /* The column of values to use as item keys. Defaults to the first column. */ + keyColumn?: string; + /* The column of values to display as primary text. Defaults to the `keyColumn` value. */ + labelColumn?: string; + + // TODO #1890 : descriptionColumn, iconColumn + + settings?: Settings; +} + +export function ListView({ + table, + keyColumn: keyColumnName, + labelColumn: labelColumnName, + settings, + ...props +}: ListViewProps): JSX.Element { + const { scale } = useSpectrumThemeProvider(); + const itemHeight = LIST_VIEW_ROW_HEIGHTS[props.density ?? 'regular'][scale]; + + const { getFormattedString: formatValue } = useFormatter(settings); + + const deserializeRow = useItemRowDeserializer({ + table, + keyColumnName, + labelColumnName, + formatValue, + }); + + const { viewportData, onScroll } = useViewportData< + NormalizedItemData, + DhType.Table + >({ + reuseItemsOnTableResize: true, + table, + itemHeight, + deserializeRow, + }); + + return ( + <ListViewBase + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + onScroll={onScroll} + > + {viewportData.items} + </ListViewBase> + ); +} + +export default ListView; diff --git a/packages/jsapi-components/src/spectrum/index.ts b/packages/jsapi-components/src/spectrum/index.ts index c434d5d810..49fd5a7e25 100644 --- a/packages/jsapi-components/src/spectrum/index.ts +++ b/packages/jsapi-components/src/spectrum/index.ts @@ -1 +1,2 @@ +export * from './ListView'; export * from './Picker'; diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 1555908b88..a3d98b2498 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -3,6 +3,8 @@ export * from './SelectionUtils'; export * from './SpectrumUtils'; export * from './useAsyncInterval'; export * from './useCallbackWithAction'; +export * from './useCheckOverflow'; +export * from './useContentRect'; export { default as useContextOrThrow } from './useContextOrThrow'; export * from './useDebouncedCallback'; export * from './useDelay'; diff --git a/packages/react-hooks/src/useCheckOverflow.test.ts b/packages/react-hooks/src/useCheckOverflow.test.ts new file mode 100644 index 0000000000..9499e91889 --- /dev/null +++ b/packages/react-hooks/src/useCheckOverflow.test.ts @@ -0,0 +1,59 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import type { DOMRefValue } from '@react-types/shared'; +import { TestUtils } from '@deephaven/utils'; +import { useCheckOverflow } from './useCheckOverflow'; + +const { createMockProxy } = TestUtils; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe('useCheckOverflow', () => { + const isOverflowing = createMockProxy<HTMLDivElement>({ + scrollWidth: 101, + offsetWidth: 100, + }); + + const scrollWidthMatchesOffsetWidth = createMockProxy<HTMLDivElement>({ + scrollWidth: 100, + offsetWidth: 100, + }); + + const offsetWidthGreaterThanScrollWidth = createMockProxy<HTMLDivElement>({ + scrollWidth: 99, + offsetWidth: 100, + }); + + it.each([ + [null, false], + [isOverflowing, true], + [scrollWidthMatchesOffsetWidth, false], + [offsetWidthGreaterThanScrollWidth, false], + ])( + 'should check if a Spectrum `DOMRefValue` is overflowing: %s, %s', + (el, expected) => { + const { result } = renderHook(() => useCheckOverflow()); + + const elRef = + el == null + ? null + : createMockProxy<DOMRefValue<HTMLDivElement>>({ + UNSAFE_getDOMNode: () => createMockProxy<HTMLDivElement>(el), + }); + + act(() => { + result.current.checkOverflow(elRef); + }); + + expect(result.current.isOverflowing).toBe(expected); + + act(() => { + result.current.resetIsOverflowing(); + }); + + expect(result.current.isOverflowing).toBe(false); + } + ); +}); diff --git a/packages/react-hooks/src/useCheckOverflow.ts b/packages/react-hooks/src/useCheckOverflow.ts new file mode 100644 index 0000000000..9b33c3eb8e --- /dev/null +++ b/packages/react-hooks/src/useCheckOverflow.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from 'react'; +import type { DOMRefValue } from '@react-types/shared'; + +export interface CheckOverflowResult { + /** + * Callback to check if a Spectrum `DOMRefValue` is overflowing. If an + * overflowing value is passed, `isOverflowing` will be set to true. Note that + * calling again with a non-overflowing value will *not* reset the state. + * Instead `resetIsOverflowing` must be called explicitly. This is to allow + * multiple `DOMRefValue`s to be checked and `isOverflowing` to remain `true` + * if at least one of them is overflowing. + */ + checkOverflow: <T extends HTMLElement>(elRef: DOMRefValue<T> | null) => void; + + /** + * Will be set to true whenever `checkOverflow` is called with an overflowing + * `DOMRefValue`. It will remain `true` until `resetIsOverflowing` is called. + * Default state is `false`. + */ + isOverflowing: boolean; + + /** Reset `isOverflowing` to false */ + resetIsOverflowing: () => void; +} + +/** + * Provides a callback to check a Spectrum `DOMRefValue` for overflow. If + * overflow is detected, `isOverflowing` will be set to `true` until reset by + * calling `resetIsOverflowing`. + */ +export function useCheckOverflow(): CheckOverflowResult { + const [isOverflowing, setIsOverflowing] = useState(false); + + /** + * Check if a Spectrum `DOMRefValue` is overflowing. + */ + const checkOverflow = useCallback( + <T extends HTMLElement>(elRef: DOMRefValue<T> | null) => { + const el = elRef?.UNSAFE_getDOMNode(); + + if (el == null) { + return; + } + + if (el.scrollWidth > el.offsetWidth) { + setIsOverflowing(true); + } + }, + [] + ); + + /** Reset `isOverflowing` to false */ + const resetIsOverflowing = useCallback(() => { + setIsOverflowing(false); + }, []); + + return { + isOverflowing, + checkOverflow, + resetIsOverflowing, + }; +} + +export default useCheckOverflow; diff --git a/packages/react-hooks/src/useContentRect.test.ts b/packages/react-hooks/src/useContentRect.test.ts new file mode 100644 index 0000000000..42e248b822 --- /dev/null +++ b/packages/react-hooks/src/useContentRect.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestUtils } from '@deephaven/utils'; +import { useContentRect } from './useContentRect'; +import useResizeObserver from './useResizeObserver'; + +jest.mock('./useResizeObserver'); + +const { asMock, createMockProxy } = TestUtils; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); + asMock(useResizeObserver).mockName('useResizeObserver'); +}); + +describe.each([true, false])('useContentRect - explicitMap:%s', explicitMap => { + const mock = { + refValue: document.createElement('div'), + mappedValue: document.createElement('span'), + resizeEntry: createMockProxy<ResizeObserverEntry>({ + contentRect: new DOMRect(0, 0, 100, 100), + }), + observer: createMockProxy<ResizeObserver>(), + }; + + const mockMap = explicitMap ? jest.fn(() => mock.mappedValue) : undefined; + + it('should initially return zero size contentRect', () => { + const { result } = renderHook(() => useContentRect(mockMap)); + expect(useResizeObserver).toHaveBeenCalledWith(null, expect.any(Function)); + expect(result.current.contentRect).toEqual(new DOMRect()); + }); + + it('should pass expected value to resize observer based on presence of map function', () => { + const { result } = renderHook(() => useContentRect(mockMap)); + + act(() => { + result.current.ref(mock.refValue); + }); + + if (mockMap != null) { + expect(mockMap).toHaveBeenCalledWith(mock.refValue); + } + expect(useResizeObserver).toHaveBeenCalledWith( + mockMap == null ? mock.refValue : mock.mappedValue, + expect.any(Function) + ); + expect(result.current.contentRect).toEqual(new DOMRect()); + }); + + it.each([ + [[], new DOMRect()], + [[mock.resizeEntry], mock.resizeEntry.contentRect], + ])( + 'should update contentRect when resize observer triggers: %s', + (entries, expected) => { + const { result } = renderHook(() => useContentRect(mockMap)); + + act(() => { + result.current.ref(mock.refValue); + }); + + const handleResize = asMock(useResizeObserver).mock.calls.at(-1)?.[1]; + + act(() => { + handleResize?.(entries, mock.observer); + }); + + expect(result.current.contentRect).toEqual(expected); + } + ); +}); diff --git a/packages/react-hooks/src/useContentRect.ts b/packages/react-hooks/src/useContentRect.ts new file mode 100644 index 0000000000..3725cc9da1 --- /dev/null +++ b/packages/react-hooks/src/useContentRect.ts @@ -0,0 +1,62 @@ +import { identityExtractHTMLElement } from '@deephaven/utils'; +import { useCallback, useMemo, useState } from 'react'; +import useMappedRef from './useMappedRef'; +import useResizeObserver from './useResizeObserver'; + +export interface UseContentRectResult<T> { + contentRect: DOMRectReadOnly; + ref: (refValue: T) => void; +} + +/** + * Returns a callback ref that will track the `contentRect` of a given refValue. + * If the `contentRect` is undefined, it will be set to a new `DOMRect` with + * zeros for all dimensions. + * @param map Optional mapping function to extract an HTMLElement from the given + * refValue + * @returns Content rect and a ref callback + */ +export function useContentRect<T>( + map: (ref: T) => HTMLElement | null = identityExtractHTMLElement +): UseContentRectResult<T> { + const [x, setX] = useState<number>(0); + const [y, setY] = useState<number>(0); + const [width, setWidth] = useState<number>(0); + const [height, setHeight] = useState<number>(0); + + const contentRect = useMemo( + () => new DOMRect(x, y, width, height), + [height, width, x, y] + ); + + const [el, setEl] = useState<HTMLElement | null>(null); + + // Callback ref maps the passed in refValue and passes to `setEl` + const ref = useMappedRef(setEl, map); + + const handleResize = useCallback( + ([firstEntry]: ResizeObserverEntry[]): void => { + const rect = firstEntry?.contentRect ?? { + x: 0, + y: 0, + width: 0, + height: 0, + }; + + setX(rect.x); + setY(rect.y); + setWidth(rect.width); + setHeight(rect.height); + }, + [] + ); + + useResizeObserver(el, handleResize); + + return { + ref, + contentRect, + }; +} + +export default useContentRect; diff --git a/packages/utils/src/UIConstants.ts b/packages/utils/src/UIConstants.ts index 3be0c189ef..a7cf22fabf 100644 --- a/packages/utils/src/UIConstants.ts +++ b/packages/utils/src/UIConstants.ts @@ -2,7 +2,6 @@ export const ACTION_ICON_HEIGHT = 24; export const COMBO_BOX_ITEM_HEIGHT = 32; export const COMBO_BOX_TOP_OFFSET = 4; export const ITEM_KEY_PREFIX = 'DH_ITEM_KEY'; -export const LIST_VIEW_ROW_HEIGHT = 32; export const PICKER_ITEM_HEIGHT = 32; export const PICKER_TOP_OFFSET = 4; export const TABLE_ROW_HEIGHT = 33; @@ -12,3 +11,19 @@ export const VIEWPORT_SIZE = 500; export const VIEWPORT_PADDING = 250; export const SPELLCHECK_FALSE_ATTRIBUTE = { spellCheck: false } as const; + +// Copied from https://github.com/adobe/react-spectrum/blob/b2d25ef23b827ec2427bf47b343e6dbd66326ed3/packages/%40react-spectrum/list/src/ListView.tsx#L78 +export const LIST_VIEW_ROW_HEIGHTS = { + compact: { + medium: 32, + large: 40, + }, + regular: { + medium: 40, + large: 50, + }, + spacious: { + medium: 48, + large: 60, + }, +} as const; diff --git a/tests/styleguide.spec.ts b/tests/styleguide.spec.ts index 0d6cf06304..5c15fe69ee 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-list-views', 'sample-section-pickers', 'sample-section-tooltips', 'sample-section-icons', diff --git a/tests/styleguide.spec.ts-snapshots/list-views-chromium-linux.png b/tests/styleguide.spec.ts-snapshots/list-views-chromium-linux.png new file mode 100644 index 0000000000..020ed77f30 Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/list-views-chromium-linux.png differ diff --git a/tests/styleguide.spec.ts-snapshots/list-views-firefox-linux.png b/tests/styleguide.spec.ts-snapshots/list-views-firefox-linux.png new file mode 100644 index 0000000000..25e11db072 Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/list-views-firefox-linux.png differ diff --git a/tests/styleguide.spec.ts-snapshots/list-views-webkit-linux.png b/tests/styleguide.spec.ts-snapshots/list-views-webkit-linux.png new file mode 100644 index 0000000000..81be65cac8 Binary files /dev/null and b/tests/styleguide.spec.ts-snapshots/list-views-webkit-linux.png differ