Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions apps/ui/src/components/CoachTip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, test } from "bun:test";
import { cleanup, render } from "@solidjs/testing-library";
import type { CommandId } from "../keyboard/commands";
import { formatChordForDisplay, shortcutFor } from "../keyboard/keymap";
import { CoachTip, CoachTipContent, CoachTipTrigger } from "./CoachTip";

// CoachTip's rendered contract (RIG-2530): a Kobalte Tooltip whose content is a
// control's label + its keymap-resolved chord. Defends: chord derivation via
// shortcutFor (never hand-authored), the ARIA tooltip wiring the primitive
// owns (role="tooltip" + aria-describedby), focus reveal, the label-only path
// when no keymap row exists, and the sequence-aware branch that keeps a leader
// sequence out of ShortcutChip's "+"-split.

function setPlatform(platform: "mac" | "other"): void {
Object.defineProperty(navigator, "platform", {
value: platform === "mac" ? "MacIntel" : "Linux x86_64",
configurable: true,
});
}

const cmd = (id: string) => id as CommandId;

// Kobalte mounts the portalled content through createPresence on a macrotask,
// so a focus that opens the tooltip is observable only after one setTimeout(0).
async function settle(): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, 0);
await promise;
}

const tooltipOf = (root: HTMLElement) =>
root.querySelector<HTMLElement>('[role="tooltip"]');

afterEach(() => {
cleanup();
setPlatform("other");
});

describe("CoachTip (RIG-2530)", () => {
test("label + chord: view.bridge on other shows the label and a Ctrl+B chip derived from the keymap", async () => {
setPlatform("other");
const { getByRole, baseElement } = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Bridge
</CoachTipTrigger>
<CoachTipContent label="Bridge" command={cmd("view.bridge")} />
</CoachTip>
));

getByRole("button").focus();
await settle();

const tooltip = tooltipOf(baseElement);
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("Bridge");

const chip = tooltip?.querySelector(".cx-palette-shortcut");
expect(chip).not.toBeNull();
const kbds = Array.from(chip?.querySelectorAll("kbd") ?? []).map(
(k) => k.textContent,
);
// Grounded in DEFAULT_KEYMAP via shortcutFor — never a hand-authored string.
expect(shortcutFor(cmd("view.bridge"), "other")).toBe("Ctrl+B");
expect(kbds).toEqual(["Ctrl", "B"]);
});

test("aria wiring: focusing the trigger opens the tooltip and links aria-describedby to the content id", async () => {
setPlatform("other");
const { getByRole, baseElement } = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Bridge
</CoachTipTrigger>
<CoachTipContent label="Bridge" command={cmd("view.bridge")} />
</CoachTip>
));

const trigger = getByRole("button");
trigger.focus();
await settle();

const tooltip = tooltipOf(baseElement);
expect(tooltip).not.toBeNull();
expect(tooltip?.id).toBeTruthy();
expect(trigger.getAttribute("aria-describedby")).toBe(tooltip?.id ?? "");
});

test("focus reveal: the tooltip opens on trigger focus with no pointer event", async () => {
const { getByRole, baseElement } = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Bridge
</CoachTipTrigger>
<CoachTipContent label="Bridge" command={cmd("view.bridge")} />
</CoachTip>
));

expect(tooltipOf(baseElement)).toBeNull();
getByRole("button").focus();
await settle();
expect(tooltipOf(baseElement)).not.toBeNull();
});

test("label-only: a command with no keymap row renders the label and no chip", async () => {
setPlatform("other");
// Guard the premise: view.backlog has no keymap row on this base.
expect(shortcutFor(cmd("view.backlog"), "other")).toBeUndefined();

const { getByRole, baseElement } = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Backlog
</CoachTipTrigger>
<CoachTipContent label="Backlog" command={cmd("view.backlog")} />
</CoachTip>
));

getByRole("button").focus();
await settle();

const tooltip = tooltipOf(baseElement);
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("Backlog");
expect(tooltip?.querySelector(".cx-palette-shortcut")).toBeNull();
expect(tooltip?.querySelector("kbd")).toBeNull();
});

test("sequence handling: a 'then'-sequence chord renders plain text (no kbd split), a '+'-chord renders the kbd chip", async () => {
// The sequence fixture is #544's formatChordForDisplay output (DL-251),
// so a format change surfaces here rather than silently mis-rendering.
const sequence = formatChordForDisplay("G B", "other");
expect(sequence).toBe("G then B");

const seq = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Go
</CoachTipTrigger>
<CoachTipContent label="Go" chord={sequence} />
</CoachTip>
));
seq.getByRole("button").focus();
await settle();
const seqTip = tooltipOf(seq.baseElement);
expect(seqTip?.textContent).toContain(sequence);
expect(seqTip?.querySelector("kbd")).toBeNull();
cleanup();

const plain = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Bridge
</CoachTipTrigger>
<CoachTipContent label="Bridge" chord="Ctrl+B" />
</CoachTip>
));
plain.getByRole("button").focus();
await settle();
const plainTip = tooltipOf(plain.baseElement);
const kbds = Array.from(plainTip?.querySelectorAll("kbd") ?? []).map(
(k) => k.textContent,
);
expect(kbds).toEqual(["Ctrl", "B"]);
});
});
71 changes: 71 additions & 0 deletions apps/ui/src/components/CoachTip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Coaching tooltip (RIG-2530) — the reusable label+chord Tooltip adopted across
// command-backed chrome. Built on the Kobalte v2-alpha `Tooltip` primitive
// (a11y-hard behavior: hover+focus reveal, open-delay timing, Escape dismiss,
// aria-describedby wiring — DL-150), styled by the shipped `.cx-tooltip` box.
// The chord is ALWAYS the keymap-resolved display string (via `shortcutFor`),
// never hand-authored (DL-234's single-derivation rule). Sequence-aware: a
// leader sequence ("G then B") renders as plain text in the chip's style, since
// ShortcutChip splits on "+" and would otherwise emit one giant <kbd> (A3).

import { Tooltip } from "@kobalte/core/tooltip";
import type { Component, ParentProps } from "solid-js";
import { Show } from "solid-js";
import "../design/components/tooltip.css";
import type { CommandId } from "../keyboard/commands";
import { detectPlatform } from "../keyboard/dispatch";
import { shortcutFor } from "../keyboard/keymap";
import { ShortcutChip } from "./ShortcutChip";

/** House open delay, mirrors --cx-tooltip-delay (tokens.css:227). */
export const COACH_TIP_DELAY_MS = 400;

/** Kobalte Tooltip root with openDelay defaulted to COACH_TIP_DELAY_MS;
* hover+focus reveal is Kobalte's default (triggerOnFocusOnly stays unset). */
export const CoachTip: Component<ParentProps<{ openDelay?: number }>> = (
props,
) => (
<Tooltip openDelay={props.openDelay ?? COACH_TIP_DELAY_MS}>
{props.children}
</Tooltip>
);

/** The trigger — Kobalte's polymorphic Trigger, re-exported so call sites author
* <CoachTipTrigger as="button" type="button" class=… onClick=…> with their
* existing attributes. */
export const CoachTipTrigger = Tooltip.Trigger;

/** Portal + Content(class="cx-tooltip") rendering `label`, then the chord:
* chord = props.chord ?? shortcutFor(props.command, detectPlatform());
* undefined → label only; contains " then " → plain-text sequence; otherwise
* <ShortcutChip chord={chord}>. Never destructures props. */
export const CoachTipContent: Component<{
label: string;
command?: CommandId;
chord?: string;
/** Fully REPLACES the `.cx-tooltip` box class (ShortcutChip parity) — it does
* not augment it, so a caller passing this must re-include the row layout
* (.cx-tooltip's flex + the .cx-tooltip-label / .cx-palette-shortcut parts). */
class?: string;
}> = (props) => {
const chord = () =>
props.chord ??
(props.command ? shortcutFor(props.command, detectPlatform()) : undefined);
const isSequence = () => chord()?.includes(" then ") ?? false;
return (
<Tooltip.Portal>
<Tooltip.Content class={props.class ?? "cx-tooltip"}>
<span class="cx-tooltip-label">{props.label}</span>
<Show when={chord()} keyed>
{(resolved) => (
<Show
when={isSequence()}
fallback={<ShortcutChip chord={resolved} />}
>
<span class="cx-palette-shortcut">{resolved}</span>
</Show>
)}
</Show>
</Tooltip.Content>
</Tooltip.Portal>
);
};
14 changes: 12 additions & 2 deletions apps/ui/src/design/components/tooltip.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
/* Tooltip — .cx-tooltip (D3, Kobalte). Elev-1 float, open delay
--cx-tooltip-delay (the delay is Kobalte's timing prop — this owns the
visual box). Never load-bearing: the same info is reachable elsewhere.
Display surface (no interactive states). Consumes only --cx-* tiers. */
Display surface (no interactive states). Consumes only --cx-* tiers.
Lays out its content as a row: the label, then a right-aligned chord chip
(.cx-palette-shortcut, reused — its margin-left:auto needs a flex parent). */

