import { describe, expect, it } from "vitest"; import { defineToolcraft, fireEvent, renderControlsPanelWithSchema, screen, waitFor, } from "../testing/controls-panel-test-utils"; function renderFontPickerControl() { const schema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { font: { defaultValue: { fontId: "inter", fontSize: 16, fontWeight: "400", letterSpacing: "normal", lineHeight: "normal", opacity: 100, textCase: "uppercase", }, label: "Font", target: "typography.font", type: "fontPicker", }, }, layout: "standalone", title: "Typography", }, ], title: "Generation Controls", }, }, }); return renderControlsPanelWithSchema(schema); } function openFontPicker() { fireEvent.click(screen.getByRole("button", { name: "Select Font" })); } function getFontValue() { return JSON.parse(screen.getByTestId("font-value").textContent ?? "{}"); } function nextAnimationFrame() { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } describe("ControlsPanel font picker controls", () => { it("renders font picker as one compound popup control shell", () => { renderFontPickerControl(); expect(screen.getByText("Case")).toBeTruthy(); expect(screen.getByRole("combobox", { name: "Text case" }).textContent).toContain( "Uppercase", ); const fontControlItem = screen .getByText("Weight") .closest("[data-control-item-compound-context]"); expect(fontControlItem).toBeNull(); openFontPicker(); const triggerValue = document.querySelector( '[data-slot="font-picker-trigger-value"]', ); const trigger = triggerValue?.closest('[data-slot="select-trigger"]'); expect(trigger).toBeTruthy(); expect(trigger?.getAttribute("data-variant")).toBe("default"); expect(trigger?.getAttribute("data-size")).toBe("default"); expect(trigger?.className).toContain("h-7"); expect(trigger?.className).not.toContain("h-9"); expect(triggerValue?.style.fontFamily).toContain("Inter"); expect(triggerValue?.style.fontWeight).toBe("400"); expect(screen.getByText("Weight")).toBeTruthy(); const familyWeightRow = document.querySelector( '[data-slot="font-picker-family-weight-row"]', ); expect(familyWeightRow?.className).toContain("grid-cols-2"); expect(familyWeightRow?.className).not.toContain("max-content"); expect(screen.getByText("Size")).toBeTruthy(); expect((screen.getByLabelText("Font size") as HTMLInputElement).value).toBe("16"); expect(screen.getByText("Color")).toBeTruthy(); expect(screen.getByLabelText("Color opacity")).toBeTruthy(); expect(screen.getByPlaceholderText("Find font")).toBeTruthy(); expect(screen.getByText("Sans").closest("button")?.getAttribute("data-state")).toBe( "active", ); expect(screen.getByLabelText("Letter spacing")).toBeTruthy(); expect(screen.getByLabelText("Line height")).toBeTruthy(); const footerLabels = document.querySelectorAll( '[data-slot="font-picker-footer-label"]', ); expect(footerLabels).toHaveLength(0); const footerIcons = document.querySelectorAll( '[data-slot="font-picker-footer-icon"]', ); expect(footerIcons).toHaveLength(2); expect(footerIcons[0]?.getAttribute("class")).toContain("size-4"); expect(footerIcons[1]?.getAttribute("class")).toContain("size-4"); const footerSliders = document.querySelectorAll( '[data-slot="font-picker-footer-slider"] [data-slot="slider"]', ); expect(footerSliders).toHaveLength(2); expect(footerSliders[0]?.getAttribute("data-variant")).toBe("discrete"); expect(footerSliders[1]?.getAttribute("data-variant")).toBe("discrete"); expect(footerSliders[0]?.className).toContain("[&_[data-slot=slider-range]]:transition-none"); expect(footerSliders[0]?.className).toContain("[&_[data-slot=slider-thumb]]:transition-none"); expect(footerSliders[1]?.className).toContain("[&_[data-slot=slider-range]]:transition-none"); expect(footerSliders[1]?.className).toContain("[&_[data-slot=slider-thumb]]:transition-none"); expect( footerSliders[0]?.querySelectorAll('[data-slot="slider-marker"]'), ).toHaveLength(4); expect( footerSliders[1]?.querySelectorAll('[data-slot="slider-marker"]'), ).toHaveLength(4); const lineHeightMarkerOffsets = Array.from( footerSliders[1]?.querySelectorAll('[data-slot="slider-marker"]') ?? [], ).map((marker) => Number.parseFloat(marker.style.left)); expect(lineHeightMarkerOffsets).toHaveLength(4); expect(lineHeightMarkerOffsets[0]).toBeCloseTo(20); expect(lineHeightMarkerOffsets[1]).toBeCloseTo(40); expect(lineHeightMarkerOffsets[2]).toBeCloseTo(60); expect(lineHeightMarkerOffsets[3]).toBeCloseTo(80); }); it("commits font family, spacing, line-height, and size values", async () => { renderFontPickerControl(); openFontPicker(); const triggerValue = document.querySelector( '[data-slot="font-picker-trigger-value"]', ); fireEvent.click(screen.getByText("Roboto").closest("button") as HTMLButtonElement); fireEvent.change(screen.getByLabelText("Letter spacing"), { target: { value: "4" }, }); fireEvent.change(screen.getByLabelText("Line height"), { target: { value: "5" }, }); await waitFor(() => { expect(getFontValue()).toEqual({ color: "#FFFFFF", fontId: "roboto", fontSize: 16, fontWeight: "400", letterSpacing: "wider", lineHeight: "loose", opacity: 100, textCase: "uppercase", }); }); expect(triggerValue?.style.fontFamily).toContain("Roboto"); fireEvent.change(screen.getByLabelText("Line height"), { target: { value: "4.4" }, }); await waitFor(() => { expect(getFontValue()).toEqual({ color: "#FFFFFF", fontId: "roboto", fontSize: 16, fontWeight: "400", letterSpacing: "wider", lineHeight: "relaxed", opacity: 100, textCase: "uppercase", }); }); const fontSizeInput = screen.getByLabelText("Font size") as HTMLInputElement; fireEvent.change(fontSizeInput, { target: { value: "24" }, }); expect(getFontValue()).toEqual({ color: "#FFFFFF", fontId: "roboto", fontSize: 16, fontWeight: "400", letterSpacing: "wider", lineHeight: "relaxed", opacity: 100, textCase: "uppercase", }); fireEvent.blur(fontSizeInput); await waitFor(() => { expect(getFontValue()).toEqual({ color: "#FFFFFF", fontId: "roboto", fontSize: 24, fontWeight: "400", letterSpacing: "wider", lineHeight: "relaxed", opacity: 100, textCase: "uppercase", }); }); fireEvent.change(fontSizeInput, { target: { value: "768" }, }); fireEvent.blur(fontSizeInput); await waitFor(() => { expect(getFontValue()).toEqual({ color: "#FFFFFF", fontId: "roboto", fontSize: 768, fontWeight: "400", letterSpacing: "wider", lineHeight: "relaxed", opacity: 100, textCase: "uppercase", }); }); }); it("commits color edits and resets empty font size to the default", async () => { renderFontPickerControl(); openFontPicker(); const colorHexInput = screen.getByLabelText("Color hex") as HTMLInputElement; fireEvent.change(colorHexInput, { target: { value: "#C1FF00" }, }); fireEvent.blur(colorHexInput); const colorOpacityInput = screen.getByLabelText("Color opacity") as HTMLInputElement; fireEvent.change(colorOpacityInput, { target: { value: "82" }, }); fireEvent.blur(colorOpacityInput); await waitFor(() => { expect(getFontValue()).toEqual({ color: "#C1FF00", fontId: "inter", fontSize: 16, fontWeight: "400", letterSpacing: "normal", lineHeight: "normal", opacity: 82, textCase: "uppercase", }); }); const fontSizeInput = screen.getByLabelText("Font size") as HTMLInputElement; fireEvent.change(fontSizeInput, { target: { value: "" }, }); expect(fontSizeInput.value).toBe(""); fireEvent.blur(fontSizeInput); expect((screen.getByLabelText("Font size") as HTMLInputElement).value).toBe("16"); }); it("virtualizes the font list with the shared Toolcraft scrollbar", async () => { renderFontPickerControl(); openFontPicker(); const listViewport = document.querySelector( '[data-slot="font-picker-list-viewport"]', ); expect(listViewport).toBeTruthy(); expect(listViewport?.className).toContain("toolcraft-scrollbar"); expect(listViewport?.className).not.toContain("no-scrollbar"); await nextAnimationFrame(); await nextAnimationFrame(); Object.defineProperty(listViewport, "clientHeight", { configurable: true, value: 240, }); listViewport!.scrollTop = 4800; fireEvent.scroll(listViewport!); await nextAnimationFrame(); await waitFor(() => { const firstVirtualFont = document.querySelector( '[data-slot="font-picker-list"] button span', ); expect(firstVirtualFont?.textContent).not.toBe("Inter"); }); }); });