diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature index aba3240..cb01ce4 100644 --- a/apps/web/cypress/e2e/recipe-form.feature +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -68,6 +68,29 @@ Feature: Recipe form — associating ingredients Then the ingredient "Carotte" should be visible in the picker And there should be 0 ingredient rows + Scenario: The save/cancel bar stays pinned to the foot of a long form + When I visit "/recettes/nouvelle" + And I add a step + And I add a step + And I scroll the recipe form to the top + Then the recipe form action bar is pinned to the bottom of the viewport + And I should see "Enregistrer" + And I should see "Annuler" + + Scenario: Cancelling a new recipe goes back to the catalog + When I visit "/recettes/nouvelle" + And I fill in the "recipe-name" field with "Brouillon abandonné" + And I click the button "Annuler" + Then the URL should include "/recettes" + And the URL should not include "/nouvelle" + + Scenario: Cancelling an edit goes back to that recipe + Given recipe 7 exists with an egg omelette + When I visit "/recettes/7/modifier" + And I click the button "Annuler" + Then the URL should include "/recettes/7" + And the URL should not include "/modifier" + Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another Given recipe 7 exists with an egg omelette And updating recipe 7 will succeed diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index 6b214cf..f021be7 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -95,6 +95,25 @@ Then( }, ); +When("I scroll the recipe form to the top", () => { + // `.app-content` is the one scroll container (AppLayout.scss) — the sticky + // action bar pins against it, so it's what has to be scrolled to prove it. + cy.get(".app-content").scrollTo("top"); +}); + +Then("the recipe form action bar is pinned to the bottom of the viewport", () => { + cy.get(".recipe-form__actions--sticky").then(($bar) => { + const rect = $bar[0].getBoundingClientRect(); + const viewportHeight = Cypress.config("viewportHeight"); + // Scrolled to the top of a form taller than the viewport, a pinned bar + // sits at the container's bottom padding edge (`.app-content` keeps its + // own `--space-xl` padding) rather than far below the fold — allow a + // generous band for that padding + the bar's own. + expect(rect.bottom).to.be.greaterThan(viewportHeight - 120); + expect(rect.bottom).to.be.at.most(viewportHeight + 1); + }); +}); + Given("recipe 7 exists with an egg omelette", () => { const existingRecipe = { id: 7, diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 6cb8f8c..db51a4e 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -1384,6 +1384,25 @@ margin-top: var(--space-xl); } + // Pins the Enregistrer/Annuler pair to the bottom of `.app-content` (the + // one scroll container — AppLayout.scss) so a long recipe never needs + // scrolling back down to save or leave. `bottom: 0` lands it a hair above + // the true viewport edge because `.app-content` carries its own padding — + // a small, acceptable gap, and correct on every breakpoint without a + // per-viewport override. The opaque `background` + top border keep the + // form content readable as it scrolls underneath. + &__actions--sticky { + position: sticky; + bottom: 0; + z-index: 1; + display: flex; + justify-content: flex-end; + gap: var(--space-sm); + padding: var(--space-md) 0; + background: var(--color-background); + border-top: 1px solid var(--color-border); + } + button[type="submit"] { padding: 0.6rem var(--space-lg); font-family: var(--font-body); @@ -1404,6 +1423,25 @@ cursor: not-allowed; } } + + // Secondary action next to the primary Enregistrer — outline/ghost, same + // language as `.recipe-detail-panel__delete-confirm`'s non-danger button, + // so "Annuler" never competes visually with "Enregistrer". + &__cancel { + padding: 0.6rem var(--space-lg); + font-family: var(--font-body); + font-size: var(--font-size-base); + font-weight: 600; + cursor: pointer; + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + + &:hover { + background: var(--color-surface-alt); + } + } } // --- Unresolved-ingredient review (ImportRecipePage) ------------------------ diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 3495dd6..6abb9ac 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -356,6 +356,7 @@ "addStep": "Ajouter une étape", "submit": "Enregistrer", "submitting": "Enregistrement…", + "cancel": "Annuler", "genericError": "Le formulaire contient des erreurs" } }, diff --git a/apps/web/src/pages/recipes/RecipeFormPage.tsx b/apps/web/src/pages/recipes/RecipeFormPage.tsx index c7d7aac..5258e9d 100644 --- a/apps/web/src/pages/recipes/RecipeFormPage.tsx +++ b/apps/web/src/pages/recipes/RecipeFormPage.tsx @@ -186,6 +186,17 @@ export function RecipeFormPage() { steps.length > 0 && steps.every((step) => step.description.trim().length > 0); + /** + * Leaves the form without saving — back to the recipe being edited, or to + * the catalog for a brand-new one. No "discard changes?" guard on purpose: + * "Annuler" is an explicit action on a form with no autosave, same posture + * as every other cancel in the app (the only confirm-guarded action is the + * irreversible *delete* on `RecipeDetailPanel`, not abandoning a draft). + */ + function handleCancel() { + void navigate(recipeId !== null ? `/recettes/${recipeId}` : "/recettes"); + } + async function handleSubmit(e: FormEvent) { e.preventDefault(); setFormError(null); @@ -346,7 +357,13 @@ export function RecipeFormPage() {

{t("recipes.form.incompleteIngredientsHint")}

)} -
+ {/* Pinned to the bottom of the scroll area (`--sticky`) so a long + recipe (many ingredients/steps) never needs scrolling back down + just to save or leave — see `recipes.scss`. */} +
+