From 532bda5c74f1f9c4d3c6b67304e210388ab2695a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 30 Sep 2026 17:45:40 -0400 Subject: [PATCH 1/2] Add a searchable Font Awesome Free Solid picker to the definition icon field (task 9ca0bc36) The Font Awesome section of the definition icon picker gains a search field. An empty search shows the curated quick picks as before; a query filters the full Free Solid set by name or label and shows a bounded grid (40 at a time, with Show more) of glyph and label tiles. Picking a result, or typing an exact Free Solid name (curated or not), stores fa:. Text that names no Free Solid icon shows an inline message and blocks saving, distinguishing "select from the results" from "not a Free Solid icon". A saved non-curated fa: opens selected in its search results. Co-Authored-By: Claude Opus 5.5 --- admin/src/definitions/DefinitionsTab.css | 29 +++ admin/src/definitions/DefinitionsTab.test.tsx | 211 ++++++++++++++++++ admin/src/definitions/DefinitionsTab.tsx | 135 ++++++++--- admin/src/definitions/definitionIcons.tsx | 43 +++- 4 files changed, 391 insertions(+), 27 deletions(-) diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index 7442c83..39a275a 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -614,3 +614,32 @@ .defs-emoji { 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; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 72e4023..5cc6f30 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -7,8 +7,10 @@ import { FA_DEFINITION_ICONS, emojiIconKey, faIconName, + faTypedIconKey, findDefinitionIcon, normalizeDefinitionIconName, + searchFaIcons, } from "./definitionIcons"; import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; import { storeTokens } from "../auth/oidc"; @@ -1529,6 +1531,215 @@ 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 bounded, labelled results with Show more", 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 40 of ${total}`)).toBeTruthy(); + + fireEvent.click(within(s).getByRole("button", { name: "Show more" })); + expect(resultKeys(s)).toHaveLength(80); + + // 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("selecting a search result POSTs fa: 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: 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: 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 ───────────────────────────────────────────────────── */ describe("DefinitionsTab legacy icon edits", () => { diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index bea4353..8a00871 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -4,6 +4,7 @@ */ import { useEffect, + useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -53,8 +54,12 @@ import { emojiIconText, faIconKey, faIconName, + faTextProblem, + faTypedIconKey, + findDefinitionIcon, lucideIconKey, normalizeDefinitionIconName, + searchFaIcons, } from "./definitionIcons"; import { GlyphSvg } from "../icons/GlyphSvg"; import { weekdayOf } from "../schedule/cycle"; @@ -87,6 +92,9 @@ const REPEATS_BY_RULE: Record = { /** How many upcoming dates the edit sheet previews. */ const PREVIEW_COUNT = 5; +/** Font Awesome search results shown at once, and added per "Show more". */ +const FA_RESULTS_PAGE = 40; + /** Quiet period after the last rule edit before the preview is requested. */ const PREVIEW_DEBOUNCE_MS = 300; @@ -101,6 +109,11 @@ interface Draft { icon: string | null; /** The emoji field's text; typing in it sets `icon` to `emoji:`. */ emoji: string; + /** + * The Font Awesome search text; typing an exact Free Solid name sets + * `icon` to `fa:`, and picking a search result keeps the text. + */ + faText: string; assigneeIds: number[]; repeats: Repeats; interval: string; @@ -135,6 +148,7 @@ function newDraft(activeChildren: AdminChild[], today: string): Draft { title: "", icon: null, emoji: "", + faText: "", assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], repeats: "daily", interval: "1", @@ -157,10 +171,13 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], to const active = new Set(activeChildren.map((child) => child.id)); const dueTimes = emptyDueTimes(); for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; + const fa = findDefinitionIcon(definition.icon); return { title: definition.title, icon: 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). assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), repeats: REPEATS_BY_RULE[rule.rule_type], @@ -250,6 +267,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | if (draft.emoji && draft.icon === null) { 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); if (typeof rule === "string") return rule; const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( @@ -479,6 +497,9 @@ function EditSheet({ ? [] : target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id)); const [pickerOpen, setPickerOpen] = useState(false); + const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]); + const [faShown, setFaShown] = useState(FA_RESULTS_PAGE); + const noIcon = draft.icon === null && !draft.emoji && !draft.faText.trim(); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); @@ -619,12 +640,10 @@ function EditSheet({ @@ -640,7 +659,7 @@ function EditSheet({ aria-label={label} data-icon-option={name} className={on ? "defs-icon defs-icon--on" : "defs-icon"} - onClick={() => update({ icon: lucideIconKey(name), emoji: "" })} + onClick={() => update({ icon: lucideIconKey(name), emoji: "", faText: "" })} > @@ -650,26 +669,88 @@ function EditSheet({ Font Awesome -
- {FA_DEFINITION_ICONS.map((icon) => { - const { name, label } = icon; - const on = faIconName(draft.icon) === name; - return ( - - ); - })} -
+ {faResults.slice(0, faShown).map((icon) => { + const { name, label } = icon; + const on = faIconName(draft.icon) === name; + return ( + + ); + })} + +

+ Showing {Math.min(faShown, faResults.length)} of {faResults.length} +

+ {faResults.length > faShown ? ( + + ) : null} + + ) : null + ) : ( +
+ {FA_DEFINITION_ICONS.map((icon) => { + const { name, label } = icon; + const on = faIconName(draft.icon) === name; + return ( + + ); + })} +
+ )} diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx index 575dd9f..b326b05 100644 --- a/admin/src/definitions/definitionIcons.tsx +++ b/admin/src/definitions/definitionIcons.tsx @@ -8,7 +8,13 @@ * and a legacy bare `` still names the same Lucide entry. */ 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"; 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; } +/** + * 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:` value holds; anything else gives "". */ export function emojiIconText(value: string | null): string { return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : ""; -- 2.55.0 From 5f52def42212434dc17fe4c133395eed5b9072aa Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 30 Sep 2026 18:06:24 -0400 Subject: [PATCH 2/2] Page the Font Awesome search results instead of accumulating them (task 9ca0bc36) "Show more" now advances a page index and renders exactly one 40-icon window (with Previous to go back), so repeated clicks never mount more than one page. Adds a test that pages through all matches for "-" and asserts every page stays bounded. Co-Authored-By: Claude Opus 5.5 --- admin/src/definitions/DefinitionsTab.css | 2 ++ admin/src/definitions/DefinitionsTab.test.tsx | 36 +++++++++++++++++-- admin/src/definitions/DefinitionsTab.tsx | 33 ++++++++++------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index 39a275a..121b384 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -642,4 +642,6 @@ .defs-show-more { align-self: flex-start; + display: flex; + gap: 8px; } diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 5cc6f30..aede057 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -1609,7 +1609,7 @@ describe("DefinitionsTab Font Awesome search", () => { expect(within(s).queryByRole("radiogroup", { name: "Font Awesome search results" })).toBeNull(); }); - it("a query swaps the quick picks for bounded, labelled results with Show more", async () => { + 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(); @@ -1619,10 +1619,17 @@ describe("DefinitionsTab Font Awesome search", () => { 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 40 of ${total}`)).toBeTruthy(); + 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)).toHaveLength(80); + 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" } }); @@ -1634,6 +1641,29 @@ describe("DefinitionsTab Font Awesome search", () => { 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: and renders on its row", async () => { await renderReady(); fireEvent.click(screen.getByRole("button", { name: "New" })); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index 8a00871..81282e6 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -92,7 +92,7 @@ const REPEATS_BY_RULE: Record = { /** How many upcoming dates the edit sheet previews. */ const PREVIEW_COUNT = 5; -/** Font Awesome search results shown at once, and added per "Show more". */ +/** 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. */ @@ -498,7 +498,9 @@ function EditSheet({ : target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id)); const [pickerOpen, setPickerOpen] = useState(false); const faResults = useMemo(() => searchFaIcons(draft.faText), [draft.faText]); - const [faShown, setFaShown] = useState(FA_RESULTS_PAGE); + 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 [confirming, setConfirming] = useState(false); @@ -676,7 +678,7 @@ function EditSheet({ placeholder="Search or type an icon name, e.g. dog" value={draft.faText} onChange={(e) => { - setFaShown(FA_RESULTS_PAGE); + setFaPage(0); update({ faText: e.target.value, icon: faTypedIconKey(e.target.value), emoji: "" }); }} /> @@ -693,7 +695,7 @@ function EditSheet({ role="radiogroup" aria-label="Font Awesome search results" > - {faResults.slice(0, faShown).map((icon) => { + {faResults.slice(faStart, faEnd).map((icon) => { const { name, label } = icon; const on = faIconName(draft.icon) === name; return ( @@ -716,16 +718,21 @@ function EditSheet({ })}

- Showing {Math.min(faShown, faResults.length)} of {faResults.length} + Showing {faStart + 1}–{faEnd} of {faResults.length}

- {faResults.length > faShown ? ( - + {faResults.length > FA_RESULTS_PAGE ? ( +
+ {faPage > 0 ? ( + + ) : null} + {faEnd < faResults.length ? ( + + ) : null} +
) : null} ) : null -- 2.55.0