feat: Add nesting of tags in a tree

- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room

Closes #687.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:38 +02:00
parent ce97a54aaa
commit ca0ba818b7
22 changed files with 1084 additions and 95 deletions
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { useShortenedText } from '../use-shortened-text';
const CANDIDATES = ['Work/Clients/Acme/Sales', 'Work/../Acme/Sales', 'Work/.../Sales'];
/**
* Reports `width` for the observed element and measures text at 10px per
* character, so a width of N*10 fits any candidate of N characters or fewer.
*/
function stubMeasurement(width: number) {
// Implementing the interface rather than passing an anonymous class keeps the
// members the hook never calls from reading as dead code.
class StubResizeObserver implements ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
/** The hook observes once on mount; hand it `width` straight back. */
observe(target: Element) {
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', StubResizeObserver);
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: (text: string) => ({ width: text.length * 10 }),
} as unknown as CanvasRenderingContext2D);
}
function Probe({ candidates }: { candidates: string[] }) {
const [ref, text] = useShortenedText(candidates);
return <span ref={ref} data-testid="probe">{text}</span>;
}
function renderProbe(candidates: string[]): string {
render(<Probe candidates={candidates} />);
return screen.getByTestId('probe').textContent ?? '';
}
describe('useShortenedText', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns the longest candidate where the DOM cannot be measured', () => {
// No ResizeObserver: server rendering, and jsdom by default. Showing the
// whole path beats shortening it on a guess.
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('keeps the full path when the element is wide enough', () => {
stubMeasurement(230);
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('steps down only as far as the width requires', () => {
stubMeasurement(200);
expect(renderProbe(CANDIDATES)).toBe('Work/../Acme/Sales');
});
it('falls back to the shortest candidate when none of them fit', () => {
stubMeasurement(40);
expect(renderProbe(CANDIDATES)).toBe('Work/.../Sales');
});
});
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useMemo } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
/**
* Names tags for the screen, bound to the user's tag settings.
*
* Resolving the definitions and the nesting setting here rather than at every
* call site means no caller can forget the setting and render a nested name to
* someone who never asked for nesting. Subscribing to it also keeps names in
* step the moment it is toggled: reading it straight from the store inside the
* formatter would leave every list showing stale names until something else
* happened to re-render them.
*/
export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords);
const nested = useSettingsStore((state) => state.nestedTags);
return useMemo(
() => ({
/** The tag's display name. */
tagName: (id: string) => formatKeyword(id, keywords, nested),
/** Its progressively shorter forms, longest first, for `useShortenedText`. */
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)),
}),
[keywords, nested],
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useMemo, useState } from "react";
/**
* Measures text the way the browser will, using the font the element actually
* renders with. One canvas is reused for every measurement.
*/
let measureContext: CanvasRenderingContext2D | null | undefined;
function measureText(text: string, font: string): number {
if (measureContext === undefined) {
measureContext = document.createElement("canvas").getContext("2d");
}
if (!measureContext) return 0;
measureContext.font = font;
return measureContext.measureText(text).width;
}
/**
* Picks the first of `candidates` that fits the element the returned ref is
* attached to, remeasuring whenever that element is resized.
*
* Candidates run longest first, so the result is the most complete one there is
* room for. A character budget cannot do this job: the columns this is used in
* are resized by the user and share their row with controls whose width depends
* on the locale, so any fixed number is either so generous that it never
* triggers or so tight that it shortens text that would have fit.
*
* Attach the ref to an element whose width does *not* depend on its own text -
* a flex child that is allowed to shrink, i.e. one with `truncate` or
* `min-w-0`. On anything else, picking a shorter candidate would change the
* width that picked it and the two would oscillate.
*
* Where measurement is unavailable - server rendering, and jsdom under test -
* this returns the first candidate, so the text is complete rather than
* arbitrarily shortened.
*/
export function useShortenedText(
candidates: string[],
): [(node: HTMLElement | null) => void, string] {
const [element, setElement] = useState<HTMLElement | null>(null);
const [box, setBox] = useState<{ width: number; font: string } | null>(null);
useEffect(() => {
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const style = window.getComputedStyle(element);
setBox({
width: entry.contentRect.width,
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
});
});
observer.observe(element);
return () => observer.disconnect();
}, [element]);
// Candidates are rebuilt on every render, so key the choice on their content.
// They must not contain a newline, which keeps this join unambiguous.
const key = candidates.join("\n");
return [
setElement,
useMemo(() => {
const options = key.split("\n");
if (!box || box.width === 0) return options[0];
return (
options.find((option) => measureText(option, box.font) <= box.width)
?? options[options.length - 1]
);
}, [key, box]),
];
}