Appearance
[UX] members — My memberships (BCC)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 23 Patterns:content-management/accordion,forms/checkbox,forms/form-validation
Summary
The BCC membership widget is a consent-capture form, and its two weakest points are exactly the two that matter for consent: the terms a member is agreeing to can be unreachable (the cotisation PDF link only resolves for 2025 and 2026, and the checkbox that accepts it is nested inside the accordion header button, so keyboard and screen-reader users may not be able to reach either), and a failed save produces no feedback of any kind — the widget contains zero role="alert", role="status" or aria-live markup anywhere (grep across widgets/src/ returns none). On top of that, one validation rule can block saving through a field the user cannot even see. Most of these live in shared code (useMembershipsBase, Condition.vue, SelectField.vue, Toast.vue), so the B-Active widget (queue #4) inherits them one-for-one.
Findings
1. A blocked save does nothing at all — no message, no toast, no scroll — and can be blocked by a hidden field — Blocker · MSG-01
Where: widgets/src/widgets/my-memberships/stores/bcc.store.ts:40-43, widgets/src/widgets/my-memberships/composables/useMembershipsBase.ts:119-155, widgets/src/widgets/my-memberships/components/MembershipBCC.vue:13-16What: The row schema makes Allergies required when Allergique is true (.when('Allergique', { is: true, then: schema.required("Veuillez préciser vos allergies") })). submit is handleSubmit(...), so when that rule fails vee-validate never runs the callback — and nothing renders memberships[i].Allergies's error. The textarea at MembershipBCC.vue:15 is bound with plain v-model="model.Allergies", not useField, so no errorMessage exists to display; there is no form-level validation summary either. isSubmitting is only set inside the callback (useMembershipsBase.ts:120), so not even the ConfirmToast overlay appears. The result of clicking « Sauvegarder » is literally nothing. It gets worse: the rule is not conditioned on Est_membre, but the whole allergies block is inside <template v-if="isMember"> (MembershipBCC.vue:4-23). A member who answers "J'ai des allergies alimentaires = Oui", leaves the detail blank, then sets "Je souhaite être membre = Non" is now blocked by a required field that is no longer rendered anywhere on the page. The same is true for any family member whose accordion panel is collapsed — the invalid row is off-screen. Why it matters: The member presses the only button in the widget and gets zero response. There is no way to discover the cause without dev tools, and in the hidden-field case no way at all. Allergy data is safety-relevant, so the failure lands on the users most likely to enter it. forms/form-validation's reference implementation carries an explicit <p id="form-status" role="status" aria-live="polite"> for precisely this case. Fix: (a) render field errors next to their fields — bind the allergies textarea through useField('memberships[' + i + '].Allergies') or pass the errorMessage down from the field array; (b) add a form-level status region (role="status" / role="alert") in MyMembershipsBCC.vue that names the invalid rows and expands their accordion panels on failed submit; (c) scope the Allergies requirement to Est_membre === true in the yup schema so an unreachable field can never block the form.
2. The cotisation rules a member must accept are only linked for 2025 and 2026 — High · FORM-12 (proposed)
Where: widgets/src/widgets/my-memberships/components/ConditionsBCC.vue:4,26-29What: :href="links[session.year]" reads a hardcoded two-entry map. For any other year — 2027 onwards, or a host page that omits/mistypes the year attribute, which makes session.year NaN (session.store.ts:24) — the expression is undefined, Vue drops the attribute, and <a class="underline"> renders as underlined text that is not clickable, not focusable and gives no sign that it was ever meant to be a link. The checkbox next to it still says « J'ai pris connaissance du règlement des cotisations {{ year }} » and is still required before saving. Why it matters: The member is required to attest they have read a document the interface will not show them, and the failure is silent — it looks like plain text, not a broken link. That is a consent record the association cannot rely on. Fix: Serve the terms URL per year from the API (it already serves the lock date via /api/widget/adhesions-lock) rather than a client-side literal map. If a URL is genuinely unavailable, do not render a checkbox that claims the document was read — show a blocking message instead.
3. The consent checkbox and the terms link are nested inside the accordion header — High · A11Y-03
Where: widgets/src/widgets/my-memberships/components/Condition.vue:8-12What: <BccAccordionHeader> wraps <Checkbox> whose slot contains the condition text — and, for the first two conditions, an <a href target="_blank"> (ConditionsBCC.vue:4,8). PrimeVue 4's AccordionHeader renders a native <button> (at minimum an element with role="button" and its own tab stop), so a checkbox input and a link end up as interactive descendants of a button. That is invalid HTML and an unreliable a11y contract: Space/Enter on the header toggles the panel rather than the checkbox, and the button's accessible name swallows the whole condition sentence including the link text. The mouse path is patched with @click.stop on the checkbox (line 9); the keyboard path is not patched at all. Caveat: node_modules is not installed in this checkout, so I could not confirm the exact element BccAccordionHeader emits — the nesting of interactive content inside the header is visible in the source either way.Why it matters: Keyboard and screen-reader members may be unable to tick the consent boxes or open the terms — which means they cannot save at all, since the Save button is gated on all three (finding 5). Fix: Take the checkbox out of the header. Render the condition as checkbox + label, with a separate, small « En savoir plus » disclosure trigger beside it (aria-expanded + aria-controls) for the help text. This is a Condition.vue change, not an upstream one — the BCC library is being used outside its contract here, not failing to provide something.
4. The terms of the RGPD consent are collapsed behind the consent control itself — High · FORM-13 (proposed)
Where: widgets/src/widgets/my-memberships/components/ConditionsBCC.vue:10-17, widgets/src/widgets/my-memberships/components/Condition.vue:1-20What: The visible statement is one line — « J'autorise BCC Nancy à conserver mes informations personnelles de manière confidentielle ». Everything that makes it meaningful (purpose, the named recipients — Bureau, Référent Santé, Responsables des activités — the five-year retention period, and the rights and contact address) sits in the #help slot, inside a BccAccordion rendered with :value="null", i.e. collapsed on load. The only affordance to open it is the same header the checkbox lives in, and there is no "more info" cue. content-management/accordion's own "When not to use" list names this case explicitly: do not put legally required information (Terms and Conditions) in an accordion — use popovers, modals or side panels. Why it matters: Consent is recorded (Conditions_acceptees = true, bcc.store.ts:52) against terms most members will never have seen. This is the one thing the widget exists to do, and it is the part most easily ticked blind. Fix: Show the RGPD notice inline, always visible, above or beside its checkbox — it is ~90 words. Keep the accordion for genuinely optional help (the cotisation "have you filled the November form" note is a fair use of it).
5. Every select and both textareas are unlabelled for assistive tech — High · FORM-01
Where: widgets/src/widgets/my-memberships/components/SelectField.vue:3-4, widgets/src/widgets/my-memberships/components/MembershipBCC.vue:3,5,9,12,14-15,19-21What: SelectField renders <label><slot /></label> with no for, and Selectbox (components/Selectbox.vue:2-11) passes no inputId / labelId / aria-labelledby to BccSelect. All four BCC questions — membership, image rights, fitness to participate, allergies — are therefore comboboxes whose only accessible name is their own value. The allergies textarea has <label for="allergies"> against <BccTextarea name="allergies" /> (MembershipBCC.vue:14-15) — name is not id, so unless the library forwards it (unconfirmed: node_modules is not installed) the association is dead. The « Autres renseignements » textarea (line 21) has no <label> at all, only an <h2> and a <span>. The image-rights clarification at line 8 is likewise unlinked (aria-describedby). Why it matters: A screen-reader member hears "combobox, Oui" four times over with no way to tell which question is which — and one of them decides whether BCC may publish their image. forms/checkbox lists "Poor Label Association" as a named anti-pattern for the same reason. Fix: Fixable in this repo, not upstream: Checkbox.vue already proves the library accepts input-id. Generate an id in SelectField/Selectbox with useId(), put it on the label's for and pass it as input-id (or aria-labelledby), give both textareas real ids, and wire the two helper paragraphs with aria-describedby.
6. A failed mount leaves a skeleton forever, with no error and no retry — High · MSG-03
Where: widgets/src/widgets/my-memberships/MyMembershipsBCC.vue:82-86, widgets/src/widgets/my-memberships/composables/useMembershipsBase.ts:73-100What: onMounted awaits session.loginApi(), family.get() and memberships.initialize() with no try/catch. apiGet throws on any non-2xx (api.ts:32), and loading is only ever set false on the success path (useMembershipsBase.ts:99). An expired widget token, a 500, or the member's browser being offline therefore leaves BccSkeleton (line 7) rendered indefinitely. The rejection surfaces only as an unhandled promise in the console. This is an embedded widget inside someone else's host page, so there is no app shell or route-level error boundary to catch it — and unlike my-events, this widget ships no EventNotFound.vue equivalent. Why it matters: The member sees a grey bar on the BCC website and no way to know whether to wait, reload, or call someone. The widget already has good error copy for the submit path (ConfirmToast.vue:18-21, which names a contact) — the load path has none. Fix: Wrap the onMounted chain in try/catch, add a loadError state, and render the same BccMessage/contact treatment used for submit failures, with a « Réessayer » button that re-runs initialize(). Also give the skeleton a visible « Chargement de ton adhésion… » label (CONTENT-04).
7. Save is disabled until all three conditions are ticked, and never says why — Medium · FORM-05
Where: widgets/src/widgets/my-memberships/MyMembershipsBCC.vue:51,90-93What: :disabled="!allConditionsAccepted". On mobile the conditions column is stacked below the members accordion (grid-cols-1 lg:grid-cols-2, line 17), so the member sees a dead button in a sticky footer with the reason off-screen. A side effect: because the click can never fire while conditions are unticked, the per-condition error styling and message in Condition.vue:19,25,45-48 ("Les conditions doivent être acceptées") is unreachable code — the red state never renders. Why it matters: A disabled control gives the user nothing to act on and no explanation. It also means the one place the form does have proper inline validation messaging is switched off. Fix: Keep the button enabled, validate on click, and on failure announce and scroll to the unticked conditions — which also switches the existing Condition.vue error state back on.
8. Pending change requests are invisible and cannot be withdrawn; edits appear to revert — Medium · MSG-04
Where: widgets/src/widgets/my-memberships/MyMembershipsBCC.vue:34-37, widgets/src/widgets/my-memberships/composables/useMembershipsBase.ts:143-148, widgets/src/widgets/my-memberships/composables/useAdhesionLock.ts:24-33What: After the lock date, edits are filed as change requests and submit then calls initialize(), which refetches and overwrites the form with the stored server values. So the member's changes visibly disappear from the form seconds after saving; the only trace is a « Demande en attente » tag and a toast that auto-dismisses in five seconds. There is no way to see what was requested, and no cancel affordance — even though the API exposes one (DELETE /api/widget/adhesion-change-requests/:id, widget-adhesions.controller.ts:266) and useAdhesionLock never wires it up. Why it matters: Watching your edits revert is indistinguishable from the save having failed, and the member cannot correct or withdraw a mistaken request without emailing an admin. Fix: Render the pending payload (a diff, or "demandé : Droit image → Non"), and add a « Annuler ma demande » control calling the existing DELETE endpoint.
9. Resigning membership is a silent one-click action that also discards an answer — Medium · MSG-05
Where: widgets/src/widgets/my-memberships/components/MembershipBCC.vue:3,33-37What: Setting « Je souhaite être membre de BCC Nancy » to Non hides the rest of the form and, on save, patches Est_membre: false — the member has resigned, with no confirmation and no statement of what that means (dues? activities? access?). The watch(isMember) at lines 33-37 additionally wipes Droit_image without telling anyone; a member who toggles Non then Non→Oui again silently loses their image-rights answer and must notice it has gone blank. Allergies and Renseignements are kept in the payload but hidden from view. Why it matters: An irreversible-feeling status change gets less friction than a checkbox, and previously supplied consent data is destroyed without notice. Fix: Confirm the switch to Non, spelling out the consequence; and either keep Droit_image (it is already ignored server-side when not a member) or say it will be cleared.
10. Copy, headings and timing polish — Low · A11Y-04, NAV-02, A11Y-05
Where: widgets/src/components/Toast.vue:41-43,15-18, widgets/src/components/Container.vue:4, widgets/src/widgets/my-memberships/components/MembershipBCC.vue:11,19, widgets/src/widgets/my-memberships/MyMembershipsBCC.vue:8-11, widgets/src/widgets/my-memberships/components/ConditionsBCC.vue:4,8What: (a) Toast.vue sets a 5s setTimeout in onMounted with no onUnmounted(clearTimeout) — NAV-02, and it dismisses the error toast on a timer too. (b) Heading levels: Container renders the widget title as <h2> and MembershipBCC uses <h2> again for its subsections — they should be <h3> (A11Y-04; no <h1> is correct here, the widget is embedded in a host page). (c) The toast close button's only accessible name is « Close », English in an otherwise entirely French UI (A11Y-05). (d) The empty state reads « Ce formulaire est réservé aux personnes majeures », but the actual trigger is family.members.length === 0, which happens whenever session.data.Famille_parent is null (family.store.ts:17) — including for an adult still recorded as a child of their parents' family, or a member with no family record at all. The copy asserts a cause that is often wrong; the contact line rescues it, so this is only a copy fix. (e) The two terms links are target="_blank" with no new-window cue and no rel="noopener noreferrer". Fix: Clear the timeout on unmount and do not auto-dismiss errors; demote the subsection headings; translate « Close » to « Fermer »; reword the empty state to describe the state rather than guess the cause (« Aucune adhésion à afficher pour ton compte »); add rel and a new-window indication.
Unverified
- A11Y-01 (contrast) — needs computed colour values. The condition tiles use
bg-brand-100/border-brand-200andtext-neutral-400/text-neutral-500helper text (Condition.vue:45-48,MyMembershipsBCC.vue:29,MembershipBCC.vue:8,20); small grey-on-white helper text is the usual offender. Not asserted. - A11Y-06 (responsive / short viewport) — needs a rendered page, and this one is unusually exposed:
ConfirmToast.vue:50positions the overlay with-boundingY + windowHeight/2 - 60, computed from the host page's geometry via an injecteduseElementBounding. In a short viewport, or when the widget is far down a long host page, the toast can land off-screen. Worth a manual check on a real BCC page. - A11Y-02 (24×24 targets) —
BccCheckbox's rendered size is library CSS (PrimeVue's default is 20px, which would fail), butnode_modulesis not installed in this checkout so I could not read it. If it fails, the fix is upstream in@bcc-code/component-library-vue. BccAccordionHeader's rendered element andBccTextarea's handling ofnamevsid— same reason. Both affect findings 3 and 5; the nesting and thefor/namemismatch are visible in this repo's source regardless.- CONTENT-01 — not-applicable, see project-level i18n finding.
Baseline additions
Two proposed rules, both generalising beyond this feature (any project with a terms-acceptance step):
| ID | Proposed rule |
|---|---|
| FORM-12 | A consent control links to the exact document being agreed to, and that link resolves for every value the page can render (every year, locale, plan, tenant). A consent checkbox whose document is unreachable must not be presented as accepted-in-good-faith — block instead. |
| FORM-13 | Terms the user must accept are legible at the point of consent — not collapsed behind a disclosure, and never behind one whose trigger is the consent control itself. Optional help may be progressively disclosed; the thing being agreed to may not. |
Candidate third (seen here, but only once so far — hold until a second project shows it): a silent-validation-failure rule — "a submit that is refused by client-side validation must produce at least one visible, announced message, and must reveal the offending field even if it is inside a collapsed or conditionally rendered region." Finding 1 is currently written against MSG-01.
Cross-project note
- members / My memberships — B-Active (queue #4) is not a hypothesis, it is the same code.
MyMembershipsBActive.vueis a near-copy (identical accordion, identical empty-state copy at lines 8-11, same sticky Save footer), and both stores are thin wrappers overuseMembershipsBase. Findings 1, 3, 4, 5, 6, 7, 8, 9 and 10 apply verbatim;Condition.vue,SelectField.vue,Selectbox.vue,Checkbox.vue,Toast.vueandConfirmToast.vueare literally shared. Fix once, verify twice. The other four widgets shareToast/ConfirmToastand the unguardedonMountedchain, so findings 6 and 10a likely span all six. - Silent client-side validation failure (finding 1) is worth probing in all three other projects — playout uses FormKit (which surfaces field errors by default, so likely a pass), while customer-portal (PrimeVue + hand-rolled) and tt-time-tracker (PrimeVue 4) are hand-wired and plausibly share it.
- Unlabelled selects (finding 5) — customer-portal and tt-time-tracker both use PrimeVue
Select, which has the sameinputId/labelIdrequirement. Likely the same defect wherever a wrapper component renders a bare<label>. - No live region for status/errors (
MSG-01) — zeroaria-live/role=alertin this entire widget bundle. Cheap to grep for in the other three; if they are also empty, this is an alignment issue rather than three separate bugs.