Appearance
[UX] members — BCC memberships
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 6 Patterns: forms/search-field (consulted); data-display/table, data-display/filter-panel (baseline only)
Summary
The bcc-adhesions route is a two-tab view: a members list built on the shared TableData (Adhésions tab) and a year-over-year diff (CompareMemberships). The list surface inherits the project-level TableData defects and adds no new ones; the Comparaison tab is where the feature-specific harm sits — it presents a membership comparison with no error branch, so a single failed year fetch is shown as an authoritative "all members lost / none joined" result. Its year controls are also unlabelled and its swap button has no accessible name.
Findings
1. Comparaison renders a materially wrong result on a partial fetch failure — High · MSG-06 (proposed cluster)
Where: admin/src/client/views/BCC/CompareMemberships.vue:25-37, components/app/CompareMemberships.vue:181-193What: before/after come from two independent useApiData calls. loading is before.isFetching || after.isFetching and the tab lists are derived purely from before.list.value / after.list.value. There is no isError branch anywhere. If either year's request fails, its list resolves to [] and the diff is computed against an empty set: a failed after fetch makes every prior-year member appear under "Membres perdus" and zero under "Nouveaux membres" (and vice-versa for before). Why it matters: The comparison drives operational action — the count tags and the "lost members" list are exactly what staff would work from (the sibling "Manquants"/relances flow). A transient network error is rendered as a confident, wrong roster of members who left, with nothing on screen signalling the data is incomplete. Fix: Gate the tables on an explicit error state from each useApiData (surface a retry, e.g. reuse the pattern the TableData isError fix introduces), and never compute a diff while either side is in error. Placed at High, not Blocker: the feature still loads and the healthy path is correct, but the failure path shows fabricated-looking authoritative data.
2. Year-comparison selects have no associated label — Medium · FORM-01
Where: components/app/CompareMemberships.vue:14-32What: The two BccSelects (year-before, year-after) are identified only by a visual "→" between them and the heading "Années X → Y". Neither has a <label>, aria-label, or visible field label. Why it matters: A screen-reader user hears two unlabelled year comboboxes with no way to tell which is the "from" and which the "to"; the search-field/forms guidance (consulted pattern) calls a label or hint a required part of the anatomy. Fix: Give each select an accessible name ("Année de départ" / "Année d'arrivée"), via aria-label or a visually-hidden <label>.
3. Swap-years button is icon-only with no accessible name — Medium · A11Y-05
Where: components/app/CompareMemberships.vue:33-39What: The BccButton that swaps the two years passes only :icon="piIcon('arrow-right-arrow-left')" — no label, no aria-label. (Contrast the "Manquants" and "Ajouter" buttons, which carry label.) Why it matters: Icon-only control with no name is unusable/ambiguous for screen-reader and voice-control users. @bcc-code internals are not installed, so whether BccButton derives a name from the icon is Unverified — but nothing in this call site supplies one. Fix: Add label (visually hidden if desired) or aria-label="Inverser les années".
4. Two <h1> elements on the Comparaison tab — Medium · A11Y-04
Where: components/app/CompareMemberships.vue:5-7 (<h1>Comparaison des adhésions</h1>) nesting TableData at :69-77, which itself renders <h1>{{ name }}</h1> (components/app/tables/TableData.vue:11) from :name="tab.label". What: When the Comparaison tab is active the page has two <h1>s — "Comparaison des adhésions" and "Nouveaux membres"/"Membres perdus". TableData unconditionally emitting an <h1> for its title name is the root cause; this feature is the composition that exposes it by wrapping TableData under its own page <h1>. Why it matters: Multiple top-level headings break the document outline screen-reader users navigate by. (The Adhésions tab is fine — the view has no heading of its own, so TableData's single <h1> is correct there.) Fix: Either demote the nested table title to a lower level when embedded, or make TableData's title heading level configurable and set it to h2 inside CompareMemberships.
5. "Désactiver" deactivates a membership with no confirmation — Medium · MSG-05
Where: components/app/tables/TableData.vue:428-430 (reached because this view sets :archive-mode="true", views/BCC/Adhesions.vue:13) What: In archiveMode, the row context-menu "Désactiver" calls data.update.mutateAsync({...row, status: "inactive"}) immediately, whereas the non-archive "Supprimer" routes through the "Êtes-vous sûr ?" BccDialog. Deactivation is a data-mutating action with no confirmation and no on-screen undo. Why it matters: One mis-click on the (mouse-only, right-click) menu flips a member's Est_membre/status with no prompt and no visible feedback that it happened. Confirmation should scale with consequence; here the more destructive-feeling default (delete) confirms and the silent one does not. Fix: Route the archiveMode "Désactiver" through the same confirm dialog (with copy naming what deactivation does), or show a toast with an undo affordance.
6. Table & filter search fields are labelled only by placeholder, and results aren't announced — Medium · FORM-01 / MSG-01
Where: components/app/tables/TableData.vue:44-51 (main search BccInput placeholder="Rechercher…"); components/app/tables/FilterDrawer.vue:62-67 (enum-option search) and the drawer's checkbox filters. What: The search field (this feature's forms/search-field pattern) has a placeholder as its only label — no <label>/aria-label — and typing filters the table with no aria-live/role="status" announcement of the new result count. The consulted search-field pattern lists a label/hint and a search-status region as required anatomy and calls out announcing "loading/completion". Why it matters: Placeholder text disappears on input and is not a reliable accessible name; screen-reader users get no feedback that the list narrowed or emptied. Shared across every TableData list, so fixing it once is high-leverage. Fix: Add an accessible name to the search input (visually-hidden <label> or aria-label="Rechercher") and an aria-live="polite" node reporting "N résultats". Best fixed in the shared TableData, not per view.
Project-level defects this feature inherits (referenced, not re-filed — see PROJECT-LEVEL.md)
- A11Y-03 —
TableDatasort headers, row-to-edit and the row context menu are mouse-only (right-click is the sole path to "Désactiver" here). Project-wideTableDatacluster. - MSG-06 —
TableData:129-140never readsisError; a failed list load renders as « Aucun élement ». Project-wideTableDatacluster. - Filter panel —
FilterDrawerfocus handling is broken (no focus trap / return; opens without moving focus). Project-wideTableDatacluster. - NAV-03 — fails project-wide;
admin/index.htmlsets one static « BCC Nancy Admin » for all routes. - CONTENT-01 — not-applicable — see project-level i18n finding (French literals by design).
Unverified
- A11Y-01 (contrast): faint
text-neutral-400/text-neutral-500counts, placeholders and the drawer facet counts are not measurable from code. - A11Y-06 (responsive/short viewport): the sticky header row, the right-side filter drawer (
max-w-sm, full-height) and the year-picker toolbar wrapping need a rendered viewport. - A11Y-05 name derivation: whether
@bcc-codeBccButtonauto-names from:icon—node_modulesnot installed. - DATA-01 (undeclared
suborgprop): not-applicable — admin SPA route, mounted by the router, not by a host page passing mount attributes. - Client-side full-list diff:
CompareMembershipsdiffs whole fetched arrays; if/api/adhesions-bcc?year=…is ever server-truncated the diff would silently mis-count. Notake/limit is visible at the call site, so recorded as a caveat rather than a finding.
Baseline additions
- none — every finding maps to an existing rule or the already-proposed
MSG-06cluster (failed fetch must not render as authoritative empty/derived data). Finding 1 is a notable instance: MSG-06 usually surfaces as an empty state; here it surfaces as a wrong derived comparison, which is worse and worth citing when the cluster is reconciled.
Cross-project note
Findings 2, 3, 4, 6 are shared-component / composition shapes likely present wherever the same stack repeats: B-Active memberships (#15) reuses CompareMemberships and TableData identically — expect the same unlabelled year selects, swap button, double-<h1> and search-label gaps there. The placeholder-only search label (Finding 6) and MSG-05 archive-no-confirm (Finding 5) recur across every members admin list that uses TableData with searchable/archive-mode. The MSG-06 "derived-from-empty" variant (Finding 1) is worth checking in customer-portal and tt-time-tracker dashboards, which also compute comparisons/aggregates from fetched lists without an error branch.