diff --git a/packages/code-studio/src/styleguide/Pickers.tsx b/packages/code-studio/src/styleguide/Pickers.tsx index 89adb62356..f6a035a6fc 100644 --- a/packages/code-studio/src/styleguide/Pickers.tsx +++ b/packages/code-studio/src/styleguide/Pickers.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { cloneElement, useCallback, useState } from 'react'; import { Flex, Item, @@ -6,14 +6,51 @@ import { ItemKey, Section, Text, + PickerNormalized, } from '@deephaven/components'; import { vsPerson } from '@deephaven/icons'; import { Icon } from '@adobe/react-spectrum'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { generateNormalizedItems, sampleSectionIdAndClasses } from './utils'; +import { getPositionOfSelectedItem } from '@deephaven/react-hooks'; +import { PICKER_ITEM_HEIGHTS, PICKER_TOP_OFFSET } from '@deephaven/utils'; +import { + generateItemElements, + generateNormalizedItems, + sampleSectionIdAndClasses, +} from './utils'; // Generate enough items to require scrolling const items = [...generateNormalizedItems(52)]; +const itemElementsA = [...generateItemElements(0, 51)]; +const itemElementsB = [...generateItemElements(52, 103)]; +const itemElementsC = [...generateItemElements(104, 155)]; +const itemElementsD = [...generateItemElements(156, 207)]; +const itemElementsE = [...generateItemElements(208, 259)]; + +const mixedItemsWithIconsNoDescriptions = [ + 'String 1', + 'String 2', + 'String 3', + '', + 'Some really long text that should get truncated', + 444, + 999, + true, + false, + ...itemElementsA.map((itemEl, i) => + i % 5 > 0 + ? itemEl + : cloneElement(itemEl, { + ...itemEl.props, + children: [ + , + + {itemEl.props.children} + , + ], + }) + ), +]; function PersonIcon(): JSX.Element { return ( @@ -26,6 +63,17 @@ function PersonIcon(): JSX.Element { export function Pickers(): JSX.Element { const [selectedKey, setSelectedKey] = useState(null); + const getInitialScrollPosition = useCallback( + async () => + getPositionOfSelectedItem({ + keyedItems: items, + itemHeight: PICKER_ITEM_HEIGHTS.medium, + selectedKey, + topOffset: PICKER_TOP_OFFSET, + }), + [selectedKey] + ); + const onChange = useCallback((key: ItemKey): void => { setSelectedKey(key); }, []); @@ -37,27 +85,11 @@ export function Pickers(): JSX.Element { - Aaa + Aaa - - {/* 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 - + + {mixedItemsWithIconsNoDescriptions} @@ -65,41 +97,46 @@ export function Pickers(): JSX.Element { {'String 1'} {'String 2'} {'String 3'} -
- Item Aaa - Item Bbb +
+ Item Aaa + Item Bbb Complex Ccc
- Item Ddd - Item Eee + Item Ddd + Item Eee Complex Fff - + Label Description - + Label that causes overflow Description that causes overflow
+
{itemElementsA}
+
{itemElementsB}
+
{itemElementsC}
+
{itemElementsD}
+
{itemElementsE}
- - {items} - + /> ); diff --git a/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap b/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap index a9964ea190..6ddf7d2bc4 100644 --- a/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap +++ b/packages/code-studio/src/styleguide/__snapshots__/utils.test.ts.snap @@ -6,6 +6,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "AAA", "key": 100, + "textValue": "AAA", }, "key": "A", }, @@ -13,6 +14,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "BBB", "key": 200, + "textValue": "BBB", }, "key": "B", }, @@ -20,6 +22,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "CCC", "key": 300, + "textValue": "CCC", }, "key": "C", }, @@ -27,6 +30,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "DDD", "key": 400, + "textValue": "DDD", }, "key": "D", }, @@ -34,6 +38,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "EEE", "key": 500, + "textValue": "EEE", }, "key": "E", }, @@ -41,6 +46,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "FFF", "key": 600, + "textValue": "FFF", }, "key": "F", }, @@ -48,6 +54,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "GGG", "key": 700, + "textValue": "GGG", }, "key": "G", }, @@ -55,6 +62,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "HHH", "key": 800, + "textValue": "HHH", }, "key": "H", }, @@ -62,6 +70,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "III", "key": 900, + "textValue": "III", }, "key": "I", }, @@ -69,6 +78,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "JJJ", "key": 1000, + "textValue": "JJJ", }, "key": "J", }, @@ -76,6 +86,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "KKK", "key": 1100, + "textValue": "KKK", }, "key": "K", }, @@ -83,6 +94,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "LLL", "key": 1200, + "textValue": "LLL", }, "key": "L", }, @@ -90,6 +102,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "MMM", "key": 1300, + "textValue": "MMM", }, "key": "M", }, @@ -97,6 +110,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "NNN", "key": 1400, + "textValue": "NNN", }, "key": "N", }, @@ -104,6 +118,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "OOO", "key": 1500, + "textValue": "OOO", }, "key": "O", }, @@ -111,6 +126,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "PPP", "key": 1600, + "textValue": "PPP", }, "key": "P", }, @@ -118,6 +134,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "QQQ", "key": 1700, + "textValue": "QQQ", }, "key": "Q", }, @@ -125,6 +142,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "RRR", "key": 1800, + "textValue": "RRR", }, "key": "R", }, @@ -132,6 +150,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "SSS", "key": 1900, + "textValue": "SSS", }, "key": "S", }, @@ -139,6 +158,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "TTT", "key": 2000, + "textValue": "TTT", }, "key": "T", }, @@ -146,6 +166,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "UUU", "key": 2100, + "textValue": "UUU", }, "key": "U", }, @@ -153,6 +174,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "VVV", "key": 2200, + "textValue": "VVV", }, "key": "V", }, @@ -160,6 +182,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "WWW", "key": 2300, + "textValue": "WWW", }, "key": "W", }, @@ -167,6 +190,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "XXX", "key": 2400, + "textValue": "XXX", }, "key": "X", }, @@ -174,6 +198,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "YYY", "key": 2500, + "textValue": "YYY", }, "key": "Y", }, @@ -181,6 +206,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ZZZ", "key": 2600, + "textValue": "ZZZ", }, "key": "Z", }, @@ -188,6 +214,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "aaa", "key": 2700, + "textValue": "aaa", }, "key": "a", }, @@ -195,6 +222,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "bbb", "key": 2800, + "textValue": "bbb", }, "key": "b", }, @@ -202,6 +230,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ccc", "key": 2900, + "textValue": "ccc", }, "key": "c", }, @@ -209,6 +238,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ddd", "key": 3000, + "textValue": "ddd", }, "key": "d", }, @@ -216,6 +246,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "eee", "key": 3100, + "textValue": "eee", }, "key": "e", }, @@ -223,6 +254,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "fff", "key": 3200, + "textValue": "fff", }, "key": "f", }, @@ -230,6 +262,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ggg", "key": 3300, + "textValue": "ggg", }, "key": "g", }, @@ -237,6 +270,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "hhh", "key": 3400, + "textValue": "hhh", }, "key": "h", }, @@ -244,6 +278,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "iii", "key": 3500, + "textValue": "iii", }, "key": "i", }, @@ -251,6 +286,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "jjj", "key": 3600, + "textValue": "jjj", }, "key": "j", }, @@ -258,6 +294,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "kkk", "key": 3700, + "textValue": "kkk", }, "key": "k", }, @@ -265,6 +302,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "lll", "key": 3800, + "textValue": "lll", }, "key": "l", }, @@ -272,6 +310,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "mmm", "key": 3900, + "textValue": "mmm", }, "key": "m", }, @@ -279,6 +318,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "nnn", "key": 4000, + "textValue": "nnn", }, "key": "n", }, @@ -286,6 +326,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ooo", "key": 4100, + "textValue": "ooo", }, "key": "o", }, @@ -293,6 +334,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ppp", "key": 4200, + "textValue": "ppp", }, "key": "p", }, @@ -300,6 +342,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "qqq", "key": 4300, + "textValue": "qqq", }, "key": "q", }, @@ -307,6 +350,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "rrr", "key": 4400, + "textValue": "rrr", }, "key": "r", }, @@ -314,6 +358,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "sss", "key": 4500, + "textValue": "sss", }, "key": "s", }, @@ -321,6 +366,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ttt", "key": 4600, + "textValue": "ttt", }, "key": "t", }, @@ -328,6 +374,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "uuu", "key": 4700, + "textValue": "uuu", }, "key": "u", }, @@ -335,6 +382,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "vvv", "key": 4800, + "textValue": "vvv", }, "key": "v", }, @@ -342,6 +390,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "www", "key": 4900, + "textValue": "www", }, "key": "w", }, @@ -349,6 +398,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "xxx", "key": 5000, + "textValue": "xxx", }, "key": "x", }, @@ -356,6 +406,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "yyy", "key": 5100, + "textValue": "yyy", }, "key": "y", }, @@ -363,6 +414,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "zzz", "key": 5200, + "textValue": "zzz", }, "key": "z", }, @@ -370,6 +422,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "AAA1", "key": 5300, + "textValue": "AAA1", }, "key": "A1", }, @@ -377,6 +430,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "BBB1", "key": 5400, + "textValue": "BBB1", }, "key": "B1", }, @@ -384,6 +438,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "CCC1", "key": 5500, + "textValue": "CCC1", }, "key": "C1", }, @@ -391,6 +446,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "DDD1", "key": 5600, + "textValue": "DDD1", }, "key": "D1", }, @@ -398,6 +454,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "EEE1", "key": 5700, + "textValue": "EEE1", }, "key": "E1", }, @@ -405,6 +462,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "FFF1", "key": 5800, + "textValue": "FFF1", }, "key": "F1", }, @@ -412,6 +470,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "GGG1", "key": 5900, + "textValue": "GGG1", }, "key": "G1", }, @@ -419,6 +478,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "HHH1", "key": 6000, + "textValue": "HHH1", }, "key": "H1", }, @@ -426,6 +486,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "III1", "key": 6100, + "textValue": "III1", }, "key": "I1", }, @@ -433,6 +494,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "JJJ1", "key": 6200, + "textValue": "JJJ1", }, "key": "J1", }, @@ -440,6 +502,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "KKK1", "key": 6300, + "textValue": "KKK1", }, "key": "K1", }, @@ -447,6 +510,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "LLL1", "key": 6400, + "textValue": "LLL1", }, "key": "L1", }, @@ -454,6 +518,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "MMM1", "key": 6500, + "textValue": "MMM1", }, "key": "M1", }, @@ -461,6 +526,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "NNN1", "key": 6600, + "textValue": "NNN1", }, "key": "N1", }, @@ -468,6 +534,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "OOO1", "key": 6700, + "textValue": "OOO1", }, "key": "O1", }, @@ -475,6 +542,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "PPP1", "key": 6800, + "textValue": "PPP1", }, "key": "P1", }, @@ -482,6 +550,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "QQQ1", "key": 6900, + "textValue": "QQQ1", }, "key": "Q1", }, @@ -489,6 +558,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "RRR1", "key": 7000, + "textValue": "RRR1", }, "key": "R1", }, @@ -496,6 +566,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "SSS1", "key": 7100, + "textValue": "SSS1", }, "key": "S1", }, @@ -503,6 +574,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "TTT1", "key": 7200, + "textValue": "TTT1", }, "key": "T1", }, @@ -510,6 +582,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "UUU1", "key": 7300, + "textValue": "UUU1", }, "key": "U1", }, @@ -517,6 +590,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "VVV1", "key": 7400, + "textValue": "VVV1", }, "key": "V1", }, @@ -524,6 +598,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "WWW1", "key": 7500, + "textValue": "WWW1", }, "key": "W1", }, @@ -531,6 +606,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "XXX1", "key": 7600, + "textValue": "XXX1", }, "key": "X1", }, @@ -538,6 +614,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "YYY1", "key": 7700, + "textValue": "YYY1", }, "key": "Y1", }, @@ -545,6 +622,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ZZZ1", "key": 7800, + "textValue": "ZZZ1", }, "key": "Z1", }, @@ -552,6 +630,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "aaa1", "key": 7900, + "textValue": "aaa1", }, "key": "a1", }, @@ -559,6 +638,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "bbb1", "key": 8000, + "textValue": "bbb1", }, "key": "b1", }, @@ -566,6 +646,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ccc1", "key": 8100, + "textValue": "ccc1", }, "key": "c1", }, @@ -573,6 +654,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ddd1", "key": 8200, + "textValue": "ddd1", }, "key": "d1", }, @@ -580,6 +662,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "eee1", "key": 8300, + "textValue": "eee1", }, "key": "e1", }, @@ -587,6 +670,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "fff1", "key": 8400, + "textValue": "fff1", }, "key": "f1", }, @@ -594,6 +678,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ggg1", "key": 8500, + "textValue": "ggg1", }, "key": "g1", }, @@ -601,6 +686,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "hhh1", "key": 8600, + "textValue": "hhh1", }, "key": "h1", }, @@ -608,6 +694,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "iii1", "key": 8700, + "textValue": "iii1", }, "key": "i1", }, @@ -615,6 +702,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "jjj1", "key": 8800, + "textValue": "jjj1", }, "key": "j1", }, @@ -622,6 +710,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "kkk1", "key": 8900, + "textValue": "kkk1", }, "key": "k1", }, @@ -629,6 +718,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "lll1", "key": 9000, + "textValue": "lll1", }, "key": "l1", }, @@ -636,6 +726,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "mmm1", "key": 9100, + "textValue": "mmm1", }, "key": "m1", }, @@ -643,6 +734,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "nnn1", "key": 9200, + "textValue": "nnn1", }, "key": "n1", }, @@ -650,6 +742,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ooo1", "key": 9300, + "textValue": "ooo1", }, "key": "o1", }, @@ -657,6 +750,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ppp1", "key": 9400, + "textValue": "ppp1", }, "key": "p1", }, @@ -664,6 +758,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "qqq1", "key": 9500, + "textValue": "qqq1", }, "key": "q1", }, @@ -671,6 +766,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "rrr1", "key": 9600, + "textValue": "rrr1", }, "key": "r1", }, @@ -678,6 +774,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "sss1", "key": 9700, + "textValue": "sss1", }, "key": "s1", }, @@ -685,6 +782,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "ttt1", "key": 9800, + "textValue": "ttt1", }, "key": "t1", }, @@ -692,6 +790,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "uuu1", "key": 9900, + "textValue": "uuu1", }, "key": "u1", }, @@ -699,6 +798,7 @@ exports[`generateNormalizedItems should generate normalized items 1`] = ` "item": { "content": "vvv1", "key": 10000, + "textValue": "vvv1", }, "key": "v1", }, diff --git a/packages/code-studio/src/styleguide/utils.ts b/packages/code-studio/src/styleguide/utils.ts index 7e7f9087e3..e3a778d764 100644 --- a/packages/code-studio/src/styleguide/utils.ts +++ b/packages/code-studio/src/styleguide/utils.ts @@ -1,10 +1,39 @@ import cl from 'classnames'; -import { useCallback, useState } from 'react'; -import { NormalizedItem } from '@deephaven/components'; +import { createElement, useCallback, useState } from 'react'; +import { Item, ItemElement, 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 `Item` elements. + */ +export function* generateItemElements( + start: number, + end: number +): Generator { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const len = letters.length; + + for (let i = start; i <= end; 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}`; + const content = `${letter.repeat(3)}${suffix}`; + + // eslint-disable-next-line react/no-children-prop + yield createElement(Item, { + key, + textValue: content, + children: content, + }); + } +} + /** * Generate a given number of NormalizedItems. * @param count The number of items to generate @@ -23,12 +52,14 @@ export function* generateNormalizedItems( } const letter = letters[charI]; const key = `${letter}${suffix}`; + const content = `${letter.repeat(3)}${suffix}`; yield { key, item: { key: (i + 1) * 100, - content: `${letter}${letter}${letter}${suffix}`, + content, + textValue: content, }, }; } diff --git a/packages/components/src/spectrum/ItemContent.tsx b/packages/components/src/spectrum/ItemContent.tsx index 1526d3ed64..f11e6c9638 100644 --- a/packages/components/src/spectrum/ItemContent.tsx +++ b/packages/components/src/spectrum/ItemContent.tsx @@ -7,6 +7,7 @@ import { } from 'react'; import cl from 'classnames'; import { isElementOfType, useCheckOverflow } from '@deephaven/react-hooks'; +import { NON_BREAKING_SPACE } from '@deephaven/utils'; import { Text } from './Text'; import { TooltipOptions } from './utils'; import ItemTooltip from './ItemTooltip'; @@ -46,7 +47,7 @@ export function ItemContent({ /* eslint-disable no-param-reassign */ if (content === '') { // Prevent the item height from collapsing when the content is empty - content = '\xa0'; // Non-breaking space + content = NON_BREAKING_SPACE; } else if (typeof content === 'boolean') { // Boolean values need to be stringified to render content = String(content); diff --git a/packages/components/src/spectrum/picker/Picker.tsx b/packages/components/src/spectrum/picker/Picker.tsx index d4bba67aa8..f9fcf64263 100644 --- a/packages/components/src/spectrum/picker/Picker.tsx +++ b/packages/components/src/spectrum/picker/Picker.tsx @@ -1,41 +1,38 @@ -import { useCallback, useMemo } from 'react'; -import { DOMRef } from '@react-types/shared'; +import { useCallback, useMemo, useState } from 'react'; +import type { DOMRef } from '@react-types/shared'; import { Picker as SpectrumPicker } from '@adobe/react-spectrum'; -import { - getPositionOfSelectedItem, - findSpectrumPickerScrollArea, - usePopoverOnScrollRef, -} from '@deephaven/react-hooks'; +import cl from 'classnames'; import { EMPTY_FUNCTION, - PICKER_ITEM_HEIGHT, + PICKER_ITEM_HEIGHTS, PICKER_TOP_OFFSET, } from '@deephaven/utils'; -import cl from 'classnames'; import { - isNormalizedSection, NormalizedSpectrumPickerProps, - normalizeItemList, - normalizeTooltipOptions, - NormalizedItem, ItemOrSection, - TooltipOptions, + getPositionOfSelectedItemElement, ItemKey, - getItemKey, + normalizeTooltipOptions, + TooltipOptions, + isItemElementWithDescription, + isSectionElement, } from '../utils/itemUtils'; -import { Section } from '../shared'; -import { useRenderNormalizedItem } from '../utils'; +import { wrapItemChildren } from '../utils/itemWrapperUtils'; +import usePickerScrollOnOpen from './usePickerScrollOnOpen'; +import { useSpectrumThemeProvider } from '../../theme'; export type PickerProps = { - children: ItemOrSection | ItemOrSection[] | NormalizedItem[]; + children: ItemOrSection | ItemOrSection[]; + /** Can be set to true or a TooltipOptions to enable item tooltips */ tooltip?: boolean | TooltipOptions; + /** The currently selected key in the collection (controlled). */ selectedKey?: ItemKey | null; + /** The initial selected key in the collection (uncontrolled). */ defaultSelectedKey?: ItemKey; - /** Function to retrieve initial scroll position when opening the picker */ - getInitialScrollPosition?: () => Promise; + /** * Handler that is called when the selection change. * Note that under the hood, this is just an alias for Spectrum's @@ -68,9 +65,9 @@ export type PickerProps = { /** * 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, - * number, boolean, or a Spectrum element. The remaining props are just - * pass through props for the Spectrum Picker component. + * provided via the `children` prop. Each item can be a string, number, boolean, + * or a Spectrum element. The remaining props are just pass through props + * for the Spectrum Picker component. * See https://react-spectrum.adobe.com/react-spectrum/Picker.html */ export function Picker({ @@ -78,7 +75,6 @@ export function Picker({ tooltip = true, defaultSelectedKey, selectedKey, - getInitialScrollPosition, onChange, onOpenChange, onScroll = EMPTY_FUNCTION, @@ -87,104 +83,90 @@ export function Picker({ UNSAFE_className, ...spectrumPickerProps }: PickerProps): JSX.Element { - const normalizedItems = useMemo( - () => normalizeItemList(children), - [children] - ); + const { scale } = useSpectrumThemeProvider(); + const itemHeight = PICKER_ITEM_HEIGHTS[scale]; const tooltipOptions = useMemo( () => normalizeTooltipOptions(tooltip), [tooltip] ); - const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions); + // `null` is a valid value for `selectedKey` in controlled mode, so we check + // for explicit `undefined` to identify uncontrolled mode. + const isUncontrolled = selectedKey === undefined; + const [uncontrolledSelectedKey, setUncontrolledSelectedKey] = + useState(defaultSelectedKey); - const getInitialScrollPositionInternal = useCallback( - () => - getInitialScrollPosition == null - ? getPositionOfSelectedItem({ - keyedItems: normalizedItems, - // TODO: #1890 & deephaven-plugins#371 add support for sections and - // items with descriptions since they impact the height calculations - itemHeight: PICKER_ITEM_HEIGHT, - selectedKey, - topOffset: PICKER_TOP_OFFSET, - }) - : getInitialScrollPosition(), - [getInitialScrollPosition, normalizedItems, selectedKey] + const wrappedItems = useMemo( + () => wrapItemChildren(children, tooltipOptions), + [children, tooltipOptions] ); - const { ref: scrollRef, onOpenChange: popoverOnOpenChange } = - usePopoverOnScrollRef( - findSpectrumPickerScrollArea, - onScroll, - getInitialScrollPositionInternal - ); - - const onOpenChangeInternal = useCallback( - (isOpen: boolean): void => { - // Attach scroll event handling - popoverOnOpenChange(isOpen); + // Item descriptions and Section elements introduce variable item heights. + // This throws off scroll position calculations, so we disable auto scrolling + // if either of these are found. + const disableScrollOnOpen = useMemo( + () => + wrappedItems.some( + item => isSectionElement(item) || isItemElementWithDescription(item) + ), + [wrappedItems] + ); - onOpenChange?.(isOpen); - }, - [onOpenChange, popoverOnOpenChange] + const getInitialScrollPosition = useCallback( + async () => + disableScrollOnOpen + ? null + : getPositionOfSelectedItemElement({ + items: wrappedItems, + itemHeight, + selectedKey: isUncontrolled ? uncontrolledSelectedKey : selectedKey, + topOffset: PICKER_TOP_OFFSET, + }), + [ + disableScrollOnOpen, + isUncontrolled, + itemHeight, + selectedKey, + uncontrolledSelectedKey, + wrappedItems, + ] ); const onSelectionChangeInternal = useCallback( (key: ItemKey): void => { - // The `key` arg will always be a string due to us setting the `Item` key - // prop in `renderItem`. We need to find the matching item to determine - // the actual key. - const selectedItem = normalizedItems.find( - item => String(getItemKey(item)) === key - ); - - const actualKey = getItemKey(selectedItem) ?? key; + // If our component is uncontrolled, track the selected key internally + // so that we can scroll to the selected item if the user re-opens + if (isUncontrolled) { + setUncontrolledSelectedKey(key); + } - (onChange ?? onSelectionChange)?.(actualKey); + (onChange ?? onSelectionChange)?.(key); }, - [normalizedItems, onChange, onSelectionChange] + [isUncontrolled, onChange, onSelectionChange] ); + const { ref: scrollRef, onOpenChange: onOpenChangeInternal } = + usePickerScrollOnOpen({ + getInitialScrollPosition, + onScroll, + onOpenChange, + }); + return ( } - onOpenChange={onOpenChangeInternal} + ref={scrollRef as DOMRef} UNSAFE_className={cl('dh-picker', UNSAFE_className)} - items={normalizedItems} - // Spectrum Picker treats keys as strings if the `key` prop is explicitly - // 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 == null ? selectedKey : selectedKey.toString()} + selectedKey={selectedKey as NormalizedSpectrumPickerProps['selectedKey']} defaultSelectedKey={ - defaultSelectedKey == null - ? defaultSelectedKey - : defaultSelectedKey.toString() - } - // `onChange` is just an alias for `onSelectionChange` - onSelectionChange={ - onSelectionChangeInternal as NormalizedSpectrumPickerProps['onSelectionChange'] + defaultSelectedKey as NormalizedSpectrumPickerProps['defaultSelectedKey'] } + onSelectionChange={onSelectionChangeInternal} + onOpenChange={onOpenChangeInternal} > - {itemOrSection => { - if (isNormalizedSection(itemOrSection)) { - return ( -
- {renderNormalizedItem} -
- ); - } - - return renderNormalizedItem(itemOrSection); - }} + {wrappedItems}
); } diff --git a/packages/components/src/spectrum/picker/PickerNormalized.tsx b/packages/components/src/spectrum/picker/PickerNormalized.tsx new file mode 100644 index 0000000000..9c1d17bf5f --- /dev/null +++ b/packages/components/src/spectrum/picker/PickerNormalized.tsx @@ -0,0 +1,112 @@ +import { useMemo } from 'react'; +import { Picker as SpectrumPicker } from '@adobe/react-spectrum'; +import type { DOMRef } from '@react-types/shared'; +import cl from 'classnames'; +import { EMPTY_FUNCTION } from '@deephaven/utils'; +import { Section } from '../shared'; +import type { PickerProps as PickerBaseProps } from './Picker'; + +import { + getItemKey, + isNormalizedSection, + NormalizedItem, + NormalizedSection, + normalizeTooltipOptions, + useRenderNormalizedItem, + useStringifiedSelection, +} from '../utils'; +import usePickerScrollOnOpen from './usePickerScrollOnOpen'; + +export interface PickerNormalizedProps + extends Omit { + normalizedItems: (NormalizedItem | NormalizedSection)[]; + getInitialScrollPosition?: () => Promise; + onScroll?: (event: Event) => void; +} + +/** + * Picker that takes an array of `NormalizedItem` or `NormalizedSection` items + * as children and uses a render item function to render the items. This is + * necessary to support windowed data. + */ +export function PickerNormalized({ + normalizedItems, + tooltip = true, + selectedKey, + defaultSelectedKey, + disabledKeys, + UNSAFE_className, + getInitialScrollPosition, + onChange, + onOpenChange, + onScroll = EMPTY_FUNCTION, + onSelectionChange, + ...props +}: PickerNormalizedProps): JSX.Element { + const tooltipOptions = useMemo( + () => normalizeTooltipOptions(tooltip), + [tooltip] + ); + + const renderNormalizedItem = useRenderNormalizedItem(tooltipOptions); + + const { ref: scrollRef, onOpenChange: onOpenChangeInternal } = + usePickerScrollOnOpen({ + getInitialScrollPosition, + onScroll, + onOpenChange, + }); + + // Spectrum Picker treats keys as strings if the `key` prop is explicitly + // set on `Item` elements. Since we do this in `renderItem`, we need to + // map original key types to and from strings so that selection works. + const { + selectedStringKey, + defaultSelectedStringKey, + disabledStringKeys, + onStringSelectionChange, + } = useStringifiedSelection({ + normalizedItems, + selectedKey, + defaultSelectedKey, + disabledKeys, + onChange: onChange ?? onSelectionChange, + }); + + return ( + } + UNSAFE_className={cl( + 'dh-picker', + 'dh-picker-normalized', + UNSAFE_className + )} + items={normalizedItems} + selectedKey={selectedStringKey} + defaultSelectedKey={defaultSelectedStringKey} + disabledKeys={disabledStringKeys} + onSelectionChange={onStringSelectionChange} + onOpenChange={onOpenChangeInternal} + > + {itemOrSection => { + if (isNormalizedSection(itemOrSection)) { + return ( +
+ {renderNormalizedItem} +
+ ); + } + + return renderNormalizedItem(itemOrSection); + }} +
+ ); +} + +export default PickerNormalized; diff --git a/packages/components/src/spectrum/picker/index.ts b/packages/components/src/spectrum/picker/index.ts index c434d5d810..2ce75f0446 100644 --- a/packages/components/src/spectrum/picker/index.ts +++ b/packages/components/src/spectrum/picker/index.ts @@ -1 +1,3 @@ export * from './Picker'; +export * from './PickerNormalized'; +export * from './usePickerScrollOnOpen'; diff --git a/packages/components/src/spectrum/picker/usePickerScrollOnOpen.test.ts b/packages/components/src/spectrum/picker/usePickerScrollOnOpen.test.ts new file mode 100644 index 0000000000..fa829ce5d2 --- /dev/null +++ b/packages/components/src/spectrum/picker/usePickerScrollOnOpen.test.ts @@ -0,0 +1,71 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { TestUtils } from '@deephaven/utils'; +import { + findSpectrumPickerScrollArea, + usePopoverOnScrollRef, + UsePopoverOnScrollRefResult, +} from '@deephaven/react-hooks'; +import { usePickerScrollOnOpen } from './usePickerScrollOnOpen'; + +const { asMock, createMockProxy } = TestUtils; + +jest.mock('@deephaven/react-hooks'); + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); +}); + +describe('usePickerScrollOnOpen', () => { + const getInitialScrollPosition = jest + .fn() + .mockName('getInitialScrollPosition'); + const onScroll = jest.fn().mockName('onScroll'); + const onOpenChange = jest.fn().mockName('onOpenChange'); + + const mockUsePopoverOnScrollRefResult = + createMockProxy>(); + + beforeEach(() => { + asMock(usePopoverOnScrollRef) + .mockName('usePopoverOnScrollRef') + .mockReturnValue(mockUsePopoverOnScrollRefResult); + }); + + it('should return ref from usePopoverOnScrollRef', () => { + const { result } = renderHook(() => + usePickerScrollOnOpen({ + getInitialScrollPosition, + onScroll, + onOpenChange, + }) + ); + + expect(usePopoverOnScrollRef).toHaveBeenCalledWith( + findSpectrumPickerScrollArea, + onScroll, + getInitialScrollPosition + ); + expect(result.current.ref).toBe(mockUsePopoverOnScrollRefResult.ref); + }); + + it.each([true, false])( + 'should return a callback that calls popoverOnOpenChange and onOpenChange: %s', + isOpen => { + const { result } = renderHook(() => + usePickerScrollOnOpen({ + getInitialScrollPosition, + onScroll, + onOpenChange, + }) + ); + + result.current.onOpenChange(isOpen); + + expect(mockUsePopoverOnScrollRefResult.onOpenChange).toHaveBeenCalledWith( + isOpen + ); + expect(onOpenChange).toHaveBeenCalledWith(isOpen); + } + ); +}); diff --git a/packages/components/src/spectrum/picker/usePickerScrollOnOpen.ts b/packages/components/src/spectrum/picker/usePickerScrollOnOpen.ts new file mode 100644 index 0000000000..d9ad3114f4 --- /dev/null +++ b/packages/components/src/spectrum/picker/usePickerScrollOnOpen.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import type { DOMRef } from '@react-types/shared'; +import { + findSpectrumPickerScrollArea, + usePopoverOnScrollRef, +} from '@deephaven/react-hooks'; + +export interface UsePickerScrollOnOpenOptions { + getInitialScrollPosition?: () => Promise; + onScroll: (event: Event) => void; + onOpenChange?: (isOpen: boolean) => void; +} + +export interface UsePickerScrollOnOpenResult { + ref: DOMRef; + onOpenChange: (isOpen: boolean) => void; +} + +/** + * Handle scroll event registration and scrolling to initial scroll position + * whenever a Picker popover is opened. + * @param getInitialScrollPosition Function to get the initial scroll position. + * @param onScroll Callback for scroll events. + * @param onOpenChange Callback for open change events. + * @return A ref to attach to the Picker and a callback to handle open change + * events for the Picker. + */ +export function usePickerScrollOnOpen({ + getInitialScrollPosition, + onScroll, + onOpenChange, +}: UsePickerScrollOnOpenOptions): UsePickerScrollOnOpenResult { + const { ref, onOpenChange: popoverOnOpenChange } = usePopoverOnScrollRef( + findSpectrumPickerScrollArea, + onScroll, + getInitialScrollPosition + ); + + const onOpenChangeInternal = useCallback( + (isOpen: boolean): void => { + // Attach scroll event handling + popoverOnOpenChange(isOpen); + + onOpenChange?.(isOpen); + }, + [onOpenChange, popoverOnOpenChange] + ); + + return { + ref, + onOpenChange: onOpenChangeInternal, + }; +} + +export default usePickerScrollOnOpen; diff --git a/packages/components/src/spectrum/utils/index.ts b/packages/components/src/spectrum/utils/index.ts index ef406aba98..e70285dc86 100644 --- a/packages/components/src/spectrum/utils/index.ts +++ b/packages/components/src/spectrum/utils/index.ts @@ -1,4 +1,6 @@ export * from './itemUtils'; +export * from './itemWrapperUtils'; export * from './themeUtils'; export * from './useRenderNormalizedItem'; export * from './useStringifiedMultiSelection'; +export * from './useStringifiedSelection'; diff --git a/packages/components/src/spectrum/utils/itemUtils.test.tsx b/packages/components/src/spectrum/utils/itemUtils.test.tsx index 0585cfb8c8..ad4a985496 100644 --- a/packages/components/src/spectrum/utils/itemUtils.test.tsx +++ b/packages/components/src/spectrum/utils/itemUtils.test.tsx @@ -15,10 +15,13 @@ import { ItemOrSection, SectionElement, itemSelectionToStringSet, + getPositionOfSelectedItemElement, + isItemElementWithDescription, } from './itemUtils'; import type { PickerProps } from '../picker/Picker'; import { Item, Section } from '../shared'; import { Text } from '../Text'; +import ItemContent from '../ItemContent'; beforeEach(() => { expect.hasAssertions(); @@ -76,7 +79,10 @@ const expectedItems = { No textValue
, { - item: { content: No textValue }, + item: { + content: No textValue, + textValue: undefined, + }, }, ], explicitKey: [ @@ -171,6 +177,111 @@ describe('getItemKey', () => { ); }); +describe('getPositionOfSelectedItemElement', () => { + const items = [ + A, + B, + C, + ]; + const itemHeight = 40; + const topOffset = 5; + + it.each([null, undefined])( + 'should return top offset if selectedKey is not defined: %s', + async selectedKey => { + const actual = await getPositionOfSelectedItemElement({ + items, + itemHeight, + selectedKey, + topOffset, + }); + + expect(actual).toEqual(topOffset); + } + ); + + it('should return top offset if selectedKey is not found', async () => { + const selectedKey = '4'; + + const actual = await getPositionOfSelectedItemElement({ + items, + itemHeight, + selectedKey, + topOffset, + }); + + expect(actual).toEqual(topOffset); + }); + + it.each(['1', '2', '3'])( + 'should return the position of the selected item element: %s', + async selectedKey => { + const expected = (Number(selectedKey) - 1) * itemHeight + topOffset; + + const actual = await getPositionOfSelectedItemElement({ + items, + itemHeight, + selectedKey, + topOffset, + }); + + expect(actual).toEqual(expected); + } + ); +}); + +describe('isItemElementWithDescription', () => { + it.each([ + [ + 'Item with description', + true, + + Label + Description + , + ], + [ + 'ItemContent with description', + true, + + + Label + Description + + , + ], + [ + 'Section with Item description', + false, +
+ + Label + Description + +
, + ], + [ + 'Item no description', + false, + + Label + , + ], + [ + 'ItemContent no description', + false, + + + Label + + , + ], + ])(`%s should return %s`, (_label, expected, node) => { + const actual = isItemElementWithDescription(node); + expect(actual).toEqual(expected); + }); +}); + describe('isNormalizedItemsWithKeysList', () => { const mock = { normalizedItemWithKey: { diff --git a/packages/components/src/spectrum/utils/itemUtils.ts b/packages/components/src/spectrum/utils/itemUtils.ts index 351d13f94d..a103ea03fe 100644 --- a/packages/components/src/spectrum/utils/itemUtils.ts +++ b/packages/components/src/spectrum/utils/itemUtils.ts @@ -1,13 +1,22 @@ -import { isValidElement, Key, ReactElement, ReactNode } from 'react'; +import { Key, ReactElement, ReactNode } from 'react'; import { SpectrumPickerProps } from '@adobe/react-spectrum'; import type { ItemRenderer } from '@react-types/shared'; import Log from '@deephaven/log'; +import { isElementOfType } from '@deephaven/react-hooks'; import { KeyedItem, SelectionT } from '@deephaven/utils'; import { Item, ItemProps, Section, SectionProps } from '../shared'; import { PopperOptions } from '../../popper'; +import { Text } from '../Text'; +import ItemContent from '../ItemContent'; const log = Log.module('itemUtils'); +/** + * `Item.textValue` prop needs to be a non-empty string for accessibility + * purposes. This is not displayed in the UI. + */ +export const ITEM_EMPTY_STRING_TEXT_VALUE = 'Empty'; + export const INVALID_ITEM_ERROR_MESSAGE = 'Items must be strings, numbers, booleans, or
elements:'; @@ -20,7 +29,7 @@ type SectionPropsNoItemRenderer = Omit, 'children'> & { children: Exclude['children'], ItemRenderer>; }; -type ItemElement = ReactElement>; +export type ItemElement = ReactElement>; export type SectionElement = ReactElement>; export type ItemElementOrPrimitive = number | string | boolean | ItemElement; @@ -45,7 +54,7 @@ export type ItemSelectionChangeHandler = (key: ItemKey) => void; export interface NormalizedItemData { key?: ItemKey; content: ReactNode; - textValue?: string; + textValue: string | undefined; } export interface NormalizedSectionData { @@ -96,6 +105,45 @@ export function getItemKey< return (item?.item?.key ?? item?.key) as TKey; } +/** + * Get the position of the item with the given selected key in a list of items. + * @param items The items to search + * @param itemHeight The height of each item + * @param selectedKey The key of the selected item + * @param topOffset The top offset of the list + * @returns The position of the selected item or the top offset if not found + */ +export async function getPositionOfSelectedItemElement< + TKey extends string | number | boolean | undefined, +>({ + items, + itemHeight, + selectedKey, + topOffset, +}: { + items: ItemElement[]; + selectedKey: TKey | null | undefined; + itemHeight: number; + topOffset: number; +}): Promise { + let position = topOffset; + + if (selectedKey == null) { + return position; + } + + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + if (item.key === selectedKey) { + return position; + } + + position += itemHeight; + } + + return topOffset; +} + /** * Determine if a node is a Section element. * @param node The node to check @@ -104,7 +152,7 @@ export function getItemKey< export function isSectionElement( node: ReactNode ): node is ReactElement> { - return isValidElement>(node) && node.type === Section; + return isElementOfType(node, Section); } /** @@ -115,7 +163,34 @@ export function isSectionElement( export function isItemElement( node: ReactNode ): node is ReactElement> { - return isValidElement>(node) && node.type === Item; + return isElementOfType(node, Item); +} + +/** + * Determine if a node is an Item element containing a child `Text` element with + * a `slot` prop set to `description`. + * @param node The node to check + * @returns True if the node is an Item element with a description + */ +export function isItemElementWithDescription( + node: ReactNode +): node is ReactElement> { + if (!isItemElement(node)) { + return false; + } + + // If children are wrapped in `ItemContent`, go down 1 level + const children = isElementOfType(node.props.children, ItemContent) + ? node.props.children.props.children + : node.props.children; + + const childrenArray = Array.isArray(children) ? children : [children]; + + const result = childrenArray.some( + child => child.props?.slot === 'description' && isElementOfType(child, Text) + ); + + return result; } /** @@ -315,12 +390,17 @@ export function normalizeTooltipOptions( * @param itemKeys The selection of `ItemKey`s * @returns The selection of strings */ -export function itemSelectionToStringSet( - itemKeys?: 'all' | Iterable -): undefined | 'all' | Set { +export function itemSelectionToStringSet< + TKeys extends 'all' | Iterable | undefined, + TResult extends TKeys extends 'all' + ? 'all' + : TKeys extends Iterable + ? Set + : undefined, +>(itemKeys: TKeys): TResult { if (itemKeys == null || itemKeys === 'all') { - return itemKeys as undefined | 'all'; + return itemKeys as undefined | 'all' as TResult; } - return new Set([...itemKeys].map(String)); + return new Set([...itemKeys].map(String)) as TResult; } diff --git a/packages/components/src/spectrum/utils/itemWrapperUtils.test.tsx b/packages/components/src/spectrum/utils/itemWrapperUtils.test.tsx new file mode 100644 index 0000000000..7551074fd1 --- /dev/null +++ b/packages/components/src/spectrum/utils/itemWrapperUtils.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { ItemContent } from '../ItemContent'; +import { Item } from '../shared'; +import { ITEM_EMPTY_STRING_TEXT_VALUE } from './itemUtils'; +import { wrapItemChildren } from './itemWrapperUtils'; + +describe.each([null, { placement: 'top' }] as const)( + 'wrapItemChildren: %s', + tooltipOptions => { + it('should wrap primitives with Item elements', () => { + const given = [ + 'Item 1', + 2, + 'Item 3', + '', + // eslint-disable-next-line react/jsx-key + Item 4, + + Item 5 + , + + Item 6 + , + ]; + + const expected = [ + + Item 1 + , + + 2 + , + + Item 3 + , + + + {/* eslint-disable react/jsx-curly-brace-presence */} + {''} + + , + + Item 4 + , + + Item 5 + , + + Item 6 + , + ]; + + const actual = wrapItemChildren(given, tooltipOptions); + + expect(actual).toEqual(expected); + }); + } +); diff --git a/packages/components/src/spectrum/utils/itemWrapperUtils.tsx b/packages/components/src/spectrum/utils/itemWrapperUtils.tsx new file mode 100644 index 0000000000..47d8ae75bf --- /dev/null +++ b/packages/components/src/spectrum/utils/itemWrapperUtils.tsx @@ -0,0 +1,91 @@ +import { cloneElement, ReactElement } from 'react'; +import { Item } from '@adobe/react-spectrum'; +import { isElementOfType } from '@deephaven/react-hooks'; +import { + isItemElement, + isSectionElement, + ItemElement, + ItemOrSection, + ITEM_EMPTY_STRING_TEXT_VALUE, + SectionElement, + TooltipOptions, +} from './itemUtils'; +import { ItemProps } from '../shared'; +import ItemContent from '../ItemContent'; + +/** + * Ensure all primitive children are wrapped in `Item` elements and that all + * `Item` element content is wrapped in `ItemContent` elements to handle text + * overflow consistently and to support tooltips. + * @param itemsOrSections The items or sections to wrap + * @param tooltipOptions The tooltip options to use when wrapping items + * @returns The wrapped items or sections + */ +export function wrapItemChildren( + itemsOrSections: ItemOrSection | ItemOrSection[], + tooltipOptions: TooltipOptions | null +): (ItemElement | SectionElement)[] { + const itemsOrSectionsArray = Array.isArray(itemsOrSections) + ? itemsOrSections + : [itemsOrSections]; + + return itemsOrSectionsArray.map(item => { + if (isItemElement(item)) { + // Item content is already wrapped + if (isElementOfType(item.props.children, ItemContent)) { + return item; + } + + const key = item.key ?? item.props.textValue; + const textValue = + item.props.textValue === '' + ? ITEM_EMPTY_STRING_TEXT_VALUE + : item.props.textValue; + + // Wrap in `ItemContent` so we can support tooltips and handle text + // overflow + return cloneElement(item, { + ...item.props, + key, + textValue, + children: ( + + {item.props.children} + + ), + }); + } + + if (isSectionElement(item)) { + return cloneElement(item, { + ...item.props, + key: + item.key ?? + (typeof item.props.title === 'string' ? item.props.title : undefined), + children: wrapItemChildren( + item.props.children, + tooltipOptions + ) as ReactElement>[], + }); + } + + if ( + typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean' + ) { + const text = String(item); + const textValue = text === '' ? ITEM_EMPTY_STRING_TEXT_VALUE : text; + + return ( + + {text} + + ); + } + + return item; + }); +} + +export default wrapItemChildren; diff --git a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx index 2904bbb558..1f199653d7 100644 --- a/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx +++ b/packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx @@ -1,7 +1,12 @@ import { Key, useCallback } from 'react'; import { ItemContent } from '../ItemContent'; import { Item } from '../shared'; -import { getItemKey, NormalizedItem, TooltipOptions } from './itemUtils'; +import { + getItemKey, + ITEM_EMPTY_STRING_TEXT_VALUE, + NormalizedItem, + TooltipOptions, +} from './itemUtils'; /** * Returns a render function that can be used to render a normalized item in @@ -31,8 +36,10 @@ export function useRenderNormalizedItem( // The `textValue` prop gets used to provide the content of `