Put Settings on NLDD's form system #201

Merged
robbertbos merged 1 commit from settings-nldd-forms-impl into main 2026-07-26 19:51:28 +00:00
Owner

Every Settings panel expressed a field, a label, a hint and an error in its own way. This gives them one shape, taken from the design system.

The shape

<nldd-form-field label="Zichtbaar label" :optional="true">
  <nldd-text-field ... />
  <nldd-form-field-help-text>Hint die bij dit veld hoort.</nldd-form-field-help-text>
</nldd-form-field>

plus one panel-level error surface, <nldd-banner variant="critical" :text="error" />.

Three rules make that a single skeleton rather than a family of them. The label lives in label on the field, never a sibling <p> and never a <label for> pointing at a custom element. The hint lives in nldd-form-field-help-text as a child of the field. Errors are panel-level and go through nldd-banner, which sets role="alert" and aria-atomic itself instead of four panels spelling it out in three different ways.

Defects this fixed along the way

  • Hints were sighted-only. ExportPanel wired its hint with aria-describedby pointing at an id on an nldd-checkbox-field host; an IDREF cannot cross a shadow boundary, so nothing was ever announced. PreferencesPanel used supporting-label, which renders inside the shadow root with no ARIA pointing at it. As help-text children, form-field.js writes the describedby itself.
  • A failed save in Voorkeuren was completely silent. usePutSettings has no onError and there is no submit button. It now has a permanently mounted role="status" region and a critical banner. That surfaced a second bug: saveTheme applied the theme before the PUT resolved and the theme store persists to localStorage with no revert, so a failure would have said "Opslaan mislukt" while the app was already re-themed.
  • A name voice control could not say. The import diff table's row checkbox had aria-label="Toepassen: theme" while the visible label read "Toepassen: Thema" (WCAG 2.5.3).
  • A focus move that never ran. AccountDeletePanel focuses the first consent checkbox on open. The native input sits two shadow roots deep (nldd-checkbox-field > nldd-checkbox > input), so the one-level query returned null.
  • The confirm-your-email field was spellchecking the address. spellcheck is not a declared prop on nldd-text-field; it renders spellcheck from noSpellcheck.
  • A sentence announced twice. The VERVANG ALLES confirmation had the same text as a visible <p> and again as accessible-label.

Costs, stated rather than patched around

  1. "(optioneel)" leaves the accessible name. form-field forwards only label to accessible-label, so "Basis-URL (optioneel)" becomes "Basis-URL" plus a visual badge. The label has no parts and no slot, so there is no supported way to add the word back. optional-label="(optioneel)" is not a fallback. Accept it or drop that edit.
  2. VERVANG ALLES loses its <strong>. label is a plain string attribute. If the bold is judged essential for a destructive confirmation, drop that edit rather than splitting the sentence across label and supporting-label.
  3. Error text is now dark and bold, not red. The red icon and tinted background carry the semantics. Screenshot checked; it reads well, but a reviewer expecting red text may read it as a bug.
  4. Test coverage lost. Error strings move from slotted content to a text attribute, so role="alert" is no longer asserted anywhere in the suite. The stub does not boot, so there is nothing to assert against.

Verified

  • vue-tsc clean, 540 tests green on a cold vite cache (rm -rf node_modules/.vite), build green, eslint 0 errors
  • Settings viewed in a running preview. Two things the spec could not settle from source are confirmed: the empty form-field header collapses to zero height, and the reserved status strip is not distracting when idle
  • The critical banner triggered and screenshotted

PreferencesPanel gets the unit test it never had: status region, failure banner, and both save handlers.

Deliberately out of scope

Field-level error association (422 -> Token, 409 -> Basis-URL) and converting ImportPanel's recovery-key field. Both were proposed and both were refuted on mechanism: form-field.js computes shouldShow = isInvalid && referencedIds.includes(el.id), so without error-message="<id>" plus a matching id the message renders invisible and no describedby reaches the input. The status-only error computed also returns null for a network failure, which would fail silently where today it says "Opslaan mislukt." Both are worth doing with that correction; neither belongs in this PR.

Also out: nldd-form (no submit anywhere in Settings), nldd-form-section (renders its title as a <legend>, skipped by heading navigation and re-announced on every field entry), and nldd-segmented-control for Thema (fits on the merits, but would leave Sync-interval a dropdown - a product decision across all Settings choice controls at once).

