Skip to content

Commit e50f0f6

Browse files
authored
feat: Picker Component (#1821)
Supports deephaven/deephaven-plugins#292
1 parent 448f0f0 commit e50f0f6

13 files changed

Lines changed: 465 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
import { Picker } from '@deephaven/components';
3+
import { vsPerson } from '@deephaven/icons';
4+
import { Flex, Icon, Item, Text } from '@adobe/react-spectrum';
5+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
6+
import { sampleSectionIdAndClasses } from './utils';
7+
8+
function PersonIcon(): JSX.Element {
9+
return (
10+
<Icon>
11+
<FontAwesomeIcon icon={vsPerson} />
12+
</Icon>
13+
);
14+
}
15+
16+
export function Pickers(): JSX.Element {
17+
return (
18+
// eslint-disable-next-line react/jsx-props-no-spreading
19+
<div {...sampleSectionIdAndClasses('pickers')}>
20+
<h2 className="ui-title">Pickers</h2>
21+
22+
<Flex gap={14}>
23+
<Picker label="Single Child" tooltip={{ placement: 'bottom-end' }}>
24+
<Item>Aaa</Item>
25+
</Picker>
26+
27+
<Picker label="Mixed Children Types" tooltip>
28+
{/* eslint-disable react/jsx-curly-brace-presence */}
29+
{'String 1'}
30+
{'String 2'}
31+
{'String 3'}
32+
{''}
33+
{'Some really long text that should get truncated'}
34+
{/* eslint-enable react/jsx-curly-brace-presence */}
35+
{444}
36+
{999}
37+
{true}
38+
{false}
39+
<Item>Item Aaa</Item>
40+
<Item>Item Bbb</Item>
41+
<Item textValue="Complex Ccc">
42+
<PersonIcon />
43+
<Text>Complex Ccc</Text>
44+
</Item>
45+
</Picker>
46+
</Flex>
47+
</div>
48+
);
49+
}
50+
51+
export default Pickers;

packages/code-studio/src/styleguide/StyleGuide.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { HIDE_FROM_E2E_TESTS_CLASS } from './utils';
3030
import { GoldenLayout } from './GoldenLayout';
3131
import { RandomAreaPlotAnimation } from './RandomAreaPlotAnimation';
3232
import SpectrumComparison from './SpectrumComparison';
33+
import Pickers from './Pickers';
3334

3435
const stickyProps = {
3536
position: 'sticky',
@@ -111,6 +112,7 @@ function StyleGuide(): React.ReactElement {
111112
<ContextMenus />
112113
<DropdownMenus />
113114
<Navigations />
115+
<Pickers />
114116
<Tooltips />
115117
<Icons />
116118
<Editors />

packages/components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export { default as SelectValueList } from './SelectValueList';
4848
export * from './SelectValueList';
4949
export * from './shortcuts';
5050
export { default as SocketedButton } from './SocketedButton';
51+
export * from './spectrum';
5152
export * from './SpectrumUtils';
5253
export * from './TableViewEmptyState';
5354
export * from './TextWithTooltip';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './picker';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useMemo } from 'react';
2+
import { Item, Picker as SpectrumPicker } from '@adobe/react-spectrum';
3+
import { Tooltip } from '../../popper';
4+
import {
5+
NormalizedSpectrumPickerProps,
6+
normalizePickerItemList,
7+
normalizeTooltipOptions,
8+
PickerItem,
9+
PickerItemKey,
10+
TooltipOptions,
11+
} from './PickerUtils';
12+
import { PickerItemContent } from './PickerItemContent';
13+
14+
export type PickerProps = {
15+
children: PickerItem | PickerItem[];
16+
/** Can be set to true or a TooltipOptions to enable item tooltips */
17+
tooltip?: boolean | TooltipOptions;
18+
/** The currently selected key in the collection (controlled). */
19+
selectedKey?: PickerItemKey | null;
20+
/** The initial selected key in the collection (uncontrolled). */
21+
defaultSelectedKey?: PickerItemKey;
22+
/**
23+
* Handler that is called when the selection change.
24+
* Note that under the hood, this is just an alias for Spectrum's
25+
* `onSelectionChange`. We are renaming for better consistency with other
26+
* components.
27+
*/
28+
onChange?: (key: PickerItemKey) => void;
29+
/**
30+
* Handler that is called when the selection changes.
31+
* @deprecated Use `onChange` instead
32+
*/
33+
onSelectionChange?: (key: PickerItemKey) => void;
34+
} /*
35+
* Support remaining SpectrumPickerProps.
36+
* Note that `selectedKey`, `defaultSelectedKey`, and `onSelectionChange` are
37+
* re-defined above to account for boolean types which aren't included in the
38+
* React `Key` type, but are actually supported by the Spectrum Picker component.
39+
*/ & Omit<
40+
NormalizedSpectrumPickerProps,
41+
| 'children'
42+
| 'items'
43+
| 'onSelectionChange'
44+
| 'selectedKey'
45+
| 'defaultSelectedKey'
46+
>;
47+
48+
/**
49+
* Picker component for selecting items from a list of items. Items can be
50+
* provided via the `items` prop or as children. Each item can be a string,
51+
* number, boolean, or a Spectrum <Item> element. The remaining props are just
52+
* pass through props for the Spectrum Picker component.
53+
* See https://react-spectrum.adobe.com/react-spectrum/Picker.html
54+
*/
55+
export function Picker({
56+
children,
57+
tooltip,
58+
defaultSelectedKey,
59+
selectedKey,
60+
onChange,
61+
onSelectionChange,
62+
...spectrumPickerProps
63+
}: PickerProps): JSX.Element {
64+
const normalizedItems = useMemo(
65+
() => normalizePickerItemList(children),
66+
[children]
67+
);
68+
69+
const tooltipOptions = useMemo(
70+
() => normalizeTooltipOptions(tooltip),
71+
[tooltip]
72+
);
73+
74+
return (
75+
<SpectrumPicker
76+
// eslint-disable-next-line react/jsx-props-no-spreading
77+
{...spectrumPickerProps}
78+
items={normalizedItems}
79+
// Type assertions are necessary for `selectedKey`, `defaultSelectedKey`,
80+
// and `onSelectionChange` due to Spectrum types not accounting for
81+
// `boolean` keys
82+
selectedKey={selectedKey as NormalizedSpectrumPickerProps['selectedKey']}
83+
defaultSelectedKey={
84+
defaultSelectedKey as NormalizedSpectrumPickerProps['defaultSelectedKey']
85+
}
86+
// `onChange` is just an alias for `onSelectionChange`
87+
onSelectionChange={
88+
(onChange ??
89+
onSelectionChange) as NormalizedSpectrumPickerProps['onSelectionChange']
90+
}
91+
>
92+
{({ content, textValue }) => (
93+
<Item textValue={textValue === '' ? 'Empty' : textValue}>
94+
<PickerItemContent>{content}</PickerItemContent>
95+
{tooltipOptions == null || content === '' ? null : (
96+
<Tooltip options={tooltipOptions}>{content}</Tooltip>
97+
)}
98+
</Item>
99+
)}
100+
</SpectrumPicker>
101+
);
102+
}
103+
104+
export default Picker;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { isValidElement, ReactNode } from 'react';
2+
import { Text } from '@adobe/react-spectrum';
3+
import stylesCommon from '../../SpectrumComponent.module.scss';
4+
5+
export interface PickerItemContentProps {
6+
children: ReactNode;
7+
}
8+
9+
/**
10+
* Picker item content. Text content will be wrapped in a Spectrum Text
11+
* component with ellipsis overflow handling.
12+
*/
13+
export function PickerItemContent({
14+
children: content,
15+
}: PickerItemContentProps): JSX.Element {
16+
if (isValidElement(content)) {
17+
return content;
18+
}
19+
20+
if (content === '') {
21+
// Prevent the item height from collapsing when the content is empty
22+
// eslint-disable-next-line no-param-reassign
23+
content = <>&nbsp;</>;
24+
}
25+
26+
return (
27+
<Text UNSAFE_className={stylesCommon.spectrumEllipsis}>{content}</Text>
28+
);
29+
}
30+
31+
export default PickerItemContent;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React from 'react';
2+
import { Item, Text } from '@adobe/react-spectrum';
3+
import {
4+
NormalizedPickerItem,
5+
normalizeTooltipOptions,
6+
normalizePickerItemList,
7+
PickerItem,
8+
} from './PickerUtils';
9+
import type { PickerProps } from './Picker';
10+
11+
beforeEach(() => {
12+
expect.hasAssertions();
13+
});
14+
15+
/* eslint-disable react/jsx-key */
16+
const expectedNormalizations = new Map<PickerItem, NormalizedPickerItem>([
17+
[
18+
999,
19+
{
20+
content: '999',
21+
key: 999,
22+
textValue: '999',
23+
},
24+
],
25+
[
26+
true,
27+
{
28+
content: 'true',
29+
key: true,
30+
textValue: 'true',
31+
},
32+
],
33+
[
34+
false,
35+
{
36+
content: 'false',
37+
key: false,
38+
textValue: 'false',
39+
},
40+
],
41+
[
42+
'',
43+
{
44+
content: '',
45+
key: '',
46+
textValue: '',
47+
},
48+
],
49+
[
50+
'String',
51+
{
52+
content: 'String',
53+
key: 'String',
54+
textValue: 'String',
55+
},
56+
],
57+
[
58+
<Item>Single string child no textValue</Item>,
59+
{
60+
content: 'Single string child no textValue',
61+
key: 'Single string child no textValue',
62+
textValue: 'Single string child no textValue',
63+
},
64+
],
65+
[
66+
<Item>
67+
<span>No textValue</span>
68+
</Item>,
69+
{
70+
content: <span>No textValue</span>,
71+
key: '',
72+
textValue: '',
73+
},
74+
],
75+
[
76+
<Item textValue="textValue">Single string</Item>,
77+
{
78+
content: 'Single string',
79+
key: 'Single string',
80+
textValue: 'textValue',
81+
},
82+
],
83+
[
84+
<Item key="explicit.key" textValue="textValue">
85+
Explicit key
86+
</Item>,
87+
{
88+
content: 'Explicit key',
89+
key: 'explicit.key',
90+
textValue: 'textValue',
91+
},
92+
],
93+
[
94+
<Item textValue="textValue">
95+
<i>i</i>
96+
<Text>Complex</Text>
97+
</Item>,
98+
{
99+
content: [<i>i</i>, <Text>Complex</Text>],
100+
key: 'textValue',
101+
textValue: 'textValue',
102+
},
103+
],
104+
]);
105+
/* eslint-enable react/jsx-key */
106+
107+
const mixedItems = [...expectedNormalizations.keys()];
108+
109+
const children = {
110+
empty: [] as PickerProps['children'],
111+
single: mixedItems[0] as PickerProps['children'],
112+
mixed: mixedItems as PickerProps['children'],
113+
};
114+
115+
describe('normalizePickerItemList', () => {
116+
it.each([children.empty, children.single, children.mixed])(
117+
'should return normalized picker items: %s',
118+
given => {
119+
const childrenArray = Array.isArray(given) ? given : [given];
120+
121+
const expected = childrenArray.map(item =>
122+
expectedNormalizations.get(item)
123+
);
124+
125+
const actual = normalizePickerItemList(given);
126+
expect(actual).toEqual(expected);
127+
}
128+
);
129+
});
130+
131+
describe('normalizeTooltipOptions', () => {
132+
it.each([
133+
[undefined, null],
134+
[null, null],
135+
[false, null],
136+
[true, { placement: 'top-start' }],
137+
[{ placement: 'bottom-end' }, { placement: 'bottom-end' }],
138+
] as const)('should return: %s', (options, expected) => {
139+
const actual = normalizeTooltipOptions(options);
140+
expect(actual).toEqual(expected);
141+
});
142+
});

0 commit comments

Comments
 (0)