flexdesk 0.18.0 → 0.18.1
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/CHANGELOG.md +38 -0
- package/css/flexdesk.css +33 -5
- package/css/tokens.css +7 -0
- package/dist/charts.js +78 -12
- package/dist/charts.js.map +2 -2
- package/dist/{chunk-5W4EEKSK.js → chunk-3BLIVOFD.js} +3 -3
- package/dist/{chunk-P74F6I6P.js → chunk-4X4SY3CP.js} +4 -4
- package/dist/{chunk-P74F6I6P.js.map → chunk-4X4SY3CP.js.map} +2 -2
- package/dist/{chunk-ZL3VKINP.js → chunk-HL3IIHU3.js} +494 -3
- package/dist/{chunk-ZL3VKINP.js.map → chunk-HL3IIHU3.js.map} +4 -4
- package/dist/{chunk-RKVYIXDC.js → chunk-NFZJ6P42.js} +19 -500
- package/dist/chunk-NFZJ6P42.js.map +7 -0
- package/dist/{chunk-U5GKJUOC.js → chunk-QRBOIUU3.js} +5 -6
- package/dist/chunk-QRBOIUU3.js.map +7 -0
- package/dist/flexdesk.css +33 -5
- package/dist/flow.js +1 -1
- package/dist/git.js +3 -3
- package/dist/tiles.js +4 -4
- package/dist/tokens.css +7 -0
- package/dist/widgets.js +226 -58
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2 -2
- package/docs/components/charts.md +40 -0
- package/docs/components/chat-shell.md +8 -2
- package/docs/components/filter-builder.md +52 -6
- package/docs/components/forms-and-dialogs.md +8 -0
- package/docs/components/input-fields.md +24 -0
- package/docs/components/menus.md +4 -0
- package/docs/components/shell.md +4 -0
- package/docs/components/workspace-picker.md +46 -2
- package/package.json +1 -1
- package/src/charts/plot_config_editor.js +92 -6
- package/src/charts/plot_grid.js +6 -1
- package/src/ui/components/chat_shell.js +7 -1
- package/src/ui/components/context_menu.js +8 -2
- package/src/ui/components/filter_builder.js +53 -28
- package/src/ui/components/filter_model.js +86 -18
- package/src/ui/components/inline_renamer.js +10 -5
- package/src/ui/components/modal.js +25 -8
- package/src/ui/components/workspace_picker.js +125 -27
- package/src/ui/utils/tooltip_service.js +81 -14
- package/dist/chunk-RKVYIXDC.js.map +0 -7
- package/dist/chunk-U5GKJUOC.js.map +0 -7
- /package/dist/{chunk-5W4EEKSK.js.map → chunk-3BLIVOFD.js.map} +0 -0
|
@@ -39,16 +39,27 @@
|
|
|
39
39
|
*
|
|
40
40
|
* ══ THE KEYBOARD ═══════════════════════════════════════════════════════════
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
42
|
+
* Focus is real: what the keyboard is on is `document.activeElement`, so a screen
|
|
43
|
+
* reader says each stop, a button in the card or the footer included, and Tab and
|
|
44
|
+
* the arrows agree about where the reader is.
|
|
45
|
+
*
|
|
46
|
+
* Opening puts the caret in the input. The arrows walk focus over every stop,
|
|
47
|
+
* wrapping: the input, the rows a person can choose (a disabled one is stepped
|
|
48
|
+
* over), Load more, the action rows, the card's buttons, the footer's; after
|
|
49
|
+
* `open({ select })` they start from the row it chose. Tab is shorter: the rows are
|
|
50
|
+
* ONE tab stop (the row last focused, else the current one, else the first), and so
|
|
51
|
+
* are the action rows. An answer or a redraw leaves focus where it is, on a row that
|
|
52
|
+
* moved too; Load more stays focusable while it loads.
|
|
53
|
+
*
|
|
54
|
+
* Typing always types: a character pressed anywhere in the panel goes to the input.
|
|
55
|
+
* In the input, the first row or action row of what is shown wears the highlight,
|
|
56
|
+
* and Enter takes it, or, while the answer to what was typed is on its way, takes
|
|
57
|
+
* the first one when it lands. Enter or Space on a row takes that row. Shift+Delete
|
|
58
|
+
* removes the highlighted row, and focus goes on to the next. Backspace away from
|
|
59
|
+
* the input stops at the panel. Esc closes and gives focus back to where it was, as
|
|
60
|
+
* does a pick or a verb, before it runs, so a dialog it opens opens over nothing. A
|
|
61
|
+
* press outside closes and leaves focus where it was pressed. Tab out of the panel
|
|
62
|
+
* closes it.
|
|
52
63
|
*
|
|
53
64
|
* ══ NOTHING IS REBUILT UNDER A POINTER ═════════════════════════════════════
|
|
54
65
|
*
|
|
@@ -58,8 +69,9 @@
|
|
|
58
69
|
* Which node holds what lives in maps here, never in DOM attributes.
|
|
59
70
|
*
|
|
60
71
|
* Nor is a list moved under one. The list scrolls to the highlight only when the
|
|
61
|
-
* keyboard moved
|
|
62
|
-
*
|
|
72
|
+
* keyboard moved it, `open({ select })` asked for a row, or the row the arrows chose
|
|
73
|
+
* went away (focus then goes to the input); typing and a new query's answer go back
|
|
74
|
+
* to the top.
|
|
63
75
|
* A redraw, Load more by the mouse and a removal leave the scroll where it is.
|
|
64
76
|
*
|
|
65
77
|
* ══ AN ASYNC REMOVE MAY ASK ════════════════════════════════════════════════
|
|
@@ -123,7 +135,9 @@ function button(className) {
|
|
|
123
135
|
* Keep `container`'s children one per key: `build()` makes `{ node, ... }` for
|
|
124
136
|
* a new key, `update(entry, item)` brings any entry up to date, a key that went
|
|
125
137
|
* loses its node, and a node already in its place is not moved (moving one
|
|
126
|
-
* under a pressed pointer is half of rebuilding it).
|
|
138
|
+
* under a pressed pointer is half of rebuilding it). The node holding focus is
|
|
139
|
+
* never moved, wherever it belongs now: the others go round it, because moving it
|
|
140
|
+
* would drop the keyboard on the body.
|
|
127
141
|
* @returns {(items: object[], keyOf: (item: object) => unknown) => Map<string, object>}
|
|
128
142
|
*/
|
|
129
143
|
function keyedChildren(container, build, update) {
|
|
@@ -141,8 +155,11 @@ function keyedChildren(container, build, update) {
|
|
|
141
155
|
if (!next.has(key)) entry.node.remove();
|
|
142
156
|
}
|
|
143
157
|
drawn = next;
|
|
158
|
+
const focused = document.activeElement;
|
|
144
159
|
nodes.forEach((node, index) => {
|
|
145
|
-
if (
|
|
160
|
+
if (node.contains(focused)) {
|
|
161
|
+
while (container.children[index] !== node) container.append(container.children[index]);
|
|
162
|
+
} else if (container.children[index] !== node) {
|
|
146
163
|
container.insertBefore(node, container.children[index] || null);
|
|
147
164
|
}
|
|
148
165
|
});
|
|
@@ -402,6 +419,8 @@ export function createPickerSwitcher({
|
|
|
402
419
|
let activeNode = null;
|
|
403
420
|
/** The arrows chose the highlight; an answer does not move it. */
|
|
404
421
|
let moved = false;
|
|
422
|
+
/** The row last focused, per list of rows. */
|
|
423
|
+
const rovingKeys = {};
|
|
405
424
|
let nextEntryId = 0;
|
|
406
425
|
const kept = new Map();
|
|
407
426
|
|
|
@@ -482,6 +501,7 @@ export function createPickerSwitcher({
|
|
|
482
501
|
const node = part('div', 'twm-wspicker__row');
|
|
483
502
|
node.id = `${uid}-row-${++nextEntryId}`;
|
|
484
503
|
node.setAttribute('role', 'option');
|
|
504
|
+
node.tabIndex = -1;
|
|
485
505
|
const icon = glyph('', 'twm-wspicker__row-icon');
|
|
486
506
|
const text = part('span', 'twm-wspicker__row-text');
|
|
487
507
|
const name = part('span', 'twm-wspicker__row-name');
|
|
@@ -523,6 +543,7 @@ export function createPickerSwitcher({
|
|
|
523
543
|
const disabled = !!model.disabled;
|
|
524
544
|
paintRow(entry, { ...model, disabled, isCurrentRow: isCurrent(item) });
|
|
525
545
|
entry.run = disabled ? null : () => pick(item);
|
|
546
|
+
if (disabled) entry.node.tabIndex = -1;
|
|
526
547
|
const removable = !!onRemove && model.removable !== false;
|
|
527
548
|
entry.remove = removable ? () => removeItem(item) : null;
|
|
528
549
|
show(entry.bin, removable);
|
|
@@ -646,6 +667,18 @@ export function createPickerSwitcher({
|
|
|
646
667
|
}
|
|
647
668
|
for (const [key, entry] of drawnFoot) next.push({ key: `foot:${key}`, node: entry.node, run: entry.run });
|
|
648
669
|
stops = next;
|
|
670
|
+
syncTabStops();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** One tab stop per list of rows: the row last focused, else the current one, else the first. */
|
|
674
|
+
function syncTabStops() {
|
|
675
|
+
const currentKey = current == null ? null : `row:${idOf(current)}`;
|
|
676
|
+
for (const prefix of ['row:', 'action:']) {
|
|
677
|
+
const rows = stops.filter((s) => s.key.startsWith(prefix));
|
|
678
|
+
const chosen = rows.find((s) => s.key === rovingKeys[prefix])
|
|
679
|
+
?? rows.find((s) => s.key === currentKey) ?? rows[0];
|
|
680
|
+
for (const stop of rows) stop.node.tabIndex = stop === chosen ? 0 : -1;
|
|
681
|
+
}
|
|
649
682
|
}
|
|
650
683
|
|
|
651
684
|
function paintActive() {
|
|
@@ -671,6 +704,11 @@ export function createPickerSwitcher({
|
|
|
671
704
|
}
|
|
672
705
|
if (!moved || !stops.some((s) => s.key === activeKey)) {
|
|
673
706
|
reveal = moved;
|
|
707
|
+
// The stop that held focus went, with its node or by being hidden: the
|
|
708
|
+
// keyboard must not be left on the body, where none of this panel's keys
|
|
709
|
+
// are heard, or on what no longer is a stop.
|
|
710
|
+
const at = document.activeElement;
|
|
711
|
+
if (moved && isOpen && (at === document.body || (panel.contains(at) && at !== input))) input.focus();
|
|
674
712
|
moved = false;
|
|
675
713
|
activeKey = stops.find((s) => s.primary)?.key ?? null;
|
|
676
714
|
}
|
|
@@ -678,13 +716,25 @@ export function createPickerSwitcher({
|
|
|
678
716
|
if (reveal) revealActive();
|
|
679
717
|
}
|
|
680
718
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const next = at === -1 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
|
|
685
|
-
activeKey = stops[next].key;
|
|
719
|
+
/** Focus on `stop`, without the browser's own scroll: `revealActive` is the one that may. */
|
|
720
|
+
function focusStop(stop) {
|
|
721
|
+
activeKey = stop.key;
|
|
686
722
|
moved = true;
|
|
723
|
+
stop.node.focus({ preventScroll: true });
|
|
687
724
|
paintActive();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function move(step) {
|
|
728
|
+
const walk = [input, ...stops.map((s) => s.node)];
|
|
729
|
+
// `open({ select })` chose a row and left the caret in the input: the arrows
|
|
730
|
+
// go on from that row.
|
|
731
|
+
const from = document.activeElement === input && moved ? activeNode : document.activeElement;
|
|
732
|
+
const next = walk[(walk.indexOf(from) + step + walk.length) % walk.length];
|
|
733
|
+
if (next === input) {
|
|
734
|
+
input.focus();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
focusStop(stops.find((s) => s.node === next));
|
|
688
738
|
revealActive();
|
|
689
739
|
}
|
|
690
740
|
|
|
@@ -713,7 +763,8 @@ export function createPickerSwitcher({
|
|
|
713
763
|
if (remaining > 0) {
|
|
714
764
|
setText(moreLabel, labels.loadMore({ next: Math.min(pageSize, remaining), shown: items.length, total }));
|
|
715
765
|
}
|
|
716
|
-
|
|
766
|
+
// Not `disabled`: that would drop focus on the body. `loadMore` refuses for itself.
|
|
767
|
+
more.setAttribute('aria-disabled', String(loading || loadingMore));
|
|
717
768
|
|
|
718
769
|
collectStops(rowEntries, actionEntries, shown);
|
|
719
770
|
settleActive();
|
|
@@ -806,8 +857,8 @@ export function createPickerSwitcher({
|
|
|
806
857
|
failure = labels.failedMore(err);
|
|
807
858
|
}
|
|
808
859
|
loadingMore = false;
|
|
809
|
-
//
|
|
810
|
-
//
|
|
860
|
+
// Focus stays on Load more while there is more, else goes to the first
|
|
861
|
+
// row it brought that can be chosen. Only the keyboard scrolls to it.
|
|
811
862
|
const stayed = activeKey === 'more';
|
|
812
863
|
if (stayed && items.length >= total) {
|
|
813
864
|
const first = added.find((item) => !modelOf(item).disabled);
|
|
@@ -817,7 +868,11 @@ export function createPickerSwitcher({
|
|
|
817
868
|
}
|
|
818
869
|
}
|
|
819
870
|
renderEverything();
|
|
820
|
-
if (
|
|
871
|
+
if (stayed) {
|
|
872
|
+
const stop = stops.find((s) => s.key === activeKey);
|
|
873
|
+
if (stop) focusStop(stop);
|
|
874
|
+
if (byKey) revealActive();
|
|
875
|
+
}
|
|
821
876
|
}
|
|
822
877
|
|
|
823
878
|
more.addEventListener('click', () => loadMore());
|
|
@@ -834,6 +889,7 @@ export function createPickerSwitcher({
|
|
|
834
889
|
const at = stops.findIndex((s) => s.key === removedKey);
|
|
835
890
|
const following = stops.slice(at + 1).find((s) => s.key.startsWith('row:'))
|
|
836
891
|
?? stops.slice(0, Math.max(at, 0)).reverse().find((s) => s.key.startsWith('row:'));
|
|
892
|
+
const fromRow = stops[at]?.node === document.activeElement;
|
|
837
893
|
// Before the call: a dialog it opens takes focus before it returns.
|
|
838
894
|
setBusy(true);
|
|
839
895
|
let outcome;
|
|
@@ -858,6 +914,11 @@ export function createPickerSwitcher({
|
|
|
858
914
|
moved = true;
|
|
859
915
|
}
|
|
860
916
|
ask(remote ? answered : '');
|
|
917
|
+
if (fromRow && following) {
|
|
918
|
+
focusStop(following);
|
|
919
|
+
revealActive();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
861
922
|
}
|
|
862
923
|
input.focus();
|
|
863
924
|
}
|
|
@@ -935,6 +996,8 @@ export function createPickerSwitcher({
|
|
|
935
996
|
pickFirst = false;
|
|
936
997
|
moved = false;
|
|
937
998
|
activeKey = null;
|
|
999
|
+
delete rovingKeys['row:'];
|
|
1000
|
+
delete rovingKeys['action:'];
|
|
938
1001
|
const last = kept.get(cacheKey());
|
|
939
1002
|
items = last ? [...last.items] : [];
|
|
940
1003
|
total = last ? last.total : null;
|
|
@@ -994,11 +1057,34 @@ export function createPickerSwitcher({
|
|
|
994
1057
|
close({ restoreFocus: true });
|
|
995
1058
|
}
|
|
996
1059
|
|
|
997
|
-
//
|
|
1060
|
+
// A press in the panel leaves focus where it is: on a row or a button it is
|
|
1061
|
+
// about to act and close; anywhere else it would drop the keyboard on nothing.
|
|
998
1062
|
panel.addEventListener('mousedown', (event) => {
|
|
999
1063
|
if (event.target !== input) event.preventDefault();
|
|
1000
1064
|
});
|
|
1001
1065
|
|
|
1066
|
+
// Focus gets here by the arrows, Tab or a script, and the highlight goes with it.
|
|
1067
|
+
panel.addEventListener('focusin', ({ target, relatedTarget }) => {
|
|
1068
|
+
if (target === input) {
|
|
1069
|
+
// Back from a stop, not in from outside: `open({ select })` chose first.
|
|
1070
|
+
if (panel.contains(relatedTarget)) {
|
|
1071
|
+
moved = false;
|
|
1072
|
+
settleActive();
|
|
1073
|
+
}
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const stop = stops.find((s) => s.node === target);
|
|
1077
|
+
if (!stop) return;
|
|
1078
|
+
activeKey = stop.key;
|
|
1079
|
+
moved = true;
|
|
1080
|
+
// The reader has gone on: an answer still to land does not pull the highlight back.
|
|
1081
|
+
selectId = null;
|
|
1082
|
+
const prefix = ['row:', 'action:'].find((p) => stop.key.startsWith(p));
|
|
1083
|
+
if (prefix) rovingKeys[prefix] = stop.key;
|
|
1084
|
+
syncTabStops();
|
|
1085
|
+
paintActive();
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1002
1088
|
input.addEventListener('input', () => {
|
|
1003
1089
|
moved = false;
|
|
1004
1090
|
scroller.scrollTop = 0;
|
|
@@ -1020,23 +1106,35 @@ export function createPickerSwitcher({
|
|
|
1020
1106
|
event.preventDefault();
|
|
1021
1107
|
event.stopPropagation();
|
|
1022
1108
|
};
|
|
1109
|
+
const stop = stops.find((s) => s.node === target);
|
|
1023
1110
|
if (key === 'Escape') {
|
|
1024
1111
|
handled();
|
|
1025
1112
|
close({ restoreFocus: true });
|
|
1026
1113
|
} else if (key === 'ArrowDown' || key === 'ArrowUp') {
|
|
1027
1114
|
handled();
|
|
1028
|
-
if (document.activeElement !== input) input.focus();
|
|
1029
1115
|
move(key === 'ArrowDown' ? 1 : -1);
|
|
1030
1116
|
} else if (key === 'Enter' && target === input) {
|
|
1031
1117
|
handled();
|
|
1032
1118
|
enter();
|
|
1033
|
-
} else if (key === '
|
|
1119
|
+
} else if ((key === 'Enter' || key === ' ') && stop) {
|
|
1120
|
+
// A row is no button, because it holds one; and Load more scrolls for
|
|
1121
|
+
// a key and not for a click.
|
|
1122
|
+
handled();
|
|
1123
|
+
stop.run();
|
|
1124
|
+
} else if (key === 'Delete' && shiftKey) {
|
|
1034
1125
|
handled();
|
|
1035
1126
|
stops.find((s) => s.key === activeKey)?.remove?.();
|
|
1036
1127
|
} else if (key === 'Backspace' && target !== input) {
|
|
1037
1128
|
// A window manager's keymap reads Backspace on anything that is not
|
|
1038
|
-
// a field as "up one level"; a
|
|
1129
|
+
// a field as "up one level"; a row or a button here is not a field.
|
|
1039
1130
|
handled();
|
|
1131
|
+
} else if (target !== input && key.length === 1 && key !== ' '
|
|
1132
|
+
&& !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1133
|
+
// Moved before the press lands, so the character lands in the input;
|
|
1134
|
+
// and no further, because a keymap would read it as a shortcut: the
|
|
1135
|
+
// press began on something that is not a field.
|
|
1136
|
+
event.stopPropagation();
|
|
1137
|
+
input.focus();
|
|
1040
1138
|
}
|
|
1041
1139
|
});
|
|
1042
1140
|
|
|
@@ -13,6 +13,10 @@ let currentTarget = null;
|
|
|
13
13
|
let listenersAttached = false;
|
|
14
14
|
let mouseCheckInterval = null;
|
|
15
15
|
let lastMousePosition = { x: 0, y: 0 };
|
|
16
|
+
/** The target keyboard focus is showing a tooltip for: no pointer holds it up. */
|
|
17
|
+
let focusTarget = null;
|
|
18
|
+
/** The last press was a key. Focus that follows a pointer's, or a script's after one, shows nothing. */
|
|
19
|
+
let byKeyboard = false;
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Create or get the tooltip DOM element.
|
|
@@ -140,14 +144,15 @@ function showTooltip(target) {
|
|
|
140
144
|
const hasPlain = target.classList && target.classList.contains('twm-has-tooltip');
|
|
141
145
|
|
|
142
146
|
if (!hasLatex && !hasPlain) return;
|
|
147
|
+
if (!target.getAttribute(hasLatex ? 'data-latex-tooltip' : 'data-tooltip')) return;
|
|
143
148
|
|
|
149
|
+
stopMouseCheck();
|
|
144
150
|
currentTarget = target;
|
|
145
151
|
const tooltip = createTooltipElement();
|
|
146
152
|
|
|
147
153
|
if (hasLatex) {
|
|
148
154
|
// Render LaTeX using KaTeX
|
|
149
155
|
const latex = target.getAttribute('data-latex-tooltip');
|
|
150
|
-
if (!latex) return;
|
|
151
156
|
|
|
152
157
|
try {
|
|
153
158
|
if (typeof katex !== 'undefined') {
|
|
@@ -166,7 +171,6 @@ function showTooltip(target) {
|
|
|
166
171
|
} else if (hasPlain) {
|
|
167
172
|
// Plain text tooltip with line breaks preserved
|
|
168
173
|
const text = target.getAttribute('data-tooltip');
|
|
169
|
-
if (!text) return;
|
|
170
174
|
|
|
171
175
|
// Replace with actual line breaks, preserve formatting
|
|
172
176
|
const formattedText = text.replace(/ /g, '\n');
|
|
@@ -197,11 +201,13 @@ function showTooltip(target) {
|
|
|
197
201
|
|
|
198
202
|
// Position and show
|
|
199
203
|
requestAnimationFrame(() => {
|
|
204
|
+
// Gone in the frame's wait: focus passing through, or the pointer leaving.
|
|
205
|
+
if (currentTarget !== target) return;
|
|
200
206
|
positionTooltip(target, tooltip);
|
|
201
207
|
tooltip.classList.add('visible');
|
|
202
208
|
|
|
203
|
-
// Start mouse position check for auto-hide
|
|
204
|
-
startMouseCheck();
|
|
209
|
+
// Start mouse position check for auto-hide, unless focus is what holds it.
|
|
210
|
+
if (focusTarget !== target) startMouseCheck();
|
|
205
211
|
});
|
|
206
212
|
}
|
|
207
213
|
|
|
@@ -213,23 +219,51 @@ function hideTooltip() {
|
|
|
213
219
|
tooltipElement.classList.remove('visible');
|
|
214
220
|
}
|
|
215
221
|
currentTarget = null;
|
|
222
|
+
focusTarget = null;
|
|
223
|
+
stopMouseCheck();
|
|
224
|
+
}
|
|
216
225
|
|
|
217
|
-
|
|
226
|
+
function stopMouseCheck() {
|
|
218
227
|
if (mouseCheckInterval) {
|
|
219
228
|
clearInterval(mouseCheckInterval);
|
|
220
229
|
mouseCheckInterval = null;
|
|
221
230
|
}
|
|
222
231
|
}
|
|
223
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The pointer has gone from what it showed, or a scroll took that away. What focus
|
|
235
|
+
* holds is not the pointer's to hide: it stays, or comes back from under the
|
|
236
|
+
* pointer's, as long as its target is in sight.
|
|
237
|
+
*/
|
|
238
|
+
function pointerGone() {
|
|
239
|
+
const held = focusTarget;
|
|
240
|
+
if (held && held === currentTarget) return;
|
|
241
|
+
hideTooltip();
|
|
242
|
+
if (!held) return;
|
|
243
|
+
focusTarget = held;
|
|
244
|
+
showTooltip(held);
|
|
245
|
+
requestAnimationFrame(() => {
|
|
246
|
+
if (held === focusTarget && held === currentTarget) followFocus();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A scroll while focus holds the tooltip, the one the focus itself caused included:
|
|
252
|
+
* the tooltip goes with its target, and waits while the target is out of sight.
|
|
253
|
+
*/
|
|
254
|
+
function followFocus() {
|
|
255
|
+
const { left, top, width, height } = focusTarget.getBoundingClientRect();
|
|
256
|
+
const inSight = focusTarget.contains(document.elementFromPoint(left + width / 2, top + height / 2));
|
|
257
|
+
tooltipElement.classList.toggle('visible', inSight);
|
|
258
|
+
if (inSight) positionTooltip(focusTarget, tooltipElement);
|
|
259
|
+
}
|
|
260
|
+
|
|
224
261
|
/**
|
|
225
262
|
* Start the mouse position check interval.
|
|
226
263
|
* Hides tooltip if mouse is no longer over the target element.
|
|
227
264
|
*/
|
|
228
265
|
function startMouseCheck() {
|
|
229
|
-
|
|
230
|
-
if (mouseCheckInterval) {
|
|
231
|
-
clearInterval(mouseCheckInterval);
|
|
232
|
-
}
|
|
266
|
+
stopMouseCheck();
|
|
233
267
|
|
|
234
268
|
// Check every 3 seconds if mouse is still on the target element
|
|
235
269
|
mouseCheckInterval = setInterval(() => {
|
|
@@ -249,11 +283,11 @@ function startMouseCheck() {
|
|
|
249
283
|
elementUnderMouse.closest('.twm-has-tooltip, .has-latex-tooltip') === currentTarget;
|
|
250
284
|
|
|
251
285
|
if (!isStillOnTarget) {
|
|
252
|
-
|
|
286
|
+
pointerGone();
|
|
253
287
|
}
|
|
254
288
|
} else {
|
|
255
289
|
// Mouse is outside the document
|
|
256
|
-
|
|
290
|
+
pointerGone();
|
|
257
291
|
}
|
|
258
292
|
}, 3000);
|
|
259
293
|
}
|
|
@@ -289,11 +323,44 @@ function attachListeners() {
|
|
|
289
323
|
if (!(target instanceof Element)) return;
|
|
290
324
|
const tooltipTarget = target.closest('.has-latex-tooltip, .twm-has-tooltip');
|
|
291
325
|
if (!tooltipTarget) return;
|
|
292
|
-
|
|
326
|
+
pointerGone();
|
|
327
|
+
}, true);
|
|
328
|
+
|
|
329
|
+
// On the window, ahead of a chord handler that stops the event. A chord is not
|
|
330
|
+
// a person walking the page by keyboard: it says nothing either way.
|
|
331
|
+
window.addEventListener('keydown', (event) => {
|
|
332
|
+
if (!event.ctrlKey && !event.metaKey && !event.altKey) byKeyboard = true;
|
|
333
|
+
}, true);
|
|
334
|
+
window.addEventListener('pointerdown', () => {
|
|
335
|
+
byKeyboard = false;
|
|
293
336
|
}, true);
|
|
294
337
|
|
|
295
|
-
//
|
|
296
|
-
|
|
338
|
+
// Keyboard focus shows what hovering shows. The focus a click leaves, or that
|
|
339
|
+
// its handler moves into a field, would keep a tooltip standing after the pointer
|
|
340
|
+
// has gone. `:focus-visible` cannot tell: it is true for any text field.
|
|
341
|
+
document.addEventListener('focusin', (event) => {
|
|
342
|
+
const target = event.target;
|
|
343
|
+
if (!(target instanceof Element) || !byKeyboard) return;
|
|
344
|
+
const tooltipTarget = target.closest('.has-latex-tooltip, .twm-has-tooltip');
|
|
345
|
+
if (!tooltipTarget) return;
|
|
346
|
+
focusTarget = tooltipTarget;
|
|
347
|
+
showTooltip(tooltipTarget);
|
|
348
|
+
}, true);
|
|
349
|
+
|
|
350
|
+
document.addEventListener('focusout', (event) => {
|
|
351
|
+
if (!focusTarget) return;
|
|
352
|
+
const next = event.relatedTarget;
|
|
353
|
+
if (next instanceof Node && focusTarget.contains(next)) return;
|
|
354
|
+
// A pointer's tooltip on another target is the pointer's to take down.
|
|
355
|
+
if (focusTarget === currentTarget) hideTooltip();
|
|
356
|
+
else focusTarget = null;
|
|
357
|
+
}, true);
|
|
358
|
+
|
|
359
|
+
// Hide on scroll, unless focus is what holds it.
|
|
360
|
+
document.addEventListener('scroll', () => {
|
|
361
|
+
if (focusTarget && focusTarget === currentTarget) followFocus();
|
|
362
|
+
else pointerGone();
|
|
363
|
+
}, true);
|
|
297
364
|
|
|
298
365
|
// ESC key hides all tooltips application-wide (does not stop propagation)
|
|
299
366
|
document.addEventListener('keydown', (event) => {
|