Task b50b048f: Lucide icon picker (shipped subset) (#205)
This commit was merged in pull request #205.
This commit is contained in:
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "nestquest-admin",
|
"name": "nestquest-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.47.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
@@ -2257,6 +2258,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
|
||||||
|
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.47.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -414,6 +414,30 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.defs-icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defs-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: var(--nq-a-tap-min);
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: var(--nq-a-density-radius);
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defs-icon--on {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--nq-brand-gradient);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.defs-due-times {
|
.defs-due-times {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import DefinitionsTab from "./DefinitionsTab";
|
import DefinitionsTab from "./DefinitionsTab";
|
||||||
|
import { DEFINITION_ICONS, DefinitionIcon } 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";
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ function fakeApi(initial: QuestDefinition[]) {
|
|||||||
id: 99,
|
id: 99,
|
||||||
title: body.title,
|
title: body.title,
|
||||||
description: null,
|
description: null,
|
||||||
icon: null,
|
icon: body.icon ?? null,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
skip_on_away: body.skip_on_away,
|
skip_on_away: body.skip_on_away,
|
||||||
rule: body.rule,
|
rule: body.rule,
|
||||||
@@ -158,6 +159,7 @@ function fakeApi(initial: QuestDefinition[]) {
|
|||||||
? {
|
? {
|
||||||
...d,
|
...d,
|
||||||
title: body.title ?? d.title,
|
title: body.title ?? d.title,
|
||||||
|
icon: "icon" in body ? body.icon : d.icon,
|
||||||
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
||||||
rule: body.rule ?? d.rule,
|
rule: body.rule ?? d.rule,
|
||||||
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
||||||
@@ -331,6 +333,7 @@ describe("DefinitionsTab", () => {
|
|||||||
path: "/api/v1/admin/quest-definitions",
|
path: "/api/v1/admin/quest-definitions",
|
||||||
body: {
|
body: {
|
||||||
title: "Feed the cat",
|
title: "Feed the cat",
|
||||||
|
icon: null,
|
||||||
rule: {
|
rule: {
|
||||||
rule_type: "weekly",
|
rule_type: "weekly",
|
||||||
interval: 2,
|
interval: 2,
|
||||||
@@ -419,6 +422,7 @@ describe("DefinitionsTab", () => {
|
|||||||
path: "/api/v1/admin/quest-definitions/11",
|
path: "/api/v1/admin/quest-definitions/11",
|
||||||
body: {
|
body: {
|
||||||
title: "Take out the bins",
|
title: "Take out the bins",
|
||||||
|
icon: null,
|
||||||
rule: {
|
rule: {
|
||||||
rule_type: "monthly_day",
|
rule_type: "monthly_day",
|
||||||
interval: 1,
|
interval: 1,
|
||||||
@@ -436,9 +440,6 @@ describe("DefinitionsTab", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
// The icon is never sent, so it is carried through unchanged.
|
|
||||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
|
||||||
|
|
||||||
await screen.findByText("Take out the bins");
|
await screen.findByText("Take out the bins");
|
||||||
expect(api.listFetches()).toBe(2);
|
expect(api.listFetches()).toBe(2);
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
@@ -918,3 +919,169 @@ describe("DefinitionsTab occurrence preview", () => {
|
|||||||
expect(api.previews()).toHaveLength(1);
|
expect(api.previews()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DefinitionsTab icon picker", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The Lucide name a row renders, or null for the generic fallback glyph. */
|
||||||
|
function rowIcon(row: HTMLElement): string | null {
|
||||||
|
const svg = row.querySelector(".defs-row-glyph svg");
|
||||||
|
expect(svg).not.toBeNull();
|
||||||
|
return svg!.getAttribute("data-icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconRadio(s: HTMLElement, label: string): HTMLElement {
|
||||||
|
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
|
||||||
|
name: label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("ships the curated subset, and every entry renders an SVG", () => {
|
||||||
|
expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([
|
||||||
|
"bed",
|
||||||
|
"bath",
|
||||||
|
"shower-head",
|
||||||
|
"toilet",
|
||||||
|
"brush",
|
||||||
|
"smile",
|
||||||
|
"shirt",
|
||||||
|
"washing-machine",
|
||||||
|
"utensils",
|
||||||
|
"cooking-pot",
|
||||||
|
"apple",
|
||||||
|
"brush-cleaning",
|
||||||
|
"spray-can",
|
||||||
|
"trash-2",
|
||||||
|
"recycle",
|
||||||
|
"sofa",
|
||||||
|
"house",
|
||||||
|
"backpack",
|
||||||
|
"book-open",
|
||||||
|
"pencil",
|
||||||
|
"music",
|
||||||
|
"dog",
|
||||||
|
"cat",
|
||||||
|
"fish",
|
||||||
|
"sprout",
|
||||||
|
"droplets",
|
||||||
|
"bike",
|
||||||
|
"volleyball",
|
||||||
|
"gamepad-2",
|
||||||
|
"star",
|
||||||
|
"heart",
|
||||||
|
"sun",
|
||||||
|
"moon",
|
||||||
|
"clock",
|
||||||
|
"calendar",
|
||||||
|
"list-checks",
|
||||||
|
]);
|
||||||
|
expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24);
|
||||||
|
expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40);
|
||||||
|
for (const { name } of DEFINITION_ICONS) {
|
||||||
|
const { container, unmount } = render(<DefinitionIcon name={name} />);
|
||||||
|
const svg = container.querySelector("svg");
|
||||||
|
expect(svg, name).not.toBeNull();
|
||||||
|
expect(svg!.getAttribute("data-icon")).toBe(name);
|
||||||
|
expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a chosen icon is POSTed for a new task and renders on its row", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
const s = sheet();
|
||||||
|
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
|
||||||
|
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: "Make the bed" },
|
||||||
|
});
|
||||||
|
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" }));
|
||||||
|
fireEvent.click(iconRadio(s, "Bed"));
|
||||||
|
expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true");
|
||||||
|
expect(iconRadio(s, "Bed").className).toContain("defs-icon--on");
|
||||||
|
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
expect(api.writes()[0].method).toBe("POST");
|
||||||
|
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
|
||||||
|
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("definition-11"));
|
||||||
|
const s = sheet();
|
||||||
|
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
|
||||||
|
fireEvent.click(iconRadio(s, "Bins"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||||
|
expect(api.writes()).toHaveLength(1);
|
||||||
|
const { method, path, body } = api.writes()[0];
|
||||||
|
expect(method).toBe("PATCH");
|
||||||
|
expect(path).toBe("/api/v1/admin/quest-definitions/11");
|
||||||
|
expect((body as { icon: string }).icon).toBe("trash-2");
|
||||||
|
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
|
||||||
|
expect(body).not.toHaveProperty("assignee_child_ids");
|
||||||
|
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unchanged icon is still sent on edit and shows as selected", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
|
||||||
|
fireEvent.click(screen.getByTestId("definition-10"));
|
||||||
|
const s = sheet();
|
||||||
|
expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true");
|
||||||
|
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||||
|
target: { value: "Brush teeth well" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
|
||||||
|
const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" };
|
||||||
|
await renderReady([legacy]);
|
||||||
|
const row = screen.getByTestId("definition-20");
|
||||||
|
expect(rowIcon(row)).toBeNull();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
* Definitions ("Tasks") tab — list + edit sheet with icon picker
|
||||||
*
|
* (design/ADMIN-SPEC.md §3.1–3.3).
|
||||||
* The icon picker (§3.3) is a separate task: an existing definition's icon is
|
|
||||||
* carried through unchanged by never sending it.
|
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -44,6 +42,7 @@ import {
|
|||||||
PlusGlyph,
|
PlusGlyph,
|
||||||
TaskGlyph,
|
TaskGlyph,
|
||||||
} from "./glyphs";
|
} from "./glyphs";
|
||||||
|
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
|
||||||
import "./DefinitionsTab.css";
|
import "./DefinitionsTab.css";
|
||||||
|
|
||||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||||
@@ -78,6 +77,8 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
|
|||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
title: string;
|
title: string;
|
||||||
|
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
|
||||||
|
icon: string | null;
|
||||||
assigneeIds: number[];
|
assigneeIds: number[];
|
||||||
repeats: Repeats;
|
repeats: Repeats;
|
||||||
interval: string;
|
interval: string;
|
||||||
@@ -117,6 +118,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
return {
|
return {
|
||||||
title: "",
|
title: "",
|
||||||
|
icon: null,
|
||||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||||
repeats: "daily",
|
repeats: "daily",
|
||||||
interval: "1",
|
interval: "1",
|
||||||
@@ -141,6 +143,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
|
|||||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||||
return {
|
return {
|
||||||
title: definition.title,
|
title: definition.title,
|
||||||
|
icon: definition.icon,
|
||||||
// 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],
|
||||||
@@ -233,6 +236,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
|
icon: draft.icon,
|
||||||
rule,
|
rule,
|
||||||
assignee_child_ids: [...draft.assigneeIds],
|
assignee_child_ids: [...draft.assigneeIds],
|
||||||
windows,
|
windows,
|
||||||
@@ -319,7 +323,7 @@ function DefinitionRow({
|
|||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
>
|
>
|
||||||
<span className="defs-row-glyph">
|
<span className="defs-row-glyph">
|
||||||
<TaskGlyph />
|
<DefinitionIcon name={definition.icon} />
|
||||||
</span>
|
</span>
|
||||||
<span className="defs-row-text">
|
<span className="defs-row-text">
|
||||||
<span className="defs-row-title">{definition.title}</span>
|
<span className="defs-row-title">{definition.title}</span>
|
||||||
@@ -558,6 +562,35 @@ function EditSheet({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Icon" id="defs-label-icon">
|
||||||
|
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={draft.icon === null}
|
||||||
|
aria-label="No icon"
|
||||||
|
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
|
onClick={() => update({ icon: null })}
|
||||||
|
>
|
||||||
|
<TaskGlyph size={18} />
|
||||||
|
</button>
|
||||||
|
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={draft.icon === name}
|
||||||
|
aria-label={label}
|
||||||
|
data-icon-option={name}
|
||||||
|
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
|
||||||
|
onClick={() => update({ icon: name })}
|
||||||
|
>
|
||||||
|
<Icon size={18} aria-hidden="true" focusable="false" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label="Assigned children" id="defs-label-children">
|
<Field label="Assigned children" id="defs-label-children">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
|
||||||
|
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
|
||||||
|
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Apple,
|
||||||
|
Backpack,
|
||||||
|
Bath,
|
||||||
|
Bed,
|
||||||
|
Bike,
|
||||||
|
BookOpen,
|
||||||
|
Brush,
|
||||||
|
BrushCleaning,
|
||||||
|
Calendar,
|
||||||
|
Cat,
|
||||||
|
Clock,
|
||||||
|
CookingPot,
|
||||||
|
Dog,
|
||||||
|
Droplets,
|
||||||
|
Fish,
|
||||||
|
Gamepad2,
|
||||||
|
Heart,
|
||||||
|
House,
|
||||||
|
ListChecks,
|
||||||
|
Moon,
|
||||||
|
Music,
|
||||||
|
Pencil,
|
||||||
|
Recycle,
|
||||||
|
Shirt,
|
||||||
|
ShowerHead,
|
||||||
|
Smile,
|
||||||
|
Sofa,
|
||||||
|
SprayCan,
|
||||||
|
Sprout,
|
||||||
|
Star,
|
||||||
|
Sun,
|
||||||
|
Toilet,
|
||||||
|
Trash2,
|
||||||
|
Utensils,
|
||||||
|
Volleyball,
|
||||||
|
WashingMachine,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { TaskGlyph } from "./glyphs";
|
||||||
|
|
||||||
|
export interface DefinitionIconEntry {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
Icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
|
||||||
|
{ name: "bed", label: "Bed", Icon: Bed },
|
||||||
|
{ name: "bath", label: "Bath", Icon: Bath },
|
||||||
|
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
|
||||||
|
{ name: "toilet", label: "Toilet", Icon: Toilet },
|
||||||
|
{ name: "brush", label: "Brush", Icon: Brush },
|
||||||
|
{ name: "smile", label: "Smile", Icon: Smile },
|
||||||
|
{ name: "shirt", label: "Clothes", Icon: Shirt },
|
||||||
|
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
|
||||||
|
{ name: "utensils", label: "Meal", Icon: Utensils },
|
||||||
|
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
|
||||||
|
{ name: "apple", label: "Snack", Icon: Apple },
|
||||||
|
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
|
||||||
|
{ name: "spray-can", label: "Clean", Icon: SprayCan },
|
||||||
|
{ name: "trash-2", label: "Bins", Icon: Trash2 },
|
||||||
|
{ name: "recycle", label: "Recycling", Icon: Recycle },
|
||||||
|
{ name: "sofa", label: "Tidy", Icon: Sofa },
|
||||||
|
{ name: "house", label: "Home", Icon: House },
|
||||||
|
{ name: "backpack", label: "School bag", Icon: Backpack },
|
||||||
|
{ name: "book-open", label: "Reading", Icon: BookOpen },
|
||||||
|
{ name: "pencil", label: "Homework", Icon: Pencil },
|
||||||
|
{ name: "music", label: "Music", Icon: Music },
|
||||||
|
{ name: "dog", label: "Dog", Icon: Dog },
|
||||||
|
{ name: "cat", label: "Cat", Icon: Cat },
|
||||||
|
{ name: "fish", label: "Fish", Icon: Fish },
|
||||||
|
{ name: "sprout", label: "Plants", Icon: Sprout },
|
||||||
|
{ name: "droplets", label: "Water", Icon: Droplets },
|
||||||
|
{ name: "bike", label: "Bike", Icon: Bike },
|
||||||
|
{ name: "volleyball", label: "Sport", Icon: Volleyball },
|
||||||
|
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
|
||||||
|
{ name: "star", label: "Star", Icon: Star },
|
||||||
|
{ name: "heart", label: "Heart", Icon: Heart },
|
||||||
|
{ name: "sun", label: "Morning", Icon: Sun },
|
||||||
|
{ name: "moon", label: "Bedtime", Icon: Moon },
|
||||||
|
{ name: "clock", label: "Clock", Icon: Clock },
|
||||||
|
{ name: "calendar", label: "Calendar", Icon: Calendar },
|
||||||
|
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
|
||||||
|
|
||||||
|
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
|
||||||
|
return name === null ? null : (BY_NAME.get(name) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A definition's glyph; no icon, or one outside the subset, gets 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 <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user