fix(borne): confirmation avant Abandon de la commande (geste destructeur)
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 39s
CI / static-tests (push) Successful in 1m11s
CI / js-tests (push) Successful in 37s
CI / secret-scan (pull_request) Successful in 17s
CI / php-lint (pull_request) Successful in 31s
CI / static-tests (pull_request) Successful in 1m7s
CI / js-tests (pull_request) Successful in 41s
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 39s
CI / static-tests (push) Successful in 1m11s
CI / js-tests (push) Successful in 37s
CI / secret-scan (pull_request) Successful in 17s
CI / php-lint (pull_request) Successful in 31s
CI / static-tests (pull_request) Successful in 1m7s
CI / js-tests (pull_request) Successful in 41s
Le bouton Abandon effacait toute la commande au moindre tap. Ajout d'une modale de confirmation reutilisable (confirm-modal.js, CSP-safe : role=dialog, aria-modal, focus piege, Echap et clic-fond = annuler, focus rendu au declencheur, focus initial sur Annuler). Abandon passe par cette confirmation avant clearCart + retour accueil. La suppression d'une ligne reste immediate (re-ajoutable, et le stepper offre le decrement doux) ; les cibles tactiles 44px ont ete posees au lot panier-unique. Tests : confirm-modal.test.js (6 cas : affichage, confirmer, annuler, Echap, clic-fond, echappement) + order-panel (Abandon demande confirmation). JS 118 verts.
This commit is contained in:
parent
6bf3597b5e
commit
ba660e7d5a
5 changed files with 203 additions and 2 deletions
|
|
@ -989,6 +989,42 @@ button {
|
|||
animation: composer-fade-in var(--transition-base) both;
|
||||
}
|
||||
|
||||
/* Modale de confirmation d'un geste destructeur (Abandon). */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 210;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
animation: composer-fade-in var(--transition-base) both;
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: var(--space-6);
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-modal__message {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0 0 var(--space-5);
|
||||
}
|
||||
|
||||
.confirm-modal__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@keyframes composer-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
|
|
|||
73
src/public/borne/assets/js/confirm-modal.js
Normal file
73
src/public/borne/assets/js/confirm-modal.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* confirm-modal.js — Modale de confirmation reutilisable pour un geste destructeur
|
||||
* (ex. Abandon de toute la commande). CSP-safe (createElement + addEventListener,
|
||||
* aucun handler inline). Accessible : role="dialog" aria-modal, fond mis aria-hidden,
|
||||
* Echap et clic sur le fond = annuler, focus piege dans la modale et rendu au
|
||||
* declencheur a la fermeture. Defaut sur Annuler : un appui accidentel sur Entree
|
||||
* n'execute pas l'action destructrice. Public non-technique : message + 2 boutons clairs.
|
||||
*/
|
||||
|
||||
import { escHtml } from './state.js';
|
||||
|
||||
/**
|
||||
* Affiche une demande de confirmation. onConfirm n'est appele que si l'utilisateur
|
||||
* confirme explicitement ; Annuler / Echap / clic-fond ferment sans rien faire.
|
||||
* @param {{message:string, confirmLabel?:string, cancelLabel?:string, onConfirm:Function}} opts
|
||||
* @returns {{close:Function}}
|
||||
*/
|
||||
export function confirmAction({ message, confirmLabel = 'Confirmer', cancelLabel = 'Annuler', onConfirm }) {
|
||||
const previouslyFocused = document.activeElement;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-msg">
|
||||
<p class="confirm-modal__message" id="confirm-modal-msg">${escHtml(message)}</p>
|
||||
<div class="confirm-modal__actions">
|
||||
<button type="button" class="confirm-modal__cancel btn btn--secondary">${escHtml(cancelLabel)}</button>
|
||||
<button type="button" class="confirm-modal__confirm btn btn--primary">${escHtml(confirmLabel)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.appendChild(overlay);
|
||||
document.body.style.overflow = 'hidden';
|
||||
const bgSiblings = Array.from(document.body.children).filter(el => el !== overlay);
|
||||
bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true'));
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
bgSiblings.forEach(el => el.removeAttribute('aria-hidden'));
|
||||
overlay.remove();
|
||||
document.body.style.overflow = prevOverflow;
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Echap = annuler ; Tab/Shift+Tab pieges sur les boutons de la modale.
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = Array.from(overlay.querySelectorAll('button:not([disabled])'));
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('.confirm-modal__cancel').addEventListener('click', close);
|
||||
overlay.querySelector('.confirm-modal__confirm').addEventListener('click', () => {
|
||||
close();
|
||||
onConfirm();
|
||||
});
|
||||
|
||||
// Focus initial sur Annuler (defaut sur), pour ne pas confirmer par inadvertance.
|
||||
requestAnimationFrame(() => overlay.querySelector('.confirm-modal__cancel').focus());
|
||||
|
||||
return { close };
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getMode,
|
||||
} from './state.js';
|
||||
import { refreshCartBadge } from './nav.js';
|
||||
import { confirmAction } from './confirm-modal.js';
|
||||
|
||||
/**
|
||||
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
|
||||
|
|
@ -206,9 +207,18 @@ export function renderOrderPanel(container) {
|
|||
|
||||
const abandon = container.querySelector('.order-panel__abandon');
|
||||
if (abandon) {
|
||||
// Geste destructeur (efface toute la commande) -> confirmation explicite
|
||||
// avant d'agir, plutot qu'un effacement immediat au moindre tap.
|
||||
abandon.addEventListener('click', () => {
|
||||
clearCart();
|
||||
window.location.href = 'index.html';
|
||||
confirmAction({
|
||||
message: 'Abandonner toute la commande ? Votre selection sera perdue.',
|
||||
confirmLabel: 'Oui, abandonner',
|
||||
cancelLabel: 'Continuer ma commande',
|
||||
onConfirm: () => {
|
||||
clearCart();
|
||||
window.location.href = 'index.html';
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
67
tests/js/confirm-modal.test.js
Normal file
67
tests/js/confirm-modal.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Tests de confirm-modal.js (node:test + jsdom). Modale de confirmation d'un geste
|
||||
* destructeur : onConfirm n'est appele QUE sur confirmation explicite ; Annuler /
|
||||
* Echap / clic-fond ferment sans agir. Import dynamique apres pose des globals jsdom.
|
||||
*/
|
||||
import { test, before, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
let confirmAction;
|
||||
|
||||
before(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.localStorage = dom.window.localStorage;
|
||||
global.requestAnimationFrame = (cb) => cb();
|
||||
({ confirmAction } = await import('../../src/public/borne/assets/js/confirm-modal.js'));
|
||||
});
|
||||
|
||||
beforeEach(() => { document.body.innerHTML = ''; });
|
||||
|
||||
test('confirmAction: affiche une modale role=dialog avec le message', () => {
|
||||
confirmAction({ message: 'Abandonner ?', onConfirm: () => {} });
|
||||
const modal = document.querySelector('.confirm-overlay .confirm-modal[role="dialog"]');
|
||||
assert.ok(modal);
|
||||
assert.equal(modal.getAttribute('aria-modal'), 'true');
|
||||
assert.match(modal.querySelector('.confirm-modal__message').textContent, /Abandonner/);
|
||||
});
|
||||
|
||||
test('confirmAction: Confirmer appelle onConfirm puis ferme', () => {
|
||||
let called = 0;
|
||||
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||
document.querySelector('.confirm-modal__confirm').click();
|
||||
assert.equal(called, 1);
|
||||
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||
});
|
||||
|
||||
test('confirmAction: Annuler ferme sans appeler onConfirm', () => {
|
||||
let called = 0;
|
||||
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||
document.querySelector('.confirm-modal__cancel').click();
|
||||
assert.equal(called, 0);
|
||||
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||
});
|
||||
|
||||
test('confirmAction: Echap ferme sans appeler onConfirm', () => {
|
||||
let called = 0;
|
||||
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
assert.equal(called, 0);
|
||||
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||
});
|
||||
|
||||
test('confirmAction: clic sur le fond ferme sans appeler onConfirm', () => {
|
||||
let called = 0;
|
||||
confirmAction({ message: 'x', onConfirm: () => { called++; } });
|
||||
const overlay = document.querySelector('.confirm-overlay');
|
||||
overlay.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||
assert.equal(called, 0);
|
||||
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||
});
|
||||
|
||||
test('confirmAction: le message est echappe (anti-XSS)', () => {
|
||||
confirmAction({ message: '<img src=x onerror=alert(1)>', onConfirm: () => {} });
|
||||
assert.equal(document.querySelectorAll('img[onerror]').length, 0);
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ before(async () => {
|
|||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.localStorage = dom.window.localStorage;
|
||||
global.requestAnimationFrame = (cb) => cb();
|
||||
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
|
||||
await import('../../src/public/borne/assets/js/order-panel.js'));
|
||||
});
|
||||
|
|
@ -156,6 +157,20 @@ test('renderOrderPanel: stepper - a quantite 1 retire la ligne', () => {
|
|||
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||
});
|
||||
|
||||
test('renderOrderPanel: Abandon demande confirmation avant d effacer', () => {
|
||||
localStorage.setItem('wakdo_cart', JSON.stringify([simple()]));
|
||||
const el = document.createElement('aside');
|
||||
renderOrderPanel(el);
|
||||
el.querySelector('.order-panel__abandon').click();
|
||||
// Une modale de confirmation apparait ; le panier n'est PAS encore efface.
|
||||
assert.ok(document.querySelector('.confirm-overlay'));
|
||||
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||
// Annuler conserve le panier et ferme la modale.
|
||||
document.querySelector('.confirm-modal__cancel').click();
|
||||
assert.equal(document.querySelector('.confirm-overlay'), null);
|
||||
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||
});
|
||||
|
||||
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
|
||||
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
|
||||
const el = document.createElement('aside');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue