Replace text-only tooltips with custom richly styled tooltips (#3436)

* Replace the title attribute with custom FloatingMenu tooltips

* Separate tooltip labels and descriptions into two styled blocks

* Move keyboard shortcut tooltips to a separate section at the bottom

* Update shortcut key styling in tooltips and hints bar

* Fix .to_string()
This commit is contained in:
Keavon Chambers
2025-11-30 13:32:58 -08:00
committed by GitHub
parent 94e5c8fc05
commit e8ebcc2c21
94 changed files with 1323 additions and 580 deletions
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
export type MenuType = "Popover" | "Tooltip" | "Dropdown" | "Dialog" | "Cursor";
/// Prevents the escape key from closing the parent floating menu of the given element.
/// This works by momentarily setting the `data-escape-does-not-close` attribute on the parent floating menu element.
@@ -64,7 +64,6 @@
let measuringOngoingGuard = false;
let minWidthParentWidth = 0;
let pointerStillDown = false;
let workspaceBounds = new DOMRect();
let floatingMenuBounds = new DOMRect();
let floatingMenuContentBounds = new DOMRect();
@@ -174,34 +173,50 @@
function positionAndStyleFloatingMenu() {
if (type === "Cursor") return;
const workspace = document.querySelector("[data-workspace]");
const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
if (!self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
const viewportBounds = document.documentElement.getBoundingClientRect();
workspaceBounds = workspace.getBoundingClientRect();
const windowBounds = document.documentElement.getBoundingClientRect();
floatingMenuBounds = self.getBoundingClientRect();
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
floatingMenuContentBounds = floatingMenuContentDiv.getBoundingClientRect();
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
const overflowingLeft = floatingMenuContentBounds.left - windowEdgeMargin <= windowBounds.left;
const overflowingRight = floatingMenuContentBounds.right + windowEdgeMargin >= windowBounds.right;
const overflowingTop = floatingMenuContentBounds.top - windowEdgeMargin <= windowBounds.top;
const overflowingBottom = floatingMenuContentBounds.bottom + windowEdgeMargin >= windowBounds.bottom;
// TODO: Make this work for all types. This is currently limited to tooltips because they're inherently small and transient.
// TODO: But on popovers and dropdowns, it's a bit harder to do this right. First we check if it's overflowing and flip the direction to avoid the overflow.
// TODO: But once it's flipped, if the position moves and the menu would no longer be overflowing, we're still flipped and thus unable to automatically notice the need to flip back.
// TODO: So as a result, once flipped, it stays flipped forever even if the menu spawner element is moved back away from the edge of the window.
if (type === "Tooltip") {
// Flip direction if overflowing the edge of the window
if (direction === "Top" && overflowingTop) direction = "Bottom";
else if (direction === "Bottom" && overflowingBottom) direction = "Top";
else if (direction === "Left" && overflowingLeft) direction = "Right";
else if (direction === "Right" && overflowingRight) direction = "Left";
}
const inParentFloatingMenu = Boolean(floatingMenuContainer.closest("[data-floating-menu-content]"));
if (!inParentFloatingMenu) {
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
const tailOffset = type === "Popover" ? 10 : 0;
let tailOffset = 0;
if (type === "Popover") tailOffset = 10;
if (type === "Tooltip") tailOffset = direction === "Bottom" ? 20 : 10;
if (direction === "Bottom") floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`;
if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + (viewportBounds.height - floatingMenuBounds.y)}px`;
if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + (windowBounds.height - floatingMenuBounds.y)}px`;
if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`;
if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (viewportBounds.width - floatingMenuBounds.x)}px`;
if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (windowBounds.width - floatingMenuBounds.x)}px`;
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.y}px`;
if (tail && direction === "Top") tail.style.bottom = `${viewportBounds.height - floatingMenuBounds.y}px`;
if (tail && direction === "Top") tail.style.bottom = `${windowBounds.height - floatingMenuBounds.y}px`;
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.x}px`;
if (tail && direction === "Left") tail.style.right = `${viewportBounds.width - floatingMenuBounds.x}px`;
if (tail && direction === "Left") tail.style.right = `${windowBounds.width - floatingMenuBounds.x}px`;
}
type Edge = "Top" | "Bottom" | "Left" | "Right";
@@ -212,31 +227,31 @@
zeroedBorderVertical = direction === "Top" ? "Bottom" : "Top";
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (floatingMenuContentBounds.left - windowEdgeMargin <= workspaceBounds.left) {
if (overflowingLeft) {
floatingMenuContentDiv.style.left = `${windowEdgeMargin}px`;
if (workspaceBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
if (windowBounds.left + floatingMenuContainerBounds.left === 12) zeroedBorderHorizontal = "Left";
}
if (floatingMenuContentBounds.right + windowEdgeMargin >= workspaceBounds.right) {
if (overflowingRight) {
floatingMenuContentDiv.style.right = `${windowEdgeMargin}px`;
if (workspaceBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
if (windowBounds.right - floatingMenuContainerBounds.right === 12) zeroedBorderHorizontal = "Right";
}
}
if (direction === "Left" || direction === "Right") {
zeroedBorderHorizontal = direction === "Left" ? "Right" : "Left";
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (floatingMenuContentBounds.top - windowEdgeMargin <= workspaceBounds.top) {
if (overflowingTop) {
floatingMenuContentDiv.style.top = `${windowEdgeMargin}px`;
if (workspaceBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
if (windowBounds.top + floatingMenuContainerBounds.top === 12) zeroedBorderVertical = "Top";
}
if (floatingMenuContentBounds.bottom + windowEdgeMargin >= workspaceBounds.bottom) {
if (overflowingBottom) {
floatingMenuContentDiv.style.bottom = `${windowEdgeMargin}px`;
if (workspaceBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
if (windowBounds.bottom - floatingMenuContainerBounds.bottom === 12) zeroedBorderVertical = "Bottom";
}
}
// Remove the rounded corner from the content where the tail perfectly meets the corner
if (type === "Popover" && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
if (displayTail && windowEdgeMargin === 6 && zeroedBorderVertical && zeroedBorderHorizontal) {
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
switch (`${zeroedBorderVertical}${zeroedBorderHorizontal}`) {
case "TopLeft":
@@ -585,14 +600,18 @@
flex-direction: column;
}
&.top .tail {
&.top .tail,
&.topleft .tail,
&.topright .tail {
border-width: 8px 6px 0 6px;
border-color: var(--color-2-mildblack) transparent transparent transparent;
margin-left: -6px;
margin-bottom: 2px;
}
&.bottom .tail {
&.bottom .tail,
&.bottomleft .tail,
&.bottomright .tail {
border-width: 0 6px 8px 6px;
border-color: transparent transparent var(--color-2-mildblack) transparent;
margin-left: -6px;
@@ -5,7 +5,9 @@
let styleName = "";
export { styleName as style };
export let styles: Record<string, string | number | undefined> = {};
export let tooltip: string | undefined = undefined;
export let tooltipLabel: string | undefined = undefined;
export let tooltipDescription: string | undefined = undefined;
export let tooltipShortcut: string | undefined = undefined;
// TODO: Add middle-click drag scrolling
export let scrollableX = false;
export let scrollableY = false;
@@ -26,13 +28,15 @@
<!-- Excluded events because these require `|passive` or `|nonpassive` modifiers. Use a <div> for these instead: `on:wheel`, `on:touchmove`, `on:touchstart` -->
<div
data-tooltip-label={tooltipLabel}
data-tooltip-description={tooltipDescription}
data-tooltip-shortcut={tooltipShortcut}
data-scrollable-x={scrollableX ? "" : undefined}
data-scrollable-y={scrollableY ? "" : undefined}
class={`layout-col ${className} ${extraClasses}`.trim()}
class:scrollable-x={scrollableX}
class:scrollable-y={scrollableY}
style={`${styleName} ${extraStyles}`.trim() || undefined}
title={tooltip}
bind:this={self}
on:auxclick
on:blur
@@ -5,7 +5,9 @@
let styleName = "";
export { styleName as style };
export let styles: Record<string, string | number | undefined> = {};
export let tooltip: string | undefined = undefined;
export let tooltipLabel: string | undefined = undefined;
export let tooltipDescription: string | undefined = undefined;
export let tooltipShortcut: string | undefined = undefined;
// TODO: Add middle-click drag scrolling
export let scrollableX = false;
export let scrollableY = false;
@@ -26,13 +28,15 @@
<!-- Excluded events because these require `|passive` or `|nonpassive` modifiers. Use a <div> for these instead: `on:wheel`, `on:touchmove`, `on:touchstart` -->
<div
data-tooltip-label={tooltipLabel}
data-tooltip-description={tooltipDescription}
data-tooltip-shortcut={tooltipShortcut}
data-scrollable-x={scrollableX ? "" : undefined}
data-scrollable-y={scrollableY ? "" : undefined}
class={`layout-row ${className} ${extraClasses}`.trim()}
class:scrollable-x={scrollableX}
class:scrollable-y={scrollableY}
style={`${styleName} ${extraStyles}`.trim() || undefined}
title={tooltip}
bind:this={self}
on:auxclick
on:blur