feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471

Open
maximus wants to merge 6 commits from issue-429-category-combobox-portal into issue-372-systems-journal-first
Owner

Resolves #429
Generated autonomously by /autopilot run of 2026-10-18

Résumé

Maillon 4 sur 8 de la pile planned-2026-10-16-lot-v018, basé sur issue-372-systems-journal-first (PR #470).

La liste ouverte de CategoryCombobox était en absolute dans le conteneur de son champ : tout ancêtre défilant la coupait, le corps de la fenêtre de répartition (max-h-[60vh] overflow-y-auto) comme l'enveloppe overflow-x-auto du tableau des transactions. Deux <select> natifs de catégories restaient aussi dans l'app. Désormais :

  • Liste en portail (D1) : CategoryCombobox.tsx rend son <ul role="listbox"> par createPortal(…, document.body), en position: fixed, z-[300] (la fenêtre la plus haute de l'app est à z-[210]), à la largeur du champ, 4 px dessous. Elle est placée avant la première peinture (useLayoutEffect), puis suit son champ à chaque scroll (écouteur en capture sur window, donc de tout conteneur) et resize, même quand le champ sort de la zone visible de son conteneur, sans jamais se fermer pour cela (décision du « Plan d'exécution »). Les écouteurs ne vivent que pendant open et sont retirés dans le retour de l'effet. Le clic extérieur compte la liste comme intérieure (listRef) : elle n'est plus dans le DOM du conteneur. onMouseDown={e => e.preventDefault()} reste sur les options, aria-controls / aria-activedescendant restent valides (ids globaux). Markup fermé inchangé : <div class="relative"><input …/></div>, sans portail.
  • src/components/shared/placeListbox.ts (nouveau, pur) : placeListbox({ anchor, viewportHeight }) → { left, width, openUpward, top | bottom } ; ouverture vers le haut quand la place sous le champ manque pour les 240 px de la liste et suffit au-dessus, en bottom CSS pour qu'une liste plus courte finisse quand même juste au-dessus du champ ; sous le champ quand la place manque des deux côtés. samePlacement évite un nouveau rendu quand un défilement ne déplace pas le champ. followAnchor(target, onMove, list) pose les deux écouteurs, ignore le défilement de la liste elle-même et rend le nettoyage, drapeau de capture compris.
  • AdjustmentForm : le <select> de la catégorie d'une ligne devient CategoryCombobox (compact, ariaLabel adjustments.category) dans un conteneur flex-1 min-w-0, comme dans la fenêtre de répartition. Une nouvelle ligne prend toujours la première catégorie de la liste ; la sauvegarde ne change pas.
  • useAdjustments (D2) : la liste vient de transactionService.getAllCategories() (actives et saisissables, la même requête que Transactions, plus is_inputable = 1) au lieu de sa propre requête sur toutes les actives. Lecture faite : state.categories n'alimente que deux sélecteurs, AdjustmentForm (par AdjustmentDetailPanel) et SplitAdjustmentModal ouverte depuis la page Ajustements ; la vue de détail nomme la catégorie d'une ligne par category_name, tiré de la jointure de getEntriesByAdjustmentId ; AdjustmentListPanel ne lit aucune catégorie. Aucune liste complète n'est donc gardée pour l'affichage. Une ligne enregistrée sur une catégorie non saisissable garde sa valeur, champ vide tant qu'on ne la change pas (D2). Conséquence notée au CHANGELOG : la fenêtre de répartition ouverte depuis Ajustements propose la même liste que depuis Transactions.
  • LinkTransfersModal : le filtre de catégorie du sélecteur de transactions devient CategoryCombobox (compact), sur le modèle de TransactionFilterBar : « Toutes les catégories » en option extra, active tant que le filtre est vide, qui le remet à null ; la liste des transactions se recharge comme avant.
  • SplitAdjustmentModal et TransactionTable : aucun changement, le portail suffit.

Fichiers

Fichier Changement
src/components/shared/CategoryCombobox.tsx liste en portail, placement et écouteurs pendant open, clic extérieur qui compte la liste
src/components/shared/placeListbox.ts nouveau module pur : placement, comparaison, écouteurs
src/components/shared/placeListbox.test.ts 13 tests (placement, bornes du retournement, écouteurs avec la règle de retrait du DOM)
src/components/adjustments/AdjustmentForm.tsx combobox par ligne
src/components/adjustments/AdjustmentForm.render.test.tsx 5 tests (renderToStaticMarkup)
src/components/adjustments/AdjustmentForm.wiring.test.ts 3 tests (harnais hookHarness)
src/hooks/useAdjustments.ts liste des catégories saisissables (D2)
src/hooks/useAdjustments.test.ts 2 tests (harnais)
src/components/balance/LinkTransfersModal.tsx combobox du filtre de catégorie
src/components/balance/LinkTransfersModal.render.test.tsx 2 tests (createPortal remplacé par l'identité, document factice)
src/components/balance/LinkTransfersModal.wiring.test.ts 2 tests (harnais)
src/i18n/locales/fr.json, en.json docs.adjustments.steps[1] : « tapez quelques lettres pour filtrer la liste des catégories »
docs/guide-utilisateur.md §7, étape 2, même ajout
docs/architecture.md section Tests : sous-section « Portails (#429) »
CHANGELOG.md, CHANGELOG.fr.md Changed / Modifié

Aucune clé i18n ajoutée, aucune migration, aucun fichier Rust, aucun appel réseau.

Décisions

Toutes consignées dans reports/run-2026-10-18-lot-v018/decisions-log.md : 4 HIGH, 7 MEDIUM, 0 LOW.

  • HIGH — Préflight passé (#369, #419, #421 fermées, tag v0.17.2) ; sites repérés par leur contenu ; markup fermé relu sur la base (ringedCategory de TransactionTable.render.test.tsx).
  • HIGH — z-[300] : la plus haute couche de l'app est z-[210] (FeedbackConsentDialog), aucune couche en CSS ; la classe est bien produite par le build.
  • HIGH — LinkTransfersModal et AdjustmentForm suivent les extraits de l'issue tels quels (aucun placeholder pour la ligne d'ajustement : une catégorie absente de la liste s'affiche vide, D2) ; le <label> du filtre est gardé.
  • HIGH — Mutations non observables déclarées (tableau ci-dessous), avec leur raison.
  • MEDIUM — Règle de retournement : vers le haut quand la place en dessous est inférieure à 240 px et que la place au-dessus atteint 240 px. « L'espace au-dessus plus grand » du corps se lit des deux façons ; cette lecture est la seule qui tienne la liste de tests de l'issue (« pas de retournement quand il manque des deux côtés »). Écarté : monter dès que la place au-dessus dépasse celle d'en dessous.
  • MEDIUM — Placement vers le haut par bottom CSS (distance au bas de la fenêtre) : une liste de moins de 240 px finit quand même 4 px au-dessus du champ. Écartés : top = haut du champ − 244 (une liste courte flotterait loin de son champ), translateY(-100%).
  • MEDIUM — useLayoutEffect plutôt que useEffect pour le placement et les écouteurs : la liste est placée avant la première peinture, et une position laissée par une ouverture précédente n'est jamais peinte. Le nettoyage reste le retour de l'effet, conditionné par open, comme le demande le plan. Conséquence : le composant ouvert ne tourne pas sous le harnais, qui refuse useLayoutEffect (et déjà useId).
  • MEDIUM — La plomberie des écouteurs sort en fonction pure (followAnchor), testée avec une cible factice qui ne retire un écouteur que par type, fonction et drapeau de capture, soit le cas des écouteurs orphelins relevé par la revue de sécurité du plan. maxHeight en style depuis LISTBOX_MAX_HEIGHT plutôt que la classe max-h-60 : une seule source pour la règle de retournement et la hauteur rendue.
  • MEDIUM — D2 par getAllCategories() plutôt qu'une seconde copie de la requête dans le hook : une seule liste pour tous les sélecteurs de catégorie (Transactions, mot-clé #423, filtre des transferts, ajustements), et le hook ne lance plus de SQL lui-même.
  • MEDIUM — Tests au-delà de la liste de l'issue : useAdjustments.test.ts, AdjustmentForm.wiring.test.ts et LinkTransfersModal.wiring.test.ts sous le harnais, nécessaires pour rendre observables « onExtraSelect ne remet pas null », la catégorie par défaut d'une nouvelle ligne et la source de la liste. Les tests de LinkTransfersModal posent aussi un document factice : createPortal(…, document.body) lit document.body avant l'appel remplacé.
  • MEDIUM — Docs au-delà de « architecture.md si la section composants décrit CategoryCombobox » (elle ne le décrit pas) : sous-section « Portails » dans la section Tests (tester une fenêtre en portail ; ce qui du portail du combobox est épinglé et ce qui reste manuel ; la liste hors du DOM de son conteneur) ; guide §7 et docs.adjustments.steps FR/EN, comme #423 l'avait fait pour le champ parent.

Preuves

  • Sonde : src/zz_tmp_probe.test.ts avec un expect(1).toBe(2) → npx vitest run src/zz_tmp_probe.test.ts sonde rc=1, fichier supprimé.
  • npm run build vert (tsc + vite) ; la classe z-[300] est dans la CSS produite. npm test sur la tête : 147 fichiers / 2375 tests verts dans le worktree, soit 6 fichiers et 27 tests de plus que la base (141 / 2348). Aucun fichier Rust touché.
  • TransactionTable.render.test.tsx, AddKeywordDialog.render.test.tsx, CategoryForm.render.test.tsx et CategoryDetailPanel.render.test.tsx verts sans modification : le rendu fermé du combobox n'a pas bougé.
  • Mutations : implémentation commitée avant la première (3631ef0, 619637e, aa22948) ; une à la fois, restauration par git checkout --, git status --short vide après chacune.
# Fichier Mutation Tests rouges rc
M1 placeListbox.ts retournement vers le haut retiré (if (false && …)) 3 / 13 1
M6 placeListbox.ts autre lecture : monter dès que la place au-dessus dépasse celle d'en dessous 1 / 13 1
M15 placeListbox.ts écart de 4 px retiré sous le champ 4 / 13 1
M14 placeListbox.ts samePlacement ignore top 1 / 13 1
M7 placeListbox.ts scroll écouté hors phase de capture 3 / 13 1
M8 placeListbox.ts nettoyage sans le drapeau de capture (écouteur orphelin) 2 / 13 1
M9 placeListbox.ts défilement de la liste elle-même non ignoré 1 / 13 1
M2 useAdjustments.ts retour exact à l'ancienne requête (is_active = 1, toutes les actives) 2 / 2 (reçoit la catégorie non saisissable) 1
M2b transactionService.ts AND is_inputable = 1 retiré de getAllCategories 0 / 2375, non observable : texte SQL d'un service partagé, aucun test ne le lit (l'issue : « un test sur le texte SQL n'apporte rien ») ; preuve par lecture 0
M3 LinkTransfersModal.tsx « Toutes les catégories » ne remet pas null (setCategoryId(categoryId)) 1 / 4 1
M12 LinkTransfersModal.tsx option extra jamais active (activeExtra={null}) 3 / 4 1
M13 LinkTransfersModal.tsx option extra retirée 2 / 4 1
M4 AdjustmentForm.tsx compact retiré 2 / 8 1
M5 AdjustmentForm.tsx combobox retiré (retour au <select> de la base) 7 / 8 1
M10 AdjustmentForm.tsx nouvelle ligne sur la dernière catégorie 1 / 8 1
M11 AdjustmentForm.tsx le combobox écrit dans amount 2 / 8 1
M16 CategoryCombobox.tsx clic extérieur sans listRef (un clic sur une option fermerait la liste) 0 / 2375, non observable 0
M17 CategoryCombobox.tsx l'effet ne rend pas le nettoyage de followAnchor 0 / 2375, non observable 0

M1 à M9 ont tourné sur placeListbox.test.ts, M2 sur useAdjustments.test.ts, M3, M12 et M13 sur les deux tests de LinkTransfersModal, M4, M5, M10 et M11 sur les deux tests d'AdjustmentForm ; M2b, M16 et M17 sur toute la suite. M16 et M17 : le composant ouvert ne se rend pas sans DOM (renderToStaticMarkup le rend fermé, le harnais refuse ses useId et useLayoutEffect) et jsdom est écarté par l'issue ; la logique qu'ils câblent est épinglée dans placeListbox.test.ts, leur câblage relève des vérifications manuelles 6 (M16) et 8 (M17).

Vérifications manuelles dues (prochaine release)

Dans l'app lancée (Linux), thèmes clair et sombre :

  1. Répartition, une ligne (Transactions → bouton Répartition d'une ligne catégorisée, édition Base) : ouvrir la liste de catégories de la ligne ; elle s'affiche en entier par-dessus le pied de la fenêtre, sans faire défiler le corps.
  2. Répartition, six lignes : ajouter des lignes jusqu'à six ; la liste de la dernière ligne s'ouvre vers le haut quand la place manque en dessous. Liste ouverte, faire défiler le corps de la fenêtre à la molette (pointeur hors de la liste) : la liste suit son champ, même quand le champ passe sous l'en-tête ou le pied de la fenêtre, et ne se ferme pas.
  3. Tableau des transactions : ouvrir la catégorie de la dernière ligne affichée ; la liste n'est plus coupée au bas du tableau, et s'ouvre vers le haut près du bas de la fenêtre. Faire défiler la page, liste ouverte : la liste suit son champ.
  4. Filtre des transferts (Bilan → menu d'un compte → Lier transferts) : le filtre « Catégorie » montre « Toutes les catégories » ; taper quelques lettres filtre la liste ; choisir une catégorie recharge les transactions ; choisir « Toutes les catégories » (en tête) vide le filtre et recharge.
  5. Dialogue mot-clé (clic droit sur une transaction → nouveau mot-clé) : la liste de catégories s'ouvre par-dessus le dialogue, en entier.
  6. Clavier et souris sur l'un des combobox ci-dessus : flèches (la liste défile sur l'option surlignée), Entrée sélectionne, Échap ferme sans sélectionner ; un clic sur une option de la liste la sélectionne (la liste est hors du conteneur dans le DOM : c'est la preuve de M16) ; un clic à l'extérieur ferme sans sélectionner.
  7. Fenêtre fermée pendant que la liste est ouverte : la liste disparaît avec elle (par exemple, liste ouverte dans la répartition, clic sur Annuler ou sur la croix) ; rouvrir la fenêtre puis la liste : une seule liste, à la bonne place.
  8. Écouteurs (npm run tauri dev, inspecteur web, console) : getEventListeners(window).scroll?.length et getEventListeners(window).resize?.length donnent un de plus pendant qu'une liste est ouverte, et reviennent à leur valeur de départ après sa fermeture, y compris quand la fenêtre qui la contient se ferme liste ouverte. C'est la seule preuve de M17 : un écouteur orphelin ne se voit pas à l'écran.
  9. Ajustements (édition Base) : nouvel ajustement, « Ajouter une entrée » : la ligne montre la première catégorie de la liste ; taper filtre, seules les catégories actives et saisissables sont proposées ; enregistrer puis rouvrir en édition : catégorie gardée. Section Répartitions de la page Ajustements : la fenêtre ne propose plus que les catégories saisissables.

À regarder

  • Limite connue : dans une fenêtre de moins de 520 px de haut environ, la place manque des deux côtés ; la liste reste sous le champ (règle de l'issue) et peut être coupée par le bas de la fenêtre. Pas de hauteur réduite à la place disponible, hors du plan.
  • La liste suit son champ hors de la zone visible de son conteneur (décision de Max) : elle peut alors passer par-dessus l'en-tête ou le pied d'une fenêtre tant qu'on fait défiler.
  • La liste n'étant plus dans le DOM de son conteneur, un futur écouteur natif de clic extérieur posé par un conteneur la verrait dehors (aucun aujourd'hui autour d'un combobox : PageHelp, TemplateActions, PeriodSelector, ProfileSwitcher, SecurityPicker et ContextMenu n'en contiennent pas). Les événements React, eux, remontent toujours par l'arbre du composant : les stopPropagation des fenêtres (AddKeywordDialog, LinkTransfersModal, MappingRow) continuent de s'appliquer.
  • Suite possible, hors de cette issue : couvrir useId et useLayoutEffect dans src/test/hookHarness.ts rendrait le combobox ouvert testable sous le harnais (M16, M17, clavier).
  • Le maillon 5 (#393) réutilise ce combobox dans sa fenêtre : ses props publiques n'ont pas changé.
  • Niveau calculé : normal (aucune ligne Haut remplie : pas d'appel réseau ajouté ni modifié, aucun manifeste, lock, workflow ni fichier de base ; docs/architecture.md au moins normal).

🤖 Generated with Claude Code

Resolves #429 Generated autonomously by /autopilot run of 2026-10-18 ## Résumé Maillon 4 sur 8 de la pile `planned-2026-10-16-lot-v018`, basé sur `issue-372-systems-journal-first` (PR #470). La liste ouverte de `CategoryCombobox` était en `absolute` dans le conteneur de son champ : tout ancêtre défilant la coupait, le corps de la fenêtre de répartition (`max-h-[60vh] overflow-y-auto`) comme l'enveloppe `overflow-x-auto` du tableau des transactions. Deux `<select>` natifs de catégories restaient aussi dans l'app. Désormais : - **Liste en portail (D1)** : `CategoryCombobox.tsx` rend son `<ul role="listbox">` par `createPortal(…, document.body)`, en `position: fixed`, `z-[300]` (la fenêtre la plus haute de l'app est à `z-[210]`), à la largeur du champ, 4 px dessous. Elle est placée avant la première peinture (`useLayoutEffect`), puis suit son champ à chaque `scroll` (écouteur en capture sur `window`, donc de tout conteneur) et `resize`, même quand le champ sort de la zone visible de son conteneur, sans jamais se fermer pour cela (décision du « Plan d'exécution »). Les écouteurs ne vivent que pendant `open` et sont retirés dans le retour de l'effet. Le clic extérieur compte la liste comme intérieure (`listRef`) : elle n'est plus dans le DOM du conteneur. `onMouseDown={e => e.preventDefault()}` reste sur les options, `aria-controls` / `aria-activedescendant` restent valides (ids globaux). **Markup fermé inchangé** : `<div class="relative"><input …/></div>`, sans portail. - **`src/components/shared/placeListbox.ts`** (nouveau, pur) : `placeListbox({ anchor, viewportHeight })` → `{ left, width, openUpward, top | bottom }` ; ouverture vers le haut quand la place sous le champ manque pour les 240 px de la liste et suffit au-dessus, en `bottom` CSS pour qu'une liste plus courte finisse quand même juste au-dessus du champ ; sous le champ quand la place manque des deux côtés. `samePlacement` évite un nouveau rendu quand un défilement ne déplace pas le champ. `followAnchor(target, onMove, list)` pose les deux écouteurs, ignore le défilement de la liste elle-même et rend le nettoyage, drapeau de capture compris. - **`AdjustmentForm`** : le `<select>` de la catégorie d'une ligne devient `CategoryCombobox` (`compact`, `ariaLabel` `adjustments.category`) dans un conteneur `flex-1 min-w-0`, comme dans la fenêtre de répartition. Une nouvelle ligne prend toujours la première catégorie de la liste ; la sauvegarde ne change pas. - **`useAdjustments` (D2)** : la liste vient de `transactionService.getAllCategories()` (actives et saisissables, la même requête que Transactions, plus `is_inputable = 1`) au lieu de sa propre requête sur toutes les actives. **Lecture faite** : `state.categories` n'alimente que deux sélecteurs, `AdjustmentForm` (par `AdjustmentDetailPanel`) et `SplitAdjustmentModal` ouverte depuis la page Ajustements ; la vue de détail nomme la catégorie d'une ligne par `category_name`, tiré de la jointure de `getEntriesByAdjustmentId` ; `AdjustmentListPanel` ne lit aucune catégorie. Aucune liste complète n'est donc gardée pour l'affichage. Une ligne enregistrée sur une catégorie non saisissable garde sa valeur, champ vide tant qu'on ne la change pas (D2). Conséquence notée au CHANGELOG : la fenêtre de répartition ouverte depuis Ajustements propose la même liste que depuis Transactions. - **`LinkTransfersModal`** : le filtre de catégorie du sélecteur de transactions devient `CategoryCombobox` (`compact`), sur le modèle de `TransactionFilterBar` : « Toutes les catégories » en option extra, active tant que le filtre est vide, qui le remet à `null` ; la liste des transactions se recharge comme avant. - **`SplitAdjustmentModal`** et **`TransactionTable`** : aucun changement, le portail suffit. ## Fichiers | Fichier | Changement | |---|---| | `src/components/shared/CategoryCombobox.tsx` | liste en portail, placement et écouteurs pendant `open`, clic extérieur qui compte la liste | | `src/components/shared/placeListbox.ts` | nouveau module pur : placement, comparaison, écouteurs | | `src/components/shared/placeListbox.test.ts` | 13 tests (placement, bornes du retournement, écouteurs avec la règle de retrait du DOM) | | `src/components/adjustments/AdjustmentForm.tsx` | combobox par ligne | | `src/components/adjustments/AdjustmentForm.render.test.tsx` | 5 tests (`renderToStaticMarkup`) | | `src/components/adjustments/AdjustmentForm.wiring.test.ts` | 3 tests (harnais `hookHarness`) | | `src/hooks/useAdjustments.ts` | liste des catégories saisissables (D2) | | `src/hooks/useAdjustments.test.ts` | 2 tests (harnais) | | `src/components/balance/LinkTransfersModal.tsx` | combobox du filtre de catégorie | | `src/components/balance/LinkTransfersModal.render.test.tsx` | 2 tests (`createPortal` remplacé par l'identité, `document` factice) | | `src/components/balance/LinkTransfersModal.wiring.test.ts` | 2 tests (harnais) | | `src/i18n/locales/fr.json`, `en.json` | `docs.adjustments.steps[1]` : « tapez quelques lettres pour filtrer la liste des catégories » | | `docs/guide-utilisateur.md` | §7, étape 2, même ajout | | `docs/architecture.md` | section Tests : sous-section « Portails (#429) » | | `CHANGELOG.md`, `CHANGELOG.fr.md` | Changed / Modifié | Aucune clé i18n ajoutée, aucune migration, aucun fichier Rust, aucun appel réseau. ## Décisions Toutes consignées dans `reports/run-2026-10-18-lot-v018/decisions-log.md` : 4 HIGH, 7 MEDIUM, 0 LOW. - **HIGH** — Préflight passé (#369, #419, #421 fermées, tag `v0.17.2`) ; sites repérés par leur contenu ; markup fermé relu sur la base (`ringedCategory` de `TransactionTable.render.test.tsx`). - **HIGH** — `z-[300]` : la plus haute couche de l'app est `z-[210]` (`FeedbackConsentDialog`), aucune couche en CSS ; la classe est bien produite par le build. - **HIGH** — `LinkTransfersModal` et `AdjustmentForm` suivent les extraits de l'issue tels quels (aucun `placeholder` pour la ligne d'ajustement : une catégorie absente de la liste s'affiche vide, D2) ; le `<label>` du filtre est gardé. - **HIGH** — Mutations non observables déclarées (tableau ci-dessous), avec leur raison. - **MEDIUM** — Règle de retournement : vers le haut quand la place en dessous est inférieure à 240 px **et** que la place au-dessus atteint 240 px. « L'espace au-dessus plus grand » du corps se lit des deux façons ; cette lecture est la seule qui tienne la liste de tests de l'issue (« pas de retournement quand il manque des deux côtés »). Écarté : monter dès que la place au-dessus dépasse celle d'en dessous. - **MEDIUM** — Placement vers le haut par `bottom` CSS (distance au bas de la fenêtre) : une liste de moins de 240 px finit quand même 4 px au-dessus du champ. Écartés : `top = haut du champ − 244` (une liste courte flotterait loin de son champ), `translateY(-100%)`. - **MEDIUM** — `useLayoutEffect` plutôt que `useEffect` pour le placement et les écouteurs : la liste est placée avant la première peinture, et une position laissée par une ouverture précédente n'est jamais peinte. Le nettoyage reste le retour de l'effet, conditionné par `open`, comme le demande le plan. Conséquence : le composant ouvert ne tourne pas sous le harnais, qui refuse `useLayoutEffect` (et déjà `useId`). - **MEDIUM** — La plomberie des écouteurs sort en fonction pure (`followAnchor`), testée avec une cible factice qui ne retire un écouteur que par type, fonction et drapeau de capture, soit le cas des écouteurs orphelins relevé par la revue de sécurité du plan. `maxHeight` en style depuis `LISTBOX_MAX_HEIGHT` plutôt que la classe `max-h-60` : une seule source pour la règle de retournement et la hauteur rendue. - **MEDIUM** — D2 par `getAllCategories()` plutôt qu'une seconde copie de la requête dans le hook : une seule liste pour tous les sélecteurs de catégorie (Transactions, mot-clé #423, filtre des transferts, ajustements), et le hook ne lance plus de SQL lui-même. - **MEDIUM** — Tests au-delà de la liste de l'issue : `useAdjustments.test.ts`, `AdjustmentForm.wiring.test.ts` et `LinkTransfersModal.wiring.test.ts` sous le harnais, nécessaires pour rendre observables « `onExtraSelect` ne remet pas `null` », la catégorie par défaut d'une nouvelle ligne et la source de la liste. Les tests de `LinkTransfersModal` posent aussi un `document` factice : `createPortal(…, document.body)` lit `document.body` avant l'appel remplacé. - **MEDIUM** — Docs au-delà de « `architecture.md` si la section composants décrit `CategoryCombobox` » (elle ne le décrit pas) : sous-section « Portails » dans la section Tests (tester une fenêtre en portail ; ce qui du portail du combobox est épinglé et ce qui reste manuel ; la liste hors du DOM de son conteneur) ; guide §7 et `docs.adjustments.steps` FR/EN, comme #423 l'avait fait pour le champ parent. ## Preuves - Sonde : `src/zz_tmp_probe.test.ts` avec un `expect(1).toBe(2)` → `npx vitest run src/zz_tmp_probe.test.ts` **sonde rc=1**, fichier supprimé. - `npm run build` vert (tsc + vite) ; la classe `z-[300]` est dans la CSS produite. `npm test` sur la tête : **147 fichiers / 2375 tests** verts dans le worktree, soit 6 fichiers et 27 tests de plus que la base (141 / 2348). Aucun fichier Rust touché. - `TransactionTable.render.test.tsx`, `AddKeywordDialog.render.test.tsx`, `CategoryForm.render.test.tsx` et `CategoryDetailPanel.render.test.tsx` verts sans modification : le rendu fermé du combobox n'a pas bougé. - Mutations : implémentation commitée avant la première (`3631ef0`, `619637e`, `aa22948`) ; une à la fois, restauration par `git checkout --`, `git status --short` vide après chacune. | # | Fichier | Mutation | Tests rouges | rc | |---|---|---|---|---| | M1 | `placeListbox.ts` | retournement vers le haut retiré (`if (false && …)`) | 3 / 13 | 1 | | M6 | `placeListbox.ts` | autre lecture : monter dès que la place au-dessus dépasse celle d'en dessous | 1 / 13 | 1 | | M15 | `placeListbox.ts` | écart de 4 px retiré sous le champ | 4 / 13 | 1 | | M14 | `placeListbox.ts` | `samePlacement` ignore `top` | 1 / 13 | 1 | | M7 | `placeListbox.ts` | `scroll` écouté hors phase de capture | 3 / 13 | 1 | | M8 | `placeListbox.ts` | nettoyage sans le drapeau de capture (écouteur orphelin) | 2 / 13 | 1 | | M9 | `placeListbox.ts` | défilement de la liste elle-même non ignoré | 1 / 13 | 1 | | M2 | `useAdjustments.ts` | retour exact à l'ancienne requête (`is_active = 1`, toutes les actives) | 2 / 2 (reçoit la catégorie non saisissable) | 1 | | M2b | `transactionService.ts` | `AND is_inputable = 1` retiré de `getAllCategories` | 0 / 2375, **non observable** : texte SQL d'un service partagé, aucun test ne le lit (l'issue : « un test sur le texte SQL n'apporte rien ») ; preuve par lecture | 0 | | M3 | `LinkTransfersModal.tsx` | « Toutes les catégories » ne remet pas `null` (`setCategoryId(categoryId)`) | 1 / 4 | 1 | | M12 | `LinkTransfersModal.tsx` | option extra jamais active (`activeExtra={null}`) | 3 / 4 | 1 | | M13 | `LinkTransfersModal.tsx` | option extra retirée | 2 / 4 | 1 | | M4 | `AdjustmentForm.tsx` | `compact` retiré | 2 / 8 | 1 | | M5 | `AdjustmentForm.tsx` | combobox retiré (retour au `<select>` de la base) | 7 / 8 | 1 | | M10 | `AdjustmentForm.tsx` | nouvelle ligne sur la dernière catégorie | 1 / 8 | 1 | | M11 | `AdjustmentForm.tsx` | le combobox écrit dans `amount` | 2 / 8 | 1 | | M16 | `CategoryCombobox.tsx` | clic extérieur sans `listRef` (un clic sur une option fermerait la liste) | 0 / 2375, **non observable** | 0 | | M17 | `CategoryCombobox.tsx` | l'effet ne rend pas le nettoyage de `followAnchor` | 0 / 2375, **non observable** | 0 | M1 à M9 ont tourné sur `placeListbox.test.ts`, M2 sur `useAdjustments.test.ts`, M3, M12 et M13 sur les deux tests de `LinkTransfersModal`, M4, M5, M10 et M11 sur les deux tests d'`AdjustmentForm` ; M2b, M16 et M17 sur toute la suite. M16 et M17 : le composant ouvert ne se rend pas sans DOM (`renderToStaticMarkup` le rend fermé, le harnais refuse ses `useId` et `useLayoutEffect`) et jsdom est écarté par l'issue ; la logique qu'ils câblent est épinglée dans `placeListbox.test.ts`, leur câblage relève des vérifications manuelles 6 (M16) et 8 (M17). ## Vérifications manuelles dues (prochaine release) Dans l'app lancée (Linux), thèmes clair et sombre : 1. **Répartition, une ligne** (Transactions → bouton Répartition d'une ligne catégorisée, édition Base) : ouvrir la liste de catégories de la ligne ; elle s'affiche en entier par-dessus le pied de la fenêtre, sans faire défiler le corps. 2. **Répartition, six lignes** : ajouter des lignes jusqu'à six ; la liste de la dernière ligne s'ouvre vers le haut quand la place manque en dessous. Liste ouverte, faire défiler le corps de la fenêtre à la molette (pointeur hors de la liste) : la liste suit son champ, même quand le champ passe sous l'en-tête ou le pied de la fenêtre, et ne se ferme pas. 3. **Tableau des transactions** : ouvrir la catégorie de la dernière ligne affichée ; la liste n'est plus coupée au bas du tableau, et s'ouvre vers le haut près du bas de la fenêtre. Faire défiler la page, liste ouverte : la liste suit son champ. 4. **Filtre des transferts** (Bilan → menu d'un compte → Lier transferts) : le filtre « Catégorie » montre « Toutes les catégories » ; taper quelques lettres filtre la liste ; choisir une catégorie recharge les transactions ; choisir « Toutes les catégories » (en tête) vide le filtre et recharge. 5. **Dialogue mot-clé** (clic droit sur une transaction → nouveau mot-clé) : la liste de catégories s'ouvre par-dessus le dialogue, en entier. 6. **Clavier et souris** sur l'un des combobox ci-dessus : flèches (la liste défile sur l'option surlignée), Entrée sélectionne, Échap ferme sans sélectionner ; un clic sur une option de la liste la sélectionne (la liste est hors du conteneur dans le DOM : c'est la preuve de M16) ; un clic à l'extérieur ferme sans sélectionner. 7. **Fenêtre fermée pendant que la liste est ouverte** : la liste disparaît avec elle (par exemple, liste ouverte dans la répartition, clic sur Annuler ou sur la croix) ; rouvrir la fenêtre puis la liste : une seule liste, à la bonne place. 8. **Écouteurs** (`npm run tauri dev`, inspecteur web, console) : `getEventListeners(window).scroll?.length` et `getEventListeners(window).resize?.length` donnent un de plus pendant qu'une liste est ouverte, et reviennent à leur valeur de départ après sa fermeture, y compris quand la fenêtre qui la contient se ferme liste ouverte. C'est la seule preuve de M17 : un écouteur orphelin ne se voit pas à l'écran. 9. **Ajustements** (édition Base) : nouvel ajustement, « Ajouter une entrée » : la ligne montre la première catégorie de la liste ; taper filtre, seules les catégories actives et saisissables sont proposées ; enregistrer puis rouvrir en édition : catégorie gardée. Section Répartitions de la page Ajustements : la fenêtre ne propose plus que les catégories saisissables. ## À regarder - **Limite connue** : dans une fenêtre de moins de 520 px de haut environ, la place manque des deux côtés ; la liste reste sous le champ (règle de l'issue) et peut être coupée par le bas de la fenêtre. Pas de hauteur réduite à la place disponible, hors du plan. - La liste suit son champ hors de la zone visible de son conteneur (décision de Max) : elle peut alors passer par-dessus l'en-tête ou le pied d'une fenêtre tant qu'on fait défiler. - La liste n'étant plus dans le DOM de son conteneur, un futur écouteur natif de clic extérieur posé par un conteneur la verrait dehors (aucun aujourd'hui autour d'un combobox : `PageHelp`, `TemplateActions`, `PeriodSelector`, `ProfileSwitcher`, `SecurityPicker` et `ContextMenu` n'en contiennent pas). Les événements React, eux, remontent toujours par l'arbre du composant : les `stopPropagation` des fenêtres (`AddKeywordDialog`, `LinkTransfersModal`, `MappingRow`) continuent de s'appliquer. - Suite possible, hors de cette issue : couvrir `useId` et `useLayoutEffect` dans `src/test/hookHarness.ts` rendrait le combobox ouvert testable sous le harnais (M16, M17, clavier). - Le maillon 5 (#393) réutilise ce combobox dans sa fenêtre : ses props publiques n'ont pas changé. - Niveau calculé : normal (aucune ligne Haut remplie : pas d'appel réseau ajouté ni modifié, aucun manifeste, lock, workflow ni fichier de base ; `docs/architecture.md` au moins normal). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 4 commits 2026-10-18 22:45:45 +00:00
The open list of CategoryCombobox was positioned `absolute` inside its
input's container, so a scrolling ancestor clipped it: the body of the
split window, the `overflow-x-auto` wrapper of the Transactions table.
It is now rendered in a portal on document.body, `position: fixed`, at
z-[300] (above every window, the highest at z-[210]).

The pure rule lives in placeListbox.ts: under the input, as wide as it,
4 px below; above it when the room below is short of the list's 240 px
and the room above has them; below when neither side has them. The
list is placed in a layout effect before the first paint, then follows
the input on every scroll (capture phase, any scrolling container) and
resize while it is open, skipping scrolls of the list itself; the
listeners are removed with the list (followAnchor returns the cleanup).
The outside press counts the list as inside, since it no longer sits in
the container's DOM. The closed markup is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each line of the adjustment form chose its category in a native select
showing raw names. It now takes the compact CategoryCombobox of the
split window: typing filters, hierarchical order, seed names
translated. A new line still takes the first category of the list.

D2: the hook reads the Transactions list, getAllCategories (active and
open to input), instead of its own query on every active category. The
list feeds two pickers only, the form and the split window of the
Adjustments page; the detail view names a line's category from the
entries' join, so a line saved on a category hidden from input keeps
its name there and its value, its field empty until changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The category filter of the "Link transfers" window was a native select.
It now takes the compact CategoryCombobox, modelled on the Transactions
filter bar: "Any category" first as an extra option, active while the
filter is empty, and picking it sets the filter back to null; the list
of transactions reloads as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: describe the category combobox in a portal and its new places (#429)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
7053ae5fc6
CHANGELOG (EN and FR, Changed): typing in the category of an adjustment
line and in the category filter of the "Link transfers" window, the
adjustment lines limited to the categories open to input, and the open
list no longer cut by the edge of a window or of the Transactions table.

Guide, section 7, and its in-app twin (docs.adjustments.steps, FR and
EN): type a few letters to filter the category list of a line.

architecture.md, Tests section: how a test renders a window drawn by
createPortal (identity mock, document stub), what of the combobox's
portal is pinned without a DOM (placeListbox.ts) and what is checked in
the app, and that the list no longer sits in its container's DOM.

Resolves #429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-18 22:46:03 +00:00
maximus added 1 commit 2026-10-18 22:48:48 +00:00
docs: state the exact flip rule and the portal errors (#429)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
874563540c
Self-review: the CHANGELOG entries said the list opens upward when the
room below is short; it does only when it also fits above (in a short
window it stays below). The Portails paragraph of architecture.md now
quotes the two errors a test meets without the mock, both reproduced
on a throwaway test: "Target container is not a DOM element" without
a DOM, "Portals are not currently supported by the server renderer"
with a DOM-like container.

Resolves #429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added 1 commit 2026-10-18 22:49:23 +00:00
docs: name the portal windows as examples, not as tested (#429)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
14ebf0f37d
Self-review: the Portails paragraph listed StarterAccountsModal next to
LinkTransfersModal as if both had a test of this kind; only
LinkTransfersModal has one. The other windows are named as windows the
same rule applies to.

Resolves #429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Niveau : normal
Verdict : APPROVE

Auto-revue de l'auteur (worker /autopilot du 2026-10-18), faite avec la checklist de /pr-review sur le diff complet de la PR contre sa base issue-372-systems-journal-first : ce n'est pas une revue indépendante, status:approved n'est pas posé.

Résumé

La liste ouverte de CategoryCombobox passe en portail sur document.body (position: fixed, z-[300]), placée par une fonction pure (placeListbox) et gardée sur son champ par des écouteurs scroll (capture) et resize qui ne vivent que pendant open (followAnchor, nettoyage rendu par l'effet). La ligne d'ajustement et le filtre des transferts prennent le combobox ; les lignes d'ajustement ne proposent plus que les catégories actives et saisissables (D2, par getAllCategories). Markup fermé inchangé, aucune clé i18n ajoutée, aucun appel réseau.

Niveau (Step 0bis, REVIEW.md de main)

  • CHANGELOG.md, CHANGELOG.fr.md, docs/guide-utilisateur.md : bas (**/*.md, docs/**).
  • docs/architecture.md : au moins normal (exception de la liste).
  • src/components/shared/CategoryCombobox.tsx, placeListbox.ts, src/components/adjustments/AdjustmentForm.tsx, src/components/balance/LinkTransfersModal.tsx, src/hooks/useAdjustments.ts et les six fichiers de test : motif src/** (code livré), condition non remplie : aucun appel réseau ajouté ni modifié (getTransactionPage et getAllCategories lisent la base locale), aucune URL ouverte, aucun consentement touché. Normal.
  • src/i18n/locales/*.json : normal (traductions).
  • Aucun fichier ajouté à la racine ni dans src-tauri/, aucun manifeste, lock ou workflow.

PR : normal.

Checklist

  • Sécurité : aucun secret ; noms de catégories rendus comme texte par React (pas de HTML injecté) ; aucune entrée système nouvelle.
  • Exactitude : relu contre l'issue et son « Plan d'exécution » : portail créé seulement à l'ouverture ; placement avant la première peinture puis à chaque scroll/resize, sans fermeture au défilement ; écouteurs retirés au nettoyage, drapeau de capture compris ; clic extérieur qui compte la liste ; onMouseDown des options gardé ; aria-controls/aria-activedescendant vers des ids du même document. Les événements React d'une option remontent toujours par l'arbre du composant : les stopPropagation de AddKeywordDialog, LinkTransfersModal et MappingRow s'appliquent comme avant ; aucun écouteur natif de clic extérieur n'entoure un combobox (PageHelp, TemplateActions, PeriodSelector, ProfileSwitcher, SecurityPicker, ContextMenu relus). Thème : variables sur :root et .dark sur <html>, donc héritées dans body. D2 : state.categories n'alimente que deux sélecteurs ; la vue de détail lit category_name de la jointure.
  • Tests : 6 fichiers, 27 tests (147 / 2375 sur la tête, contre 141 / 2348 sur la base) ; harnais hookHarness pour tout ce qui passe par un effet ou un gestionnaire, rendu de nouveau après chaque setter ; faux t marqué ; assertions sur attributs (value="…", class="w-full px-2 py-1 ), pas sur des sous-chaînes ambiguës. Sonde rc=1.
  • Qualité : aucun code mort, aucun texte en dur ; commits conventionnels, chacun avec le numéro de l'issue ; le mot-clé de fermeture est dans le dernier commit et en première ligne du corps, et la timeline de l'issue porte un seul pull_ref closes, venant de cette PR, rien depuis la prose.
  • Données : aucune migration ; lecture par getAllCategories qui passe par le verrou de db.ts ; aucun booléen lié ; aucune écriture.

Mutations du relecteur (en plus des 18 de l'auteur, tableau du corps)

# Fichier Mutation Tests rouges rc
R4 placeListbox.ts écart soustrait au lieu d'ajouté vers le haut 2 / 13 1
R9 placeListbox.ts resize jamais écouté 3 / 13 1
R5 LinkTransfersModal.tsx compact retiré du filtre 2 / 4 1
R8 AdjustmentForm.tsx liste tronquée passée au combobox (categories.slice(1)) 2 / 8 1

Deux sondes jetables (supprimées) vérifient l'affirmation de architecture.md : sans DOM, le vrai createPortal lève « Target container is not a DOM element » ; avec un conteneur factice, renderToStaticMarkup lève « Portals are not currently supported by the server renderer ».

Constats, tous corrigés

  1. CHANGELOG.md et CHANGELOG.fr.md disaient que la liste s'ouvre vers le haut quand la place manque en dessous ; elle ne le fait que si elle tient au-dessus. Corrigé dans 8745635.
  2. docs/architecture.md, « Portails » : les deux messages d'erreur cités, pour qu'un test qui les rencontre trouve la section (8745635) ; StarterAccountsModal se lisait comme déjà testé ainsi, reformulé (14ebf0f).
  3. Corps de la PR, avant publication : la vérification manuelle 7 prétendait prouver M17 à l'écran ; un écouteur orphelin ne se voit pas. Remplacée par la vérification 8 (getEventListeners(window) dans l'inspecteur web).

Non bloquant

  • M16 et M17 non observables (corps de la PR) : le combobox ouvert ne tourne pas sans DOM (useId et useLayoutEffect refusés par le harnais, renderToStaticMarkup le rend fermé). La logique est épinglée dans placeListbox.test.ts, le câblage dans le composant tient en deux lignes lisibles ; les vérifications manuelles 6 et 8 le couvrent. Suggestion de suivi : couvrir useId et useLayoutEffect dans src/test/hookHarness.ts rendrait le composant ouvert testable (clavier, clic, portail).
  • Fenêtre courte (moins de 520 px environ) : la place manque des deux côtés, la liste reste dessous et peut être coupée par le bas de la fenêtre ; règle de l'issue, aucune réduction de hauteur prévue.
  • Vérification 8 : getEventListeners est une commande de la console de l'inspecteur WebKit ; si elle manque sous WebKitGTK, un point d'arrêt sur followAnchor et sur son nettoyage donne la même preuve.
  • CI : non attendue ici (l'orchestrateur la suit) ; check-frontend doit tourner, check-rust non (aucun chemin src-tauri/**).

🤖 Generated with Claude Code

Niveau : normal Verdict : APPROVE **Auto-revue de l'auteur** (worker `/autopilot` du 2026-10-18), faite avec la checklist de `/pr-review` sur le diff complet de la PR contre sa base `issue-372-systems-journal-first` : ce n'est pas une revue indépendante, `status:approved` n'est pas posé. ## Résumé La liste ouverte de `CategoryCombobox` passe en portail sur `document.body` (`position: fixed`, `z-[300]`), placée par une fonction pure (`placeListbox`) et gardée sur son champ par des écouteurs `scroll` (capture) et `resize` qui ne vivent que pendant `open` (`followAnchor`, nettoyage rendu par l'effet). La ligne d'ajustement et le filtre des transferts prennent le combobox ; les lignes d'ajustement ne proposent plus que les catégories actives et saisissables (D2, par `getAllCategories`). Markup fermé inchangé, aucune clé i18n ajoutée, aucun appel réseau. ## Niveau (Step 0bis, `REVIEW.md` de `main`) - `CHANGELOG.md`, `CHANGELOG.fr.md`, `docs/guide-utilisateur.md` : bas (`**/*.md`, `docs/**`). - `docs/architecture.md` : au moins normal (exception de la liste). - `src/components/shared/CategoryCombobox.tsx`, `placeListbox.ts`, `src/components/adjustments/AdjustmentForm.tsx`, `src/components/balance/LinkTransfersModal.tsx`, `src/hooks/useAdjustments.ts` et les six fichiers de test : motif `src/**` (code livré), condition non remplie : aucun appel réseau ajouté ni modifié (`getTransactionPage` et `getAllCategories` lisent la base locale), aucune URL ouverte, aucun consentement touché. Normal. - `src/i18n/locales/*.json` : normal (traductions). - Aucun fichier ajouté à la racine ni dans `src-tauri/`, aucun manifeste, lock ou workflow. PR : **normal**. ## Checklist - **Sécurité** : aucun secret ; noms de catégories rendus comme texte par React (pas de HTML injecté) ; aucune entrée système nouvelle. - **Exactitude** : relu contre l'issue et son « Plan d'exécution » : portail créé seulement à l'ouverture ; placement avant la première peinture puis à chaque `scroll`/`resize`, sans fermeture au défilement ; écouteurs retirés au nettoyage, drapeau de capture compris ; clic extérieur qui compte la liste ; `onMouseDown` des options gardé ; `aria-controls`/`aria-activedescendant` vers des ids du même document. Les événements React d'une option remontent toujours par l'arbre du composant : les `stopPropagation` de `AddKeywordDialog`, `LinkTransfersModal` et `MappingRow` s'appliquent comme avant ; aucun écouteur natif de clic extérieur n'entoure un combobox (`PageHelp`, `TemplateActions`, `PeriodSelector`, `ProfileSwitcher`, `SecurityPicker`, `ContextMenu` relus). Thème : variables sur `:root` et `.dark` sur `<html>`, donc héritées dans `body`. D2 : `state.categories` n'alimente que deux sélecteurs ; la vue de détail lit `category_name` de la jointure. - **Tests** : 6 fichiers, 27 tests (147 / 2375 sur la tête, contre 141 / 2348 sur la base) ; harnais `hookHarness` pour tout ce qui passe par un effet ou un gestionnaire, rendu de nouveau après chaque setter ; faux `t` marqué ; assertions sur attributs (`value="…"`, `class="w-full px-2 py-1 `), pas sur des sous-chaînes ambiguës. Sonde rc=1. - **Qualité** : aucun code mort, aucun texte en dur ; commits conventionnels, chacun avec le numéro de l'issue ; le mot-clé de fermeture est dans le dernier commit et en première ligne du corps, et la timeline de l'issue porte un seul `pull_ref` `closes`, venant de cette PR, rien depuis la prose. - **Données** : aucune migration ; lecture par `getAllCategories` qui passe par le verrou de `db.ts` ; aucun booléen lié ; aucune écriture. ## Mutations du relecteur (en plus des 18 de l'auteur, tableau du corps) | # | Fichier | Mutation | Tests rouges | rc | |---|---|---|---|---| | R4 | `placeListbox.ts` | écart soustrait au lieu d'ajouté vers le haut | 2 / 13 | 1 | | R9 | `placeListbox.ts` | `resize` jamais écouté | 3 / 13 | 1 | | R5 | `LinkTransfersModal.tsx` | `compact` retiré du filtre | 2 / 4 | 1 | | R8 | `AdjustmentForm.tsx` | liste tronquée passée au combobox (`categories.slice(1)`) | 2 / 8 | 1 | Deux sondes jetables (supprimées) vérifient l'affirmation de `architecture.md` : sans DOM, le vrai `createPortal` lève « Target container is not a DOM element » ; avec un conteneur factice, `renderToStaticMarkup` lève « Portals are not currently supported by the server renderer ». ## Constats, tous corrigés 1. `CHANGELOG.md` et `CHANGELOG.fr.md` disaient que la liste s'ouvre vers le haut quand la place manque en dessous ; elle ne le fait que si elle tient au-dessus. Corrigé dans `8745635`. 2. `docs/architecture.md`, « Portails » : les deux messages d'erreur cités, pour qu'un test qui les rencontre trouve la section (`8745635`) ; `StarterAccountsModal` se lisait comme déjà testé ainsi, reformulé (`14ebf0f`). 3. Corps de la PR, avant publication : la vérification manuelle 7 prétendait prouver M17 à l'écran ; un écouteur orphelin ne se voit pas. Remplacée par la vérification 8 (`getEventListeners(window)` dans l'inspecteur web). ## Non bloquant - **M16 et M17 non observables** (corps de la PR) : le combobox ouvert ne tourne pas sans DOM (`useId` et `useLayoutEffect` refusés par le harnais, `renderToStaticMarkup` le rend fermé). La logique est épinglée dans `placeListbox.test.ts`, le câblage dans le composant tient en deux lignes lisibles ; les vérifications manuelles 6 et 8 le couvrent. Suggestion de suivi : couvrir `useId` et `useLayoutEffect` dans `src/test/hookHarness.ts` rendrait le composant ouvert testable (clavier, clic, portail). - **Fenêtre courte** (moins de 520 px environ) : la place manque des deux côtés, la liste reste dessous et peut être coupée par le bas de la fenêtre ; règle de l'issue, aucune réduction de hauteur prévue. - **Vérification 8** : `getEventListeners` est une commande de la console de l'inspecteur WebKit ; si elle manque sous WebKitGTK, un point d'arrêt sur `followAnchor` et sur son nettoyage donne la même preuve. - **CI** : non attendue ici (l'orchestrateur la suit) ; `check-frontend` doit tourner, `check-rust` non (aucun chemin `src-tauri/**`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

CI lue par l'orchestrateur du run /autopilot du 2026-10-18 : check-frontend vert sur la tête 14ebf0f (run 579).

  • vitest : 147 fichiers, 2375 tests, tous verts. Les fichiers ajoutés ont tous tourné : placeListbox.test.ts (13), AdjustmentForm.render.test.tsx (5), AdjustmentForm.wiring.test.ts (3), LinkTransfersModal.render.test.tsx (2), LinkTransfersModal.wiring.test.ts (2) et useAdjustments.test.ts (2).
  • TransactionTable.render.test.tsx (6) est vert sans modification : le markup du combobox fermé n'a pas changé.
  • La garde locales.keys.test.ts (8) est verte.
  • check-rust ne s'applique pas à ce maillon (aucun chemin Rust touché).
CI lue par l'orchestrateur du run `/autopilot` du 2026-10-18 : `check-frontend` **vert** sur la tête `14ebf0f` (run 579). - vitest : 147 fichiers, 2375 tests, tous verts. Les fichiers ajoutés ont tous tourné : `placeListbox.test.ts` (13), `AdjustmentForm.render.test.tsx` (5), `AdjustmentForm.wiring.test.ts` (3), `LinkTransfersModal.render.test.tsx` (2), `LinkTransfersModal.wiring.test.ts` (2) et `useAdjustments.test.ts` (2). - `TransactionTable.render.test.tsx` (6) est vert sans modification : le markup du combobox fermé n'a pas changé. - La garde `locales.keys.test.ts` (8) est verte. - `check-rust` ne s'applique pas à ce maillon (aucun chemin Rust touché).
Author
Owner

Niveau : normal

Verdict : APPROVE

Résumé : le portail tient ce que la PR annonce. Sous jsdom, dans une copie jetable, le combobox ouvert se rend sur body, se place sous son champ ou au-dessus, suit un défilement de conteneur et un resize, garde la liste ouverte sur un mousedown d'option et retire ses écouteurs à la fermeture comme au démontage : M16 et M17, déclarées non observables, rougissent toutes deux sous mutation. D2, le câblage d'AdjustmentForm et le filtre des transferts sont épinglés. Trois suggestions, aucune bloquante.

Ce qui a tourné

Copie hors dépôt (clone local, tête 14ebf0f récupérée sur Forgejo), supprimée en fin de revue.

  • npm ci, sonde vitest rc=1, npm run build vert (.z-\[300\]{z-index:300} présent dans la CSS produite), npm test : 147 fichiers, 2375 tests verts, les chiffres du corps et de la CI (check-frontend vert sur la tête, run 579).
  • 14 mutations du relecteur sur la logique pure et le câblage : 13 rouges, R2 survit (suggestion 1).
  • Test jetable sous jsdom (installé dans la copie seule, --no-save, rien de commité), 8 cas sur le combobox ouvert : liste enfant de body et hors du conteneur, classes fixed et z-[300], top/left/width sous le champ, bottom à 104 px près du bas de la fenêtre, suivi d'un scroll de conteneur et d'un resize, scroll de la liste ignoré, mousedown puis clic sur une option qui sélectionne, mousedown extérieur qui ferme sans sélectionner, écouteurs scroll et resize à +1 pendant l'ouverture et revenus à 0 après Échap comme après un démontage liste ouverte, flèche bas puis Entrée. 8/8 verts sur la tête.
  • Second test jetable sous jsdom sur la vraie LinkTransfersModal (fenêtre et liste toutes deux en portail) : choisir une catégorie recharge avec son id sans appeler onClose (le stopPropagation de la fenêtre s'applique par l'arbre React), « Toutes les catégories » remet null, la saisie filtre (epar → « Épargne »). 2/2 verts.
# Fichier Mutation Rouges Tests
R1 placeListbox.ts retournement sans condition sur la place au-dessus 1/13 placeListbox.test.ts
R2 placeListbox.ts spaceBelow < maxHeight → <= 0/13 idem
R3 placeListbox.ts spaceAbove >= maxHeight → > 1/13 idem
R4 placeListbox.ts vers le haut par un top fixe (haut du champ − 244) 2/13 idem
R5 placeListbox.ts écart de 4 px oublié vers le haut 2/13 idem
R6 placeListbox.ts samePlacement ignore left 1/13 idem
R7 placeListbox.ts resize écouté en capture 3/13 idem
R8 placeListbox.ts liste reconnue par identité, sans contains (défilement d'une option compté) 1/13 idem
R9 placeListbox.ts nettoyage qui oublie resize 2/13 idem
R10 AdjustmentForm.tsx le combobox écrit toujours dans la ligne 0 2/8 AdjustmentForm.*
R11 AdjustmentForm.tsx value={null} 3/8 idem
R12 LinkTransfersModal.tsx « Toutes les catégories » toujours active 1/4 LinkTransfersModal.*
R13 LinkTransfersModal.tsx onChange qui remet null 1/4 idem
R14 useAdjustments.ts échec de lecture avalé 1/2 useAdjustments.test.ts
C1 = M16 CategoryCombobox.tsx clic extérieur sans listRef 1/8 jsdom jetable
C2 = M17 CategoryCombobox.tsx effet sans nettoyage 1/8 idem
C3 CategoryCombobox.tsx écouteurs posés sur document (aucun resize reçu) 2/8 idem
C4 CategoryCombobox.tsx portail dans le conteneur 1/8 idem
C5 CategoryCombobox.tsx aucun placement à l'ouverture 6/8 idem

Une mutation à la fois, restauration par git checkout --, git diff --stat vide après chacune.

Vérifié à la lecture

  • Couche la plus haute de l'app : z-[210] (FeedbackConsentDialog) ; aucun z-index en CSS.
  • Thème : variables sur :root, classe .dark posée sur <html> par useTheme : le portail en hérite.
  • Aucun écouteur natif de clic extérieur autour d'un combobox : PageHelp et SecurityPicker ne citent CategoryCombobox qu'en commentaire ; AddKeywordDialog, LinkTransfersModal et MappingRow passent par un stopPropagation React, qui suit l'arbre du composant.
  • D2 : getAllCategories fait SELECT * avec is_active = 1 AND is_inputable = 1, mêmes colonnes que l'ancienne requête ; state.categories n'alimente qu'AdjustmentForm (par AdjustmentDetailPanel) et SplitAdjustmentModal ; la vue de détail lit category_name de la jointure de getEntriesByAdjustmentId. Le hook ne lance plus de SQL lui-même, conforme à la règle « logique métier dans services/ ».
  • AppShell est en h-screen overflow-hidden : le document ne défile pas, window.innerHeight vaut la hauteur de référence du position: fixed.
  • Les deux messages d'erreur cités par architecture.md existent dans react-dom 19.2.4 ; StarterAccountsModal et DetailAccountWizard rendent bien par createPortal.
  • Changelogs : « Toutes les catégories » / « Any category » et « Lier transferts » / « Link transfers » sont les libellés des locales ; docs.adjustments.steps[1] modifiée dans les deux langues, guide §7 aligné ; aucune clé ajoutée.
  • Commits conventionnels ; la timeline de #429 porte un seul pull_ref closes, venant de cette PR, et Resolves #429 figure aussi dans les messages des trois derniers commits.

Suggestions (non bloquantes)

  1. R2 survit : la borne du dessous n'est pas épinglée. placeListbox.test.ts:33 (« stays below when the room below is exactly the list's maximum height ») place le champ à 100 px dans une fenêtre de 374 px : 96 px au-dessus, donc la liste reste dessous avec < comme avec <=. Effet nul pour l'utilisateur (un pixel), mais le test promet plus qu'il ne prouve. Avec anchorAt(300) et viewportHeight: 574 (240 px dessous, 296 au-dessus), le cas est vert sur la tête et rouge sous R2 (vérifié dans la copie).
  2. La liste ne suit pas un déplacement de son champ venu d'un nouveau rendu, sans scroll ni resize. Observé sous jsdom : champ descendu de 200 px par un rendu du parent, liste restée à top: 134px. L'ancienne liste absolute suivait dans ce cas. Cas réel : LinkTransfersModal.tsx:206 et AddKeywordDialog.tsx:137 sont centrées verticalement (items-center) et changent de hauteur quand leur contenu asynchrone arrive (lignes de transactions, aperçu des correspondances) ; une liste ouverte avant cette arrivée reste décalée de la moitié de l'écart jusqu'au prochain défilement. Fenêtre de quelques dizaines de millisecondes après l'ouverture, clic sur une option toujours correct : pas bloquant. Piste éprouvée dans la copie : un useLayoutEffect sans tableau de dépendances qui appelle updatePlacement() tant que open. Le cas jsdom passe alors (liste à 334px), les 9 autres cas restent verts, sans boucle de rendu (samePlacement rend l'état précédent) ; tout rendu du parent repasse par le combobox, qui n'est pas mémoïsé.
  3. Commentaire devenu sans objet : AddKeywordDialog.tsx:149-151 justifie la sortie des deux champs du bloc défilant par une liste que le scrollport couperait. Avec le portail, ce n'est plus la raison ; le reformuler évite qu'un lecteur croie la contrainte toujours active.

Les trois tiennent dans un suivi, avec celui que propose le corps (useId et useLayoutEffect dans hookHarness). Le maillon 5 (#393, PR #472) réutilise le combobox dans une fenêtre : la suggestion 2 le concerne aussi si cette fenêtre charge du contenu après son ouverture.

Vérifications manuelles

Les 9 du corps restent dues sous WebKitGTK : peinture réelle, thèmes, z-[300] au-dessus des fenêtres, getEventListeners. Les tests jetables prouvent la logique de 6 et 8 dans un DOM, pas le rendu de WebKitGTK.

Actions faites

  • Copie de revue, scripts de mutation et journaux supprimés (/tmp/pr471-*) ; dépôt de travail non touché.
  • #429 : status:review → status:approved.

🤖 Generated with Claude Code

Niveau : normal **Verdict : APPROVE** **Résumé** : le portail tient ce que la PR annonce. Sous jsdom, dans une copie jetable, le combobox ouvert se rend sur `body`, se place sous son champ ou au-dessus, suit un défilement de conteneur et un `resize`, garde la liste ouverte sur un `mousedown` d'option et retire ses écouteurs à la fermeture comme au démontage : M16 et M17, déclarées non observables, rougissent toutes deux sous mutation. D2, le câblage d'`AdjustmentForm` et le filtre des transferts sont épinglés. Trois suggestions, aucune bloquante. ### Ce qui a tourné Copie hors dépôt (clone local, tête `14ebf0f` récupérée sur Forgejo), supprimée en fin de revue. - `npm ci`, sonde vitest rc=1, `npm run build` vert (`.z-\[300\]{z-index:300}` présent dans la CSS produite), `npm test` : 147 fichiers, 2375 tests verts, les chiffres du corps et de la CI (`check-frontend` vert sur la tête, run 579). - 14 mutations du relecteur sur la logique pure et le câblage : 13 rouges, R2 survit (suggestion 1). - Test jetable sous jsdom (installé dans la copie seule, `--no-save`, rien de commité), 8 cas sur le combobox ouvert : liste enfant de `body` et hors du conteneur, classes `fixed` et `z-[300]`, `top`/`left`/`width` sous le champ, `bottom` à 104 px près du bas de la fenêtre, suivi d'un `scroll` de conteneur et d'un `resize`, `scroll` de la liste ignoré, `mousedown` puis clic sur une option qui sélectionne, `mousedown` extérieur qui ferme sans sélectionner, écouteurs `scroll` et `resize` à +1 pendant l'ouverture et revenus à 0 après Échap comme après un démontage liste ouverte, flèche bas puis Entrée. 8/8 verts sur la tête. - Second test jetable sous jsdom sur la vraie `LinkTransfersModal` (fenêtre et liste toutes deux en portail) : choisir une catégorie recharge avec son id sans appeler `onClose` (le `stopPropagation` de la fenêtre s'applique par l'arbre React), « Toutes les catégories » remet `null`, la saisie filtre (`epar` → « Épargne »). 2/2 verts. | # | Fichier | Mutation | Rouges | Tests | |---|---|---|---|---| | R1 | `placeListbox.ts` | retournement sans condition sur la place au-dessus | 1/13 | `placeListbox.test.ts` | | R2 | `placeListbox.ts` | `spaceBelow < maxHeight` → `<=` | **0/13** | idem | | R3 | `placeListbox.ts` | `spaceAbove >= maxHeight` → `>` | 1/13 | idem | | R4 | `placeListbox.ts` | vers le haut par un `top` fixe (haut du champ − 244) | 2/13 | idem | | R5 | `placeListbox.ts` | écart de 4 px oublié vers le haut | 2/13 | idem | | R6 | `placeListbox.ts` | `samePlacement` ignore `left` | 1/13 | idem | | R7 | `placeListbox.ts` | `resize` écouté en capture | 3/13 | idem | | R8 | `placeListbox.ts` | liste reconnue par identité, sans `contains` (défilement d'une option compté) | 1/13 | idem | | R9 | `placeListbox.ts` | nettoyage qui oublie `resize` | 2/13 | idem | | R10 | `AdjustmentForm.tsx` | le combobox écrit toujours dans la ligne 0 | 2/8 | `AdjustmentForm.*` | | R11 | `AdjustmentForm.tsx` | `value={null}` | 3/8 | idem | | R12 | `LinkTransfersModal.tsx` | « Toutes les catégories » toujours active | 1/4 | `LinkTransfersModal.*` | | R13 | `LinkTransfersModal.tsx` | `onChange` qui remet `null` | 1/4 | idem | | R14 | `useAdjustments.ts` | échec de lecture avalé | 1/2 | `useAdjustments.test.ts` | | C1 = M16 | `CategoryCombobox.tsx` | clic extérieur sans `listRef` | 1/8 | jsdom jetable | | C2 = M17 | `CategoryCombobox.tsx` | effet sans nettoyage | 1/8 | idem | | C3 | `CategoryCombobox.tsx` | écouteurs posés sur `document` (aucun `resize` reçu) | 2/8 | idem | | C4 | `CategoryCombobox.tsx` | portail dans le conteneur | 1/8 | idem | | C5 | `CategoryCombobox.tsx` | aucun placement à l'ouverture | 6/8 | idem | Une mutation à la fois, restauration par `git checkout --`, `git diff --stat` vide après chacune. ### Vérifié à la lecture - Couche la plus haute de l'app : `z-[210]` (`FeedbackConsentDialog`) ; aucun `z-index` en CSS. - Thème : variables sur `:root`, classe `.dark` posée sur `<html>` par `useTheme` : le portail en hérite. - Aucun écouteur natif de clic extérieur autour d'un combobox : `PageHelp` et `SecurityPicker` ne citent `CategoryCombobox` qu'en commentaire ; `AddKeywordDialog`, `LinkTransfersModal` et `MappingRow` passent par un `stopPropagation` React, qui suit l'arbre du composant. - D2 : `getAllCategories` fait `SELECT *` avec `is_active = 1 AND is_inputable = 1`, mêmes colonnes que l'ancienne requête ; `state.categories` n'alimente qu'`AdjustmentForm` (par `AdjustmentDetailPanel`) et `SplitAdjustmentModal` ; la vue de détail lit `category_name` de la jointure de `getEntriesByAdjustmentId`. Le hook ne lance plus de SQL lui-même, conforme à la règle « logique métier dans `services/` ». - `AppShell` est en `h-screen overflow-hidden` : le document ne défile pas, `window.innerHeight` vaut la hauteur de référence du `position: fixed`. - Les deux messages d'erreur cités par `architecture.md` existent dans react-dom 19.2.4 ; `StarterAccountsModal` et `DetailAccountWizard` rendent bien par `createPortal`. - Changelogs : « Toutes les catégories » / « Any category » et « Lier transferts » / « Link transfers » sont les libellés des locales ; `docs.adjustments.steps[1]` modifiée dans les deux langues, guide §7 aligné ; aucune clé ajoutée. - Commits conventionnels ; la timeline de #429 porte un seul `pull_ref` `closes`, venant de cette PR, et `Resolves #429` figure aussi dans les messages des trois derniers commits. ### Suggestions (non bloquantes) 1. **R2 survit : la borne du dessous n'est pas épinglée.** `placeListbox.test.ts:33` (« stays below when the room below is exactly the list's maximum height ») place le champ à 100 px dans une fenêtre de 374 px : 96 px au-dessus, donc la liste reste dessous avec `<` comme avec `<=`. Effet nul pour l'utilisateur (un pixel), mais le test promet plus qu'il ne prouve. Avec `anchorAt(300)` et `viewportHeight: 574` (240 px dessous, 296 au-dessus), le cas est vert sur la tête et rouge sous R2 (vérifié dans la copie). 2. **La liste ne suit pas un déplacement de son champ venu d'un nouveau rendu, sans `scroll` ni `resize`.** Observé sous jsdom : champ descendu de 200 px par un rendu du parent, liste restée à `top: 134px`. L'ancienne liste `absolute` suivait dans ce cas. Cas réel : `LinkTransfersModal.tsx:206` et `AddKeywordDialog.tsx:137` sont centrées verticalement (`items-center`) et changent de hauteur quand leur contenu asynchrone arrive (lignes de transactions, aperçu des correspondances) ; une liste ouverte avant cette arrivée reste décalée de la moitié de l'écart jusqu'au prochain défilement. Fenêtre de quelques dizaines de millisecondes après l'ouverture, clic sur une option toujours correct : pas bloquant. Piste éprouvée dans la copie : un `useLayoutEffect` sans tableau de dépendances qui appelle `updatePlacement()` tant que `open`. Le cas jsdom passe alors (liste à `334px`), les 9 autres cas restent verts, sans boucle de rendu (`samePlacement` rend l'état précédent) ; tout rendu du parent repasse par le combobox, qui n'est pas mémoïsé. 3. **Commentaire devenu sans objet** : `AddKeywordDialog.tsx:149-151` justifie la sortie des deux champs du bloc défilant par une liste que le scrollport couperait. Avec le portail, ce n'est plus la raison ; le reformuler évite qu'un lecteur croie la contrainte toujours active. Les trois tiennent dans un suivi, avec celui que propose le corps (`useId` et `useLayoutEffect` dans `hookHarness`). Le maillon 5 (#393, PR #472) réutilise le combobox dans une fenêtre : la suggestion 2 le concerne aussi si cette fenêtre charge du contenu après son ouverture. ### Vérifications manuelles Les 9 du corps restent dues sous WebKitGTK : peinture réelle, thèmes, `z-[300]` au-dessus des fenêtres, `getEventListeners`. Les tests jetables prouvent la logique de 6 et 8 dans un DOM, pas le rendu de WebKitGTK. ### Actions faites - Copie de revue, scripts de mutation et journaux supprimés (`/tmp/pr471-*`) ; dépôt de travail non touché. - #429 : `status:review` → `status:approved`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m58s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin issue-429-category-combobox-portal:issue-429-category-combobox-portal
git checkout issue-429-category-combobox-portal

Merge

Merge the changes and update on Forgejo.
git checkout issue-372-systems-journal-first
git merge --no-ff issue-429-category-combobox-portal
git checkout issue-372-systems-journal-first
git merge --ff-only issue-429-category-combobox-portal
git checkout issue-429-category-combobox-portal
git rebase issue-372-systems-journal-first
git checkout issue-372-systems-journal-first
git merge --no-ff issue-429-category-combobox-portal
git checkout issue-372-systems-journal-first
git merge --squash issue-429-category-combobox-portal
git checkout issue-372-systems-journal-first
git merge --ff-only issue-429-category-combobox-portal
git checkout issue-372-systems-journal-first
git merge issue-429-category-combobox-portal
git push origin issue-372-systems-journal-first
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#471
No description provided.