Skip to content

Commit d23513a

Browse files
initial implementation for ActionMenu (#2103)
* initial implementation for ActionMenu * use filterDOMProps and add missing onAction * implement l10n * use CSF syntax for storybook * include labelable properties * add more stories to check label and id properties * add doc * added story to test more properties * added copyright header * adding tests * fixed copyright * added missing documentation * renamed l10n-key * added check for aria-label * added test for custom aria label * added tests covering isDisabled, autoFocus and onAction * added stories to represent almost all properties Co-authored-by: Robert Snow <[email protected]>
1 parent d978363 commit d23513a

File tree

8 files changed

+336
-1
lines changed

8 files changed

+336
-1
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"moreActions": "Weitere Aktionen"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"moreActions": "More actions"
3+
}

packages/@react-spectrum/menu/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
"dependencies": {
3434
"@babel/runtime": "^7.6.2",
3535
"@react-aria/focus": "^3.4.0",
36+
"@react-aria/i18n": "^3.3.1",
3637
"@react-aria/interactions": "^3.5.0",
3738
"@react-aria/menu": "^3.2.2",
3839
"@react-aria/overlays": "^3.7.0",
3940
"@react-aria/selection": "^3.5.0",
4041
"@react-aria/separator": "^3.1.2",
4142
"@react-aria/utils": "^3.8.1",
4243
"@react-aria/virtualizer": "^3.3.3",
44+
"@react-spectrum/button": "^3.5.0",
4345
"@react-spectrum/checkbox": "^3.2.3",
4446
"@react-spectrum/divider": "^3.1.2",
4547
"@react-spectrum/layout": "^3.2.0",
@@ -53,7 +55,8 @@
5355
"@react-types/menu": "^3.2.0",
5456
"@react-types/overlays": "^3.5.0",
5557
"@react-types/shared": "^3.7.0",
56-
"@spectrum-icons/ui": "^3.2.0"
58+
"@spectrum-icons/ui": "^3.2.0",
59+
"@spectrum-icons/workflow": "^3.2.0"
5760
},
5861
"devDependencies": {
5962
"@adobe/spectrum-css-temp": "3.0.0-alpha.1"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {ActionButton} from '@react-spectrum/button';
14+
import {filterDOMProps} from '@react-aria/utils';
15+
import {FocusableRef} from '@react-types/shared';
16+
// @ts-ignore
17+
import intlMessages from '../intl/*.json';
18+
import {Menu} from './Menu';
19+
import {MenuTrigger} from './MenuTrigger';
20+
import More from '@spectrum-icons/workflow/More';
21+
import React from 'react';
22+
import {SpectrumActionMenuProps} from '@react-types/menu';
23+
import {useMessageFormatter} from '@react-aria/i18n';
24+
25+
function ActionMenu<T extends object>(props: SpectrumActionMenuProps<T>, ref: FocusableRef<HTMLButtonElement>) {
26+
let formatMessage = useMessageFormatter(intlMessages);
27+
let buttonProps = filterDOMProps(props, {labelable: true});
28+
if (buttonProps['aria-label'] === undefined) {
29+
buttonProps['aria-label'] = formatMessage('moreActions');
30+
}
31+
32+
return (
33+
<MenuTrigger
34+
align={props.align}
35+
direction={props.direction}
36+
shouldFlip={props.shouldFlip}>
37+
<ActionButton
38+
ref={ref}
39+
{...buttonProps}
40+
isDisabled={props.isDisabled}
41+
isQuiet={props.isQuiet}
42+
autoFocus={props.autoFocus}>
43+
<More />
44+
</ActionButton>
45+
<Menu
46+
children={props.children}
47+
items={props.items}
48+
disabledKeys={props.disabledKeys}
49+
onAction={props.onAction} />
50+
</MenuTrigger>
51+
);
52+
}
53+
54+
/**
55+
* Convenience component to display an ActionButton with a Menu.
56+
*/
57+
let _ActionMenu = React.forwardRef(ActionMenu);
58+
export {_ActionMenu as ActionMenu};

packages/@react-spectrum/menu/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414

1515
export * from './MenuTrigger';
1616
export * from './Menu';
17+
export * from './ActionMenu';
1718
export {Item, Section} from '@react-stately/collections';
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {action} from '@storybook/addon-actions';
14+
import {ActionMenu} from '..';
15+
import {Alignment} from '@react-types/shared';
16+
import {Flex} from '../../layout';
17+
import {Item} from '../';
18+
import {Meta, Story} from '@storybook/react';
19+
import {Picker} from '../../picker';
20+
import React, {useState} from 'react';
21+
import {SpectrumActionMenuProps} from '@react-types/menu';
22+
23+
const meta: Meta<SpectrumActionMenuProps<object>> = {
24+
title: 'ActionMenu',
25+
component: ActionMenu
26+
};
27+
28+
export default meta;
29+
30+
const Template = <T extends object>(): Story<SpectrumActionMenuProps<T>> => (args) => (
31+
<ActionMenu onAction={action('action')} {...args}>
32+
<Item key="one">One</Item>
33+
<Item key="two">Two</Item>
34+
<Item key="three">Three</Item>
35+
</ActionMenu>
36+
);
37+
38+
type Direction = 'bottom' | 'top' | 'left' | 'right' | 'start' | 'end';
39+
const directionItems = [
40+
{
41+
key: 'bottom',
42+
label: 'Bottom'
43+
},
44+
{
45+
key: 'top',
46+
label: 'Top'
47+
},
48+
{
49+
key: 'left',
50+
label: 'Left'
51+
},
52+
{
53+
key: 'right',
54+
label: 'Right'
55+
},
56+
{
57+
key: 'start',
58+
label: 'Start'
59+
},
60+
{
61+
key: 'end',
62+
label: 'End'
63+
}];
64+
const alignItems = [
65+
{
66+
key: 'start',
67+
label: 'Start'
68+
},
69+
{
70+
key: 'end',
71+
label: 'End'
72+
}
73+
];
74+
75+
function isOfDirection(key: string): key is Direction {
76+
return directionItems.map(e => e.key).includes(key);
77+
}
78+
79+
function isOfAlignment(key: string): key is Alignment {
80+
return alignItems.map(e => e.key).includes(key);
81+
}
82+
83+
function DirectionAlignment() {
84+
const [align, setAlignment] = useState<Alignment>('start');
85+
const [direction, setDirection] = useState<Direction>('bottom');
86+
87+
const handleAlignChange = (key) => {
88+
if (isOfAlignment(key)) {
89+
setAlignment(key);
90+
}
91+
};
92+
93+
const handleDirectionChange = (key) => {
94+
if (isOfDirection(key)) {
95+
setDirection(key);
96+
}
97+
};
98+
99+
return (<Flex alignItems="end" columnGap={10}>
100+
<Picker label="Align" items={alignItems} selectedKey={align} onSelectionChange={handleAlignChange}>
101+
{(item) => <Item key={item.key}>{item.label}</Item>}
102+
</Picker>
103+
<Picker label="Direction" items={directionItems} selectedKey={direction} onSelectionChange={handleDirectionChange}>
104+
{(item) => <Item key={item.key}>{item.label}</Item>}
105+
</Picker>
106+
<ActionMenu
107+
onAction={action('action')}
108+
align={align}
109+
direction={direction}>
110+
<Item key="one">One</Item>
111+
<Item key="two">Two</Item>
112+
<Item key="three">Three</Item>
113+
</ActionMenu>
114+
</Flex>);
115+
}
116+
117+
export const Default = Template().bind({});
118+
Default.args = {};
119+
120+
export const AriaLabel = Template().bind({});
121+
AriaLabel.args = {'aria-label': 'Some more actions'};
122+
123+
export const DOMId = Template().bind({});
124+
DOMId.args = {id: 'my-action-menu'};
125+
126+
export const Quiet = Template().bind({});
127+
Quiet.args = {isQuiet: true};
128+
129+
export const Disabled = Template().bind({});
130+
Disabled.args = {isDisabled: true};
131+
132+
export const AutoFocus = Template().bind({});
133+
AutoFocus.args = {autoFocus: true};
134+
135+
export const DirectionAlign = () => <DirectionAlignment />;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {act, render, within} from '@testing-library/react';
14+
import {ActionMenu, Item} from '../';
15+
import {Provider} from '@react-spectrum/provider';
16+
import React from 'react';
17+
import {theme} from '@react-spectrum/theme-default';
18+
import {triggerPress} from '@react-spectrum/test-utils';
19+
20+
21+
describe('ActionMenu', function () {
22+
let onActionSpy = jest.fn();
23+
24+
beforeAll(function () {
25+
jest.useFakeTimers();
26+
});
27+
28+
afterEach(() => {
29+
onActionSpy.mockClear();
30+
act(() => {
31+
jest.runAllTimers();
32+
});
33+
});
34+
35+
it('basic test', function () {
36+
let tree = render(<Provider theme={theme}>
37+
<ActionMenu onAction={onActionSpy}>
38+
<Item>Foo</Item>
39+
<Item>Bar</Item>
40+
<Item>Baz</Item>
41+
</ActionMenu>
42+
</Provider>);
43+
44+
let button = tree.getByRole('button');
45+
expect(button).toHaveAttribute('aria-label', 'More actions');
46+
triggerPress(button);
47+
48+
let menu = tree.getByRole('menu');
49+
expect(menu).toBeTruthy();
50+
expect(menu).toHaveAttribute('aria-labelledby', button.id);
51+
52+
53+
let menuItem1 = within(menu).getByText('Foo');
54+
let menuItem2 = within(menu).getByText('Bar');
55+
let menuItem3 = within(menu).getByText('Baz');
56+
expect(menuItem1).toBeTruthy();
57+
expect(menuItem2).toBeTruthy();
58+
expect(menuItem3).toBeTruthy();
59+
60+
triggerPress(menuItem1);
61+
expect(onActionSpy).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('cústom aria label', function () {
65+
let tree = render(<Provider theme={theme}>
66+
<ActionMenu aria-label="Custom Aria Label">
67+
<Item>Foo</Item>
68+
<Item>Bar</Item>
69+
<Item>Baz</Item>
70+
</ActionMenu>
71+
</Provider>);
72+
73+
let button = tree.getByRole('button');
74+
expect(button).toHaveAttribute('aria-label', 'Custom Aria Label');
75+
});
76+
77+
it('is disabled', function () {
78+
let tree = render(<Provider theme={theme}>
79+
<ActionMenu isDisabled>
80+
<Item>Foo</Item>
81+
<Item>Bar</Item>
82+
<Item>Baz</Item>
83+
</ActionMenu>
84+
</Provider>);
85+
86+
let button = tree.getByRole('button');
87+
expect(button).toHaveAttribute('aria-label', 'More actions');
88+
triggerPress(button);
89+
90+
let menu = tree.queryByRole('menu');
91+
expect(menu).toBeNull();
92+
});
93+
94+
it('supports autofocus', function () {
95+
let tree = render(<Provider theme={theme}>
96+
<ActionMenu autoFocus>
97+
<Item>Foo</Item>
98+
<Item>Bar</Item>
99+
<Item>Baz</Item>
100+
</ActionMenu>
101+
</Provider>);
102+
103+
let button = tree.getByRole('button');
104+
expect(document.activeElement).toBe(button);
105+
});
106+
});

packages/@react-types/menu/src/index.d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,29 @@ export interface MenuProps<T> extends CollectionBase<T>, MultipleSelection {
5656

5757
export interface AriaMenuProps<T> extends MenuProps<T>, DOMProps, AriaLabelingProps {}
5858
export interface SpectrumMenuProps<T> extends AriaMenuProps<T>, StyleProps {}
59+
60+
export interface SpectrumActionMenuProps<T> extends CollectionBase<T>, DOMProps, AriaLabelingProps {
61+
/**
62+
* Alignment of the menu relative to the trigger.
63+
* @default 'start'
64+
*/
65+
align?: Alignment, // from shared types
66+
/**
67+
* Where the Menu opens relative to its trigger.
68+
* @default 'bottom'
69+
*/
70+
direction?: 'bottom' | 'top' | 'left' | 'right' | 'start' | 'end',
71+
/**
72+
* Whether the menu should automatically flip direction when space is limited.
73+
* @default true
74+
*/
75+
shouldFlip?: boolean,
76+
/** Whether the button is disabled. */
77+
isDisabled?: boolean,
78+
/** Whether the button should be displayed with a [quiet style](https://spectrum.adobe.com/page/action-button/#Quiet). */
79+
isQuiet?: boolean,
80+
/** Whether the element should receive focus on render. */
81+
autoFocus?: boolean,
82+
/** Handler that is called when an item is selected. */
83+
onAction?: (key: Key) => void
84+
}

0 commit comments

Comments
 (0)