Every Settings panel expressed a field, a label, a hint and an error in its own way. This gives them one shape, taken from the design system. ## The shape ```html <nldd-form-field label="Zichtbaar label" :optional="true"> <nldd-text-field ... /> <nldd-form-field-help-text>Hint die bij dit veld hoort.</nldd-form-field-help-text> </nldd-form-field> ``` plus one panel-level error surface, `<nldd-banner variant="critical" :text="error" />`. Three rules make that a single skeleton rather than a family of them. The label lives in `label` on the field, never a sibling `<p>` and never a `<label for>` pointing at a custom element. The hint lives in `nldd-form-field-help-text` as a child of the field. Errors are panel-level and go through `nldd-banner`, which sets `role="alert"` and `aria-atomic` itself instead of four panels spelling it out in three different ways. ## Defects this fixed along the way - **Hints were sighted-only.** ExportPanel wired its hint with `aria-describedby` pointing at an id on an `nldd-checkbox-field` host; an IDREF cannot cross a shadow boundary, so nothing was ever announced. PreferencesPanel used `supporting-label`, which renders inside the shadow root with no ARIA pointing at it. As `help-text` children, `form-field.js` writes the describedby itself. - **A failed save in Voorkeuren was completely silent.** `usePutSettings` has no `onError` and there is no submit button. It now has a permanently mounted `role="status"` region and a critical banner. That surfaced a second bug: `saveTheme` applied the theme before the PUT resolved and the theme store persists to `localStorage` with no revert, so a failure would have said "Opslaan mislukt" while the app was already re-themed. - **A name voice control could not say.** The import diff table's row checkbox had `aria-label="Toepassen: theme"` while the visible label read "Toepassen: Thema" (WCAG 2.5.3). - **A focus move that never ran.** AccountDeletePanel focuses the first consent checkbox on open. The native input sits two shadow roots deep (`nldd-checkbox-field` > `nldd-checkbox` > `input`), so the one-level query returned `null`. - **The confirm-your-email field was spellchecking the address.** `spellcheck` is not a declared prop on `nldd-text-field`; it renders `spellcheck` from `noSpellcheck`. - **A sentence announced twice.** The VERVANG ALLES confirmation had the same text as a visible `<p>` and again as `accessible-label`. ## Costs, stated rather than patched around 1. **"(optioneel)" leaves the accessible name.** `form-field` forwards only `label` to `accessible-label`, so "Basis-URL (optioneel)" becomes "Basis-URL" plus a visual badge. The label has no parts and no slot, so there is no supported way to add the word back. `optional-label="(optioneel)"` is not a fallback. Accept it or drop that edit. 2. **VERVANG ALLES loses its `<strong>`.** `label` is a plain string attribute. If the bold is judged essential for a destructive confirmation, drop that edit rather than splitting the sentence across `label` and `supporting-label`. 3. **Error text is now dark and bold, not red.** The red icon and tinted background carry the semantics. Screenshot checked; it reads well, but a reviewer expecting red text may read it as a bug. 4. **Test coverage lost.** Error strings move from slotted content to a `text` attribute, so `role="alert"` is no longer asserted anywhere in the suite. The stub does not boot, so there is nothing to assert against. ## Verified - `vue-tsc` clean, **540 tests green on a cold vite cache** (`rm -rf node_modules/.vite`), build green, eslint 0 errors - Settings viewed in a running preview. Two things the spec could not settle from source are confirmed: the empty `form-field` header collapses to zero height, and the reserved status strip is not distracting when idle - The critical banner triggered and screenshotted `PreferencesPanel` gets the unit test it never had: status region, failure banner, and both save handlers. ## Deliberately out of scope Field-level error association (422 -> Token, 409 -> Basis-URL) and converting ImportPanel's recovery-key field. Both were proposed and both were refuted on mechanism: `form-field.js` computes `shouldShow = isInvalid && referencedIds.includes(el.id)`, so without `error-message="<id>"` plus a matching `id` the message renders invisible and no describedby reaches the input. The status-only error computed also returns `null` for a network failure, which would fail silently where today it says "Opslaan mislukt." Both are worth doing with that correction; neither belongs in this PR. Also out: `nldd-form` (no submit anywhere in Settings), `nldd-form-section` (renders its title as a `<legend>`, skipped by heading navigation and re-announced on every field entry), and `nldd-segmented-control` for Thema (fits on the merits, but would leave Sync-interval a dropdown - a product decision across all Settings choice controls at once).
robbertbos force-pushed settings-nldd-forms-impl from 8e20bd88b2
All checks were successful
CI / release-scripts (pull_request) Successful in 8s
security-scan / SBOM (trivy) (pull_request) Successful in 13s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 16s
security-scan / JS SCA (npm audit) (pull_request) Successful in 21s
security-scan / Python SAST (bandit) (pull_request) Successful in 25s
CI / pre-commit (pull_request) Successful in 30s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 44s
CI / backend-test (pull_request) Successful in 59s
test-build / build (backend) (pull_request) Successful in 1m2s
test-build / build (frontend) (pull_request) Successful in 1m9s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m13s
CI / e2e (pull_request) Successful in 2m57s
CI / backend-test-postgres (pull_request) Successful in 4m5s
to 4fce37613e
All checks were successful
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 9s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 11s
security-scan / JS SCA (npm audit) (pull_request) Successful in 20s
security-scan / Python SAST (bandit) (pull_request) Successful in 20s
CI / pre-commit (pull_request) Successful in 27s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 40s
CI / backend-test (pull_request) Successful in 55s
test-build / build (backend) (pull_request) Successful in 58s
test-build / build (frontend) (pull_request) Successful in 1m5s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m58s
CI / backend-test-postgres (pull_request) Successful in 4m1s
CI / release-scripts (push) Successful in 6s
security-scan / SBOM (trivy) (push) Successful in 9s
security-scan / Filesystem scan (trivy fs) (push) Successful in 13s
security-scan / JS SCA (npm audit) (push) Successful in 20s
security-scan / Python SAST (bandit) (push) Successful in 21s
CI / pre-commit (push) Successful in 33s
security-scan / Python SCA (pip-audit) (push) Successful in 41s
publish-main / build (frontend) (push) Successful in 1m23s
publish-main / build (backend) (push) Successful in 1m32s
CI / frontend-test (push) Successful in 1m34s
publish-main / build (push) Successful in 0s
CI / e2e (push) Successful in 3m56s
CI / backend-test (push) Successful in 4m9s
CI / backend-test-postgres (push) Successful in 4m35s
2026-07-26 19:47:16 +00:00
Compare
robbertbos deleted branch settings-nldd-forms-impl 2026-07-26 19:51:28 +00:00
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
robbertbos/waggle!201
No description provided.