Admin PWA: Lucide + Font Awesome + emoji icon picker (#243)
This commit was merged in pull request #243.
This commit is contained in:
@@ -583,3 +583,14 @@
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.defs-sublabel {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-emoji {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import { DEFINITION_ICONS, DefinitionIcon, normalizeDefinitionIconName } from "./definitionIcons";
|
||||
import {
|
||||
DEFINITION_ICONS,
|
||||
DefinitionIcon,
|
||||
FA_DEFINITION_ICONS,
|
||||
emojiIconKey,
|
||||
faIconName,
|
||||
findDefinitionIcon,
|
||||
normalizeDefinitionIconName,
|
||||
} from "./definitionIcons";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
@@ -1166,7 +1174,7 @@ describe("DefinitionsTab namespaced icons", () => {
|
||||
});
|
||||
}
|
||||
|
||||
for (const icon of ["fa:dog", "emoji:🐶", "mystery:dog"]) {
|
||||
for (const icon of ["mystery:dog", "lucide:not-an-icon", "fa:not-an-icon", "emoji:a b"]) {
|
||||
it(`renders the fallback tile and selects nothing for ${icon}`, async () => {
|
||||
await renderReady([{ ...BRUSH, id: 31, icon }]);
|
||||
const row = screen.getByTestId("definition-31");
|
||||
@@ -1174,9 +1182,11 @@ describe("DefinitionsTab namespaced icons", () => {
|
||||
|
||||
fireEvent.click(row);
|
||||
const s = sheet();
|
||||
const group = within(s).getByRole("radiogroup", { name: "Icon" });
|
||||
for (const radio of within(group).getAllByRole("radio")) {
|
||||
expect(radio.getAttribute("aria-checked")).toBe("false");
|
||||
for (const name of ["Icon", "Font Awesome"]) {
|
||||
const group = within(s).getByRole("radiogroup", { name });
|
||||
for (const radio of within(group).getAllByRole("radio")) {
|
||||
expect(radio.getAttribute("aria-checked")).toBe("false");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1207,6 +1217,231 @@ describe("DefinitionsTab namespaced icons", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** What a row draws: `svg:<data-icon>`, `emoji:<text>`, or `fallback`. */
|
||||
function rowGlyph(row: HTMLElement): string {
|
||||
const glyph = row.querySelector(".defs-row-glyph");
|
||||
const emoji = glyph!.querySelector("[data-emoji]");
|
||||
if (emoji) return `emoji:${emoji.textContent}`;
|
||||
const svg = glyph!.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
const name = svg!.getAttribute("data-icon");
|
||||
if (name === null) return "fallback";
|
||||
return `${svg!.getAttribute("fill") === "none" ? "lucide" : "fa"}:${name}`;
|
||||
}
|
||||
|
||||
function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement {
|
||||
return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", {
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
function emojiInput(s: HTMLElement): HTMLInputElement {
|
||||
return within(s).getByRole("textbox", { name: "Emoji" }) as HTMLInputElement;
|
||||
}
|
||||
|
||||
async function save(s: HTMLElement) {
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
}
|
||||
|
||||
function fillNewTask(s: HTMLElement) {
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Walk the dog" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
}
|
||||
|
||||
it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => {
|
||||
expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0);
|
||||
for (const entry of FA_DEFINITION_ICONS) {
|
||||
expect(entry.kind).toBe("fa");
|
||||
expect(findDefinitionIcon(`fa:${entry.name}`)).toBe(entry);
|
||||
const { container, unmount } = render(<DefinitionIcon name={`fa:${entry.name}`} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg, entry.name).not.toBeNull();
|
||||
expect(svg!.getAttribute("data-icon")).toBe(entry.name);
|
||||
expect(svg!.getAttribute("fill")).toBe("currentColor");
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds and validates emoji keys like the registry", () => {
|
||||
expect(emojiIconKey("🐶")).toBe("emoji:🐶");
|
||||
expect(emojiIconKey("👨👩👧👦")).toBe("emoji:👨👩👧👦");
|
||||
expect(emojiIconKey("⭐".repeat(8))).toBe(`emoji:${"⭐".repeat(8)}`);
|
||||
for (const bad of ["", " ", " 🐶 ", "🐶 ", "\t🐶", "⭐".repeat(9), "a b", "<b>", "a&b", "🐶\u0007"]) {
|
||||
expect(emojiIconKey(bad), JSON.stringify(bad)).toBeNull();
|
||||
}
|
||||
expect(faIconName("fa:dog")).toBe("dog");
|
||||
expect(faIconName("lucide:dog")).toBeNull();
|
||||
expect(faIconName(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders lucide, legacy, fa and emoji rows, and the fallback for none or unknown", async () => {
|
||||
await renderReady([
|
||||
{ ...BRUSH, id: 40, icon: "lucide:dog" },
|
||||
{ ...BRUSH, id: 41, title: "Task 41", icon: "dog" },
|
||||
{ ...BRUSH, id: 42, title: "Task 42", icon: "fa:dog" },
|
||||
{ ...BRUSH, id: 43, title: "Task 43", icon: "emoji:🐶" },
|
||||
{ ...BRUSH, id: 44, title: "Task 44", icon: null },
|
||||
{ ...BRUSH, id: 45, title: "Task 45", icon: "" },
|
||||
{ ...BRUSH, id: 46, title: "Task 46", icon: "mystery:dog" },
|
||||
]);
|
||||
expect(rowGlyph(screen.getByTestId("definition-40"))).toBe("lucide:dog");
|
||||
expect(rowGlyph(screen.getByTestId("definition-41"))).toBe("lucide:dog");
|
||||
expect(rowGlyph(screen.getByTestId("definition-42"))).toBe("fa:dog");
|
||||
expect(rowGlyph(screen.getByTestId("definition-43"))).toBe("emoji:🐶");
|
||||
expect(rowGlyph(screen.getByTestId("definition-44"))).toBe("fallback");
|
||||
expect(rowGlyph(screen.getByTestId("definition-45"))).toBe("fallback");
|
||||
expect(rowGlyph(screen.getByTestId("definition-46"))).toBe("fallback");
|
||||
});
|
||||
|
||||
it("a Font Awesome choice is POSTed as fa:<name> and renders on its row", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fillNewTask(s);
|
||||
fireEvent.click(radio(s, "Font Awesome", "Dog"));
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
|
||||
expect(radio(s, "Font Awesome", "Dog").className).toContain("defs-icon--on");
|
||||
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await save(s);
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dog");
|
||||
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("fa:dog");
|
||||
});
|
||||
|
||||
it("a typed emoji is POSTed as emoji:<grapheme> and renders on its row", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fillNewTask(s);
|
||||
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await save(s);
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:🐶");
|
||||
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("emoji:🐶");
|
||||
});
|
||||
|
||||
it("a Lucide choice is POSTed as lucide:<name> after an emoji was typed", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fillNewTask(s);
|
||||
fireEvent.change(emojiInput(s), { target: { value: "🐶" } });
|
||||
fireEvent.click(radio(s, "Icon", "Dog"));
|
||||
expect(emojiInput(s).value).toBe("");
|
||||
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:dog");
|
||||
expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("lucide:dog");
|
||||
});
|
||||
|
||||
for (const bad of [" 🐶 ", "🐶 ", " ", "a b", "<b>", "a&b", "⭐".repeat(9)]) {
|
||||
it(`an invalid emoji entry ${JSON.stringify(bad)} is not saved`, async () => {
|
||||
await renderReady([{ ...BRUSH, id: 50, icon: "lucide:dog" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-50"));
|
||||
const s = sheet();
|
||||
fireEvent.change(emojiInput(s), { target: { value: bad } });
|
||||
|
||||
await save(s);
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy();
|
||||
expect(screen.getByRole("dialog")).toBe(s);
|
||||
});
|
||||
}
|
||||
|
||||
it("prefills a fa:<name> task and PATCHes an emoji replacement", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 51, icon: "fa:dog" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-51"));
|
||||
const s = sheet();
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true");
|
||||
expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
expect(emojiInput(s).value).toBe("");
|
||||
|
||||
fireEvent.change(emojiInput(s), { target: { value: "🐱" } });
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
await save(s);
|
||||
const { method, path, body } = api.writes()[0];
|
||||
expect(method).toBe("PATCH");
|
||||
expect(path).toBe("/api/v1/admin/quest-definitions/51");
|
||||
expect((body as { icon: string }).icon).toBe("emoji:🐱");
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-51"))).toBe("emoji:🐱"));
|
||||
});
|
||||
|
||||
it("prefills an emoji:<grapheme> task and PATCHes a Font Awesome replacement", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 52, icon: "emoji:🐶" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-52"));
|
||||
const s = sheet();
|
||||
expect(emojiInput(s).value).toBe("🐶");
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
for (const name of ["Icon", "Font Awesome"]) {
|
||||
const group = within(s).getByRole("radiogroup", { name });
|
||||
expect(within(group).queryAllByRole("radio", { checked: true })).toHaveLength(0);
|
||||
}
|
||||
|
||||
fireEvent.click(radio(s, "Font Awesome", "Pets"));
|
||||
expect(emojiInput(s).value).toBe("");
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:paw");
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-52"))).toBe("fa:paw"));
|
||||
});
|
||||
|
||||
it("an unchanged emoji is sent as-is on edit", async () => {
|
||||
await renderReady([{ ...BRUSH, id: 53, icon: "emoji:🐶" }]);
|
||||
fireEvent.click(screen.getByTestId("definition-53"));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Feed the dog" },
|
||||
});
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:🐶");
|
||||
});
|
||||
|
||||
it("No icon and an emptied emoji field both clear the icon to null", async () => {
|
||||
await renderReady([
|
||||
{ ...BRUSH, id: 54, icon: "emoji:🐶" },
|
||||
{ ...BRUSH, id: 55, title: "Task 55", icon: "fa:dog" },
|
||||
]);
|
||||
fireEvent.click(screen.getByTestId("definition-54"));
|
||||
let s = sheet();
|
||||
fireEvent.change(emojiInput(s), { target: { value: "" } });
|
||||
expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("true");
|
||||
await save(s);
|
||||
expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByTestId("definition-55"));
|
||||
s = sheet();
|
||||
fireEvent.click(radio(s, "Icon", "No icon"));
|
||||
expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false");
|
||||
await save(s);
|
||||
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
|
||||
await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-55"))).toBe("fallback"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Start date ─────────────────────────────────────────────────────── */
|
||||
|
||||
function startDateInput(s: HTMLElement): HTMLInputElement {
|
||||
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
import {
|
||||
DEFINITION_ICONS,
|
||||
DefinitionIcon,
|
||||
FA_DEFINITION_ICONS,
|
||||
emojiIconKey,
|
||||
emojiIconText,
|
||||
faIconKey,
|
||||
faIconName,
|
||||
lucideIconKey,
|
||||
normalizeDefinitionIconName,
|
||||
} from "./definitionIcons";
|
||||
@@ -84,8 +89,13 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
|
||||
/**
|
||||
* The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value
|
||||
* outside the curated sets is kept until replaced.
|
||||
*/
|
||||
icon: string | null;
|
||||
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
|
||||
emoji: string;
|
||||
assigneeIds: number[];
|
||||
repeats: Repeats;
|
||||
interval: string;
|
||||
@@ -126,6 +136,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
return {
|
||||
title: "",
|
||||
icon: null,
|
||||
emoji: "",
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
@@ -151,6 +162,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
|
||||
return {
|
||||
title: definition.title,
|
||||
icon: definition.icon,
|
||||
emoji: emojiIconText(definition.icon),
|
||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
||||
@@ -237,6 +249,9 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
|
||||
if (!title) return "Give the task a title.";
|
||||
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
|
||||
if (draft.windows.length === 0) return "Pick at least one window.";
|
||||
if (draft.emoji && draft.icon === null) {
|
||||
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
|
||||
}
|
||||
const rule = buildRule(draft);
|
||||
if (typeof rule === "string") return rule;
|
||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||
@@ -575,10 +590,12 @@ function EditSheet({
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={draft.icon === null}
|
||||
aria-checked={draft.icon === null && !draft.emoji}
|
||||
aria-label="No icon"
|
||||
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: null })}
|
||||
className={
|
||||
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
|
||||
}
|
||||
onClick={() => update({ icon: null, emoji: "" })}
|
||||
>
|
||||
<TaskGlyph size={18} />
|
||||
</button>
|
||||
@@ -594,13 +611,46 @@ function EditSheet({
|
||||
aria-label={label}
|
||||
data-icon-option={name}
|
||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: lucideIconKey(name) })}
|
||||
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
|
||||
>
|
||||
<GlyphSvg icon={icon} size={18} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="defs-sublabel" id="defs-label-icon-fa">
|
||||
Font Awesome
|
||||
</span>
|
||||
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
|
||||
{FA_DEFINITION_ICONS.map((icon) => {
|
||||
const { name, label } = icon;
|
||||
const on = faIconName(draft.icon) === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
data-icon-option={faIconKey(name)}
|
||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
||||
>
|
||||
<GlyphSvg icon={icon} size={18} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="defs-due">
|
||||
<span>Emoji</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
aria-label="Emoji"
|
||||
placeholder="e.g. 🐶"
|
||||
value={draft.emoji}
|
||||
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Assigned children" id="defs-label-children">
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
/**
|
||||
* The curated Lucide set a definition's `icon` may name (ADMIN-SPEC §3.3).
|
||||
* The names, labels and path data come from the shared icon registry
|
||||
* The icons a definition's `icon` may name (ADMIN-SPEC §3.3): the curated
|
||||
* Lucide set, the curated Font Awesome Free solid subset, or an emoji. The
|
||||
* names, labels and path data come from the shared icon registry
|
||||
* (`src/icons/registry.generated.ts`, generated from
|
||||
* `tools/icons/curated-icons.json`) — no CDN, no runtime fetch. The stored
|
||||
* value is the namespaced `lucide:<kebab-name>`; a legacy bare
|
||||
* `<kebab-name>` still names the same Lucide entry.
|
||||
* value is namespaced — `lucide:<name>`, `fa:<name>` or `emoji:<grapheme>` —
|
||||
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||
*/
|
||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||
import { LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
|
||||
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
|
||||
import { TaskGlyph } from "./glyphs";
|
||||
|
||||
export type DefinitionIconEntry = GlyphIcon;
|
||||
|
||||
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS;
|
||||
|
||||
export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS;
|
||||
|
||||
const LUCIDE_PREFIX = "lucide:";
|
||||
const FA_PREFIX = "fa:";
|
||||
const EMOJI_PREFIX = "emoji:";
|
||||
|
||||
/** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */
|
||||
export function lucideIconKey(name: string): string {
|
||||
return `${LUCIDE_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */
|
||||
export function faIconKey(name: string): string {
|
||||
return `${FA_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored key for typed emoji text, e.g. `emoji:🐶`. The raw text — not
|
||||
* trimmed, as the backend rejects any whitespace — must pass the registry's
|
||||
* emoji check (1-8 code points, no whitespace, control characters or `<`,
|
||||
* `>`, `&`), else null. Only an empty field means "no icon".
|
||||
*/
|
||||
export function emojiIconKey(text: string): string | null {
|
||||
const key = `${EMOJI_PREFIX}${text}`;
|
||||
return resolveIcon(key).kind === "emoji" ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Lucide name an icon value refers to: `lucide:dog` and a legacy bare
|
||||
* `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and
|
||||
@@ -32,19 +53,38 @@ export function normalizeDefinitionIconName(value: string | null): string | null
|
||||
return value.includes(":") ? null : value;
|
||||
}
|
||||
|
||||
/** The Font Awesome name an `fa:<name>` value refers to; anything else gives null. */
|
||||
export function faIconName(value: string | null): string | null {
|
||||
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
||||
export function emojiIconText(value: string | null): string {
|
||||
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated Lucide entry an icon value resolves to via the shared parser.
|
||||
* The admin draws only Lucide glyphs for now; `fa:`, `emoji:`, unknown and
|
||||
* null values give null.
|
||||
* The curated glyph (Lucide or Font Awesome) an icon value resolves to via
|
||||
* the shared parser; emoji, unknown and null values give null.
|
||||
*/
|
||||
export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null {
|
||||
const icon = resolveIcon(value);
|
||||
return icon.kind === "lucide" ? icon : null;
|
||||
return icon.kind === "lucide" || icon.kind === "fa" ? icon : null;
|
||||
}
|
||||
|
||||
/** A definition's glyph; no icon, or one outside the Lucide subset, gets the generic task glyph. */
|
||||
/**
|
||||
* A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as
|
||||
* text, or — for no icon or an unknown value — the generic task glyph.
|
||||
*/
|
||||
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
|
||||
const entry = findDefinitionIcon(name);
|
||||
if (!entry) return <TaskGlyph size={size} />;
|
||||
return <GlyphSvg icon={entry} size={size} />;
|
||||
const icon = resolveIcon(name);
|
||||
if (icon.kind === "fallback") return <TaskGlyph size={size} />;
|
||||
if (icon.kind === "emoji") {
|
||||
return (
|
||||
<span className="defs-emoji" style={{ fontSize: size }} aria-hidden="true" data-emoji={icon.text}>
|
||||
{icon.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <GlyphSvg icon={icon} size={size} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user