Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f52def422 | ||
|
|
532bda5c74 |
@@ -614,3 +614,34 @@
|
|||||||
.defs-emoji {
|
.defs-emoji {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Font Awesome search: glyph + label tiles, inline validation, paging */
|
||||||
|
.defs-icon-grid--results {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.defs-icon-result {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defs-icon-label {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defs-hint--error {
|
||||||
|
color: var(--nq-a-danger-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defs-show-more {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
FA_DEFINITION_ICONS,
|
FA_DEFINITION_ICONS,
|
||||||
emojiIconKey,
|
emojiIconKey,
|
||||||
faIconName,
|
faIconName,
|
||||||
|
faTypedIconKey,
|
||||||
findDefinitionIcon,
|
findDefinitionIcon,
|
||||||
normalizeDefinitionIconName,
|
normalizeDefinitionIconName,
|
||||||
|
searchFaIcons,
|
||||||
} from "./definitionIcons";
|
} from "./definitionIcons";
|
||||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||||
import { storeTokens } from "../auth/oidc";
|
import { storeTokens } from "../auth/oidc";
|
||||||
@@ -1529,6 +1531,245 @@ describe("DefinitionsTab Font Awesome and emoji icons", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── Font Awesome search ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
describe("DefinitionsTab Font Awesome search", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const CURATED = new Set(FA_DEFINITION_ICONS.map((icon) => icon.name));
|
||||||
|
|
||||||
|
function searchInput(s: HTMLElement): HTMLInputElement {
|
||||||
|
return within(s).getByRole("searchbox", { name: "Search Font Awesome icons" }) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function results(s: HTMLElement): HTMLElement {
|
||||||
|
return within(s).getByRole("radiogroup", { name: "Font Awesome search results" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultKeys(s: HTMLElement): string[] {
|
||||||
|
return within(results(s))
|
||||||
|
.getAllByRole("radio")
|
||||||
|
.map((option) => option.getAttribute("data-icon-option")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowFaIcon(row: HTMLElement): string | null {
|
||||||
|
const svg = row.querySelector(".defs-row-glyph svg");
|
||||||
|
return svg!.getAttribute("fill") === "none" ? null : svg!.getAttribute("data-icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
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("searches the full Free Solid set by name or label, exact and prefix matches first", () => {
|
||||||
|
expect(searchFaIcons("")).toEqual([]);
|
||||||
|
expect(searchFaIcons(" ")).toEqual([]);
|
||||||
|
const dog = searchFaIcons(" DOG ").map((icon) => icon.name);
|
||||||
|
expect(dog[0]).toBe("dog");
|
||||||
|
expect(dog).toEqual(expect.arrayContaining(["hotdog", "shield-dog"]));
|
||||||
|
expect(dog.some((name) => !CURATED.has(name))).toBe(true);
|
||||||
|
// A label match: "Shield Dog" contains "shield d" though no name does.
|
||||||
|
expect(searchFaIcons("shield d").map((icon) => icon.name)).toContain("shield-dog");
|
||||||
|
expect(searchFaIcons("dog-leashed")).toEqual([]);
|
||||||
|
|
||||||
|
expect(faTypedIconKey("dragon")).toBe("fa:dragon");
|
||||||
|
expect(faTypedIconKey(" Dragon ")).toBe("fa:dragon");
|
||||||
|
for (const bad of ["", "drag", "dgo", "dog-leashed", "github", "fa:dog"]) {
|
||||||
|
expect(faTypedIconKey(bad), bad).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the curated quick picks while the search is empty", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
expect(searchInput(s).value).toBe("");
|
||||||
|
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
|
||||||
|
expect(within(quick).getAllByRole("radio")).toHaveLength(FA_DEFINITION_ICONS.length);
|
||||||
|
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a query swaps the quick picks for one bounded, labelled page of results", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "a" } });
|
||||||
|
const total = searchFaIcons("a").length;
|
||||||
|
expect(total).toBeGreaterThan(80);
|
||||||
|
expect(within(s).queryByRole("radiogroup", { name: "Font Awesome" })).toBeNull();
|
||||||
|
expect(resultKeys(s)).toHaveLength(40);
|
||||||
|
expect(within(results(s)).getAllByRole("radio")[0].textContent).toBe(searchFaIcons("a")[0].label);
|
||||||
|
expect(within(s).getByText(`Showing 1–40 of ${total}`)).toBeTruthy();
|
||||||
|
expect(within(s).queryByRole("button", { name: "Previous" })).toBeNull();
|
||||||
|
|
||||||
|
// "Show more" replaces the page rather than appending to it.
|
||||||
|
const firstPage = resultKeys(s);
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Show more" }));
|
||||||
|
expect(resultKeys(s)).toEqual(searchFaIcons("a").slice(40, 80).map((icon) => `fa:${icon.name}`));
|
||||||
|
expect(resultKeys(s).some((key) => firstPage.includes(key))).toBe(false);
|
||||||
|
expect(within(s).getByText(`Showing 41–80 of ${total}`)).toBeTruthy();
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Previous" }));
|
||||||
|
expect(resultKeys(s)).toEqual(firstPage);
|
||||||
|
|
||||||
|
// A new query starts again from the first page.
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "dog" } });
|
||||||
|
expect(resultKeys(s)[0]).toBe("fa:dog");
|
||||||
|
expect(resultKeys(s)).toContain("fa:hotdog");
|
||||||
|
expect(within(s).queryByRole("button", { name: "Show more" })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "" } });
|
||||||
|
expect(within(s).getByRole("radiogroup", { name: "Font Awesome" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paging through every match never mounts more than one page", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "-" } });
|
||||||
|
const all = searchFaIcons("-").map((icon) => `fa:${icon.name}`);
|
||||||
|
expect(all.length).toBeGreaterThan(900);
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (;;) {
|
||||||
|
expect(results(s).children.length).toBeGreaterThan(0);
|
||||||
|
expect(results(s).children.length).toBeLessThanOrEqual(40);
|
||||||
|
seen.push(...resultKeys(s));
|
||||||
|
const more = within(s).queryByRole("button", { name: "Show more" });
|
||||||
|
if (!more) break;
|
||||||
|
fireEvent.click(more);
|
||||||
|
}
|
||||||
|
// Every match stays reachable, one page at a time, in order.
|
||||||
|
expect(seen).toEqual(all);
|
||||||
|
const lastStart = Math.floor((all.length - 1) / 40) * 40 + 1;
|
||||||
|
expect(within(s).getByText(`Showing ${lastStart}–${all.length} of ${all.length}`)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting a search result POSTs fa:<name> and renders on its row", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
fillNewTask(s);
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "hot" } });
|
||||||
|
expect(within(s).getByRole("alert").textContent).toMatch(/Select a Font Awesome icon from the results/);
|
||||||
|
const hotdog = within(results(s)).getByRole("radio", { name: "Hotdog" });
|
||||||
|
fireEvent.click(hotdog);
|
||||||
|
expect(hotdog.getAttribute("aria-checked")).toBe("true");
|
||||||
|
expect(within(s).queryByRole("alert")).toBeNull();
|
||||||
|
expect(searchInput(s).value).toBe("hot");
|
||||||
|
|
||||||
|
await save(s);
|
||||||
|
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:hotdog");
|
||||||
|
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-99"))).toBe("hotdog"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a typed non-curated Free Solid name is accepted and PATCHed", async () => {
|
||||||
|
expect(CURATED.has("dragon")).toBe(false);
|
||||||
|
await renderReady([{ ...BRUSH, id: 70, icon: "lucide:dog" }]);
|
||||||
|
fireEvent.click(screen.getByTestId("definition-70"));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "Dragon" } });
|
||||||
|
expect(within(s).queryByRole("alert")).toBeNull();
|
||||||
|
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
const lucide = within(s).getByRole("radiogroup", { name: "Icon" });
|
||||||
|
expect(within(lucide).queryAllByRole("radio", { checked: true })).toHaveLength(0);
|
||||||
|
|
||||||
|
await save(s);
|
||||||
|
expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dragon");
|
||||||
|
await waitFor(() => expect(rowFaIcon(screen.getByTestId("definition-70"))).toBe("dragon"));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [typed, message] of [
|
||||||
|
["do", /Select a Font Awesome icon from the results/],
|
||||||
|
["dgo", /“dgo” is not a Font Awesome Free Solid icon/],
|
||||||
|
["dog-leashed", /“dog-leashed” is not a Font Awesome Free Solid icon/],
|
||||||
|
["github", /“github” is not a Font Awesome Free Solid icon/],
|
||||||
|
] as const) {
|
||||||
|
it(`typed ${JSON.stringify(typed)} is rejected with a message and nothing is saved`, async () => {
|
||||||
|
await renderReady([{ ...BRUSH, id: 71, icon: "lucide:dog" }]);
|
||||||
|
fireEvent.click(screen.getByTestId("definition-71"));
|
||||||
|
const s = sheet();
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: typed } });
|
||||||
|
expect(within(s).getByRole("alert").textContent).toMatch(message);
|
||||||
|
expect(within(s).getByRole("radio", { name: "No icon" }).getAttribute("aria-checked")).toBe("false");
|
||||||
|
|
||||||
|
await save(s);
|
||||||
|
expect(api.writes()).toHaveLength(0);
|
||||||
|
expect(within(s).getByTestId("sheet-error").textContent).toMatch(message);
|
||||||
|
expect(screen.getByRole("dialog")).toBe(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("clearing the search, or No icon, removes the icon", async () => {
|
||||||
|
await renderReady([
|
||||||
|
{ ...BRUSH, id: 72, icon: "fa:dragon" },
|
||||||
|
{ ...BRUSH, id: 73, title: "Task 73", icon: "fa:dragon" },
|
||||||
|
]);
|
||||||
|
fireEvent.click(screen.getByTestId("definition-72"));
|
||||||
|
let s = sheet();
|
||||||
|
fireEvent.change(searchInput(s), { target: { value: "" } });
|
||||||
|
expect(within(s).getByRole("radio", { name: "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-73"));
|
||||||
|
s = sheet();
|
||||||
|
fireEvent.click(within(s).getByRole("radio", { name: "No icon" }));
|
||||||
|
expect(searchInput(s).value).toBe("");
|
||||||
|
await save(s);
|
||||||
|
expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a saved non-curated fa:<name> renders on its row and opens selected in the results", async () => {
|
||||||
|
await renderReady([{ ...BRUSH, id: 74, icon: "fa:dragon" }]);
|
||||||
|
expect(rowFaIcon(screen.getByTestId("definition-74"))).toBe("dragon");
|
||||||
|
fireEvent.click(screen.getByTestId("definition-74"));
|
||||||
|
const s = sheet();
|
||||||
|
expect(searchInput(s).value).toBe("dragon");
|
||||||
|
expect(within(s).queryByRole("alert")).toBeNull();
|
||||||
|
expect(within(results(s)).getByRole("radio", { name: "Dragon" }).getAttribute("aria-checked")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: "Feed the dragon" },
|
||||||
|
});
|
||||||
|
await save(s);
|
||||||
|
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a saved curated fa:<name> opens in the quick picks with an empty search", async () => {
|
||||||
|
await renderReady([{ ...BRUSH, id: 75, icon: "fa:dog" }]);
|
||||||
|
fireEvent.click(screen.getByTestId("definition-75"));
|
||||||
|
const s = sheet();
|
||||||
|
expect(searchInput(s).value).toBe("");
|
||||||
|
const quick = within(s).getByRole("radiogroup", { name: "Font Awesome" });
|
||||||
|
expect(within(quick).getByRole("radio", { name: "Dog" }).getAttribute("aria-checked")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Legacy icons ───────────────────────────────────────────────────── */
|
/* ── Legacy icons ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
describe("DefinitionsTab legacy icon edits", () => {
|
describe("DefinitionsTab legacy icon edits", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
@@ -53,8 +54,12 @@ import {
|
|||||||
emojiIconText,
|
emojiIconText,
|
||||||
faIconKey,
|
faIconKey,
|
||||||
faIconName,
|
faIconName,
|
||||||
|
faTextProblem,
|
||||||
|
faTypedIconKey,
|
||||||
|
findDefinitionIcon,
|
||||||
lucideIconKey,
|
lucideIconKey,
|
||||||
normalizeDefinitionIconName,
|
normalizeDefinitionIconName,
|
||||||
|
searchFaIcons,
|
||||||
} from "./definitionIcons";
|
} from "./definitionIcons";
|
||||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||||
import { weekdayOf } from "../schedule/cycle";
|
import { weekdayOf } from "../schedule/cycle";
|
||||||
@@ -87,6 +92,9 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
|||||||
/** How many upcoming dates the edit sheet previews. */
|
/** How many upcoming dates the edit sheet previews. */
|
||||||
const PREVIEW_COUNT = 5;
|
const PREVIEW_COUNT = 5;
|
||||||
|
|
||||||
|
/** Font Awesome search results per page; only one page is ever mounted. */
|
||||||
|
const FA_RESULTS_PAGE = 40;
|
||||||
|
|
||||||
/** Quiet period after the last rule edit before the preview is requested. */
|
/** Quiet period after the last rule edit before the preview is requested. */
|
||||||
const PREVIEW_DEBOUNCE_MS = 300;
|
const PREVIEW_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
@@ -101,6 +109,11 @@ interface Draft {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
|
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
/**
|
||||||
|
* The Font Awesome search text; typing an exact Free Solid name sets
|
||||||
|
* `icon` to `fa:<name>`, and picking a search result keeps the text.
|
||||||
|
*/
|
||||||
|
faText: string;
|
||||||
assigneeIds: number[];
|
assigneeIds: number[];
|
||||||
repeats: Repeats;
|
repeats: Repeats;
|
||||||
interval: string;
|
interval: string;
|
||||||
@@ -135,6 +148,7 @@ function newDraft(activeChildren: AdminChild[], today: string): Draft {
|
|||||||
title: "",
|
title: "",
|
||||||
icon: null,
|
icon: null,
|
||||||
emoji: "",
|
emoji: "",
|
||||||
|
faText: "",
|
||||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||||
repeats: "daily",
|
repeats: "daily",
|
||||||
interval: "1",
|
interval: "1",
|
||||||
@@ -157,10 +171,13 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], to
|
|||||||
const active = new Set(activeChildren.map((child) => child.id));
|
const active = new Set(activeChildren.map((child) => child.id));
|
||||||
const dueTimes = emptyDueTimes();
|
const dueTimes = emptyDueTimes();
|
||||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||||
|
const fa = findDefinitionIcon(definition.icon);
|
||||||
return {
|
return {
|
||||||
title: definition.title,
|
title: definition.title,
|
||||||
icon: definition.icon,
|
icon: definition.icon,
|
||||||
emoji: emojiIconText(definition.icon),
|
emoji: emojiIconText(definition.icon),
|
||||||
|
// A Free Solid icon outside the quick picks opens as its search result.
|
||||||
|
faText: fa?.kind === "fa" && !FA_DEFINITION_ICONS.includes(fa) ? fa.name : "",
|
||||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
// Inactive children cannot be submitted (the API rejects them with 422).
|
||||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
||||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
repeats: REPEATS_BY_RULE[rule.rule_type],
|
||||||
@@ -250,6 +267,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
|
|||||||
if (draft.emoji && draft.icon === null) {
|
if (draft.emoji && draft.icon === null) {
|
||||||
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
|
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
|
||||||
}
|
}
|
||||||
|
if (draft.faText.trim() && draft.icon === null) return faTextProblem(draft.faText);
|
||||||
const rule = buildRule(draft);
|
const rule = buildRule(draft);
|
||||||
if (typeof rule === "string") return rule;
|
if (typeof rule === "string") return rule;
|
||||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||||
@@ -479,6 +497,11 @@ function EditSheet({
|
|||||||
? []
|
? []
|
||||||
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]);
|
||||||
|
const [faPage, setFaPage] = useState(0);
|
||||||
|
const faStart = faPage * FA_RESULTS_PAGE;
|
||||||
|
const faEnd = Math.min(faStart + FA_RESULTS_PAGE, faResults.length);
|
||||||
|
const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
@@ -619,12 +642,10 @@ function EditSheet({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={draft.icon === null && !draft.emoji}
|
aria-checked={noIcon}
|
||||||
aria-label="No icon"
|
aria-label="No icon"
|
||||||
className={
|
className={noIcon ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
|
onClick={() => update({ icon: null, emoji: "", faText: "" })}
|
||||||
}
|
|
||||||
onClick={() => update({ icon: null, emoji: "" })}
|
|
||||||
>
|
>
|
||||||
<TaskGlyph size={18} />
|
<TaskGlyph size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -640,7 +661,7 @@ function EditSheet({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-icon-option={name}
|
data-icon-option={name}
|
||||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
|
onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })}
|
||||||
>
|
>
|
||||||
<GlyphSvg icon={icon} size={18} />
|
<GlyphSvg icon={icon} size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -650,26 +671,93 @@ function EditSheet({
|
|||||||
<span className="defs-sublabel" id="defs-label-icon-fa">
|
<span className="defs-sublabel" id="defs-label-icon-fa">
|
||||||
Font Awesome
|
Font Awesome
|
||||||
</span>
|
</span>
|
||||||
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
|
<input
|
||||||
{FA_DEFINITION_ICONS.map((icon) => {
|
className="defs-input"
|
||||||
const { name, label } = icon;
|
type="search"
|
||||||
const on = faIconName(draft.icon) === name;
|
aria-label="Search Font Awesome icons"
|
||||||
return (
|
placeholder="Search or type an icon name, e.g. dog"
|
||||||
<button
|
value={draft.faText}
|
||||||
key={name}
|
onChange={(e) => {
|
||||||
type="button"
|
setFaPage(0);
|
||||||
role="radio"
|
update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" });
|
||||||
aria-checked={on}
|
}}
|
||||||
aria-label={label}
|
/>
|
||||||
data-icon-option={faIconKey(name)}
|
{draft.faText.trim() && draft.icon === null ? (
|
||||||
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
|
<p className="defs-hint defs-hint--error" role="alert">
|
||||||
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
{faTextProblem(draft.faText)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{draft.faText.trim() ? (
|
||||||
|
faResults.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="defs-icon-grid defs-icon-grid--results"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Font Awesome search results"
|
||||||
>
|
>
|
||||||
<GlyphSvg icon={icon} size={18} />
|
{faResults.slice(faStart, faEnd).map((icon) => {
|
||||||
</button>
|
const { name, label } = icon;
|
||||||
);
|
const on = faIconName(draft.icon) === name;
|
||||||
})}
|
return (
|
||||||
</div>
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={on}
|
||||||
|
aria-label={label}
|
||||||
|
data-icon-option={faIconKey(name)}
|
||||||
|
className={
|
||||||
|
on ? "defs-icon defs-icon-result defs-icon--on" : "defs-icon defs-icon-result"
|
||||||
|
}
|
||||||
|
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
|
||||||
|
>
|
||||||
|
<GlyphSvg icon={icon} size={18} />
|
||||||
|
<span className="defs-icon-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="defs-hint">
|
||||||
|
Showing {faStart + 1}–{faEnd} of {faResults.length}
|
||||||
|
</p>
|
||||||
|
{faResults.length > FA_RESULTS_PAGE ? (
|
||||||
|
<div className="defs-show-more">
|
||||||
|
{faPage > 0 ? (
|
||||||
|
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page - 1)}>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{faEnd < faResults.length ? (
|
||||||
|
<button type="button" className="defs-retry" onClick={() => setFaPage((page) => page + 1)}>
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<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: "", faText: "" })}
|
||||||
|
>
|
||||||
|
<GlyphSvg icon={icon} size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label className="defs-due">
|
<label className="defs-due">
|
||||||
<span>Emoji</span>
|
<span>Emoji</span>
|
||||||
<input
|
<input
|
||||||
@@ -677,7 +765,9 @@ function EditSheet({
|
|||||||
aria-label="Emoji"
|
aria-label="Emoji"
|
||||||
placeholder="e.g. 🐶"
|
placeholder="e.g. 🐶"
|
||||||
value={draft.emoji}
|
value={draft.emoji}
|
||||||
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
update({ emoji: e.target.value, icon: emojiIconKey(e.target.value), faText: "" })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
* and a legacy bare `<kebab-name>` still names the same Lucide entry.
|
||||||
*/
|
*/
|
||||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||||
import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated";
|
import {
|
||||||
|
FA_ICONS,
|
||||||
|
FA_SOLID_ICONS,
|
||||||
|
LUCIDE_ICONS,
|
||||||
|
resolveIcon,
|
||||||
|
type GlyphIcon,
|
||||||
|
} from "../icons/registry.generated";
|
||||||
import { TaskGlyph } from "./glyphs";
|
import { TaskGlyph } from "./glyphs";
|
||||||
|
|
||||||
export type DefinitionIconEntry = GlyphIcon;
|
export type DefinitionIconEntry = GlyphIcon;
|
||||||
@@ -58,6 +64,41 @@ export function faIconName(value: string | null): string | null {
|
|||||||
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Font Awesome Free Solid icons whose name or label contains `query`
|
||||||
|
* (trimmed, case-insensitive): an exact name first, then name-prefix
|
||||||
|
* matches, then the rest, each in registry (name) order. An empty query
|
||||||
|
* gives no results — the curated quick picks cover that case.
|
||||||
|
*/
|
||||||
|
export function searchFaIcons(query: string): DefinitionIconEntry[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
const rank = (icon: DefinitionIconEntry) => (icon.name === q ? 0 : icon.name.startsWith(q) ? 1 : 2);
|
||||||
|
return FA_SOLID_ICONS.filter(
|
||||||
|
(icon) => icon.name.includes(q) || icon.label.toLowerCase().includes(q),
|
||||||
|
).sort((a, b) => rank(a) - rank(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored key for a typed Font Awesome name, e.g. `Dog` → `fa:dog`: the
|
||||||
|
* trimmed, lower-cased text must be the exact name of a Free Solid icon,
|
||||||
|
* else null (a misspelling, a partial search, or a Pro/Brands name).
|
||||||
|
*/
|
||||||
|
export function faTypedIconKey(text: string): string | null {
|
||||||
|
const key = faIconKey(text.trim().toLowerCase());
|
||||||
|
return resolveIcon(key).kind === "fa" ? key : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why Font Awesome search text cannot be saved as it stands: it matches
|
||||||
|
* icons but names none of them, or it matches nothing in the Free Solid set.
|
||||||
|
*/
|
||||||
|
export function faTextProblem(text: string): string {
|
||||||
|
return searchFaIcons(text).length > 0
|
||||||
|
? "Select a Font Awesome icon from the results, or type its full name."
|
||||||
|
: `“${text.trim()}” is not a Font Awesome Free Solid icon.`;
|
||||||
|
}
|
||||||
|
|
||||||
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
/** The emoji text an `emoji:<grapheme>` value holds; anything else gives "". */
|
||||||
export function emojiIconText(value: string | null): string {
|
export function emojiIconText(value: string | null): string {
|
||||||
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : "";
|
||||||
|
|||||||
Reference in New Issue
Block a user