Skip to content

Commit b5ec39e

Browse files
authored
feat(DropdownMenu): Add responsive menu alignment (react-bootstrap#5307)
* feat(DropdownMenu): Add responsive menu alignment * Apply suggestions * Allow "left" and "right" to be used in the align prop Add deprecation notice for alignRight in DropdownMenu * Remove DEVICE_SIZES in favor of Object.keys * Add comment to clarify responsive left align requires dropdown-menu-right class * Fix so that only 1 breakpoint is allowed when using responsive align * Fix menuAlign types * Fix TS types
1 parent f493a15 commit b5ec39e

File tree

8 files changed

+186
-9
lines changed

8 files changed

+186
-9
lines changed

src/DropdownButton.tsx

+14-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import PropTypes from 'prop-types';
33

44
import Dropdown, { DropdownProps } from './Dropdown';
55
import DropdownToggle, { PropsFromToggle } from './DropdownToggle';
6-
import DropdownMenu from './DropdownMenu';
6+
import DropdownMenu, { alignPropType, AlignType } from './DropdownMenu';
77

88
export interface DropdownButtonProps
99
extends DropdownProps,
1010
Omit<React.HTMLAttributes<HTMLElement>, 'onSelect' | 'title'>,
1111
React.PropsWithChildren<PropsFromToggle> {
1212
title: React.ReactNode;
13+
menuAlign?: AlignType;
1314
menuRole?: string;
1415
renderMenuOnMount?: boolean;
1516
rootCloseEvent?: 'click' | 'mousedown';
@@ -36,6 +37,15 @@ const propTypes = {
3637
/** Disables both Buttons */
3738
disabled: PropTypes.bool,
3839

40+
/**
41+
* Aligns the dropdown menu responsively.
42+
*
43+
* _see [DropdownMenu](#dropdown-menu-props) for more details_
44+
*
45+
* @type {"left"|"right"|{ sm: "left"|"right" }|{ md: "left"|"right" }|{ lg: "left"|"right" }|{ xl: "left"|"right"} }
46+
*/
47+
menuAlign: alignPropType,
48+
3949
/** An ARIA accessible role applied to the Menu component. When set to 'menu', The dropdown */
4050
menuRole: PropTypes.string,
4151

@@ -45,7 +55,7 @@ const propTypes = {
4555
/**
4656
* Which event when fired outside the component will cause it to be closed.
4757
*
48-
* _see [DropdownMenu](#menu-props) for more details_
58+
* _see [DropdownMenu](#dropdown-menu-props) for more details_
4959
*/
5060
rootCloseEvent: PropTypes.string,
5161

@@ -74,6 +84,7 @@ const DropdownButton = React.forwardRef<HTMLDivElement, DropdownButtonProps>(
7484
rootCloseEvent,
7585
variant,
7686
size,
87+
menuAlign,
7788
menuRole,
7889
renderMenuOnMount,
7990
disabled,
@@ -95,6 +106,7 @@ const DropdownButton = React.forwardRef<HTMLDivElement, DropdownButtonProps>(
95106
{title}
96107
</DropdownToggle>
97108
<DropdownMenu
109+
align={menuAlign}
98110
role={menuRole}
99111
renderOnMount={renderMenuOnMount}
100112
rootCloseEvent={rootCloseEvent}

src/DropdownMenu.tsx

+72-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
UseDropdownMenuOptions,
88
} from 'react-overlays/DropdownMenu';
99
import useMergedRefs from '@restart/hooks/useMergedRefs';
10+
import warning from 'warning';
1011
import NavbarContext from './NavbarContext';
1112
import { useBootstrapPrefix } from './ThemeProvider';
1213
import useWrappedRefWithWarning from './useWrappedRefWithWarning';
@@ -17,10 +18,21 @@ import {
1718
SelectCallback,
1819
} from './helpers';
1920

21+
export type AlignDirection = 'left' | 'right';
22+
23+
export type ResponsiveAlignProp =
24+
| { sm: AlignDirection }
25+
| { md: AlignDirection }
26+
| { lg: AlignDirection }
27+
| { xl: AlignDirection };
28+
29+
export type AlignType = AlignDirection | ResponsiveAlignProp;
30+
2031
export interface DropdownMenuProps extends BsPrefixPropsWithChildren {
2132
show?: boolean;
2233
renderOnMount?: boolean;
2334
flip?: boolean;
35+
align?: AlignType;
2436
alignRight?: boolean;
2537
onSelect?: SelectCallback;
2638
rootCloseEvent?: 'click' | 'mousedown';
@@ -29,6 +41,16 @@ export interface DropdownMenuProps extends BsPrefixPropsWithChildren {
2941

3042
type DropdownMenu = BsPrefixRefForwardingComponent<'div', DropdownMenuProps>;
3143

44+
const alignDirection = PropTypes.oneOf(['left', 'right']);
45+
46+
export const alignPropType = PropTypes.oneOfType([
47+
alignDirection,
48+
PropTypes.shape({ sm: alignDirection }),
49+
PropTypes.shape({ md: alignDirection }),
50+
PropTypes.shape({ lg: alignDirection }),
51+
PropTypes.shape({ xl: alignDirection }),
52+
]);
53+
3254
const propTypes = {
3355
/**
3456
* @default 'dropdown-menu'
@@ -44,7 +66,22 @@ const propTypes = {
4466
/** Have the dropdown switch to it's opposite placement when necessary to stay on screen. */
4567
flip: PropTypes.bool,
4668

47-
/** Aligns the Dropdown menu to the right of it's container. */
69+
/**
70+
* Aligns the dropdown menu to the specified side of the container. You can also align
71+
* the menu responsively for breakpoints starting at `sm` and up. The alignment
72+
* direction will affect the specified breakpoint or larger.
73+
*
74+
* *Note: Using responsive alignment will disable Popper usage for positioning.*
75+
*
76+
* @type {"left"|"right"|{ sm: "left"|"right" }|{ md: "left"|"right" }|{ lg: "left"|"right" }|{ xl: "left"|"right"} }
77+
*/
78+
align: alignPropType,
79+
80+
/**
81+
* Aligns the Dropdown menu to the right of it's container.
82+
*
83+
* @deprecated Use align="right"
84+
*/
4885
alignRight: PropTypes.bool,
4986

5087
onSelect: PropTypes.func,
@@ -73,7 +110,8 @@ const propTypes = {
73110
popperConfig: PropTypes.object,
74111
};
75112

76-
const defaultProps = {
113+
const defaultProps: Partial<DropdownMenuProps> = {
114+
align: 'left',
77115
alignRight: false,
78116
flip: true,
79117
};
@@ -86,6 +124,9 @@ const DropdownMenu: DropdownMenu = React.forwardRef(
86124
{
87125
bsPrefix,
88126
className,
127+
align,
128+
// When we remove alignRight from API, use the var locally to toggle
129+
// .dropdown-menu-right class below.
89130
alignRight,
90131
rootCloseEvent,
91132
flip,
@@ -102,6 +143,31 @@ const DropdownMenu: DropdownMenu = React.forwardRef(
102143
const prefix = useBootstrapPrefix(bsPrefix, 'dropdown-menu');
103144
const [popperRef, marginModifiers] = usePopperMarginModifiers();
104145

146+
const alignClasses: string[] = [];
147+
if (align) {
148+
if (typeof align === 'object') {
149+
const keys = Object.keys(align);
150+
151+
warning(
152+
keys.length === 1,
153+
'There should only be 1 breakpoint when passing an object to `align`',
154+
);
155+
156+
if (keys.length) {
157+
const brkPoint = keys[0];
158+
const direction = align[brkPoint];
159+
160+
// .dropdown-menu-right is required for responsively aligning
161+
// left in addition to align left classes.
162+
// Reuse alignRight to toggle the class below.
163+
alignRight = direction === 'left';
164+
alignClasses.push(`${prefix}-${brkPoint}-${direction}`);
165+
}
166+
} else if (align === 'right') {
167+
alignRight = true;
168+
}
169+
}
170+
105171
const {
106172
hasShown,
107173
placement,
@@ -114,7 +180,7 @@ const DropdownMenu: DropdownMenu = React.forwardRef(
114180
rootCloseEvent,
115181
show: showProps,
116182
alignEnd: alignRight,
117-
usePopper: !isNavbar,
183+
usePopper: !isNavbar && alignClasses.length === 0,
118184
popperConfig: {
119185
...popperConfig,
120186
modifiers: marginModifiers.concat(popperConfig?.modifiers || []),
@@ -137,12 +203,14 @@ const DropdownMenu: DropdownMenu = React.forwardRef(
137203
(menuProps as any).close = close;
138204
(menuProps as any).alignRight = alignEnd;
139205
}
206+
140207
if (placement) {
141208
// we don't need the default popper style,
142209
// menus are display: none when not shown.
143210
(props as any).style = { ...(props as any).style, ...menuProps.style };
144211
props['x-placement'] = placement;
145212
}
213+
146214
return (
147215
<Component
148216
{...props}
@@ -152,6 +220,7 @@ const DropdownMenu: DropdownMenu = React.forwardRef(
152220
prefix,
153221
show && 'show',
154222
alignEnd && `${prefix}-right`,
223+
...alignClasses,
155224
)}
156225
/>
157226
);

src/SplitButton.tsx

+14-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
44
import Button, { ButtonType } from './Button';
55
import ButtonGroup from './ButtonGroup';
66
import Dropdown from './Dropdown';
7+
import { alignPropType, AlignType } from './DropdownMenu';
78
import { PropsFromToggle } from './DropdownToggle';
89
import {
910
BsPrefixPropsWithChildren,
@@ -14,6 +15,7 @@ export interface SplitButtonProps
1415
extends PropsFromToggle,
1516
BsPrefixPropsWithChildren {
1617
id: string | number;
18+
menuAlign?: AlignType;
1719
menuRole?: string;
1820
onClick?: React.MouseEventHandler<this>;
1921
renderMenuOnMount?: boolean;
@@ -57,6 +59,15 @@ const propTypes = {
5759
/** Disables both Buttons */
5860
disabled: PropTypes.bool,
5961

62+
/**
63+
* Aligns the dropdown menu responsively.
64+
*
65+
* _see [DropdownMenu](#dropdown-menu-props) for more details_
66+
*
67+
* @type {"left"|"right"|{ sm: "left"|"right" }|{ md: "left"|"right" }|{ lg: "left"|"right" }|{ xl: "left"|"right"} }
68+
*/
69+
menuAlign: alignPropType,
70+
6071
/** An ARIA accessible role applied to the Menu component. When set to 'menu', The dropdown */
6172
menuRole: PropTypes.string,
6273

@@ -66,7 +77,7 @@ const propTypes = {
6677
/**
6778
* Which event when fired outside the component will cause it to be closed.
6879
*
69-
* _see [DropdownMenu](#menu-props) for more details_
80+
* _see [DropdownMenu](#dropdown-menu-props) for more details_
7081
*/
7182
rootCloseEvent: PropTypes.string,
7283

@@ -97,6 +108,7 @@ const SplitButton: SplitButton = React.forwardRef(
97108
onClick,
98109
href,
99110
target,
111+
menuAlign,
100112
menuRole,
101113
renderMenuOnMount,
102114
rootCloseEvent,
@@ -129,6 +141,7 @@ const SplitButton: SplitButton = React.forwardRef(
129141
</Dropdown.Toggle>
130142

131143
<Dropdown.Menu
144+
align={menuAlign}
132145
role={menuRole}
133146
renderOnMount={renderMenuOnMount}
134147
rootCloseEvent={rootCloseEvent}

test/DropdownMenuSpec.js

+36
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,42 @@ describe('<Dropdown.Menu>', () => {
4141
).assertSingle('div.dropdown-menu');
4242
});
4343

44+
it('does not add any extra classes when align="left"', () => {
45+
const wrapper = mount(
46+
<DropdownMenu show align="left">
47+
<DropdownItem>Item</DropdownItem>
48+
</DropdownMenu>,
49+
).find('DropdownMenu');
50+
51+
expect(wrapper.getDOMNode().className).to.equal('dropdown-menu show');
52+
});
53+
54+
it('adds right align class when align="right"', () => {
55+
mount(
56+
<DropdownMenu show align="right">
57+
<DropdownItem>Item</DropdownItem>
58+
</DropdownMenu>,
59+
).assertSingle('.dropdown-menu-right');
60+
});
61+
62+
it('adds responsive left alignment classes', () => {
63+
mount(
64+
<DropdownMenu show align={{ lg: 'left' }}>
65+
<DropdownItem>Item</DropdownItem>
66+
</DropdownMenu>,
67+
)
68+
.assertSingle('.dropdown-menu-right')
69+
.assertSingle('.dropdown-menu-lg-left');
70+
});
71+
72+
it('adds responsive right alignment classes', () => {
73+
mount(
74+
<DropdownMenu show align={{ lg: 'right' }}>
75+
<DropdownItem>Item</DropdownItem>
76+
</DropdownMenu>,
77+
).assertSingle('.dropdown-menu-lg-right');
78+
});
79+
4480
// it.only('warns about bad refs', () => {
4581
// class Parent extends React.Component {
4682
// componentDidCatch() {}

tests/simple-types-test.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ const MegaComponent = () => (
315315
show
316316
bsPrefix="dropdownmenu"
317317
style={style}
318+
align={{ sm: 'left' }}
318319
>
319320
<Dropdown.Item
320321
active
@@ -335,6 +336,8 @@ const MegaComponent = () => (
335336
<Dropdown.Divider as="div" bsPrefix="dropdowndivider" style={style} />
336337
<Dropdown.Divider as="div" bsPrefix="prefix" style={style} />
337338
</Dropdown.Menu>
339+
<Dropdown.Menu align="left" />
340+
<Dropdown.Menu align="right" />
338341
</Dropdown>
339342
<DropdownButton
340343
disabled
@@ -349,6 +352,7 @@ const MegaComponent = () => (
349352
variant="primary"
350353
bsPrefix="dropdownbtn"
351354
style={style}
355+
menuAlign={{ sm: 'left' }}
352356
>
353357
<Dropdown.Item href="#/action-1">Action</Dropdown.Item>
354358
<Dropdown.Item href="#/action-2">Another action</Dropdown.Item>
@@ -932,6 +936,7 @@ const MegaComponent = () => (
932936
variant="primary"
933937
bsPrefix="splitbutton"
934938
style={style}
939+
menuAlign={{ sm: 'left' }}
935940
/>
936941
<Table
937942
id="id"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<>
2+
<div>
3+
<DropdownButton
4+
as={ButtonGroup}
5+
menuAlign={{ lg: 'right' }}
6+
title="Left-aligned but right aligned when large screen"
7+
id="dropdown-menu-align-responsive-1"
8+
>
9+
<Dropdown.Item eventKey="1">Action 1</Dropdown.Item>
10+
<Dropdown.Item eventKey="2">Action 2</Dropdown.Item>
11+
</DropdownButton>
12+
</div>
13+
<div className="mt-2">
14+
<SplitButton
15+
menuAlign={{ lg: 'left' }}
16+
title="Right-aligned but left aligned when large screen"
17+
id="dropdown-menu-align-responsive-2"
18+
>
19+
<Dropdown.Item eventKey="1">Action 1</Dropdown.Item>
20+
<Dropdown.Item eventKey="2">Action 2</Dropdown.Item>
21+
</SplitButton>
22+
</div>
23+
</>;

www/src/examples/Dropdown/MenuAlignRight.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<DropdownButton
2-
alignRight
2+
menuAlign="right"
33
title="Dropdown right"
44
id="dropdown-menu-align-right"
55
>

0 commit comments

Comments
 (0)