From e567fd5c3e960b28ecd4c7417aed99a912413ee0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 11:12:43 +0200 Subject: [PATCH] fix(editor): keep Edit clip's Apply in view and its edit through a backdrop click The actions sat at the end of the modal's scrolling body. In a window shorter than the card, Apply was clipped out of view, and a click where it should have been landed on the backdrop, which closed the dialog and dropped the new trim and crop. ModalShell gets a footer slot outside the body and a closeOnBackdrop switch. Edit clip pins its actions in the footer and ignores backdrop clicks while it holds an unapplied change. Escape, Cancel and the close button still discard it. --- .../ai-edition/EditClipModal.test.tsx | 73 +++++++++++++++ src/components/ai-edition/Modals.tsx | 93 +++++++++++-------- .../ai-edition/NewEditorShell.module.css | 5 + 3 files changed, 131 insertions(+), 40 deletions(-) diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index b8a42069e..f8d5c271d 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -130,6 +130,79 @@ describe("EditClipModal trim duration readout (#558)", () => { }); }); +describe("EditClipModal in a short window (#1005)", () => { + // The actions used to sit at the end of the scrolling body. In a window too short for the + // card, Apply was clipped out of view, and a click where it should have been landed on the + // backdrop, which closed the dialog and dropped the new trim and crop without a word. + function renderWithSpies() { + const onClose = vi.fn(); + const onApply = vi.fn(); + renderWithI18n( + , + ); + return { onClose, onApply }; + } + + function clickBackdrop() { + const backdrop = document.querySelector('[class*="modalBackdrop"]'); + if (!backdrop) throw new Error("no modal backdrop rendered"); + fireEvent.click(backdrop); + } + + it("keeps the actions out of the scrolling body", () => { + renderWithSpies(); + const apply = screen.getByRole("button", { name: "Apply" }); + + expect(apply.closest('[class*="modalBody"]')).toBeNull(); + expect(apply.closest('[class*="modalFoot"]')).not.toBeNull(); + }); + + it("closes on a backdrop click while there is nothing to lose", () => { + const { onClose } = renderWithSpies(); + + clickBackdrop(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ + "a trim", + () => { + fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip start" }), { + clientX: 0, + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 100 })); + window.dispatchEvent(new MouseEvent("pointerup")); + }); + }, + ], + ["a crop", () => fireEvent.click(screen.getByRole("button", { name: "1:1" }))], + ])("keeps %s through a backdrop click, and drops it on Escape or Cancel", (_, edit) => { + const { onClose, onApply } = renderWithSpies(); + edit(); + + clickBackdrop(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Apply" })).toBeEnabled(); + + // Those two say "discard"; a click beside the card does not. + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledTimes(2); + expect(onApply).not.toHaveBeenCalled(); + }); +}); + describe("EditClipModal crop from the keyboard", () => { it("moves the crop with the arrows and resizes it with Shift + the arrows", () => { const onApply = vi.fn(); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index dfe2f20a7..bc5833e53 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -36,9 +36,11 @@ export function ModalShell({ open, onClose, closeOnEscape = true, + closeOnBackdrop = true, title, subtitle, wide, + footer, children, }: BaseModalProps & { title: string; @@ -47,6 +49,12 @@ export function ModalShell({ /** Off for a dialog that handles Escape itself — two listeners both fire for one * keypress, and this one's `onClose` wins whatever order they registered in. */ closeOnEscape?: boolean; + /** Off while a stray click beside the card would throw away an edit not yet applied. + * Escape, Cancel and the close button still discard it: that is what they mean. */ + closeOnBackdrop?: boolean; + /** The dialog's actions, kept out of the scrolling body so a short window never hides + * them: a click where an unseen Apply sat used to land on the backdrop. */ + footer?: ReactNode; children: ReactNode; }) { const tc = useScopedT("common"); @@ -78,7 +86,11 @@ export function ModalShell({ aria-modal="true" aria-labelledby="modal-title" > -
+
@@ -96,6 +108,7 @@ export function ModalShell({
{children}
+ {footer ?
{footer}
: null}
); @@ -918,9 +931,48 @@ export function EditClipModal({ + +
+ + +
+
+ } >
{cropPreviewSource ? ( @@ -1088,45 +1140,6 @@ export function EditClipModal({
- -
- -
- - -
-
); } diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 30b3c0947..bbeeaacef 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -2132,6 +2132,11 @@ textarea.control { gap: 20px; overflow: auto; } +/* A sibling of the body, not inside it: when the card hits its max-height only the body + shrinks and scrolls (a scroll container's flex minimum is 0, this one's is its content). */ +.modalFoot { + padding: 0 24px 24px; +} .btn { height: 34px;