superdesk-ui-framework 7.0.0-dev1 → 7.0.0-dev3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/styles/dropdowns/_basic-dropdown.scss +6 -0
- package/app/styles/grids/_grid-layout.scss +2 -0
- package/app-typescript/components/Dropdown.tsx +266 -229
- package/dist/components/Dropdowns.tsx +78 -15
- package/dist/examples.bundle.js +1 -1
- package/dist/storybook/iframe.html +1 -1
- package/dist/storybook/main.18fb8930.iframe.bundle.js +1 -0
- package/dist/storybook/manifests/components.json +1 -1
- package/dist/storybook/project.json +1 -1
- package/dist/superdesk-ui.bundle.css +1 -1
- package/dist/superdesk-ui.bundle.js +1 -1
- package/package.json +1 -1
- package/react/components/Dropdown.d.ts +6 -5
- package/react/components/Dropdown.js +163 -168
- package/storybook-static/iframe.html +1 -1
- package/storybook-static/main.18fb8930.iframe.bundle.js +1 -0
- package/storybook-static/manifests/components.json +1 -1
- package/storybook-static/project.json +1 -1
- package/storybook-static/main.f0d64657.iframe.bundle.js +0 -1
|
@@ -39,6 +39,12 @@ $focus-box-shadow: 0 0 0 3px colors.$sd-colour--focus-shadow;
|
|
|
39
39
|
.dropdown__toggle {
|
|
40
40
|
cursor: pointer;
|
|
41
41
|
|
|
42
|
+
// Scoped to the built-in toggle button the React Dropdown renders for text
|
|
43
|
+
// children — custom toggles keep whatever wrapping they already had.
|
|
44
|
+
&.dropdown__toggle--default {
|
|
45
|
+
white-space: nowrap;
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
i {
|
|
43
49
|
vertical-align: sub;
|
|
44
50
|
}
|
|
@@ -817,10 +817,12 @@ $planningEditor-width: 53rem;
|
|
|
817
817
|
|
|
818
818
|
&.side-panel__container--xx-large {
|
|
819
819
|
width: var(--width__container--xx-large);
|
|
820
|
+
max-width: 50vw;
|
|
820
821
|
}
|
|
821
822
|
|
|
822
823
|
&.side-panel__container--xxx-large {
|
|
823
824
|
width: var(--width__container--xxx-large);
|
|
825
|
+
max-width: 60vw;
|
|
824
826
|
}
|
|
825
827
|
|
|
826
828
|
&.side-panel__container--full {
|
|
@@ -15,25 +15,27 @@ interface IMenuItemRes extends IMenuItem {
|
|
|
15
15
|
onChange?(event?: any): void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type IMenuElement = IMenuItem | ISubmenu | IMenuGroup | 'divider';
|
|
19
|
+
|
|
18
20
|
export interface ISubmenu {
|
|
19
21
|
type: 'submenu';
|
|
20
22
|
label: string | React.ReactNode;
|
|
21
23
|
icon?: string;
|
|
22
|
-
items: Array<
|
|
24
|
+
items: Array<IMenuElement>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface IMenuGroup {
|
|
26
28
|
type: 'group';
|
|
27
29
|
label?: string | React.ReactNode;
|
|
28
|
-
items: Array<
|
|
30
|
+
items: Array<IMenuElement>;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface IMenu {
|
|
32
34
|
label?: string | React.ReactNode;
|
|
33
35
|
align?: 'left' | 'right';
|
|
34
|
-
items: Array<
|
|
35
|
-
header?: Array<
|
|
36
|
-
footer?: Array<
|
|
36
|
+
items: Array<IMenuElement>;
|
|
37
|
+
header?: Array<IMenuElement>;
|
|
38
|
+
footer?: Array<IMenuElement>;
|
|
37
39
|
children: React.ReactNode;
|
|
38
40
|
onChange?(event?: any): void;
|
|
39
41
|
maxHeight?: number;
|
|
@@ -41,240 +43,201 @@ interface IMenu {
|
|
|
41
43
|
|
|
42
44
|
const DROPDOWN_ID_CONTAINER = 'sd-dropdown-constainer';
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
function ensureDropdownContainer(): HTMLElement {
|
|
47
|
+
let placeholder = document.getElementById(DROPDOWN_ID_CONTAINER);
|
|
46
48
|
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
+
if (!placeholder) {
|
|
50
|
+
placeholder = document.createElement('div');
|
|
51
|
+
placeholder.id = DROPDOWN_ID_CONTAINER;
|
|
52
|
+
placeholder.style.position = 'absolute';
|
|
53
|
+
placeholder.style.top = '0';
|
|
54
|
+
placeholder.style.left = '0';
|
|
55
|
+
placeholder.style.width = '1px';
|
|
56
|
+
placeholder.style.height = '1px';
|
|
57
|
+
placeholder.setAttribute('data-test-id', 'dropdown-overlay');
|
|
58
|
+
document.body.appendChild(placeholder);
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
return placeholder;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const Dropdown = ({items, header, footer, children, align, onChange, maxHeight}: IMenu) => {
|
|
65
|
+
const [zIndex] = React.useState(getNextZIndex);
|
|
51
66
|
const [open, setOpen] = React.useState(false);
|
|
52
|
-
const [
|
|
67
|
+
const [container, setContainer] = React.useState<HTMLElement | null>(null);
|
|
53
68
|
const [menuID] = useId();
|
|
54
|
-
const
|
|
55
|
-
const buttonRef = React.useRef(null);
|
|
56
|
-
const headerElements = header?.map((el, index) => {
|
|
57
|
-
return each(el, index);
|
|
58
|
-
});
|
|
59
|
-
const maxHeightStyle = maxHeight ? {maxHeight} : {};
|
|
69
|
+
const menuRef = React.useRef<HTMLElement | null>(null);
|
|
70
|
+
const buttonRef = React.useRef<HTMLElement | null>(null);
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
// Callback refs so the same ref can be attached to either element type
|
|
73
|
+
// rendered by each branch below (ul/div for the menu, div/button for the toggle).
|
|
74
|
+
const setMenuRef = React.useCallback((element: HTMLElement | null) => {
|
|
75
|
+
menuRef.current = element;
|
|
76
|
+
}, []);
|
|
77
|
+
const setButtonRef = React.useCallback((element: HTMLElement | null) => {
|
|
78
|
+
buttonRef.current = element;
|
|
79
|
+
}, []);
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
// Created after mount rather than during render, so render stays side-effect free.
|
|
82
|
+
// The menu only opens on user interaction, so the container is always ready by then.
|
|
83
|
+
React.useLayoutEffect(() => {
|
|
84
|
+
setContainer(ensureDropdownContainer());
|
|
85
|
+
}, []);
|
|
68
86
|
|
|
87
|
+
// Any click closes the menu — including clicks on menu items, which defer
|
|
88
|
+
// their `onSelect` so the menu is gone by the time it runs.
|
|
69
89
|
React.useEffect(() => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const el = document.createElement('div');
|
|
73
|
-
el.id = DROPDOWN_ID_CONTAINER;
|
|
74
|
-
el.style.position = 'absolute';
|
|
75
|
-
el.style.top = '0';
|
|
76
|
-
el.style.left = '0';
|
|
77
|
-
el.style.width = '1px';
|
|
78
|
-
el.style.height = '1px';
|
|
79
|
-
el.setAttribute('data-test-id', 'dropdown-overlay');
|
|
80
|
-
|
|
81
|
-
document.body.appendChild(el);
|
|
90
|
+
if (!open) {
|
|
91
|
+
return;
|
|
82
92
|
}
|
|
83
|
-
}, [change]);
|
|
84
93
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
const closeMenu = () => setOpen(false);
|
|
95
|
+
|
|
96
|
+
document.addEventListener('click', closeMenu);
|
|
97
|
+
|
|
98
|
+
return () => document.removeEventListener('click', closeMenu);
|
|
90
99
|
}, [open]);
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<div
|
|
96
|
-
className="dropdown__menu dropdown__menu--has-head-foot"
|
|
97
|
-
id={menuID}
|
|
98
|
-
role="menu"
|
|
99
|
-
ref={ref}
|
|
100
|
-
style={{zIndex}}
|
|
101
|
-
>
|
|
102
|
-
<ul className="dropdown__menu-header">{headerElements}</ul>
|
|
103
|
-
<ul className="dropdown__menu-body">{dropdownElements}</ul>
|
|
104
|
-
<ul className="dropdown__menu-footer dropdown__menu-footer--has-list ">{footerElements}</ul>
|
|
105
|
-
</div>
|
|
106
|
-
);
|
|
107
|
-
} else if (header) {
|
|
108
|
-
return (
|
|
109
|
-
<div
|
|
110
|
-
className="dropdown__menu dropdown__menu--has-head-foot"
|
|
111
|
-
id={menuID}
|
|
112
|
-
role="menu"
|
|
113
|
-
ref={ref}
|
|
114
|
-
style={{zIndex}}
|
|
115
|
-
>
|
|
116
|
-
<ul className="dropdown__menu-header">{headerElements}</ul>
|
|
117
|
-
<ul className="dropdown__menu-body">{dropdownElements}</ul>
|
|
118
|
-
</div>
|
|
119
|
-
);
|
|
120
|
-
} else if (footer) {
|
|
121
|
-
return (
|
|
122
|
-
<div
|
|
123
|
-
className="dropdown__menu dropdown__menu--has-head-foot"
|
|
124
|
-
id={menuID}
|
|
125
|
-
role="menu"
|
|
126
|
-
ref={ref}
|
|
127
|
-
style={{zIndex}}
|
|
128
|
-
>
|
|
129
|
-
<ul className="dropdown__menu-body">{dropdownElements}</ul>
|
|
130
|
-
<ul className="dropdown__menu-footer dropdown__menu-footer--has-list ">{footerElements}</ul>
|
|
131
|
-
</div>
|
|
132
|
-
);
|
|
133
|
-
} else {
|
|
134
|
-
return (
|
|
135
|
-
<ul
|
|
136
|
-
className="dropdown__menu "
|
|
137
|
-
id={menuID}
|
|
138
|
-
role="menu"
|
|
139
|
-
ref={ref}
|
|
140
|
-
style={{...{zIndex, overflowY: 'auto'}, ...maxHeightStyle}}
|
|
141
|
-
>
|
|
142
|
-
{dropdownElements}
|
|
143
|
-
</ul>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
101
|
+
React.useLayoutEffect(() => {
|
|
102
|
+
const toggle = buttonRef.current;
|
|
103
|
+
const menu = menuRef.current;
|
|
147
104
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
let menuRef: any;
|
|
151
|
-
setOpen(true);
|
|
152
|
-
setTimeout(() => {
|
|
153
|
-
menuRef = ref.current;
|
|
154
|
-
let toggleRef = buttonRef.current;
|
|
155
|
-
if (toggleRef && menuRef) {
|
|
156
|
-
createPopper(toggleRef, menuRef, {
|
|
157
|
-
placement: checkAlign() ? 'bottom-end' : 'bottom-start',
|
|
158
|
-
strategy: 'fixed',
|
|
159
|
-
});
|
|
160
|
-
menuRef.style.display = 'block';
|
|
161
|
-
}
|
|
162
|
-
}, 0);
|
|
163
|
-
document.addEventListener('click', closeMenu);
|
|
164
|
-
setTimeout(() => {
|
|
165
|
-
menuRef.getElementsByTagName('button')[0].focus();
|
|
166
|
-
});
|
|
167
|
-
} else {
|
|
168
|
-
setOpen(false);
|
|
105
|
+
if (!open || toggle == null || menu == null) {
|
|
106
|
+
return;
|
|
169
107
|
}
|
|
170
|
-
}
|
|
171
108
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
109
|
+
const popper = createPopper(toggle, menu, {
|
|
110
|
+
placement: align === 'right' ? 'bottom-end' : 'bottom-start',
|
|
111
|
+
strategy: 'fixed',
|
|
112
|
+
});
|
|
176
113
|
|
|
177
|
-
|
|
178
|
-
if (align === 'right') {
|
|
179
|
-
return true;
|
|
180
|
-
} else {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
114
|
+
menu.getElementsByTagName('button')[0]?.focus();
|
|
184
115
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (placeholder) {
|
|
192
|
-
ReactDOM.unmountComponentAtNode(placeholder);
|
|
193
|
-
}
|
|
116
|
+
return () => popper.destroy();
|
|
117
|
+
}, [open, align]);
|
|
118
|
+
|
|
119
|
+
function each(item: IMenuElement, index: number): React.ReactNode {
|
|
120
|
+
if (item === 'divider') {
|
|
121
|
+
return <li className="dropdown__menu-divider" key={index} />;
|
|
194
122
|
}
|
|
195
|
-
}
|
|
196
123
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
item
|
|
201
|
-
|
|
202
|
-
|
|
124
|
+
if ('type' in item && item.type === 'submenu') {
|
|
125
|
+
// Empty submenu definitions are treated as plain items so we never
|
|
126
|
+
// portal an empty menu panel.
|
|
127
|
+
if (item.items.length === 0) {
|
|
128
|
+
const asItem = item as Partial<IMenuItem>;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<DropdownItem
|
|
132
|
+
key={index}
|
|
133
|
+
label={item.label}
|
|
134
|
+
icon={item.icon}
|
|
135
|
+
active={asItem.active}
|
|
136
|
+
onSelect={asItem.onSelect ?? (() => undefined)}
|
|
137
|
+
onChange={onChange}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
203
141
|
|
|
204
142
|
return (
|
|
205
143
|
<DropdownItemWithSubmenu
|
|
206
144
|
key={index}
|
|
207
|
-
index={index}
|
|
208
145
|
item={item}
|
|
209
|
-
|
|
210
|
-
subMenuItems={
|
|
146
|
+
zIndex={zIndex}
|
|
147
|
+
subMenuItems={item.items.map(each)}
|
|
211
148
|
onChange={onChange}
|
|
212
149
|
/>
|
|
213
150
|
);
|
|
214
|
-
}
|
|
215
|
-
let groupItems: any = [];
|
|
216
|
-
item['items'].forEach((el: any, key: number) => {
|
|
217
|
-
groupItems.push(each(el, key));
|
|
218
|
-
});
|
|
151
|
+
}
|
|
219
152
|
|
|
153
|
+
if ('type' in item && item.type === 'group') {
|
|
220
154
|
return (
|
|
221
155
|
<React.Fragment key={index}>
|
|
222
156
|
<li>
|
|
223
|
-
<div className="dropdown__menu-label">{item
|
|
157
|
+
<div className="dropdown__menu-label">{item.label}</div>
|
|
224
158
|
</li>
|
|
225
|
-
{
|
|
159
|
+
{item.items.map(each)}
|
|
226
160
|
</React.Fragment>
|
|
227
161
|
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<DropdownItem
|
|
166
|
+
key={index}
|
|
167
|
+
label={item.label}
|
|
168
|
+
icon={item.icon}
|
|
169
|
+
active={item.active}
|
|
170
|
+
onSelect={item.onSelect}
|
|
171
|
+
onChange={onChange}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderMenu() {
|
|
177
|
+
// Only constrain overflow when scrolling is requested. Submenus are portaled
|
|
178
|
+
// to document.body so they are not clipped by this overflow.
|
|
179
|
+
const menuStyle: React.CSSProperties = {
|
|
180
|
+
zIndex,
|
|
181
|
+
display: 'block',
|
|
182
|
+
...(maxHeight != null ? {maxHeight, overflowY: 'auto' as const} : {}),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (header == null && footer == null) {
|
|
231
186
|
return (
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
icon={item['icon']}
|
|
236
|
-
active={item['active']}
|
|
237
|
-
onSelect={item['onSelect']}
|
|
238
|
-
onChange={onChange}
|
|
239
|
-
/>
|
|
187
|
+
<ul className="dropdown__menu" id={menuID} role="menu" ref={setMenuRef} style={menuStyle}>
|
|
188
|
+
{items.map(each)}
|
|
189
|
+
</ul>
|
|
240
190
|
);
|
|
241
191
|
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
className="dropdown__menu dropdown__menu--has-head-foot"
|
|
196
|
+
id={menuID}
|
|
197
|
+
role="menu"
|
|
198
|
+
ref={setMenuRef}
|
|
199
|
+
style={menuStyle}
|
|
200
|
+
>
|
|
201
|
+
{header != null && <ul className="dropdown__menu-header">{header.map(each)}</ul>}
|
|
202
|
+
<ul className="dropdown__menu-body">{items.map(each)}</ul>
|
|
203
|
+
{footer != null && (
|
|
204
|
+
<ul className="dropdown__menu-footer dropdown__menu-footer--has-list">{footer.map(each)}</ul>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
242
208
|
}
|
|
243
209
|
|
|
210
|
+
const toggleProps = {
|
|
211
|
+
'aria-haspopup': 'menu' as const,
|
|
212
|
+
'aria-expanded': open,
|
|
213
|
+
onClick: () => setOpen((currentlyOpen) => !currentlyOpen),
|
|
214
|
+
};
|
|
215
|
+
|
|
244
216
|
return (
|
|
245
|
-
<div className={
|
|
246
|
-
{
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
ref: buttonRef,
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
return React.cloneElement(children, attrs);
|
|
261
|
-
})()}
|
|
262
|
-
</div>
|
|
263
|
-
) : null
|
|
217
|
+
<div className={open ? 'dropdown open' : 'dropdown'}>
|
|
218
|
+
{React.isValidElement<React.HTMLAttributes<HTMLElement>>(children) ? (
|
|
219
|
+
// The wrapper (not the cloned child) is the popper anchor, so a plain
|
|
220
|
+
// function component can be used as the toggle without forwarding a ref.
|
|
221
|
+
<div ref={setButtonRef}>
|
|
222
|
+
{React.cloneElement(children, {
|
|
223
|
+
...toggleProps,
|
|
224
|
+
className: children.props.className
|
|
225
|
+
? children.props.className + ' dropdown__toggle dropdown-toggle'
|
|
226
|
+
: 'dropdown__toggle dropdown-toggle',
|
|
227
|
+
})}
|
|
228
|
+
</div>
|
|
264
229
|
) : (
|
|
265
230
|
<button
|
|
266
|
-
|
|
267
|
-
ref={
|
|
268
|
-
className=" dropdown__toggle dropdown-toggle"
|
|
269
|
-
aria-haspopup="menu"
|
|
231
|
+
{...toggleProps}
|
|
232
|
+
ref={setButtonRef}
|
|
233
|
+
className="dropdown__toggle dropdown__toggle--default dropdown-toggle"
|
|
270
234
|
tabIndex={0}
|
|
271
|
-
aria-expanded={open}
|
|
272
|
-
onClick={toggleDisplay}
|
|
273
235
|
>
|
|
274
236
|
{children}
|
|
275
|
-
<span className="dropdown__caret"
|
|
237
|
+
<span className="dropdown__caret" />
|
|
276
238
|
</button>
|
|
277
239
|
)}
|
|
240
|
+
{open && container != null && ReactDOM.createPortal(renderMenu(), container)}
|
|
278
241
|
</div>
|
|
279
242
|
);
|
|
280
243
|
};
|
|
@@ -289,70 +252,144 @@ const DropdownItem = ({label, icon, active, onSelect, onChange}: IMenuItemRes) =
|
|
|
289
252
|
setTimeout(() => {
|
|
290
253
|
onSelect();
|
|
291
254
|
});
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
255
|
+
|
|
256
|
+
onChange?.();
|
|
295
257
|
}}
|
|
296
258
|
>
|
|
297
|
-
<i className={icon ? 'icon-' + icon : ''}
|
|
259
|
+
<i className={icon ? 'icon-' + icon : ''} />
|
|
298
260
|
{label}
|
|
299
261
|
</button>
|
|
300
262
|
</li>
|
|
301
263
|
);
|
|
302
264
|
};
|
|
303
265
|
|
|
304
|
-
const
|
|
305
|
-
const [open, setOpen] = React.useState<undefined | boolean>(undefined);
|
|
266
|
+
const SUBMENU_CLOSE_DELAY_MS = 150;
|
|
306
267
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
268
|
+
const SubmenuHoverContext = React.createContext<{
|
|
269
|
+
keepParentOpen: () => void;
|
|
270
|
+
scheduleParentClose: () => void;
|
|
271
|
+
}>({
|
|
272
|
+
keepParentOpen: () => undefined,
|
|
273
|
+
scheduleParentClose: () => undefined,
|
|
274
|
+
});
|
|
310
275
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
276
|
+
function isMovingToDropdown(relatedTarget: EventTarget | null): boolean {
|
|
277
|
+
return relatedTarget instanceof Element ? relatedTarget.closest('.dropdown__menu, .dropdown') != null : false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface IDropdownItemWithSubmenu {
|
|
281
|
+
item: ISubmenu;
|
|
282
|
+
zIndex: number;
|
|
283
|
+
subMenuItems: Array<React.ReactNode>;
|
|
284
|
+
onChange?(event?: any): void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const DropdownItemWithSubmenu = ({item, zIndex, subMenuItems, onChange}: IDropdownItemWithSubmenu) => {
|
|
288
|
+
const [open, setOpen] = React.useState(false);
|
|
289
|
+
const [submenuZIndex, setSubmenuZIndex] = React.useState(zIndex + 1);
|
|
290
|
+
const refButtonSubMenu = React.useRef<HTMLLIElement>(null);
|
|
291
|
+
const refSubMenu = React.useRef<HTMLUListElement>(null);
|
|
292
|
+
const closeTimeoutRef = React.useRef<number | null>(null);
|
|
293
|
+
const parentHover = React.useContext(SubmenuHoverContext);
|
|
294
|
+
const onSelect = (item as Partial<IMenuItem>).onSelect;
|
|
295
|
+
|
|
296
|
+
const clearCloseTimeout = React.useCallback(() => {
|
|
297
|
+
if (closeTimeoutRef.current != null) {
|
|
298
|
+
window.clearTimeout(closeTimeoutRef.current);
|
|
299
|
+
closeTimeoutRef.current = null;
|
|
300
|
+
}
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
const openSubmenu = () => {
|
|
304
|
+
if (subMenuItems.length === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
clearCloseTimeout();
|
|
309
|
+
parentHover.keepParentOpen();
|
|
310
|
+
|
|
311
|
+
if (!open) {
|
|
312
|
+
setSubmenuZIndex(getNextZIndex());
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
setOpen(true);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const scheduleClose = (closeAncestors: boolean) => {
|
|
319
|
+
clearCloseTimeout();
|
|
320
|
+
closeTimeoutRef.current = window.setTimeout(() => {
|
|
321
|
+
setOpen(false);
|
|
322
|
+
closeTimeoutRef.current = null;
|
|
323
|
+
}, SUBMENU_CLOSE_DELAY_MS);
|
|
324
|
+
|
|
325
|
+
if (closeAncestors) {
|
|
326
|
+
parentHover.scheduleParentClose();
|
|
321
327
|
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const handleMouseLeave = (event: React.MouseEvent) => {
|
|
331
|
+
scheduleClose(!isMovingToDropdown(event.relatedTarget));
|
|
332
|
+
};
|
|
322
333
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
334
|
+
React.useEffect(() => clearCloseTimeout, [clearCloseTimeout]);
|
|
335
|
+
|
|
336
|
+
React.useLayoutEffect(() => {
|
|
337
|
+
if (!open || refButtonSubMenu.current == null || refSubMenu.current == null) {
|
|
338
|
+
return;
|
|
327
339
|
}
|
|
340
|
+
|
|
341
|
+
// Portal to body so parent overflow (e.g. maxHeight) cannot clip the submenu.
|
|
342
|
+
const popper = createPopper(refButtonSubMenu.current, refSubMenu.current, {
|
|
343
|
+
placement: 'right-start',
|
|
344
|
+
strategy: 'fixed',
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return () => popper.destroy();
|
|
328
348
|
}, [open]);
|
|
329
349
|
|
|
330
350
|
return (
|
|
331
|
-
<li
|
|
332
|
-
<div className="dropdown" onMouseLeave={
|
|
351
|
+
<li ref={refButtonSubMenu} role="none">
|
|
352
|
+
<div className="dropdown" onMouseEnter={openSubmenu} onMouseLeave={handleMouseLeave}>
|
|
333
353
|
<button
|
|
334
354
|
className="dropdown__toggle dropdown-toggle"
|
|
355
|
+
role="menuitem"
|
|
335
356
|
aria-haspopup="menu"
|
|
357
|
+
aria-expanded={open}
|
|
336
358
|
tabIndex={0}
|
|
337
359
|
onClick={() => {
|
|
338
|
-
if (
|
|
339
|
-
setTimeout(() =>
|
|
340
|
-
item.onSelect();
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
if (onChange) {
|
|
344
|
-
onChange();
|
|
360
|
+
if (onSelect != null) {
|
|
361
|
+
setTimeout(() => onSelect());
|
|
345
362
|
}
|
|
363
|
+
|
|
364
|
+
onChange?.();
|
|
346
365
|
}}
|
|
347
|
-
onMouseOver={
|
|
366
|
+
onMouseOver={openSubmenu}
|
|
348
367
|
>
|
|
349
|
-
{item
|
|
350
|
-
{item
|
|
368
|
+
{item.icon ? <i className={'icon-' + item.icon} /> : null}
|
|
369
|
+
{item.label}
|
|
351
370
|
</button>
|
|
352
|
-
<ul role="menu" ref={refSubMenu} style={{display: 'none'}} className="dropdown__menu">
|
|
353
|
-
{subMenuItems}
|
|
354
|
-
</ul>
|
|
355
371
|
</div>
|
|
372
|
+
{open &&
|
|
373
|
+
ReactDOM.createPortal(
|
|
374
|
+
<SubmenuHoverContext.Provider
|
|
375
|
+
value={{
|
|
376
|
+
keepParentOpen: openSubmenu,
|
|
377
|
+
scheduleParentClose: () => scheduleClose(true),
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<ul
|
|
381
|
+
role="menu"
|
|
382
|
+
ref={refSubMenu}
|
|
383
|
+
className="dropdown__menu"
|
|
384
|
+
style={{display: 'block', zIndex: submenuZIndex}}
|
|
385
|
+
onMouseEnter={openSubmenu}
|
|
386
|
+
onMouseLeave={handleMouseLeave}
|
|
387
|
+
>
|
|
388
|
+
{subMenuItems}
|
|
389
|
+
</ul>
|
|
390
|
+
</SubmenuHoverContext.Provider>,
|
|
391
|
+
document.body,
|
|
392
|
+
)}
|
|
356
393
|
</li>
|
|
357
394
|
);
|
|
358
395
|
};
|