feat(recipes): barre Enregistrer/Annuler collée en pied du formulaire
All checks were successful
All checks were successful
`.recipe-form__actions` gagne un modificateur `--sticky` (`position: sticky; bottom: 0`) : sur une recette longue (beaucoup d'ingrédients/étapes) la barre reste visible sans redescendre. Ajoute un bouton « Annuler » secondaire qui revient à la recette éditée (`/recettes/:id`) ou au catalogue (`/recettes`) pour une création — sans garde « abandonner les modifications ? », cohérent avec les autres annulations de l'app (seule la suppression, irréversible, est confirmée). Tests Cypress : la barre reste ancrée en bas après défilement vers le haut d'un formulaire plus haut que la fenêtre ; « Annuler » navigue correctement en création comme en édition. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3dfed7e2e3
commit
8ae06f10c9
5 changed files with 99 additions and 1 deletions
|
|
@ -68,6 +68,29 @@ Feature: Recipe form — associating ingredients
|
||||||
Then the ingredient "Carotte" should be visible in the picker
|
Then the ingredient "Carotte" should be visible in the picker
|
||||||
And there should be 0 ingredient rows
|
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
|
Scenario: Preloads an existing recipe's ingredients when editing, and lets you add another
|
||||||
Given recipe 7 exists with an egg omelette
|
Given recipe 7 exists with an egg omelette
|
||||||
And updating recipe 7 will succeed
|
And updating recipe 7 will succeed
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
Given("recipe 7 exists with an egg omelette", () => {
|
||||||
const existingRecipe = {
|
const existingRecipe = {
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|
|
||||||
|
|
@ -1384,6 +1384,25 @@
|
||||||
margin-top: var(--space-xl);
|
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"] {
|
button[type="submit"] {
|
||||||
padding: 0.6rem var(--space-lg);
|
padding: 0.6rem var(--space-lg);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
|
|
@ -1404,6 +1423,25 @@
|
||||||
cursor: not-allowed;
|
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) ------------------------
|
// --- Unresolved-ingredient review (ImportRecipePage) ------------------------
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,7 @@
|
||||||
"addStep": "Ajouter une étape",
|
"addStep": "Ajouter une étape",
|
||||||
"submit": "Enregistrer",
|
"submit": "Enregistrer",
|
||||||
"submitting": "Enregistrement…",
|
"submitting": "Enregistrement…",
|
||||||
|
"cancel": "Annuler",
|
||||||
"genericError": "Le formulaire contient des erreurs"
|
"genericError": "Le formulaire contient des erreurs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,17 @@ export function RecipeFormPage() {
|
||||||
steps.length > 0 &&
|
steps.length > 0 &&
|
||||||
steps.every((step) => step.description.trim().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) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
@ -346,7 +357,13 @@ export function RecipeFormPage() {
|
||||||
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
|
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="recipe-form__actions">
|
{/* 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`. */}
|
||||||
|
<div className="recipe-form__actions recipe-form__actions--sticky">
|
||||||
|
<button type="button" className="recipe-form__cancel" onClick={handleCancel}>
|
||||||
|
{t("recipes.form.cancel")}
|
||||||
|
</button>
|
||||||
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||||
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
|
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue