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
11 changes: 11 additions & 0 deletions apps/web/src/icon-tooltip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, test } from "bun:test";

import { tooltipText } from "./icon-tooltip";

test("icon labels are capitalised word by word", () => {
expect(tooltipText(" bold text ", false)).toBe("Bold Text");
});

test("verbatim labels keep their own casing", () => {
expect(tooltipText("maggieAppleton, octocat", true)).toBe("maggieAppleton, octocat");
});
25 changes: 16 additions & 9 deletions apps/web/src/icon-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ function hasVisibleText(button: HTMLButtonElement): boolean {
return false;
}

function iconButton(target: EventTarget | null): HTMLButtonElement | null {
function iconButton(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
// Non-button marks (such as presence faces) opt in with an explicit data-tooltip.
let marked = target.closest<HTMLElement>("[data-tooltip]:not(button)");
if (marked && !marked.closest("[inert]")) return marked;
let button = target.closest<HTMLButtonElement>("button");
if (
!button || button.disabled || button.closest("[inert]")
Expand All @@ -35,16 +38,23 @@ function iconButton(target: EventTarget | null): HTMLButtonElement | null {
return button;
}

/** Names and handles opt out of capitalisation so they keep their own casing. */
export function tooltipText(label: string, verbatim: boolean): string {
return verbatim
? label.trim()
: label.trim().replace(/(^|\s)([a-z])/g, (_, space, letter) => space + letter.toUpperCase());
}

export function IconTooltip() {
useEffect(() => {
let tooltip = document.createElement("div");
tooltip.className = "icon-tooltip";
tooltip.setAttribute("data-icon-tooltip", "");
tooltip.setAttribute("aria-hidden", "true");
document.body.append(tooltip);
let active: HTMLButtonElement | null = null;
let hovered: HTMLButtonElement | null = null;
let focused: HTMLButtonElement | null = null;
let active: HTMLElement | null = null;
let hovered: HTMLElement | null = null;
let focused: HTMLElement | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let originalTitle: string | null = null;

Expand All @@ -59,7 +69,7 @@ export function IconTooltip() {
originalTitle = null;
}

function enter(button: HTMLButtonElement | null) {
function enter(button: HTMLElement | null) {
if (button === active) return;
hide();
if (!button) return;
Expand All @@ -71,10 +81,7 @@ export function IconTooltip() {
let label = button.getAttribute("data-tooltip") ?? button.getAttribute("aria-label")
?? originalTitle ?? button.querySelector(".sr-only")?.textContent;
if (!label) return hide();
tooltip.textContent = label.trim().replace(
/(^|\s)([a-z])/g,
(_, space, letter) => space + letter.toUpperCase(),
);
tooltip.textContent = tooltipText(label, button.hasAttribute("data-tooltip-verbatim"));
let rect = button.getBoundingClientRect();
let below = rect.top < tooltip.offsetHeight + GAP;
tooltip.style.top = `${below ? rect.bottom + GAP : rect.top - GAP}px`;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,13 @@ export function Header(
role="group"
>
{people.map(handle => (
<span className="room-member-face -ml-1.5 first:ml-0" key={handle.toLowerCase()}>
<Face handle={handle} ring="ground" size={24} />
<span
className="room-member-face -ml-1.5 first:ml-0"
data-tooltip={handle}
data-tooltip-verbatim=""
key={handle.toLowerCase()}
>
<Face handle={handle} ring="ground" size={24} titled={false} />
</span>
))}
{people.length > 3 && (
Expand Down
63 changes: 62 additions & 1 deletion e2e/sidecar.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { authenticate, content, expect, test } from "./room";
import { storedQuestion } from "../apps/server/src/testing/plan";

import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";

/** Long enough to be marked: the injector wants twenty characters. */
const PROSE = "Room state lives on disk as MDX beside the transcript.\n";
Expand Down Expand Up @@ -744,6 +744,67 @@ test("a rejected save is announced as an alert with motion feedback", async ({ j
await expect(card).not.toContainText("Answered by");
});

test("people on a decision are faces with verbatim handle tooltips", async ({ join, seed }) => {
await seed(PROSE);
let title = "Where should room state live?";
let open = async (handle: string) => {
let page = await join(handle);
await page.getByRole("button", { name: /^Decisions/ }).click();
let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: title }) });
await expect(card).toBeVisible();
return { page, card };
};
let ana = await open("ana");
let people = ana.card.getByRole("group", { name: /^Editing this question/ });
await expect(people).toHaveCount(0);

// Four real peers work on the same question, one after another: three faces and a "+1".
let peers = [];
for (let handle of ["Bo", "cy", "Di", "ed"]) {
let peer = await open(handle);
await peer.card.getByRole("radio").first().focus();
peers.push(peer);
await expect(ana.card.getByRole("group", { name: new RegExp(`\\b${handle}\\b`) }))
.toBeVisible();
}

await expect(people.getByRole("img")).toHaveCount(3);
let more = people.getByText("+1");
await expect(more).toBeVisible();
await expect(ana.card).not.toContainText("@Bo");

// The tooltip hides on any scroll, and scroll events arrive a frame after the
// scroll itself. Settle the page first, then arrive with real pointer movement.
let tooltip = ana.page.locator("[data-icon-tooltip]");
let settleThenHover = async (target: Locator) => {
await target.scrollIntoViewIfNeeded();
await ana.page.evaluate(() =>
new Promise<void>(done => requestAnimationFrame(() => requestAnimationFrame(() => done())))
);
let box = (await target.boundingBox())!;
await ana.page.mouse.move(0, 0);
await ana.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
};
await settleThenHover(people.getByRole("img").first());
await expect(tooltip).toHaveAttribute("data-visible", "");
await expect(tooltip).toHaveText("Bo");

await settleThenHover(more);
await expect(tooltip).toHaveText("ed");
expect(
await people.getByRole("img").evaluateAll(nodes =>
nodes.map(node => node.getAttribute("title"))
),
).toEqual([null, null, null]);

// Leaving the question clears the face; choosing is the same as being there.
await peers[3]!.card.getByRole("radio").first().blur();
await expect(more).toHaveCount(0);
await peers[0]!.page.close();
await expect(people.getByRole("img")).toHaveCount(2);
await expect(people).toHaveAttribute("aria-label", "Editing this question: cy, Di");
});

test("discarding asks first", async ({ join, seed }) => {
await seed(PROSE);
let page = await join("ana");
Expand Down
14 changes: 13 additions & 1 deletion packages/editor/src/face.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { Face, FACE_RING_CLASS } from "./face";
import { Face, FACE_RADIUS_CLASS, FACE_RING_CLASS, faceCorner } from "./face";

test("names static cover-ring classes for every supported surface", () => {
expect(FACE_RING_CLASS).toEqual({
Expand All @@ -18,3 +18,15 @@ test("an overlapping header face uses the header surface for its cover ring", ()

expect(markup).toContain("ring-2 ring-ground");
});

test("corner radius scales with size", () => {
expect(FACE_RADIUS_CLASS[faceCorner(18)]).toBe("rounded-sm");
expect(FACE_RADIUS_CLASS[faceCorner(24)]).toBe("rounded-md");
});

test("a named tooltip suppresses the native title", () => {
let titled = renderToStaticMarkup(createElement(Face, { handle: "maggie" }));
let quiet = renderToStaticMarkup(createElement(Face, { handle: "maggie", titled: false }));
expect(titled).toContain('title="maggie"');
expect(quiet).not.toContain("title=");
});
20 changes: 16 additions & 4 deletions packages/editor/src/face.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,28 @@ export type FaceProps = {
size?: number;
/** The surface behind overlapping faces, so their cover ring does not show. */
ring?: "ground" | "page";
/** Set false where a design-system tooltip already names the face. */
titled?: boolean;
};

export const FACE_RING_CLASS = {
ground: "ring-2 ring-ground",
page: "ring-2 ring-page",
} as const;

export function Face({ handle, ring, size = 20 }: FaceProps) {
export const FACE_RADIUS_CLASS = {
small: "rounded-sm",
regular: "rounded-md",
} as const;

/** Small faces need a smaller corner than the default. */
export function faceCorner(size: number): keyof typeof FACE_RADIUS_CLASS {
return size <= 18 ? "small" : "regular";
}

export function Face({ handle, ring, size = 20, titled = true }: FaceProps) {
let [failed, setFailed] = useState(false);
let edge = `shrink-0 rounded-md ${ring ? FACE_RING_CLASS[ring] : ""}`;
let edge = `shrink-0 ${FACE_RADIUS_CLASS[faceCorner(size)]} ${ring ? FACE_RING_CLASS[ring] : ""}`;
let box = { width: size, height: size };

if (failed) {
Expand All @@ -55,7 +67,7 @@ export function Face({ handle, ring, size = 20 }: FaceProps) {
className={`block ${edge}`}
role="img"
style={{ ...box, background: color(handle) }}
title={handle}
title={titled ? handle : undefined}
/>
);
}
Expand All @@ -68,7 +80,7 @@ export function Face({ handle, ring, size = 20 }: FaceProps) {
referrerPolicy="no-referrer"
src={photograph(handle, size)}
style={box}
title={handle}
title={titled ? handle : undefined}
/>
);
}
Expand Down
25 changes: 25 additions & 0 deletions packages/editor/src/presence-faces.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { PresenceFaces, presenceSplit } from "./presence-faces";

test("dedupes by handle and keeps three faces, the rest counted", () => {
let split = presenceSplit(["a", "B", "b", "c", "d", "e"]);
expect(split.shown).toEqual(["a", "B", "c"]);
expect(split.hidden).toEqual(["d", "e"]);
});

test("tooltips keep handle casing and list hidden handles", () => {
let markup = renderToStaticMarkup(
createElement(PresenceFaces, { handles: ["MaggieAppleton", "b", "c", "Dee", "eve"] }),
);
expect(markup).toContain('data-tooltip="MaggieAppleton"');
expect(markup).toContain('data-tooltip="Dee, eve"');
expect(markup).toContain("+2");
expect(markup).not.toContain("title=");
});

test("renders nothing with nobody present", () => {
expect(renderToStaticMarkup(createElement(PresenceFaces, { handles: [] }))).toBe("");
});
53 changes: 53 additions & 0 deletions packages/editor/src/presence-faces.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* The people on a decision: overlapping faces, three then a count.
*
* Each face and the count carry a design-system tooltip (see `IconTooltip` in
* the web app) with verbatim handles, so the native `title` is suppressed.
*/

import { Face } from "./face";

export const MAX_FACES = 3;

/** One face per person even when they are connected twice; first spelling wins. */
export function presenceSplit(
handles: readonly string[],
max = MAX_FACES,
): { shown: string[]; hidden: string[] } {
let seen = new Set<string>();
let unique: string[] = [];
for (let handle of handles) {
let key = handle.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
unique.push(handle);
}
return { shown: unique.slice(0, max), hidden: unique.slice(max) };
}

export function PresenceFaces({ handles }: { handles: readonly string[] }) {
let { shown, hidden } = presenceSplit(handles);
if (shown.length === 0) return null;
return (
<span
aria-label={`Editing this question: ${[...shown, ...hidden].join(", ")}`}
className="presence-faces"
role="group"
>
{shown.map(handle => (
<span className="presence-face" data-tooltip={handle} data-tooltip-verbatim="" key={handle}>
<Face handle={handle} ring="page" size={24} titled={false} />
</span>
))}
{hidden.length > 0 && (
<span
className="presence-more"
data-tooltip={hidden.join(", ")}
data-tooltip-verbatim=""
>
+{hidden.length}
</span>
)}
</span>
);
}
20 changes: 20 additions & 0 deletions packages/editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2011,3 +2011,23 @@
white-space: nowrap;
text-overflow: ellipsis;
}

/* Presence on a decision card: aligned to the 1.5rem mark beside the title. */
.presence-faces {
display: flex;
flex: none;
align-items: center;
block-size: 1.5rem;
}

.presence-face + .presence-face {
margin-inline-start: -0.375rem;
}

.presence-more {
margin-inline-start: 0.25rem;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
3 changes: 3 additions & 0 deletions packages/editor/src/widgets/questionnaire.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useCellValue } from "@mdxeditor/gurx";

import { Provenance, SidecarCard } from "../card";
import { ContentSwapLayer } from "../content-swap";
import { PresenceFaces } from "../presence-faces";
import { widgets$ } from "../widget-options";

import type { ReactNode } from "react";
Expand Down Expand Up @@ -189,7 +190,9 @@ function Undecided(
errorClassName="editor-motion-feedback"
onCancel={editable ? state.cancel : undefined}
onChange={editable ? state.change : undefined}
onQuestionFocus={editable ? state.focusQuestion : undefined}
onSubmit={editable ? state.submit : undefined}
renderPeople={people => <PresenceFaces handles={people.map(person => person.handle)} />}
renderStep={motion
? ({ children, question }) => (
<QuestionStepSwap motion={motion} question={question}>
Expand Down
Loading
Loading