.cx-tooltip {
display: block;
display: flex;
align-items: center;
gap: var(--cx-space-2);
max-width: 280px;
padding: var(--cx-space-1) var(--cx-space-2);
border: 1px solid var(--cx-border);
Expand All @@ -17,3 +21,9 @@
box-shadow: var(--cx-elev-1);
z-index: var(--cx-z-overlay);
}

/* Label sub-part — the control's name; takes free space so the chord chip
right-aligns via its own margin-left:auto. */
.cx-tooltip-label {
flex: 1 1 auto;
}
55 changes: 54 additions & 1 deletion apps/ui/src/keyboard/keymap.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, test } from "bun:test";
import type { CommandId } from "./commands";
import { shortcutFor, shortcutForAria } from "./keymap";
import {
chordSegments,
formatChordForDisplay,
type KeymapEntry,
leaderPrefixes,
shortcutFor,
shortcutForAria,
} from "./keymap";

// shortcutFor (RIG-2483, A5/D4) — the single derivation for every shortcut chip:
// the first DEFAULT_KEYMAP row bound to an id, resolveChord-resolved. Pure
Expand Down Expand Up @@ -52,3 +59,49 @@ describe("shortcutForAria", () => {
expect(shortcutForAria(id("nonexistent.command"), "other")).toBeUndefined();
});
});

// Sequence-grammar helpers (RIG-2484 T1) — pure, table-independent. Tested over
// a FIXTURE keymap because DEFAULT_KEYMAP carries no sequence rows until T2.

const seqFixture: readonly KeymapEntry[] = [
{ chord: "Mod+B", commandId: id("view.bridge") },
{ chord: "G B", commandId: id("view.bridge") },
{ chord: "G L", commandId: id("view.backlog") },
];

describe("chordSegments", () => {
test("splits a sequence on its single space", () => {
expect(chordSegments("G B")).toEqual(["G", "B"]);
});

test("a plain chord yields a one-element array", () => {
expect(chordSegments("Mod+B")).toEqual(["Mod+B"]);
expect(chordSegments("Shift+Enter")).toEqual(["Shift+Enter"]);
});
});

describe("leaderPrefixes", () => {
test("collects the resolved first segment of every sequence row, and nothing else", () => {
const prefixes = leaderPrefixes(seqFixture, "other");
expect([...prefixes]).toEqual(["G"]);
});

test("empty for a table with no sequence rows", () => {
const single: readonly KeymapEntry[] = [
{ chord: "Mod+B", commandId: id("view.bridge") },
];
expect(leaderPrefixes(single, "other").size).toBe(0);
});
});

describe("formatChordForDisplay", () => {
test("a single chord resolves platform-specifically (Mod→Cmd/Ctrl)", () => {
expect(formatChordForDisplay("Mod+B", "mac")).toBe("Cmd+B");
expect(formatChordForDisplay("Mod+B", "other")).toBe("Ctrl+B");
});

test("a sequence joins resolved segments with ' then '", () => {
expect(formatChordForDisplay("G B", "mac")).toBe("G then B");
expect(formatChordForDisplay("G L", "other")).toBe("G then L");
});
});
Loading
Loading