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.
@@ -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<IMenuItem | ISubmenu | IMenuGroup | 'divider'>;
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<IMenuItem | ISubmenu | IMenuGroup | 'divider'>;
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<IMenuItem | ISubmenu | IMenuGroup | 'divider'>;
35
- header?: Array<IMenuItem | ISubmenu | IMenuGroup | 'divider'>;
36
- footer?: Array<IMenuItem | ISubmenu | IMenuGroup | 'divider'>;
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
- export const Dropdown = ({items, header, footer, children, align, onChange, maxHeight}: IMenu) => {
45
- const [zIndex, setZIndex] = React.useState<number>(-1);
46
+ function ensureDropdownContainer(): HTMLElement {
47
+ let placeholder = document.getElementById(DROPDOWN_ID_CONTAINER);
46
48
 
47
- if (zIndex === -1) {
48
- setZIndex(getNextZIndex());
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 [change, setChange] = React.useState(false);
67
+ const [container, setContainer] = React.useState<HTMLElement | null>(null);
53
68
  const [menuID] = useId();
54
- const ref = React.useRef(null);
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
- const dropdownElements = items.map((el, index) => {
62
- return each(el, index);
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
- const footerElements = footer?.map((el, index) => {
66
- return each(el, index);
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
- const existingElement = document.getElementById(DROPDOWN_ID_CONTAINER);
71
- if (!existingElement) {
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
- React.useLayoutEffect(() => {
86
- if (change) {
87
- addInPlaceholder();
88
- }
89
- setChange(true);
94
+ const closeMenu = () => setOpen(false);
95
+
96
+ document.addEventListener('click', closeMenu);
97
+
98
+ return () => document.removeEventListener('click', closeMenu);
90
99
  }, [open]);
91
100
 
92
- function createAppendMenu() {
93
- if (header && footer) {
94
- return (
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
- function toggleDisplay() {
149
- if (!open) {
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
- function closeMenu() {
173
- document.removeEventListener('click', closeMenu);
174
- setOpen(false);
175
- }
109
+ const popper = createPopper(toggle, menu, {
110
+ placement: align === 'right' ? 'bottom-end' : 'bottom-start',
111
+ strategy: 'fixed',
112
+ });
176
113
 
177
- function checkAlign() {
178
- if (align === 'right') {
179
- return true;
180
- } else {
181
- return false;
182
- }
183
- }
114
+ menu.getElementsByTagName('button')[0]?.focus();
184
115
 
185
- function addInPlaceholder() {
186
- const placeholder = document.getElementById(DROPDOWN_ID_CONTAINER);
187
- let menu = createAppendMenu();
188
- if (open) {
189
- return ReactDOM.render(menu, placeholder);
190
- } else {
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
- function each(item: any, index: number) {
198
- if (item['type'] === 'submenu') {
199
- let submenuItems: any = [];
200
- item['items'].forEach((el: any, key: number) => {
201
- submenuItems.push(each(el, key));
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
- menuID={menuID}
210
- subMenuItems={submenuItems}
146
+ zIndex={zIndex}
147
+ subMenuItems={item.items.map(each)}
211
148
  onChange={onChange}
212
149
  />
213
150
  );
214
- } else if (item['type'] === 'group') {
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['label']}</div>
157
+ <div className="dropdown__menu-label">{item.label}</div>
224
158
  </li>
225
- {groupItems}
159
+ {item.items.map(each)}
226
160
  </React.Fragment>
227
161
  );
228
- } else if (item === 'divider') {
229
- return <li className="dropdown__menu-divider" key={index}></li>;
230
- } else {
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
- <DropdownItem
233
- key={index}
234
- label={item['label']}
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={'dropdown ' + (open ? 'open' : '')}>
246
- {typeof children === 'object' ? (
247
- React.isValidElement(children) ? (
248
- <div ref={buttonRef} style={{display: 'content'}}>
249
- {(() => {
250
- const attrs = {
251
- className: children.props.className
252
- ? children.props.className + ' dropdown__toggle dropdown-toggle'
253
- : 'dropdown__toggle dropdown-toggle',
254
- 'aria-haspopup': 'menu',
255
- 'aria-expanded': open,
256
- onClick: toggleDisplay,
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
- style={{whiteSpace: 'nowrap'}}
267
- ref={buttonRef}
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"></span>
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
- if (onChange) {
293
- onChange();
294
- }
255
+
256
+ onChange?.();
295
257
  }}
296
258
  >
297
- <i className={icon ? 'icon-' + icon : ''}></i>
259
+ <i className={icon ? 'icon-' + icon : ''} />
298
260
  {label}
299
261
  </button>
300
262
  </li>
301
263
  );
302
264
  };
303
265
 
304
- const DropdownItemWithSubmenu = ({index, item, menuID, subMenuItems, onChange}: IMenuItem | any) => {
305
- const [open, setOpen] = React.useState<undefined | boolean>(undefined);
266
+ const SUBMENU_CLOSE_DELAY_MS = 150;
306
267
 
307
- const refButtonSubMenu = React.useRef(null);
308
- const refSubMenu = React.useRef(null);
309
- const placeholder = document.getElementById(menuID);
268
+ const SubmenuHoverContext = React.createContext<{
269
+ keepParentOpen: () => void;
270
+ scheduleParentClose: () => void;
271
+ }>({
272
+ keepParentOpen: () => undefined,
273
+ scheduleParentClose: () => undefined,
274
+ });
310
275
 
311
- React.useEffect(() => {
312
- let subMenuRef: any = refSubMenu.current;
313
- let subToggleRef = refButtonSubMenu.current;
314
-
315
- if (open === true) {
316
- placeholder?.appendChild(subMenuRef);
317
- subMenuRef.style.display = 'block';
318
- } else if (open === false) {
319
- placeholder?.removeChild(subMenuRef);
320
- subMenuRef.style.display = 'none';
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
- if (subMenuRef && subToggleRef) {
324
- createPopper(subToggleRef, subMenuRef, {
325
- placement: 'right-start',
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 key={index} ref={refButtonSubMenu}>
332
- <div className="dropdown" onMouseLeave={() => setOpen(false)}>
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 (item.onSelect) {
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={() => setOpen(true)}
366
+ onMouseOver={openSubmenu}
348
367
  >
349
- {item['icon'] ? <i className={'icon-' + item['icon']}></i> : null}
350
- {item['label']}
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
  };