feat(recipes): barre Enregistrer/Annuler collée en pied du formulaire (#132) #135
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ------------------------
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@
|
|||
"addStep": "Ajouter une étape",
|
||||
"submit": "Enregistrer",
|
||||
"submitting": "Enregistrement…",
|
||||
"cancel": "Annuler",
|
||||
"genericError": "Le formulaire contient des erreurs"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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}>
|
||||
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue