flexdesk 0.18.0 → 0.18.2

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/css/flexdesk.css +83 -5
  3. package/css/tokens.css +7 -0
  4. package/dist/charts.js +78 -12
  5. package/dist/charts.js.map +2 -2
  6. package/dist/{chunk-5W4EEKSK.js → chunk-3BLIVOFD.js} +3 -3
  7. package/dist/{chunk-P74F6I6P.js → chunk-4X4SY3CP.js} +4 -4
  8. package/dist/{chunk-P74F6I6P.js.map → chunk-4X4SY3CP.js.map} +2 -2
  9. package/dist/{chunk-ZL3VKINP.js → chunk-HL3IIHU3.js} +494 -3
  10. package/dist/{chunk-ZL3VKINP.js.map → chunk-HL3IIHU3.js.map} +4 -4
  11. package/dist/{chunk-RKVYIXDC.js → chunk-NFZJ6P42.js} +19 -500
  12. package/dist/chunk-NFZJ6P42.js.map +7 -0
  13. package/dist/{chunk-U5GKJUOC.js → chunk-QRBOIUU3.js} +5 -6
  14. package/dist/chunk-QRBOIUU3.js.map +7 -0
  15. package/dist/flexdesk.css +83 -5
  16. package/dist/flow.js +22 -10
  17. package/dist/flow.js.map +2 -2
  18. package/dist/git.js +3 -3
  19. package/dist/tiles.js +4 -4
  20. package/dist/tokens.css +7 -0
  21. package/dist/widgets.js +226 -58
  22. package/dist/widgets.js.map +2 -2
  23. package/dist/wm.js +2 -2
  24. package/docs/components/charts.md +40 -0
  25. package/docs/components/chat-shell.md +8 -2
  26. package/docs/components/filter-builder.md +52 -6
  27. package/docs/components/flow-editors.md +20 -0
  28. package/docs/components/forms-and-dialogs.md +8 -0
  29. package/docs/components/input-fields.md +24 -0
  30. package/docs/components/menus.md +4 -0
  31. package/docs/components/shell.md +4 -0
  32. package/docs/components/workspace-picker.md +46 -2
  33. package/package.json +1 -1
  34. package/src/charts/plot_config_editor.js +92 -6
  35. package/src/charts/plot_grid.js +6 -1
  36. package/src/flow/kit/settings_panel.js +24 -9
  37. package/src/ui/components/chat_shell.js +7 -1
  38. package/src/ui/components/context_menu.js +8 -2
  39. package/src/ui/components/filter_builder.js +53 -28
  40. package/src/ui/components/filter_model.js +86 -18
  41. package/src/ui/components/inline_renamer.js +10 -5
  42. package/src/ui/components/modal.js +25 -8
  43. package/src/ui/components/workspace_picker.js +125 -27
  44. package/src/ui/utils/tooltip_service.js +81 -14
  45. package/dist/chunk-RKVYIXDC.js.map +0 -7
  46. package/dist/chunk-U5GKJUOC.js.map +0 -7
  47. /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
- * The caret stays in the input, so typing always types. The arrows walk a
43
- * highlight over every stop, wrapping: the rows a person can choose (a disabled
44
- * one is stepped over), Load more, the action rows, the card's buttons, the
45
- * footer's. Until they move, the highlight is the first row or action row of
46
- * what is shown, and Enter takes it, or, while the answer to what was typed is
47
- * on its way, takes the first one when it lands. Shift+Delete removes the
48
- * highlighted row. Esc closes and gives focus back to where it was, as does a
49
- * pick or a verb, before it runs, so a dialog it opens opens over nothing. A
50
- * press outside closes and leaves focus where it was pressed. Tab leaves the
51
- * input for the panel's buttons, and out of the panel closes it.
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 the highlight, `open({ select })` asked for a row, or the row
62
- * the arrows chose went away; typing and a new query's answer go back to the top.
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 (container.children[index] !== node) {
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
- function move(step) {
682
- if (!stops.length) return;
683
- const at = stops.findIndex((s) => s.key === activeKey);
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
- more.disabled = loading || loadingMore;
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
- // The highlight stays on Load more while there is more, else goes to
810
- // the first row it brought that can be chosen.
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 (byKey && stayed) revealActive();
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
- // The caret stays in the input whatever is pressed in the panel.
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 === 'Delete' && shiftKey && target === input) {
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 button of this panel is not a field.
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 &#10; with actual line breaks, preserve formatting
172
176
  const formattedText = text.replace(/&#10;/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
- // Clear mouse check interval
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
- // Clear any existing interval
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
- hideTooltip();
286
+ pointerGone();
253
287
  }
254
288
  } else {
255
289
  // Mouse is outside the document
256
- hideTooltip();
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
- hideTooltip();
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
- // Hide on scroll
296
- document.addEventListener('scroll', hideTooltip, true);
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) => {