Skip to content

Commit a69bd62

Browse files
committed
ActionMenu and ListActionMenu (#1913)
1 parent c2ce7bb commit a69bd62

6 files changed

Lines changed: 162 additions & 20 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useMemo } from 'react';
2+
import {
3+
ActionMenu as SpectrumActionMenu,
4+
SpectrumActionMenuProps,
5+
} from '@adobe/react-spectrum';
6+
import cl from 'classnames';
7+
import { ItemsOrPrimitiveChildren } from './shared';
8+
import { ItemKey, wrapItemChildren } from './utils';
9+
10+
export type ActionMenuProps<T> = Omit<
11+
SpectrumActionMenuProps<T>,
12+
'children' | 'disabledKeys'
13+
> & {
14+
disabledKeys?: Iterable<ItemKey>;
15+
children: ItemsOrPrimitiveChildren<T>;
16+
};
17+
18+
/**
19+
* Augmented version of the Spectrum ActionMenu component that supports
20+
* primitive item children.
21+
*/
22+
export function ActionMenu<T>({
23+
disabledKeys,
24+
children,
25+
UNSAFE_className,
26+
...props
27+
}: ActionMenuProps<T>): JSX.Element {
28+
const wrappedChildren = useMemo(
29+
() =>
30+
typeof children === 'function'
31+
? children
32+
: wrapItemChildren(children, null),
33+
[children]
34+
);
35+
36+
return (
37+
<SpectrumActionMenu
38+
// eslint-disable-next-line react/jsx-props-no-spreading
39+
{...props}
40+
UNSAFE_className={cl('dh-action-menu', UNSAFE_className)}
41+
disabledKeys={disabledKeys as SpectrumActionMenuProps<T>['disabledKeys']}
42+
>
43+
{wrappedChildren}
44+
</SpectrumActionMenu>
45+
);
46+
}
47+
48+
export default ActionMenu;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ActionMenuProps } from './ActionMenu';
2+
import { ItemKey } from './utils';
3+
4+
export interface ListActionMenuProps<T>
5+
extends Omit<ActionMenuProps<T>, 'onAction' | 'onOpenChange'> {
6+
/**
7+
* Handler that is called when an item is pressed.
8+
*/
9+
onAction: (actionKey: ItemKey, listItemKey: ItemKey) => void;
10+
11+
/**
12+
* Handler that is called when the the menu is opened or closed.
13+
*/
14+
onOpenChange?: (isOpen: boolean, listItemKey: ItemKey) => void;
15+
}
16+
17+
/**
18+
* This component doesn't actually render anything. It is a prop container that
19+
* gets passed to `NormalizedListView`. The actual `ActionMenu` elements will
20+
* be created from this component's props on each item in the list view.
21+
*/
22+
export function ListActionMenu<T>(_props: ListActionMenuProps<T>): null {
23+
return null;
24+
}

packages/components/src/spectrum/collections.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
export {
22
ActionBar,
33
type SpectrumActionBarProps as ActionBarProps,
4-
ActionMenu,
5-
type SpectrumActionMenuProps as ActionMenuProps,
64
MenuTrigger,
75
type SpectrumMenuTriggerProps as MenuTriggerProps,
86
TagGroup,

packages/components/src/spectrum/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ export * from './status';
1717
/**
1818
* Custom DH components wrapping React Spectrum components.
1919
*/
20+
export * from './ActionMenu';
2021
export * from './ActionGroup';
2122
export * from './ListActionGroup';
23+
export * from './ListActionMenu';
2224
export * from './listView';
2325
export * from './picker';
2426
export * from './Heading';

packages/components/src/spectrum/utils/useRenderNormalizedItem.test.tsx

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { getItemKey, NormalizedItem } from './itemUtils';
99
import { wrapIcon, wrapPrimitiveWithText } from './itemWrapperUtils';
1010
import { ListActionGroup } from '../ListActionGroup';
1111
import { ActionGroup } from '../ActionGroup';
12+
import { ListActionMenu } from '../ListActionMenu';
13+
import ActionMenu from '../ActionMenu';
1214

1315
jest.mock('./itemWrapperUtils');
1416

@@ -21,13 +23,44 @@ beforeEach(() => {
2123

2224
const onAction = jest.fn();
2325
const onChange = jest.fn();
26+
const onOpenChange = jest.fn();
2427

2528
const listActionGroup = (
2629
<ListActionGroup onAction={onAction} onChange={onChange}>
2730
<Item>Item 1</Item>
2831
</ListActionGroup>
2932
);
3033

34+
const listActionMenu = (
35+
<ListActionMenu onAction={onAction} onOpenChange={onOpenChange}>
36+
<Item>Item 1</Item>
37+
</ListActionMenu>
38+
);
39+
40+
const expectedActions = new Map([
41+
[undefined, null],
42+
[
43+
listActionGroup,
44+
// eslint-disable-next-line react/jsx-key
45+
<ActionGroup
46+
// eslint-disable-next-line react/jsx-props-no-spreading
47+
{...listActionGroup.props}
48+
onAction={expect.any(Function)}
49+
onChange={expect.any(Function)}
50+
/>,
51+
],
52+
[
53+
listActionMenu,
54+
// eslint-disable-next-line react/jsx-key
55+
<ActionMenu
56+
// eslint-disable-next-line react/jsx-props-no-spreading
57+
{...listActionMenu.props}
58+
onAction={expect.any(Function)}
59+
onOpenChange={expect.any(Function)}
60+
/>,
61+
],
62+
]);
63+
3164
describe.each([
3265
[true, true, null, undefined],
3366
[true, true, { placement: 'top' }, undefined],
@@ -46,12 +79,23 @@ describe.each([
4679
[false, true, { placement: 'top' }, listActionGroup],
4780
[false, false, null, listActionGroup],
4881
[false, false, { placement: 'top' }, listActionGroup],
82+
// ListActionMenu
83+
[true, true, null, listActionMenu],
84+
[true, true, { placement: 'top' }, listActionMenu],
85+
[true, false, null, listActionMenu],
86+
[true, false, { placement: 'top' }, listActionMenu],
87+
[false, true, null, listActionMenu],
88+
[false, true, { placement: 'top' }, listActionMenu],
89+
[false, false, null, listActionMenu],
90+
[false, false, { placement: 'top' }, listActionMenu],
4991
] as const)(
5092
'useRenderNormalizedItem: %s, %s, %s',
5193
(showItemIcons, showItemDescriptions, tooltipOptions, actions) => {
5294
beforeEach(() => {
5395
asMock(onAction).mockName('onAction');
5496
asMock(onChange).mockName('onChange');
97+
asMock(onOpenChange).mockName('onOpenChange');
98+
5599
asMock(wrapIcon).mockImplementation((a, b) => `wrapIcon(${a}, ${b})`);
56100
asMock(wrapPrimitiveWithText).mockImplementation(
57101
(a, b) => `wrapPrimitiveWithText(${a}, ${b})`
@@ -138,14 +182,7 @@ describe.each([
138182
{showItemIcons ? icon : null}
139183
{content}
140184
{showItemDescriptions ? description : null}
141-
{actions === listActionGroup ? (
142-
<ActionGroup
143-
// eslint-disable-next-line react/jsx-props-no-spreading
144-
{...listActionGroup.props}
145-
onAction={expect.any(Function)}
146-
onChange={expect.any(Function)}
147-
/>
148-
) : null}
185+
{expectedActions.get(actions)}
149186
</ItemContent>
150187
</Item>
151188
);
@@ -161,6 +198,17 @@ describe.each([
161198
const actionKeys = ['actionKey1', 'actionKey2'];
162199
actionGroup.props.onChange(actionKeys);
163200
expect(onChange).toHaveBeenCalledWith(actionKeys, itemKey);
201+
} else if (actions === listActionMenu) {
202+
const actionMenu = actual.props.children.props.children[3];
203+
expect(isElementOfType(actionMenu, ActionMenu)).toBe(true);
204+
205+
const actionKey = 'actionKey';
206+
actionMenu.props.onAction(actionKey);
207+
expect(onAction).toHaveBeenCalledWith(actionKey, itemKey);
208+
209+
const isOpen = true;
210+
actionMenu.props.onOpenChange(isOpen);
211+
expect(onOpenChange).toHaveBeenCalledWith(isOpen, itemKey);
164212
}
165213
}
166214
);

packages/components/src/spectrum/utils/useRenderNormalizedItem.tsx

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { isElementOfType } from '@deephaven/react-hooks';
12
import { Key, ReactElement, useCallback } from 'react';
23
import ActionGroup from '../ActionGroup';
4+
import ActionMenu from '../ActionMenu';
35
import { ItemContent } from '../ItemContent';
4-
import { ListActionGroupProps } from '../ListActionGroup';
6+
import { ListActionGroup, ListActionGroupProps } from '../ListActionGroup';
7+
import { ListActionMenu, ListActionMenuProps } from '../ListActionMenu';
58
import { Item } from '../shared';
69
import {
710
getItemKey,
@@ -12,7 +15,9 @@ import {
1215
} from './itemUtils';
1316
import { wrapIcon, wrapPrimitiveWithText } from './itemWrapperUtils';
1417

15-
export type ListActions<T> = ReactElement<ListActionGroupProps<T>>;
18+
export type ListActions<T> =
19+
| ReactElement<ListActionGroupProps<T>>
20+
| ReactElement<ListActionMenuProps<T>>;
1621

1722
export interface UseRenderNormalizedItemOptions {
1823
itemIconSlot: ItemIconSlot;
@@ -55,6 +60,30 @@ export function useRenderNormalizedItem({
5560
? wrapIcon(normalizedItem.item?.icon, itemIconSlot)
5661
: null;
5762

63+
let action = null;
64+
65+
if (isElementOfType(actions, ListActionGroup)) {
66+
action = (
67+
<ActionGroup
68+
// eslint-disable-next-line react/jsx-props-no-spreading
69+
{...actions.props}
70+
onAction={key => actions.props.onAction(key, itemKey)}
71+
onChange={keys => actions.props.onChange?.(keys, itemKey)}
72+
/>
73+
);
74+
} else if (isElementOfType(actions, ListActionMenu)) {
75+
action = (
76+
<ActionMenu
77+
// eslint-disable-next-line react/jsx-props-no-spreading
78+
{...actions.props}
79+
onAction={key => actions.props.onAction(key, itemKey)}
80+
onOpenChange={isOpen =>
81+
actions.props.onOpenChange?.(isOpen, itemKey)
82+
}
83+
/>
84+
);
85+
}
86+
5887
return (
5988
<Item
6089
// Note that setting the `key` prop explicitly on `Item` elements
@@ -77,14 +106,7 @@ export function useRenderNormalizedItem({
77106
{icon}
78107
{content}
79108
{description}
80-
{actions?.props == null ? null : (
81-
<ActionGroup
82-
// eslint-disable-next-line react/jsx-props-no-spreading
83-
{...actions.props}
84-
onAction={key => actions.props.onAction(key, itemKey)}
85-
onChange={keys => actions.props.onChange?.(keys, itemKey)}
86-
/>
87-
)}
109+
{action}
88110
</ItemContent>
89111
</Item>
90112
);

0 commit comments

Comments
 (0)