feat: improve visualization of tags

Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.

This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.

A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent 013ef7d557
commit 108406a885
46 changed files with 1102 additions and 832 deletions
@@ -0,0 +1,87 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { useKeywordFormat } from '../use-keyword-format';
import { useSettingsStore, KEYWORD_PALETTE, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
{ id: 'archive', label: 'Archive', color: 'red-dark' },
];
describe('useKeywordFormat', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
describe('tagColor', () => {
it('resolves a tag to its palette entry, including the new shades', () => {
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue);
expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']);
});
it('falls back to grey for a keyword this client has no definition for', () => {
// Set on the message by another client, or its tag was deleted here.
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray);
});
it('falls back to grey for a colour that is not in the palette', () => {
useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] });
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray);
});
});
describe('sortTagIds', () => {
it('follows the order the user arranged in settings', () => {
// Settings order is work, work/clients, archive - drag-reorderable, and
// deliberately not alphabetical.
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([
'work',
'work/clients',
'archive',
]);
});
it('is stable however the keywords happen to arrive', () => {
const { result } = renderHook(() => useKeywordFormat());
const expected = ['work', 'work/clients', 'archive'];
expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected);
expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected);
});
it('follows a reordering of the settings list', () => {
useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] });
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']);
});
it('puts a tag with no local definition last, ordered by name', () => {
const { result } = renderHook(() => useKeywordFormat());
expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([
'work',
'aa-unknown',
'zz-unknown',
]);
});
it("leaves the caller's array alone", () => {
const { result } = renderHook(() => useKeywordFormat());
const input = ['archive', 'work'];
result.current.sortTagIds(input);
expect(input).toEqual(['archive', 'work']);
});
});
});
+40 -6
View File
@@ -1,18 +1,22 @@
"use client";
import { useMemo } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import {
useSettingsStore,
KEYWORD_PALETTE,
FALLBACK_KEYWORD_COLOR,
type KeywordColor,
} from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
/**
* Names tags for the screen, bound to the user's tag settings.
* Names and colours 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.
* someone who never asked for nesting. Subscribing to them also keeps tags in
* step the moment either changes: reading the store inside the formatter would
* leave every list stale until something else happened to re-render it.
*/
export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords);
@@ -22,8 +26,38 @@ export function useKeywordFormat() {
() => ({
/** 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)),
/**
* The tag's colour. Falls back to grey for a keyword this client has no
* definition for - one created on another device, or whose tag was
* deleted - so such a tag still shows rather than silently vanishing.
*/
tagColor: (id: string): KeywordColor => {
const color = keywords.find((keyword) => keyword.id === id)?.color;
return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR];
},
/**
* Tag ids in the order the user arranged them in settings.
*
* The keywords on a message arrive as an unordered JMAP map, so without
* this the same two tags can swap places between rows. A tag with no
* local definition has no place in that order, so it sorts last, by name.
*/
sortTagIds: (ids: string[]): string[] => {
const rank = (id: string) => {
const index = keywords.findIndex((keyword) => keyword.id === id);
return index === -1 ? keywords.length : index;
};
return [...ids].sort(
(a, b) =>
rank(a) - rank(b) ||
formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)),
);
},
}),
[keywords, nested],
);
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react";
import type { TagBadgeVariant } from "@/components/email/tag-badge";
/**
* Below this, a named tag beside the subject would leave the subject nothing to
* occupy, so tags move up to the sender line instead. The split list runs
* 240-600px wide and defaults to 384, so it reads that way until widened, while
* the full-width focus and bottom-pane layouts keep tags with the subject.
*/
const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
/**
* Below this there is no room to name a tag anywhere on the row, and colour
* alone has to carry it. Well under the split list's default, because the
* sender line still has room for a name long after the subject line does not.
*/
const TAG_NAME_MIN_WIDTH = 320;
export interface TagDisplay {
/** Whether a tag is named or shown as colour alone. */
variant: TagBadgeVariant;
/** Which line of a multi-line row the tags belong on. */
placement: "subject" | "sender";
}
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
/**
* How message rows should draw their tags.
*
* One value for the whole list, never per row: rows are all the same width, so
* measuring each would burn a `ResizeObserver` per virtualised row and, worse,
* let neighbours disagree - one naming its tags while the next showed dots.
*/
export const TagDisplayContext = createContext<TagDisplay>(NAMED_BESIDE_SUBJECT);
export function useTagDisplay(): TagDisplay {
return useContext(TagDisplayContext);
}
/**
* Watches a container and reports what its rows have room for. Falls back to
* naming tags beside the subject where measurement is unavailable - server
* rendering, and jsdom under test - since that is the most informative form.
*/
export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDisplay {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const measured = entries[0]?.contentRect.width;
if (measured !== undefined) setWidth(measured);
});
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return useMemo(() => {
if (width === null) return NAMED_BESIDE_SUBJECT;
return {
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
};
}, [width]);
}