« get me outta code hell

client: add basic tooltip focus behavior - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-11-10 15:48:10 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-11-24 13:45:09 -0400
commit4c319007bdf151064ffed7d275001414b95f24d6 (patch)
tree37fe15920f4ffcc017063c45906f2b4c137cdab2
parent15f72dcf7bec602b979621d6c9e9c6d11617ffbb (diff)
client: add basic tooltip focus behavior
-rw-r--r--src/static/client3.js135
1 files changed, 128 insertions, 7 deletions
diff --git a/src/static/client3.js b/src/static/client3.js
index 57922022..57bc21a8 100644
--- a/src/static/client3.js
+++ b/src/static/client3.js
@@ -361,6 +361,8 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = {
     normalHoverInfoDelay: 400,
     fastHoveringInfoDelay: 150,
 
+    focusInfoDelay: 750,
+
     endFastHoveringDelay: 500,
 
     hideTooltipDelay: 500,
@@ -374,10 +376,13 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = {
 
     // These are common across all tooltips, rather than stored individually,
     // based on the principles that 1) only a single tooltip can be displayed
-    // at once, and 2) only a single hoverable can be hovered at a once.
+    // at once, and 2) likewise, only a single hoverable can be hovered,
+    // focused, or otherwise active at once.
     hoverTimeout: null,
+    focusTimeout: null,
     hideTimeout: null,
     currentlyShownTooltip: null,
+    currentlyActiveHoverable: null,
 
     // Fast hovering is a global mode which is activated as soon as any tooltip
     // is displayed and turns off after a delay of no hoverables being hovered.
@@ -413,6 +418,19 @@ function registerTooltipElement(tooltip) {
   tooltip.addEventListener('mouseleave', () => {
     handleTooltipMouseLeft(tooltip);
   });
+
+  tooltip.addEventListener('focusin', () => {
+    handleTooltipReceivedFocus(tooltip);
+  });
+
+  tooltip.addEventListener('focusout', event => {
+    // This event gets activated for tabbing *between* links inside the
+    // tooltip, which is no good and certainly doesn't represent the focus
+    // leaving the tooltip.
+    if (tooltip.contains(event.relatedTarget)) return;
+
+    handleTooltipLostFocus(tooltip);
+  });
 }
 
 // Adds DOM event listeners, so must be called during addPageListeners step.
@@ -440,6 +458,14 @@ function registerTooltipHoverableElement(hoverable, tooltip) {
   hoverable.addEventListener('mouseleave', () => {
     handleTooltipHoverableMouseLeft(hoverable);
   });
+
+  hoverable.addEventListener('focusin', () => {
+    handleTooltipHoverableReceivedFocus(hoverable);
+  });
+
+  hoverable.addEventListener('focusout', () => {
+    handleTooltipHoverableLostFocus(hoverable);
+  });
 }
 
 function handleTooltipMouseEntered(tooltip) {
@@ -455,7 +481,7 @@ function handleTooltipMouseEntered(tooltip) {
 }
 
 function handleTooltipMouseLeft(tooltip) {
-  const {state, settings} = hoverableTooltipInfo;
+  const {settings, state} = hoverableTooltipInfo;
 
   if (state.currentlyShownTooltip !== tooltip) return;
 
@@ -470,6 +496,34 @@ function handleTooltipMouseLeft(tooltip) {
   }
 }
 
+function handleTooltipReceivedFocus(tooltip) {
+  const {state} = hoverableTooltipInfo;
+
+  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
+  // be hidden while it contains the focus anyway, but this ensures the timeout
+  // will be suitably reset when the tooltip loses focus.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipLostFocus(tooltip) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Start timing out the current tooltip when it loses focus. This will be
+  // canceled if the tooltip receives focus again. Another tooltip might also
+  // display before this timeout runs, but since this is the same timeout name
+  // as all tooltip interactions, it'll get cleared appropriately.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
 function handleTooltipHoverableMouseEntered(hoverable) {
   const {event, settings, state} = hoverableTooltipInfo;
 
@@ -502,7 +556,7 @@ function handleTooltipHoverableMouseEntered(hoverable) {
 }
 
 function handleTooltipHoverableMouseLeft(hoverable) {
-  const {state, settings} = hoverableTooltipInfo;
+  const {settings, state} = hoverableTooltipInfo;
 
   // Don't show a tooltip when not over a hoverable!
   if (state.hoverTimeout) {
@@ -532,26 +586,93 @@ function handleTooltipHoverableMouseLeft(hoverable) {
   }
 }
 
+function handleTooltipHoverableReceivedFocus(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Start a timer to show the corresponding tooltip.
+  state.focusTimeout =
+    setTimeout(() => {
+      state.focusTimeout = null;
+      showTooltipFromHoverable(hoverable);
+    }, settings.focusInfoDelay);
+}
+
+function handleTooltipHoverableLostFocus(hoverable) {
+  const {settings, state} = hoverableTooltipInfo;
+
+  // Don't show a tooltip from focusing a hoverable if it isn't focused
+  // anymore! If another hoverable is receiving focus, that will be evaluated
+  // and set its own focus timeout after we clear the previous one here.
+  if (state.focusTimeout) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+  }
+
+  // Start timing out the current tooltip when the hoverable loses focus.
+  // Yes, even if focus is going *into* that very tooltip! This timeout will
+  // be immediately canceled, in that case.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function currentlyShownTooltipHasFocus() {
+  const {state} = hoverableTooltipInfo;
+
+  const {
+    currentlyShownTooltip: tooltip,
+    currentlyActiveHoverable: hoverable,
+  } = state;
+
+  // If there's no tooltip, it can't possibly have focus.
+  if (!tooltip) return false;
+
+  // If the tooltip literally contains (or is) the focused element, then that's
+  // the principle condition we're looking for.
+  if (tooltip.contains(document.activeElement)) return true;
+
+  // If the hoverable *which opened the tooltip* is focused, then that also
+  // represents the tooltip being focused (in its currently shown state).
+  if (hoverable.contains(document.activeElement)) return true;
+
+  return false;
+}
+
 function hideCurrentlyShownTooltip() {
   const {event, state} = hoverableTooltipInfo;
   const {currentlyShownTooltip: tooltip} = state;
 
-  if (!tooltip) return;
+  // If there was no tooltip to begin with, we're functionally in the desired
+  // state already, so return true.
+  if (!tooltip) return true;
 
-  dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip});
+  // Never hide the tooltip if it's focused.
+  if (currentlyShownTooltipHasFocus()) return false;
 
   state.currentlyShownTooltip = null;
+  state.currentlyActiveHoverable = null;
+
+  dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip});
+
+  return true;
 }
 
 function showTooltipFromHoverable(hoverable) {
   const {event, state} = hoverableTooltipInfo;
   const {tooltip} = state.registeredHoverables.get(hoverable);
 
-  hideCurrentlyShownTooltip();
+  if (!hideCurrentlyShownTooltip()) return false;
+
+  state.currentlyShownTooltip = tooltip;
+  state.currentlyActiveHoverable = hoverable;
 
   dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip});
 
-  state.currentlyShownTooltip = tooltip;
+  return true;
 }
 
 // Data & info card ---------------------------------------