mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
* fix(design-system): DS::Tooltip a11y — focusable trigger, keyboard parity, Esc dismiss Closes #1747. Five fixes on the tooltip primitive. 1. **Tooltip anchor not in a11y tree.** The trigger was a bare Lucide icon, which Lucide renders with `aria-hidden="true"`. The tooltip target had `role="tooltip"` but nothing referenced it, so AT users had no way to discover the description. Wrap the icon in a focusable `<button type="button">` with `aria-describedby="<tooltip-id>"` so the underlying icon stays `aria-hidden` and the button picks up the description binding. 2. **Stable per-instance id.** Each DS::Tooltip now mints a `tooltip-<8-char hex>` id wired between the trigger's `aria-describedby` and the tooltip's `id`. 3. **Keyboard parity.** Hover-only triggers locked keyboard-only users out. Add `focusin` / `focusout` listeners on the controller element so Tab onto the trigger reveals the tooltip, Tab away dismisses it. 4. **Esc-to-dismiss.** Matches the WAI-ARIA tooltip pattern. `Escape` while the tooltip is open closes it without removing focus from the trigger. 5. **Resize-safe width cap.** Replace the hard-coded `max-w-[200px]` with `max-w-[20rem]` so the tooltip scales with the user's root font-size setting (large-text accessibility pref). Slightly wider visual cap (320px @ default) but no longer clips on text-zoom. Plus: docstring note that tooltip content must be non-interactive (no buttons / links / form controls inside) — `aria-describedby` exposes content as a description, not as an interactive subtree. Callers needing actions should reach for a popover/menu primitive. API unchanged. Existing 30+ DS::Tooltip callsites work without modification — they all pass `text:`-only payloads, which still render correctly under the new markup. * fix(review): as: option + alpha focus-ring on DS::Tooltip Addresses two AI review findings on #1845: 1. **Button-inside-summary spec violation.** Wrapping the icon in `<button>` regressed keyboard/AT behavior at 13 callsites where DS::Tooltip lives inside a `<summary>` (8 provider items, lunchflow disclosure, activity_date, 4 simplefin badges). HTML's content model forbids interactive content inside `<summary>`; browsers and AT can drop focus or conflate activation with the disclosure toggle. Add `as:` parameter — default `:button` preserves the standalone a11y wrap; `:span` renders a non-focusable wrapper for summary-nested usage. `focusin` bubbles up to the controller from the ancestor `<summary>`, so keyboard tooltips still appear on tab. Migrate the 13 in-summary callsites to `as: :span`. 2. **Raw palette focus ring → alpha tokens.** Swap `outline-gray-900 theme-dark:focus-visible:outline-white` to the established focus-ring pattern `focus-visible:ring-2 focus-visible:ring-alpha-black-300 theme-dark:focus-visible:ring-alpha-white-300` — matches the DS::Toggle fix landed in #1843 review and provider_card / form-field tokens. * fix(review): bind tooltip focus on ancestor <summary> Codex P2 follow-up on #1845: \`as: :span\` renders a non-focusable trigger inside the disclosure \`<summary>\`. Keyboard users hit Tab and focus lands on the summary itself; \`focusin\` fires on the summary and bubbles UP — never down to a descendant span — so the existing listener on \`this.element\` never fires and the tooltip stays hidden for keyboard-only users on every in-summary row (provider _item partials, lunchflow disclosure, activity_date, simplefin badges). My earlier reply that the focusin "bubbles up to the Stimulus controller on the outer span" was wrong about the direction; \`focusin\` only bubbles upward. In \`addEventListeners\`, resolve \`this.element.closest("summary")\` and bind \`focusin\` / \`focusout\` / \`keydown\` on it too. Track the ancestor on the controller and undo the bindings in \`removeEventListeners\` so reconnect-on-Turbo cycles don't leak. Update the template comment to reflect the actual mechanism. * docs(ds-tooltip): correct as=:span comment to match controller mechanism --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
126 lines
3.8 KiB
JavaScript
126 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 (typically `<summary>`). 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 on in-summary rows.
|
|
// Bind the same handlers on the closest `<summary>` (if any) so
|
|
// focusing the disclosure reveals the tooltip and Esc still
|
|
// dismisses it.
|
|
this.summaryAncestor = this.element.closest("summary");
|
|
if (this.summaryAncestor) {
|
|
this.summaryAncestor.addEventListener("focusin", this.show);
|
|
this.summaryAncestor.addEventListener("focusout", this.hide);
|
|
this.summaryAncestor.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.summaryAncestor) {
|
|
this.summaryAncestor.removeEventListener("focusin", this.show);
|
|
this.summaryAncestor.removeEventListener("focusout", this.hide);
|
|
this.summaryAncestor.removeEventListener("keydown", this.handleKeydown);
|
|
this.summaryAncestor = 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`,
|
|
});
|
|
});
|
|
}
|
|
} |