Files
sure/app/components/DS/tooltip_controller.js
T
William Wei MingandCursor 819f62019b fix(ui): resolve DS Drift Patrol findings (#2745) (#2975)
* fix(ui): resolve DS Drift Patrol findings from #2745

Replace hand-rolled queue status chips and the insights nav tooltip/badge
with DS::Pill and DS::Tooltip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* i18n: use a single key for background job queue pill labels

* fix(ui): address gariasf review on insights badge and queue pills

Keep the unread insights count on theme-aware inverse tokens (DS::Pill
filled neutral is not dark-mode safe). Extend DS::Tooltip with html_class
passthrough and a/summary focus ancestors so icon-only links keep full
hover + keyboard reveal. Add queue_label locales (de/uk/zh-TW), drop the
dead latency keys, and restore mono via DS::Pill mono: true.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 07:16:47 +02:00

125 lines
3.8 KiB
JavaScript

import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["tooltip"];
static values = {
placement: { type: String, default: "top" },
offset: { type: Number, default: 10 },
crossAxis: { type: Number, default: 0 },
};
connect() {
this._cleanup = null;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
}
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
}
addEventListeners() {
this.element.addEventListener("mouseenter", this.show);
this.element.addEventListener("mouseleave", this.hide);
// Keyboard parity: keyboard users hit the trigger via Tab + focus,
// not hover. Without these the tooltip never appears for them.
this.element.addEventListener("focusin", this.show);
this.element.addEventListener("focusout", this.hide);
// Esc-to-dismiss matches the WAI-ARIA Authoring Practices for the
// tooltip pattern.
this.element.addEventListener("keydown", this.handleKeydown);
// `as: :span` renders a non-focusable trigger inside an
// already-focusable ancestor (`<summary>`, icon-only `<a>`, …).
// When the ancestor receives keyboard focus the `focusin` event
// fires on *it* and bubbles UP to the document — it never reaches
// a descendant span. Without a listener on the ancestor itself,
// the tooltip stays hidden for keyboard users. Bind the same
// handlers on the closest interactive ancestor (if any) so
// focusing it reveals the tooltip and Esc still dismisses it.
this.focusableAncestor = this.element.closest("summary, a");
if (this.focusableAncestor) {
this.focusableAncestor.addEventListener("focusin", this.show);
this.focusableAncestor.addEventListener("focusout", this.hide);
this.focusableAncestor.addEventListener("keydown", this.handleKeydown);
}
}
removeEventListeners() {
this.element.removeEventListener("mouseenter", this.show);
this.element.removeEventListener("mouseleave", this.hide);
this.element.removeEventListener("focusin", this.show);
this.element.removeEventListener("focusout", this.hide);
this.element.removeEventListener("keydown", this.handleKeydown);
if (this.focusableAncestor) {
this.focusableAncestor.removeEventListener("focusin", this.show);
this.focusableAncestor.removeEventListener("focusout", this.hide);
this.focusableAncestor.removeEventListener("keydown", this.handleKeydown);
this.focusableAncestor = null;
}
}
show = () => {
this.tooltipTarget.classList.remove("hidden");
this.startAutoUpdate();
this.update();
};
hide = () => {
this.tooltipTarget.classList.add("hidden");
this.stopAutoUpdate();
};
handleKeydown = (event) => {
if (event.key === "Escape" && !this.tooltipTarget.classList.contains("hidden")) {
this.hide();
}
};
startAutoUpdate() {
if (!this._cleanup) {
const reference = this.element.querySelector("[data-icon]");
this._cleanup = autoUpdate(
reference || this.element,
this.tooltipTarget,
this.boundUpdate
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
const reference = this.element.querySelector("[data-icon]");
computePosition(reference || this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({
mainAxis: this.offsetValue,
crossAxis: this.crossAxisValue,
}),
flip(),
shift({ padding: 5 }),
],
}).then(({ x, y }) => {
Object.assign(this.tooltipTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
}