FN-6944: reclaim quick entry textarea width
Quick entry textareas now avoid inheriting refine-button padding that caused right-side dead space. - Override quick-entry textarea right padding with a selector specific enough to beat the shared description refine rule without !important. - Add CSS cascade regression coverage confirming quick entry reclaims width while global description textareas retain overlay padding. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6944-quick-entry-width.md | 7 ++ .../dashboard/app/components/QuickEntryBox.css | 6 +- .../components/__tests__/QuickEntryBox.test.tsx | 114 +++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6944 Fusion-Task-Lineage: 2a6c7190-ff87-4da6-9437-8e57a113ce56
This commit is contained in:
7
.changeset/fn-6944-quick-entry-width.md
Normal file
7
.changeset/fn-6944-quick-entry-width.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Let quick-entry text use the full entry box width instead of wrapping early.
|
||||||
|
category: fix
|
||||||
|
dev: Adds a QuickEntryBox-specific textarea padding override and CSS cascade regression coverage.
|
||||||
@@ -81,10 +81,10 @@ Board/columns omit `.quick-entry--single-line`, keeping the tall 80px + auto-gro
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:QuickEntry 2026-06-23-02:45:
|
FNXC:QuickEntry 2026-06-24-12:00:
|
||||||
The global `.description-with-refine textarea { padding-right: 70px }` (styles.css) reserves space for a refine button overlaid at the textarea's right edge. In QuickEntryBox the refine button lives in the controls panel, NOT overlaid on the textarea, so that 70px is dead space — it left a large empty gap on the right of the quick-add input (computed padding-right: 70px). Reclaim that width down to a tight var(--space-sm). Scope to the wrapping box (.description-with-refine ancestor) so this beats the global rule on specificity, not just source order (the prior `.quick-entry-textarea-wrap textarea` override tied on specificity and lost to styles.css load order). Nothing overlays the input's right edge in this variant, so text now uses the full reclaimed width.
|
The global `.description-with-refine textarea { padding-right: 70px }` (styles.css) reserves space for a refine button overlaid at the textarea's right edge. QuickEntryBox renders its refine action in the controls panel instead of overlaying the textarea, so inheriting that reserve creates dead right-side text space. Scope the reclaim to the quick-entry input and include the wrapper classes so this rule beats the global description rule by specificity, not source order, while TaskForm/NewTaskModal and fullscreen description textareas keep their overlay padding.
|
||||||
*/
|
*/
|
||||||
.quick-entry-box .description-with-refine .quick-entry-textarea-wrap textarea {
|
.quick-entry-box .description-with-refine .quick-entry-textarea-wrap .quick-entry-input {
|
||||||
padding-right: var(--space-sm);
|
padding-right: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Task } from "@fusion/core";
|
|||||||
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment } from "../../api";
|
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment } from "../../api";
|
||||||
import { useNodes } from "../../hooks/useNodes";
|
import { useNodes } from "../../hooks/useNodes";
|
||||||
import { scopedKey } from "../../utils/projectStorage";
|
import { scopedKey } from "../../utils/projectStorage";
|
||||||
|
import { loadAllAppCss } from "../../test/cssFixture";
|
||||||
|
|
||||||
const MOCK_MODELS = [
|
const MOCK_MODELS = [
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,9 @@ const MOCK_MODELS = [
|
|||||||
const TEST_PROJECT_ID = "proj-123";
|
const TEST_PROJECT_ID = "proj-123";
|
||||||
const QUICK_ENTRY_STORAGE_KEY = scopedKey("kb-quick-entry-text", TEST_PROJECT_ID);
|
const QUICK_ENTRY_STORAGE_KEY = scopedKey("kb-quick-entry-text", TEST_PROJECT_ID);
|
||||||
const QUICK_ENTRY_BOX_CSS = readFileSync("app/components/QuickEntryBox.css", "utf8");
|
const QUICK_ENTRY_BOX_CSS = readFileSync("app/components/QuickEntryBox.css", "utf8");
|
||||||
|
const ALL_APP_CSS = loadAllAppCss();
|
||||||
|
const GLOBAL_DESCRIPTION_TEXTAREA_SELECTOR = ".description-with-refine textarea";
|
||||||
|
const QUICK_ENTRY_TEXTAREA_RECLAIM_SELECTOR = ".quick-entry-box .description-with-refine .quick-entry-textarea-wrap .quick-entry-input";
|
||||||
|
|
||||||
const originalWindowInnerWidthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
|
const originalWindowInnerWidthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
|
||||||
const originalWindowMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
const originalWindowMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
||||||
@@ -69,6 +73,49 @@ function quickEntryMobileActionsTouchRule() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeCssSelectorForRegex(selector: string) {
|
||||||
|
return selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssRuleBody(css: string, selector: string) {
|
||||||
|
return css.match(new RegExp(`${escapeCssSelectorForRegex(selector)}\\s*\\{([^}]*)\\}`))?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssDeclarationValue(ruleBody: string, property: string) {
|
||||||
|
return ruleBody.match(new RegExp(`${property}\\s*:\\s*([^;]+);`))?.[1].trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classSpecificity(selector: string) {
|
||||||
|
return selector.match(/\.[A-Za-z0-9_-]+/g)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeSpecificity(selector: string) {
|
||||||
|
return selector.split(/\s+/).filter((part) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(part)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectQuickEntryCssWidthReclaim() {
|
||||||
|
const globalRule = cssRuleBody(ALL_APP_CSS, GLOBAL_DESCRIPTION_TEXTAREA_SELECTOR);
|
||||||
|
const quickEntryRule = cssRuleBody(ALL_APP_CSS, QUICK_ENTRY_TEXTAREA_RECLAIM_SELECTOR);
|
||||||
|
expect(globalRule).not.toBeNull();
|
||||||
|
expect(quickEntryRule).not.toBeNull();
|
||||||
|
expect(cssDeclarationValue(globalRule!, "padding-right")).toBe("70px");
|
||||||
|
expect(cssDeclarationValue(quickEntryRule!, "padding-right")).toBe("var(--space-sm)");
|
||||||
|
expect(quickEntryRule).not.toContain("!important");
|
||||||
|
|
||||||
|
const quickEntrySpecificity = [classSpecificity(QUICK_ENTRY_TEXTAREA_RECLAIM_SELECTOR), typeSpecificity(QUICK_ENTRY_TEXTAREA_RECLAIM_SELECTOR)];
|
||||||
|
const globalSpecificity = [classSpecificity(GLOBAL_DESCRIPTION_TEXTAREA_SELECTOR), typeSpecificity(GLOBAL_DESCRIPTION_TEXTAREA_SELECTOR)];
|
||||||
|
expect(quickEntrySpecificity[0]).toBeGreaterThan(globalSpecificity[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectQuickEntryTextareaWidthReclaim(textarea: HTMLTextAreaElement) {
|
||||||
|
const box = textarea.closest(".quick-entry-box");
|
||||||
|
expect(box).toBeTruthy();
|
||||||
|
expect(textarea.closest(".description-with-refine")).toBeTruthy();
|
||||||
|
expect(textarea.closest(".quick-entry-textarea-wrap")).toBeTruthy();
|
||||||
|
expect(textarea.classList.contains("quick-entry-input")).toBe(true);
|
||||||
|
expectQuickEntryCssWidthReclaim();
|
||||||
|
}
|
||||||
|
|
||||||
const CREATED_TASK: Task = {
|
const CREATED_TASK: Task = {
|
||||||
id: "FN-999",
|
id: "FN-999",
|
||||||
title: "Created task",
|
title: "Created task",
|
||||||
@@ -434,6 +481,73 @@ describe("QuickEntryBox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("quick-entry textarea width reclaim (FN-6944)", () => {
|
||||||
|
it("declares a specific non-70px right-padding override while preserving description textarea overlay padding", () => {
|
||||||
|
expectQuickEntryCssWidthReclaim();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the reclaim hook for empty and populated board quick-entry textareas on desktop", () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
renderQuickEntryBox({ singleLine: false });
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
expect(textarea.placeholder).toBe("Add a task...");
|
||||||
|
expectQuickEntryTextareaWidthReclaim(textarea);
|
||||||
|
|
||||||
|
fireEvent.change(textarea, {
|
||||||
|
target: {
|
||||||
|
value: "This is a long multiline task description that should use the available quick entry width before wrapping.\nIt should not reserve the description refine-button gutter.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(textarea.value).toContain("available quick entry width");
|
||||||
|
expectQuickEntryTextareaWidthReclaim(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the same reclaim hook in list singleLine mode and at the mobile breakpoint", () => {
|
||||||
|
mockMobileViewport();
|
||||||
|
renderQuickEntryBox({ singleLine: true, defaultExpanded: false });
|
||||||
|
const box = screen.getByTestId("quick-entry-box");
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
|
||||||
|
expect(box.className).toContain("quick-entry--single-line");
|
||||||
|
expect(textarea.rows).toBe(1);
|
||||||
|
expectQuickEntryTextareaWidthReclaim(textarea);
|
||||||
|
|
||||||
|
fireEvent.change(textarea, {
|
||||||
|
target: { value: "A compact list quick entry should reclaim the same right-side text column on mobile." },
|
||||||
|
});
|
||||||
|
expectQuickEntryTextareaWidthReclaim(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the quick-entry width reclaim through duplicate warning and disabled submitting states", async () => {
|
||||||
|
let resolveCreate!: () => void;
|
||||||
|
const onCreate = vi.fn().mockImplementation(() => new Promise<void>((resolve) => { resolveCreate = resolve; }));
|
||||||
|
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
||||||
|
{ id: "FN-456", title: "Duplicate", description: "desc", column: "todo", score: 0.7 },
|
||||||
|
]);
|
||||||
|
renderQuickEntryBox({ onCreate });
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "duplicate candidate with enough text to show the padding regression" } });
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
|
||||||
|
expectQuickEntryTextareaWidthReclaim(textarea);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([]);
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(textarea).toBeDisabled();
|
||||||
|
expectQuickEntryTextareaWidthReclaim(textarea);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveCreate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("post-submission focus restoration (FN-6217)", () => {
|
describe("post-submission focus restoration (FN-6217)", () => {
|
||||||
it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => {
|
it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => {
|
||||||
mockDesktopViewport();
|
mockDesktopViewport();
|
||||||
|
|||||||
Reference in New Issue
Block a user