goblin-magic 1.3.0 → 1.6.0

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "goblin-magic",
3
- "version": "1.3.0",
3
+ "version": "1.6.0",
4
4
  "description": "goblin-magic",
5
5
  "author": "Epsitec SA",
6
6
  "contributors": [
7
7
  "Yannick Vessaz <vessaz@epsitec.ch>",
8
- "Mathieu Schroeter <schroeter@epsitec.ch>"
8
+ "Mathieu Schroeter <schroeter@epsitec.ch>",
9
+ "Samuel Loup <loup@epsitec.ch>"
9
10
  ],
10
11
  "license": "MIT",
11
12
  "config": {
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const {expect} = require('chai');
4
+ const {Elf} = require('xcraft-core-goblin/lib/test.js');
5
+
6
+ function restore(navLogic) {
7
+ navLogic.change('windows', {
8
+ 'win@1': {
9
+ panelIds: ['panel@1', 'panel@2'],
10
+ dialogIds: ['dialog@1'],
11
+ activePanelId: 'panel@1',
12
+ },
13
+ });
14
+ navLogic.change('panels', {
15
+ 'panel@1': {
16
+ tabIds: ['tab@1', 'tab@2', 'tab@3'],
17
+ lastTabIds: [],
18
+ },
19
+ 'panel@2': {
20
+ tabIds: ['tab@4', 'tab@5', 'tab@6'],
21
+ lastTabIds: [],
22
+ },
23
+ });
24
+ navLogic.change('tabs', {
25
+ 'tab@1': {highlighted: false},
26
+ 'tab@2': {highlighted: false},
27
+ 'tab@3': {highlighted: false},
28
+ 'tab@4': {highlighted: false},
29
+ 'tab@5': {highlighted: false},
30
+ 'tab@6': {highlighted: false},
31
+ });
32
+ }
33
+
34
+ describe('goblin.magic.navigation', function () {
35
+ const {
36
+ MagicNavigationLogic,
37
+ } = require('../widgets/magic-navigation/service.js');
38
+
39
+ it('moveTab', function () {
40
+ const navLogic = Elf.trial(MagicNavigationLogic);
41
+ navLogic.create('magicNavigation@test');
42
+
43
+ // --- one panel -----------------------------------------------------------
44
+
45
+ /* 1,2,3 → 2,3,1 */
46
+ restore(navLogic);
47
+ navLogic.moveTab('panel@1', 'tab@1', 'panel@1', 'tab@3', 'right');
48
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
49
+ .to.be.eql(['tab@2', 'tab@3', 'tab@1']);
50
+
51
+ /* 1,2,3 → 2,1,3 */
52
+ restore(navLogic);
53
+ navLogic.moveTab('panel@1', 'tab@1', 'panel@1', 'tab@3', 'left');
54
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
55
+ .to.be.eql(['tab@2', 'tab@1', 'tab@3']);
56
+
57
+ /* 1,2,3 → 2,1,3 */
58
+ restore(navLogic);
59
+ navLogic.moveTab('panel@1', 'tab@1', 'panel@1', 'tab@2', 'right');
60
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
61
+ .to.be.eql(['tab@2', 'tab@1', 'tab@3']);
62
+
63
+ /* 1,2,3 → 1,2,3 */
64
+ restore(navLogic);
65
+ navLogic.moveTab('panel@1', 'tab@1', 'panel@1', 'tab@2', 'left');
66
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
67
+ .to.be.eql(['tab@1', 'tab@2', 'tab@3']);
68
+
69
+ /* 1,2,3 → 3,1,2 */
70
+ restore(navLogic);
71
+ navLogic.moveTab('panel@1', 'tab@3', 'panel@1', 'tab@1', 'left');
72
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
73
+ .to.be.eql(['tab@3', 'tab@1', 'tab@2']);
74
+
75
+ /* 1,2,3 → 1,3,2 */
76
+ restore(navLogic);
77
+ navLogic.moveTab('panel@1', 'tab@3', 'panel@1', 'tab@1', 'right');
78
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
79
+ .to.be.eql(['tab@1', 'tab@3', 'tab@2']);
80
+
81
+ // --- two panels ----------------------------------------------------------
82
+
83
+ /* 2,3 → 4,5,6,1 */
84
+ restore(navLogic);
85
+ navLogic.moveTab('panel@1', 'tab@1', 'panel@2', 'tab@6', 'right');
86
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
87
+ .to.be.eql(['tab@2', 'tab@3']);
88
+ expect(navLogic.state.panels['panel@2'].tabIds.toJS()) //
89
+ .to.be.eql(['tab@4', 'tab@5', 'tab@6', 'tab@1']);
90
+
91
+ /* 1,3 → 2,4,5,6 */
92
+ restore(navLogic);
93
+ navLogic.moveTab('panel@1', 'tab@2', 'panel@2', 'tab@4', 'left');
94
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
95
+ .to.be.eql(['tab@1', 'tab@3']);
96
+ expect(navLogic.state.panels['panel@2'].tabIds.toJS()) //
97
+ .to.be.eql(['tab@2', 'tab@4', 'tab@5', 'tab@6']);
98
+
99
+ /* 1,2 → 4,5,3,6 */
100
+ restore(navLogic);
101
+ navLogic.moveTab('panel@1', 'tab@3', 'panel@2', 'tab@5', 'right');
102
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
103
+ .to.be.eql(['tab@1', 'tab@2']);
104
+ expect(navLogic.state.panels['panel@2'].tabIds.toJS()) //
105
+ .to.be.eql(['tab@4', 'tab@5', 'tab@3', 'tab@6']);
106
+
107
+ /* . → 2,4,1,5,6,3 */
108
+ restore(navLogic);
109
+ navLogic.moveTab('panel@1', 'tab@2', 'panel@2', 'tab@4', 'left');
110
+ navLogic.moveTab('panel@1', 'tab@1', 'panel@2', 'tab@5', 'left');
111
+ navLogic.moveTab('panel@1', 'tab@3', 'panel@2', 'tab@6', 'right');
112
+ expect(navLogic.state.panels['panel@1'].tabIds.toJS()) //
113
+ .to.be.eql([]);
114
+ expect(navLogic.state.panels['panel@2'].tabIds.toJS()) //
115
+ .to.be.eql(['tab@2', 'tab@4', 'tab@1', 'tab@5', 'tab@6', 'tab@3']);
116
+ });
117
+ });
@@ -0,0 +1,116 @@
1
+ import React from 'react';
2
+ import Widget from 'goblin-laboratory/widgets/widget';
3
+ import Dialog from '../dialog/widget.js';
4
+ import ListenerStack from '../listener-stack/listener-stack.js';
5
+
6
+ const listenerStack = new ListenerStack();
7
+
8
+ /**
9
+ * Dialog with custom event handling.
10
+ *
11
+ * When an html dialog with closedby="any" or closedby="closerequest" is closed using ESC,
12
+ * it's not always possible to cancel the 'cancel' event. See https://issues.chromium.org/issues/351867704.
13
+ * This component provides a dialog where the 'cancel' event can be canceled.
14
+ */
15
+ export default class CancelableDialog extends Widget {
16
+ constructor() {
17
+ super(...arguments);
18
+ /** @type {React.RefObject<Dialog>} */
19
+ this.dialog = React.createRef();
20
+ this.closeEnabled = false;
21
+ }
22
+
23
+ get dialogElement() {
24
+ return this.dialog.current.dialog.current;
25
+ }
26
+
27
+ componentDidMount() {
28
+ this.updateEscHandler();
29
+ }
30
+
31
+ componentDidUpdate() {
32
+ this.updateEscHandler();
33
+ }
34
+
35
+ componentWillUnmount() {
36
+ this.removeEscHandler();
37
+ }
38
+
39
+ updateEscHandler() {
40
+ if (this.props.open) {
41
+ this.addEscHandler();
42
+ } else {
43
+ this.removeEscHandler();
44
+ }
45
+ }
46
+
47
+ addEscHandler() {
48
+ // Do action only on the latest opened dialog
49
+ listenerStack.push('keydown', this.handleKeyDown);
50
+ }
51
+
52
+ removeEscHandler() {
53
+ listenerStack.pop('keydown', this.handleKeyDown);
54
+ }
55
+
56
+ close = () => {
57
+ this.dialog.current?.close();
58
+ };
59
+
60
+ cancel = (reason) => {
61
+ const event = new CustomEvent('cancel', {
62
+ detail: {reason},
63
+ cancelable: true,
64
+ });
65
+ this.props.onCancel?.(event);
66
+ if (!event.defaultPrevented) {
67
+ this.dialog.current.close();
68
+ }
69
+ };
70
+
71
+ handlePointerDown = (event) => {
72
+ this.props.onPointerDown?.(event);
73
+ if (event.target === this.dialogElement) {
74
+ this.closeEnabled = true;
75
+ } else {
76
+ this.closeEnabled = false;
77
+ }
78
+ };
79
+
80
+ /**
81
+ * @param {PointerEvent} event
82
+ */
83
+ handlePointerUp = (event) => {
84
+ this.props.onPointerUp?.(event);
85
+ if (this.closeEnabled && event.target === this.dialogElement) {
86
+ this.cancel('outside-click');
87
+ event.stopPropagation();
88
+ }
89
+ this.closeEnabled = false;
90
+ };
91
+
92
+ /**
93
+ * @param {KeyboardEvent} event
94
+ */
95
+ handleKeyDown = (event) => {
96
+ if (event.defaultPrevented) {
97
+ return;
98
+ }
99
+ if (event.key === 'Escape') {
100
+ this.cancel('escape-key');
101
+ }
102
+ };
103
+
104
+ render() {
105
+ const {onCancel, ...props} = this.props;
106
+ return (
107
+ <Dialog
108
+ closedby="none"
109
+ {...props}
110
+ ref={this.dialog}
111
+ onPointerDown={this.handlePointerDown}
112
+ onPointerUp={this.handlePointerUp}
113
+ />
114
+ );
115
+ }
116
+ }
@@ -11,13 +11,37 @@ export default class Dialog extends Widget {
11
11
  }
12
12
 
13
13
  componentDidMount() {
14
+ this.updateToggleListener();
14
15
  this.update();
15
16
  }
16
17
 
17
18
  componentDidUpdate() {
19
+ this.updateToggleListener();
18
20
  this.update();
19
21
  }
20
22
 
23
+ componentWillUnmount() {
24
+ this.removeToggleListener();
25
+ }
26
+
27
+ updateToggleListener() {
28
+ // Using the `onToggle` prop on the <dialog> component does not work.
29
+ // So we register the 'toggle' event manually.
30
+ if (this.props.onToggle) {
31
+ this.addToggleListener();
32
+ } else {
33
+ this.removeToggleListener();
34
+ }
35
+ }
36
+
37
+ addToggleListener() {
38
+ this.dialog.current?.addEventListener('toggle', this.props.onToggle);
39
+ }
40
+
41
+ removeToggleListener() {
42
+ this.dialog.current?.removeEventListener('toggle', this.props.onToggle);
43
+ }
44
+
21
45
  close = () => {
22
46
  this.dialog.current?.close();
23
47
  };
@@ -27,23 +51,6 @@ export default class Dialog extends Widget {
27
51
  event.stopPropagation();
28
52
  };
29
53
 
30
- handlePointerDown = (event) => {
31
- this.props.onPointerDown?.(event);
32
- if (event.target === this.dialog.current) {
33
- this.closeEnabled = true;
34
- } else {
35
- this.closeEnabled = false;
36
- }
37
- };
38
-
39
- handlePointerUp = (event) => {
40
- this.props.onPointerUp?.(event);
41
- if (this.closeEnabled && event.target === this.dialog.current) {
42
- this.dialog.current.close();
43
- }
44
- this.closeEnabled = false;
45
- };
46
-
47
54
  update() {
48
55
  if (this.props.open) {
49
56
  if (!this.dialog.current?.open) {
@@ -59,15 +66,9 @@ export default class Dialog extends Widget {
59
66
  }
60
67
 
61
68
  render() {
62
- const {open, modal, portal = false, ...props} = this.props;
69
+ const {open, modal, portal = false, onToggle, ...props} = this.props;
63
70
  const dialog = (
64
- <dialog
65
- {...props}
66
- ref={this.dialog}
67
- onClose={this.handleClose}
68
- onPointerDown={this.handlePointerDown}
69
- onPointerUp={this.handlePointerUp}
70
- />
71
+ <dialog {...props} ref={this.dialog} onClose={this.handleClose} />
71
72
  );
72
73
 
73
74
  if (portal) {
@@ -0,0 +1,62 @@
1
+ // @ts-check
2
+
3
+ export default class ListenerStack {
4
+ /** @type {Map<string,Function[]>} */
5
+ stacks = new Map();
6
+
7
+ /**
8
+ * @param {string} type
9
+ * @returns {Function[]}
10
+ */
11
+ getStack(type) {
12
+ let stack = this.stacks.get(type);
13
+ if (!stack) {
14
+ stack = [];
15
+ this.stacks.set(type, stack);
16
+ }
17
+ return stack;
18
+ }
19
+
20
+ /**
21
+ * @param {Event} event
22
+ */
23
+ handleEvent = (event) => {
24
+ const stack = this.getStack(event.type);
25
+ const last = stack.at(-1);
26
+ if (!last) {
27
+ throw new Error('Empty stack');
28
+ }
29
+ last(event);
30
+ };
31
+
32
+ /**
33
+ * @template {keyof WindowEventMap} T
34
+ * @param {T} type
35
+ * @param {(event: WindowEventMap[T]) => any} listener
36
+ */
37
+ push(type, listener) {
38
+ const stack = this.getStack(type);
39
+ stack.push(listener);
40
+ if (stack.length === 1) {
41
+ window.addEventListener(type, this.handleEvent);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @template {keyof WindowEventMap} T
47
+ * @param {T} type
48
+ * @param {(event: WindowEventMap[T]) => any} listener
49
+ */
50
+ pop(type, listener) {
51
+ const stack = this.getStack(type);
52
+ const index = stack.indexOf(listener);
53
+ if (index === -1) {
54
+ console.error(`Unknown listener for type ${type}`);
55
+ return;
56
+ }
57
+ stack.splice(index, 1);
58
+ if (stack.length === 0) {
59
+ window.removeEventListener(type, this.handleEvent);
60
+ }
61
+ }
62
+ }
@@ -1,9 +1,9 @@
1
1
  import React from 'react';
2
2
  import Widget from 'goblin-laboratory/widgets/widget';
3
3
  import * as styles from './styles.js';
4
- import Dialog from '../dialog/widget.js';
5
4
  import Movable from '../movable/widget.js';
6
5
  import isEmptyAreaElement from '../element-helpers/is-empty-area-element.js';
6
+ import CancelableDialog from '../cancelable-dialog/widget.js';
7
7
 
8
8
  /**
9
9
  * @param {PointerEvent} event1
@@ -21,7 +21,7 @@ class MagicDialog extends Widget {
21
21
  constructor() {
22
22
  super(...arguments);
23
23
  this.styles = styles;
24
- /** @type {React.RefObject<Dialog>} */
24
+ /** @type {React.RefObject<CancelableDialog>} */
25
25
  this.dialog = React.createRef();
26
26
  }
27
27
 
@@ -57,7 +57,7 @@ class MagicDialog extends Widget {
57
57
 
58
58
  handlePointerDown = (event) => {
59
59
  const {target} = event;
60
- const dialog = this.dialog.current?.dialog.current;
60
+ const dialog = this.dialog.current?.dialogElement;
61
61
  if (dialog && !dialog.contains(target)) {
62
62
  if (isEmptyAreaElement(target)) {
63
63
  this.downEvent = event;
@@ -76,30 +76,14 @@ class MagicDialog extends Widget {
76
76
  }
77
77
  };
78
78
 
79
- handleKeyDown = (event) => {
80
- this.props.onKeyDown?.(event);
81
- if (event.defaultPrevented) {
82
- return;
83
- }
84
- if (event.key === 'Escape') {
85
- this.dialog.current.close();
86
- event.stopPropagation();
87
- }
88
- };
89
-
90
79
  render() {
91
80
  const {className = '', ...props} = this.props;
92
81
  return (
93
82
  <Movable>
94
83
  {(moveableProps) => (
95
- <Dialog
84
+ <CancelableDialog
96
85
  {...props}
97
86
  {...moveableProps}
98
- onKeyDown={
99
- !this.props.modal && this.props.onClose
100
- ? this.handleKeyDown
101
- : undefined
102
- }
103
87
  ref={this.dialog}
104
88
  className={this.styles.classNames.dialog + ' ' + className}
105
89
  open={this.props.open}
@@ -258,6 +258,40 @@ class MagicNavigationLogic extends Elf.Spirit {
258
258
  }
259
259
  }
260
260
 
261
+ /**
262
+ * @param {id} srcPanelId
263
+ * @param {id} srcTabId
264
+ * @param {id} dstPanelId
265
+ * @param {id} dstTabId
266
+ * @param {`left`|`right`} side
267
+ */
268
+ moveTab(srcPanelId, srcTabId, dstPanelId, dstTabId, side) {
269
+ const {state} = this;
270
+ const tabs = [...state.panels[dstPanelId].tabIds];
271
+
272
+ if (srcPanelId === dstPanelId) {
273
+ const srcIndex = state.panels[srcPanelId].tabIds.indexOf(srcTabId);
274
+ if (side === 'right') {
275
+ tabs.splice(srcIndex, 1);
276
+ } else {
277
+ tabs.splice(srcIndex, 1);
278
+ }
279
+ }
280
+
281
+ if (side === 'right') {
282
+ const dstIndex = tabs.indexOf(dstTabId);
283
+ tabs.splice(dstIndex + 1, 0, srcTabId);
284
+ } else {
285
+ const dstIndex = tabs.indexOf(dstTabId);
286
+ tabs.splice(dstIndex, 0, srcTabId);
287
+ }
288
+ state.panels[dstPanelId].tabIds = tabs;
289
+
290
+ if (srcPanelId !== dstPanelId) {
291
+ this._removeTabAndUpdatePanel(state, srcPanelId, srcTabId);
292
+ }
293
+ }
294
+
261
295
  /**
262
296
  * @param {typeof this["state"]} state
263
297
  * @param {id} panelId
@@ -574,7 +608,7 @@ class MagicNavigation extends Elf {
574
608
  * @param {id} parentId
575
609
  * @param {string} prompt
576
610
  * @param {object} [options]
577
- * @param {'default' | 'yes-no'} [options.kind]
611
+ * @param {'default' | 'yes-no' | 'yes-no-cancel'} [options.kind]
578
612
  * @param {string} [options.advice]
579
613
  * @param {string} [options.okLabel]
580
614
  * @param {string} [options.noLabel]
@@ -765,8 +799,14 @@ class MagicNavigation extends Elf {
765
799
  }
766
800
 
767
801
  async _hasSameWidget(view1, view2) {
768
- const widget1 = view1.widget || getServiceName(view1.service);
769
- const widget2 = view2.widget || getServiceName(view2.service);
802
+ const widget1 =
803
+ view1.widget ||
804
+ getServiceName(view1.service) ||
805
+ view1.serviceId.split('@', 1)[0];
806
+ const widget2 =
807
+ view2.widget ||
808
+ getServiceName(view2.service) ||
809
+ view2.serviceId.split('@', 1)[0];
770
810
  return (
771
811
  widget1[0].toLowerCase() + widget1.slice(1) ===
772
812
  widget2[0].toLowerCase() + widget2.slice(1)
@@ -949,6 +989,14 @@ class MagicNavigation extends Elf {
949
989
  )?.[0];
950
990
  }
951
991
 
992
+ /**
993
+ * @param {id} tabId
994
+ * @returns {Promise<string | null | undefined>}
995
+ */
996
+ async getServiceId(tabId) {
997
+ return this.state.tabs[tabId]?.serviceId;
998
+ }
999
+
952
1000
  /**
953
1001
  * @param {id} panelId
954
1002
  * @returns {Promise<DesktopId | undefined >}
@@ -985,6 +1033,26 @@ class MagicNavigation extends Elf {
985
1033
  this.logic.activateTab(panelId, tabId, keepHistory);
986
1034
  }
987
1035
 
1036
+ /**
1037
+ * @param {id} srcTabId
1038
+ * @param {id} dstTabId
1039
+ * @param {`left`|`right`} side
1040
+ */
1041
+ async moveTab(srcTabId, dstTabId, side) {
1042
+ if (srcTabId === dstTabId) {
1043
+ return;
1044
+ }
1045
+ const srcPanelId = await this.findPanelId(srcTabId);
1046
+ if (!srcPanelId) {
1047
+ throw new Error(`Unknown tab '${srcTabId}'`);
1048
+ }
1049
+ const dstPanelId = await this.findPanelId(dstTabId);
1050
+ if (!dstPanelId) {
1051
+ throw new Error(`Unknown tab '${dstTabId}'`);
1052
+ }
1053
+ this.logic.moveTab(srcPanelId, srcTabId, dstPanelId, dstTabId, side);
1054
+ }
1055
+
988
1056
  /**
989
1057
  * @param {DesktopId} desktopId
990
1058
  * @returns {Promise<id | null | undefined>}
@@ -1226,6 +1294,34 @@ class MagicNavigation extends Elf {
1226
1294
  this.quest.evt(`${dialogId}-closed`, result);
1227
1295
  }
1228
1296
 
1297
+ /**
1298
+ * @param {id} viewId
1299
+ * @param {any} [result]
1300
+ * @returns {Promise<boolean>}
1301
+ */
1302
+ async _onCloseRequested(viewId, result) {
1303
+ const view = this.views.get(viewId);
1304
+ if (!view) {
1305
+ throw new Error(`Missing view '${viewId}'`);
1306
+ }
1307
+ const serviceId = this.state.tabs[viewId].serviceId;
1308
+ if (view.service && serviceId) {
1309
+ if (typeof view.service === 'string') {
1310
+ const serviceAPI = this.quest.getAPI(serviceId);
1311
+ if (serviceAPI.onCloseRequested) {
1312
+ return await serviceAPI.onCloseRequested(result);
1313
+ }
1314
+ } else {
1315
+ const ServiceClass = view.service;
1316
+ const serviceAPI = await new ServiceClass(this).api(serviceId);
1317
+ if (serviceAPI.onCloseRequested) {
1318
+ return await serviceAPI.onCloseRequested(result);
1319
+ }
1320
+ }
1321
+ }
1322
+ return true;
1323
+ }
1324
+
1229
1325
  /**
1230
1326
  * @param {id} viewOrServiceId id of dialog, tab or service
1231
1327
  * @returns {Promise<any>}
@@ -1283,6 +1379,18 @@ class MagicNavigation extends Elf {
1283
1379
  }
1284
1380
  }
1285
1381
 
1382
+ /**
1383
+ * @param {DesktopId} desktopId
1384
+ * @param {id} viewId
1385
+ * @param {any} [result]
1386
+ */
1387
+ async requestClose(desktopId, viewId, result) {
1388
+ const canBeClosed = await this._onCloseRequested(viewId, result);
1389
+ if (canBeClosed) {
1390
+ await this.closeView(desktopId, viewId, result);
1391
+ }
1392
+ }
1393
+
1286
1394
  /**
1287
1395
  * @param {id} viewOrServiceId
1288
1396
  * @param {DesktopId} desktopId
@@ -210,30 +210,35 @@ const MagicNavigationTab = withC(MagicNavigationTabNC);
210
210
  let MagicNavigationTabs = class extends Widget {
211
211
  constructor() {
212
212
  super(...arguments);
213
- this.handleKeyDown = this.handleKeyDown.bind(this);
214
- this.setTab = this.setTab.bind(this);
215
- this.handleAuxClick = this.handleAuxClick.bind(this);
216
213
  }
217
214
 
218
- handleKeyDown(event) {
215
+ handleKeyDown = (event) => {
219
216
  if (event.key === 'ArrowLeft') {
220
217
  this.doFor('magicNavigation@main', 'switchTab', {reverse: true});
221
218
  } else if (event.key === 'ArrowRight') {
222
219
  this.doFor('magicNavigation@main', 'switchTab');
223
220
  }
224
- }
221
+ };
225
222
 
226
- setTab(tabId) {
223
+ setTab = (tabId) => {
227
224
  this.doFor('magicNavigation@main', 'activateTab', {tabId});
228
- }
225
+ };
226
+
227
+ moveTab = (srcTabId, dstTabId, side) => {
228
+ this.doFor('magicNavigation@main', 'moveTab', {
229
+ srcTabId,
230
+ dstTabId,
231
+ side,
232
+ });
233
+ };
229
234
 
230
- handleAuxClick(event) {
235
+ handleAuxClick = (event) => {
231
236
  if (event.button === 1) {
232
237
  // Middle click
233
238
  const tabId = event.currentTarget.dataset.value;
234
239
  this.doFor('magicNavigation@main', 'closeTab', {tabId});
235
240
  }
236
- }
241
+ };
237
242
 
238
243
  render() {
239
244
  const tabIds = this.props.tabIds;
@@ -242,6 +247,7 @@ let MagicNavigationTabs = class extends Widget {
242
247
  <MainTabs
243
248
  currentTab={currentTabId}
244
249
  onTabClick={this.setTab}
250
+ onTabDrop={this.moveTab}
245
251
  tabIndex="0"
246
252
  onKeyDown={this.handleKeyDown}
247
253
  >
@@ -365,23 +371,35 @@ MagicNavigationPanels = withC(MagicNavigationPanels);
365
371
  let MagicNavigationDialog = class extends Widget {
366
372
  constructor() {
367
373
  super(...arguments);
368
- this.handleClose = this.handleClose.bind(this);
369
374
  }
370
375
 
371
- handleClose(event) {
376
+ handleClose = (event) => {
372
377
  const returnValue = event.currentTarget.returnValue;
373
378
  const dialogId = this.props.dialogId;
374
379
  this.doFor('magicNavigation@main', 'closeDialog', {
375
380
  dialogId,
376
381
  result: returnValue !== '' ? returnValue : undefined,
377
382
  });
378
- }
383
+ };
384
+
385
+ handleCancel = (event) => {
386
+ const dialogId = this.props.dialogId;
387
+ this.doFor('magicNavigation@main', 'requestClose', {
388
+ viewId: dialogId,
389
+ });
390
+ event.preventDefault();
391
+ };
379
392
 
380
393
  render() {
381
394
  const dialogId = this.props.dialogId;
382
395
  const widgetProps = this.props.view.get('widgetProps')?.toObject();
383
396
  return (
384
- <MagicDialog modal={widgetProps?.modal} open onClose={this.handleClose}>
397
+ <MagicDialog
398
+ modal={widgetProps?.modal}
399
+ open
400
+ onClose={this.handleClose}
401
+ onCancel={this.handleCancel}
402
+ >
385
403
  <MagicNavigationView
386
404
  id={this.props.id}
387
405
  viewId={dialogId}
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
2
  import Widget from 'goblin-laboratory/widgets/widget';
3
3
  import * as styles from './styles.js';
4
- import Dialog from '../dialog/widget.js';
5
4
  import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
6
5
  import {faBars, faChevronRight} from '@fortawesome/free-solid-svg-icons';
7
6
  import MagicButton from 'goblin-magic/widgets/magic-button/widget.js';
8
7
  import WithComputedSize from '../with-computed-size/widget.js';
8
+ import MenuDialog from '../menu-dialog/widget.js';
9
9
 
10
10
  const MenuContext = React.createContext();
11
11
  const MenuStateContext = React.createContext();
@@ -254,6 +254,23 @@ class MenuContent extends Widget {
254
254
  this.stopPropagation = this.stopPropagation.bind(this);
255
255
  }
256
256
 
257
+ /**
258
+ * @param {ToggleEvent} event
259
+ */
260
+ setFocus = (event) => {
261
+ if (event.newState !== 'open') {
262
+ return;
263
+ }
264
+ /** @type {HTMLDialogElement} */
265
+ const dialog = event.currentTarget;
266
+ if (!document.activeElement || document.activeElement === document.body) {
267
+ const firstFocusable = dialog.querySelector('[tabindex]');
268
+ if (firstFocusable) {
269
+ firstFocusable.focus();
270
+ }
271
+ }
272
+ };
273
+
257
274
  /**
258
275
  * @param {Event} event
259
276
  * @param {Menu} menu
@@ -290,6 +307,7 @@ class MenuContent extends Widget {
290
307
  } else if (event.key === 'ArrowUp') {
291
308
  this.focusNext(event, -1);
292
309
  }
310
+ event.stopPropagation();
293
311
  };
294
312
 
295
313
  getStyle(state, size) {
@@ -461,9 +479,10 @@ class MenuContent extends Widget {
461
479
  ...props
462
480
  } = this.props;
463
481
  return (
464
- <Dialog
482
+ <MenuDialog
465
483
  open={open}
466
484
  modal={modal}
485
+ onToggle={this.setFocus}
467
486
  onClose={(event) => this.handleClose(event, menu)}
468
487
  className={this.styles.classNames.menuDialog}
469
488
  portal={portal}
@@ -496,7 +515,7 @@ class MenuContent extends Widget {
496
515
  }}
497
516
  </WithComputedSize>
498
517
  )}
499
- </Dialog>
518
+ </MenuDialog>
500
519
  );
501
520
  }
502
521
 
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import Widget from 'goblin-laboratory/widgets/widget';
3
+ import Dialog from '../dialog/widget.js';
4
+
5
+ export default class MenuDialog extends Widget {
6
+ constructor() {
7
+ super(...arguments);
8
+ /** @type {React.RefObject<Dialog>} */
9
+ this.dialog = React.createRef();
10
+ this.closeEnabled = false;
11
+ }
12
+
13
+ get dialogElement() {
14
+ return this.dialog.current.dialog.current;
15
+ }
16
+
17
+ close = () => {
18
+ this.dialog.current?.close();
19
+ };
20
+
21
+ handlePointerDown = (event) => {
22
+ this.props.onPointerDown?.(event);
23
+ if (event.target === this.dialogElement) {
24
+ this.closeEnabled = true;
25
+ } else {
26
+ this.closeEnabled = false;
27
+ }
28
+ };
29
+
30
+ handlePointerUp = (event) => {
31
+ this.props.onPointerUp?.(event);
32
+ if (this.closeEnabled && event.target === this.dialogElement) {
33
+ this.dialog.current.close();
34
+ }
35
+ this.closeEnabled = false;
36
+ };
37
+
38
+ render() {
39
+ const {onCancel, ...props} = this.props;
40
+ return (
41
+ <Dialog
42
+ closedby="any"
43
+ // Even with closedby="any", the dialog is not closed by clicking on the backdrop.
44
+ // So we add custom pointerDown and pointerUp handlers.
45
+ {...props}
46
+ ref={this.dialog}
47
+ onPointerDown={this.handlePointerDown}
48
+ onPointerUp={this.handlePointerUp}
49
+ />
50
+ );
51
+ }
52
+ }
@@ -45,6 +45,7 @@ class Movable extends Widget {
45
45
  handlePointerDown = (event) => {
46
46
  this.element = event.currentTarget;
47
47
  if (
48
+ this.element !== event.target &&
48
49
  this.element.contains(event.target) &&
49
50
  isEmptyAreaElement(event.target, this.element)
50
51
  ) {
@@ -22,6 +22,22 @@ export default function styles() {
22
22
 
23
23
  ':active': {},
24
24
  },
25
+
26
+ '& > .drop-left': {
27
+ willChange: 'box-shadow',
28
+ position: 'relative',
29
+ transition: 'box-shadow 0.2s ease',
30
+ boxShadow:
31
+ 'inset 4px 0 0 color-mix(in srgb, var(--text-color), transparent 40%)',
32
+ },
33
+
34
+ '& > .drop-right': {
35
+ willChange: 'box-shadow',
36
+ position: 'relative',
37
+ transition: 'box-shadow 0.2s ease',
38
+ boxShadow:
39
+ 'inset -4px 0 0 color-mix(in srgb, var(--text-color), transparent 40%)',
40
+ },
25
41
  };
26
42
 
27
43
  return {
@@ -26,15 +26,90 @@ class TabLayoutTabs extends Widget {
26
26
  constructor() {
27
27
  super(...arguments);
28
28
  this.styles = styles;
29
- this.handleTabClick = this.handleTabClick.bind(this);
30
29
  }
31
30
 
32
- handleTabClick(value, event) {
31
+ handleTabClick = (value, event) => {
33
32
  this.props.onTabClick?.(value, event);
34
- }
33
+ };
34
+
35
+ handleDragStart = (event, tabId) => {
36
+ event.dataTransfer.effectAllowed = 'move';
37
+ event.dataTransfer.setData('text/plain', tabId);
38
+ };
39
+
40
+ #applySideClass = (el, side) => {
41
+ if (!el) {
42
+ return;
43
+ }
44
+ if (side === 'left') {
45
+ el.classList.add('drop-left');
46
+ el.classList.remove('drop-right');
47
+ } else {
48
+ el.classList.add('drop-right');
49
+ el.classList.remove('drop-left');
50
+ }
51
+ };
52
+
53
+ #dropClasses = (el) => {
54
+ if (el) {
55
+ el.classList.remove('drop-left');
56
+ el.classList.remove('drop-right');
57
+ }
58
+ };
59
+
60
+ #getSideFromEvent = (event) => {
61
+ const rect = event.currentTarget.getBoundingClientRect();
62
+ const midX = rect.left + rect.width / 2;
63
+ return event.clientX < midX ? 'left' : 'right';
64
+ };
65
+
66
+ handleDragEnter = (event) => {
67
+ event.preventDefault();
68
+ event.stopPropagation();
69
+ };
70
+
71
+ handleDragOver = (event) => {
72
+ event.preventDefault();
73
+ event.stopPropagation();
74
+ event.dataTransfer.dropEffect = 'move';
75
+
76
+ const side = this.#getSideFromEvent(event);
77
+ if (side) {
78
+ this.#applySideClass(event.currentTarget, side);
79
+ } else {
80
+ this.#dropClasses(event.currentTarget);
81
+ }
82
+ };
83
+
84
+ handleDragLeave = (event) => {
85
+ event.preventDefault();
86
+ event.stopPropagation();
87
+
88
+ const leaveToOutside = !event.currentTarget.contains(event.relatedTarget);
89
+ if (leaveToOutside) {
90
+ this.#dropClasses(event.currentTarget);
91
+ }
92
+ };
93
+
94
+ handleDrop = (event, targetTabId) => {
95
+ event.preventDefault();
96
+
97
+ const el = event.currentTarget;
98
+ const side = this.#getSideFromEvent(event);
99
+ this.#dropClasses(el);
100
+
101
+ const draggedTabId = event.dataTransfer.getData('text/plain');
102
+ if (draggedTabId && targetTabId) {
103
+ this.props.onTabDrop?.(draggedTabId, targetTabId, side, event);
104
+ }
105
+ };
106
+
107
+ handleDragEnd = (event) => {
108
+ this.#dropClasses(event?.currentTarget);
109
+ };
35
110
 
36
111
  render() {
37
- const {currentTab, onTabClick, ...props} = this.props;
112
+ const {currentTab, onTabClick, onTabDrop, ...props} = this.props;
38
113
  const className = this.props.className || '';
39
114
  return (
40
115
  <div {...props} className={this.styles.classNames.tabs + ' ' + className}>
@@ -47,6 +122,13 @@ class TabLayoutTabs extends Widget {
47
122
  'onClick': (event) =>
48
123
  (child.props.onClick || this.handleTabClick)(value, event),
49
124
  'data-active': currentTab === value,
125
+ 'draggable': true,
126
+ 'onDragStart': (e) => this.handleDragStart(e, value),
127
+ 'onDragEnter': this.handleDragEnter,
128
+ 'onDragOver': this.handleDragOver,
129
+ 'onDragLeave': this.handleDragLeave,
130
+ 'onDrop': (e) => this.handleDrop(e, value),
131
+ 'onDragEnd': this.handleDragEnd,
50
132
  });
51
133
  })}
52
134
  </div>