FN-7602: fix Record/Clear button overlap in Keyboard Shortcuts rows
Fixes overlapping Record and Clear buttons on the Keyboard Shortcuts settings rows by replacing the icon-only button class with a text button class and locking layout with flex-shrink. - Swap ShortcutCaptureInput Record/Clear buttons off the icon-only `btn-icon` class (which forced line-height:0 and a 36px mobile square, clipping labels) onto a text-button class - Add `.shortcut-capture` row CSS with `flex-shrink:0` on controls so the input and buttons never overlap and stack cleanly on mobile - Add regression tests covering the Keyboard Shortcuts section layout - Add changeset documenting the fix Files changed: .changeset/fn-7602-shortcut-row-layout.md | 7 ++ .../dashboard/app/components/SettingsModal.css | 17 ++++ .../settings/sections/ShortcutCaptureInput.tsx | 14 +++- .../__tests__/KeyboardShortcutsSection.test.tsx | 95 ++++++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7602 Fusion-Task-Lineage: 50cf6975-f0fb-42dd-87b0-50578977a0f4 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7602-shortcut-row-layout.md
Normal file
7
.changeset/fn-7602-shortcut-row-layout.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix overlapping Record and Clear buttons in the Keyboard Shortcuts settings rows on desktop and mobile.
|
||||||
|
category: fix
|
||||||
|
dev: The shortcut-capture Record/Clear buttons no longer use the icon-only `btn-icon` class (which set `line-height:0` and a mobile 36px square, clipping/overlapping the text labels); they use a text-button class and the `.shortcut-capture` row locks buttons with `flex-shrink:0` so the input and controls never overlap, stacking cleanly on mobile.
|
||||||
@@ -2550,6 +2550,17 @@ FN-7553's dedicated Keyboard Shortcuts section groups every action under a categ
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DashboardShortcuts 2026-07-05-00:00:
|
||||||
|
FN-7602 fixes an overlap bug: Record/Clear previously used the icon-only `btn-icon` class
|
||||||
|
(line-height:0 + mobile 36px square), which clipped/overlapped their text labels
|
||||||
|
("Record"/"Recording…"/"Clear") against the input and each other (IMG_1305). The input
|
||||||
|
keeps `flex: 1 1 auto; min-width: 0;` so it shrinks first, while the buttons get
|
||||||
|
`flex-shrink: 0; white-space: nowrap;` so their content-sized width (including the longer
|
||||||
|
"Recording…" label) is never crushed or allowed to overlap a neighbor on desktop. Below
|
||||||
|
768px the row stacks to a column so the buttons sit on their own row under the full-width
|
||||||
|
input, still non-overlapping.
|
||||||
|
*/
|
||||||
.shortcut-capture {
|
.shortcut-capture {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2566,6 +2577,12 @@ FN-7553's dedicated Keyboard Shortcuts section groups every action under a categ
|
|||||||
border-color: var(--color-error);
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shortcut-capture__record,
|
||||||
|
.shortcut-capture__clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-capture__record--active {
|
.shortcut-capture__record--active {
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,19 @@ export function ShortcutCaptureInput({ id, value, defaultValue, invalid, describ
|
|||||||
}}
|
}}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
FNXC:DashboardShortcuts 2026-07-05-00:00:
|
||||||
|
Record/Clear are TEXT-labeled buttons ("Record"/"Recording…"/"Clear"), not icon-only
|
||||||
|
controls. `btn-icon` sets `line-height: 0` and a mobile 36px square meant for SVG-only
|
||||||
|
buttons — applying it here clipped the label's line box and, at mobile widths, forced
|
||||||
|
"Recording…" to overflow the fixed square and overlap the Clear button/input
|
||||||
|
(reported via screenshot IMG_1305). Use `btn-sm` instead so labels render on a normal
|
||||||
|
line-height with content-sized width; `.shortcut-capture` locks these buttons with
|
||||||
|
`flex-shrink: 0` so they never collide with the input or each other.
|
||||||
|
*/}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn btn-icon shortcut-capture__record${recording ? " shortcut-capture__record--active" : ""}`}
|
className={`btn btn-sm shortcut-capture__record${recording ? " shortcut-capture__record--active" : ""}`}
|
||||||
aria-pressed={recording}
|
aria-pressed={recording}
|
||||||
title={recording ? t("settings.keyboardShortcuts.recordingTitle", "Recording… press Escape to cancel") : t("settings.keyboardShortcuts.recordTitle", "Record a new shortcut")}
|
title={recording ? t("settings.keyboardShortcuts.recordingTitle", "Recording… press Escape to cancel") : t("settings.keyboardShortcuts.recordTitle", "Record a new shortcut")}
|
||||||
onClick={() => (recording ? stopRecording() : startRecording())}
|
onClick={() => (recording ? stopRecording() : startRecording())}
|
||||||
@@ -102,7 +112,7 @@ export function ShortcutCaptureInput({ id, value, defaultValue, invalid, describ
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-icon shortcut-capture__clear"
|
className="btn btn-sm shortcut-capture__clear"
|
||||||
title={t("settings.keyboardShortcuts.clearTitle", "Disable this shortcut")}
|
title={t("settings.keyboardShortcuts.clearTitle", "Disable this shortcut")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (recording) stopRecording();
|
if (recording) stopRecording();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { ShortcutCaptureInput } from "../ShortcutCaptureInput";
|
import { ShortcutCaptureInput } from "../ShortcutCaptureInput";
|
||||||
|
|
||||||
|
const settingsModalCss = readFileSync("app/components/SettingsModal.css", "utf8");
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
||||||
FN-7553 covers the press-to-record capture control in isolation before the
|
FN-7553 covers the press-to-record capture control in isolation before the
|
||||||
@@ -110,4 +113,96 @@ describe("ShortcutCaptureInput", () => {
|
|||||||
);
|
);
|
||||||
expect(container.querySelector('[data-shortcuts-ignore="true"]')).toBeTruthy();
|
expect(container.querySelector('[data-shortcuts-ignore="true"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DashboardShortcuts 2026-07-05-00:00:
|
||||||
|
Regression for FN-7602 (IMG_1305): Record/Clear previously carried the
|
||||||
|
icon-only `btn-icon` class (line-height:0 + mobile 36px square), which
|
||||||
|
clipped/overlapped the text labels against the input and each other. These
|
||||||
|
tests assert the buttons use a text-appropriate class across idle,
|
||||||
|
recording, invalid, and cleared/disabled states, and that the
|
||||||
|
`.shortcut-capture` CSS locks a non-overlapping desktop row / mobile stack.
|
||||||
|
*/
|
||||||
|
describe("row layout (FN-7602 no-overlap regression)", () => {
|
||||||
|
it("never applies the icon-only btn-icon class to the Record or Clear buttons, idle", () => {
|
||||||
|
render(
|
||||||
|
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
const recordBtn = screen.getByRole("button", { name: /^record$/i });
|
||||||
|
const clearBtn = screen.getByRole("button", { name: /clear/i });
|
||||||
|
|
||||||
|
for (const btn of [recordBtn, clearBtn]) {
|
||||||
|
expect(btn.className.split(/\s+/)).not.toContain("btn-icon");
|
||||||
|
expect(btn.className.split(/\s+/)).toContain("btn");
|
||||||
|
expect(btn.className).toMatch(/\bbtn-sm\b|\bbtn--sm\b/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Record button text-classed and readable while recording (longer label)", () => {
|
||||||
|
render(
|
||||||
|
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
|
||||||
|
|
||||||
|
const recordingBtn = screen.getByRole("button", { name: /recording/i });
|
||||||
|
expect(recordingBtn.className.split(/\s+/)).not.toContain("btn-icon");
|
||||||
|
expect(recordingBtn.className).toContain("shortcut-capture__record--active");
|
||||||
|
expect(recordingBtn).toHaveAttribute("aria-pressed", "true");
|
||||||
|
// Only a single Record/Recording control exists — no duplicate/overlapping control.
|
||||||
|
expect(screen.getAllByRole("button", { name: /record/i })).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the invalid-binding style and buttons off btn-icon while invalid", () => {
|
||||||
|
render(
|
||||||
|
<ShortcutCaptureInput id="test-shortcut" value="Bogus" defaultValue="Ctrl+E" invalid={true} describedById="test-hint" onChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("textbox").className).toContain("shortcut-capture__input--invalid");
|
||||||
|
for (const btn of [screen.getByRole("button", { name: /^record$/i }), screen.getByRole("button", { name: /clear/i })]) {
|
||||||
|
expect(btn.className.split(/\s+/)).not.toContain("btn-icon");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still clears a bound value without btn-icon interfering", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clear/i }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks the desktop .shortcut-capture row so the input shrinks and buttons never overlap", () => {
|
||||||
|
const rowRule = settingsModalCss.match(/(?<!__record--active|__input--invalid)\.shortcut-capture\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
const inputRule = settingsModalCss.match(/\.shortcut-capture__input\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
const buttonRule = settingsModalCss.match(/\.shortcut-capture__record,\s*\n?\s*\.shortcut-capture__clear\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
|
||||||
|
expect(rowRule).toContain("display: flex;");
|
||||||
|
expect(inputRule).toContain("flex: 1 1 auto;");
|
||||||
|
expect(inputRule).toContain("min-width: 0;");
|
||||||
|
expect(buttonRule).toContain("flex-shrink: 0;");
|
||||||
|
expect(buttonRule).toContain("white-space: nowrap;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks .shortcut-capture on mobile with a full-width input and no overlapping buttons", () => {
|
||||||
|
// Anchor to the specific shortcut-capture mobile media block (the one
|
||||||
|
// immediately following `.shortcut-conflict-banner`) rather than a
|
||||||
|
// lazily-scoped regex, since the stylesheet has several unrelated
|
||||||
|
// `@media (max-width: 768px)` blocks earlier in the file that a naive
|
||||||
|
// lazy `[\s\S]*?` could otherwise bleed across.
|
||||||
|
const shortcutMediaStart = settingsModalCss.indexOf(".shortcut-conflict-banner");
|
||||||
|
const shortcutMediaBlock = settingsModalCss.slice(shortcutMediaStart).match(/@media \(max-width: 768px\) \{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||||
|
const mobileRowRule = shortcutMediaBlock.match(/\.shortcut-capture\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
const mobileInputRule = shortcutMediaBlock.match(/\.shortcut-capture__input\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
|
||||||
|
expect(mobileRowRule).toContain("flex-direction: column;");
|
||||||
|
expect(mobileInputRule).toContain("width: 100%;");
|
||||||
|
|
||||||
|
// The buttons' flex-shrink/nowrap rule is declared once (not overridden away)
|
||||||
|
// and still applies at mobile widths, so "Recording…" cannot overflow its box.
|
||||||
|
const buttonRule = settingsModalCss.match(/\.shortcut-capture__record,\s*\n?\s*\.shortcut-capture__clear\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
expect(buttonRule).toContain("flex-shrink: 0;");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user