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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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]),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user