From 6e944ac9e95f4f9cdcecd5c40a7a11d72ac621ae Mon Sep 17 00:00:00 2001 From: Shevchik Igor Date: Tue, 8 Sep 2026 04:43:19 +0000 Subject: [PATCH 1/2] fix(FormField): announce the blocks that rendered, not the props that were set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template renders each descriptive block from `props.x || !!slots.x`, while `useFormField` built `aria-describedby` from the props handed down through `provide`. Slots were not in that context at all, so the two conditions disagreed in both directions: a slot with no prop was text on screen that nothing announced, and a prop whose block never drew left the attribute naming an id that was not in the document — `hint` without a label to live in, `help` behind a rendered error, and `error: true`, which marks the field invalid without a message the error block could show. `FormField.vue` is the only scope where props and slots are both visible, so the ids are computed there and passed through the injected context as `describedBy`. The template binds the same `hasLabel` / `hasError` computeds that feed it, so the two cannot drift apart again. `useFormField` prefers that list and keeps the props-derived one as a fallback, since `formFieldInjectionKey` and `FormFieldInjectedOptions` are both published and a custom control may provide a context without it. `aria-invalid` is deliberately unchanged. It reports the field's error state, and the `#error` slot's `v-if` is satisfied by the slot merely existing — so keying invalidity off the rendered block would mark every field invalid for anyone who supplies the slot as markup. The six characterization tests added in #496 to pin this behaviour fail as intended and are rewritten; a seventh covers the `error: true` direction, which nothing had. Each of the four conditions in `describedBy` was mutated separately to confirm it is checked. Upstream still carries the defect at v4 HEAD, so this is a deliberate divergence: recorded as a §2 invariant in `.sync/PORTING.md`, with the docs page and the `skills/` forms guideline rewritten to match. Closes #497 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc --- .sync/PORTING.md | 22 +++++ docs/content/docs/2.components/form-field.md | 77 ++++++++-------- .../references/guidelines/forms.md | 44 ++++++---- src/runtime/components/FormField.vue | 29 ++++++- src/runtime/composables/useFormField.ts | 11 ++- src/runtime/types/form.ts | 6 ++ test/components/FormField.spec.ts | 87 ++++++++++++------- 7 files changed, 184 insertions(+), 92 deletions(-) diff --git a/.sync/PORTING.md b/.sync/PORTING.md index 69e1b959e..b60df2f26 100644 --- a/.sync/PORTING.md +++ b/.sync/PORTING.md @@ -539,6 +539,27 @@ material. Reproduce its *intent* in b24ui by editing files under `src/` only. `test/utils/tag-width-cap.spec.ts`, which sweeps every theme file for an absolute `max-w` on any tag- or item-named slot, nested size variants included. +- **`FormField` announces the blocks it drew, not the props it was given.** + Upstream builds `aria-describedby` in `useFormField` from + `formField.value[type]` — the props handed down through `provide` — while the + template renders each block from `props.x || !!slots.x`. The two disagree in + both directions: a slot with no prop is text on screen that nothing + announces, and a prop whose block never draws (`hint` without a label, + `help` behind a rendered error, `error: true` with no message) leaves the + attribute naming an id that is not in the document. b24ui computes the ids in + `FormField.vue`, where slots are in scope, and passes them through the + injected context as `describedBy`; `useFormField` prefers that list and keeps + the props-derived one only as a fallback for a context provided by hand, since + `formFieldInjectionKey` and `FormFieldInjectedOptions` are both published. + The template binds the same `hasLabel` / `hasError` computeds it feeds, so the + two cannot drift apart again. Upstream still carries the defect at v4 HEAD, so + a faithful port of any commit touching these two files silently reverts the + fix. Guarded by `test/components/FormField.spec.ts` — seven cases, each + verified to fail with the fix removed. `aria-invalid` is deliberately + **unchanged**: it reports the field's error state, and the `#error` slot's + `v-if` is satisfied by the slot merely existing, so keying invalidity off the + rendered block would mark every field invalid for anyone supplying the slot + as markup. ## 3. Examples (before → after) @@ -874,3 +895,4 @@ same bug facing the other way and needs reading rather than diffing. - 2026-08-30 — normalised the ledger's `decision` vocabulary and gave it a guard (`test/utils/sync-ledger.spec.ts`). Found by a routine sync check, not by anything red: tallying `.sync/nuxt-ui.json` whole showed 50 entries spelling a verdict `no-op` and 14 spelling it `noop`, interleaved across the same period — `f2ff8241` and `b0461e72` are twenty entries apart and disagree. Neither form is wrong on its face, which is why it survived: the drift is invisible in a one-entry diff and only appears when the file is counted, and no reviewer counts a 280-entry data file. It is not cosmetic. The ledger's whole purpose is being queryable after the fact — the §1 component-name rule exists because one such query was answered wrongly — and a query written against `no-op` was quietly missing a fifth of them. The 14 are now `no-op`, the four-value vocabulary is written down in §6 step 4, and the guard checks it along with the other invariants that were likewise unenforced: full-SHA keys, a `cursor` that is one of the processed commits, no entry still carrying `pending-merge`, and a non-empty `summary` on every entry. Writing that guard turned up a second drift and pinned it on this session: `pr` is a **number** in all 277 older entries and was a string in exactly the three #511 added an hour earlier, which `vue-tsc` caught the moment the file was typed at all — `pr === 509` does not match `"509"`, and a sort orders one as text. Those three are now numbers. `b24ui_sha` splits the same way, 155 abbreviated against 125 full, and is deliberately **left** split: both forms resolve under `git show`, which is the field's only use, so the assertion checks 7-to-40 lowercase hex — a value that resolves to nothing — rather than a uniformity worth 280 rows of diff. Ten mutations verified, each failing exactly one assertion. Two findings from the same audit were deliberately **not** fixed in that PR: four entries at the very start of the ledger (`2799fa6f`, `631f5dc5`, `6102a87b`, `007b136a` — PRs #68–#72) have no `.sync/log/.md`, predating the convention by one commit, and a "every entry has a log" guard was deferred with them, since it would have gone red on exactly those four. Both were done on maintainer instruction the same day — see the next entry. Last reviewed: 2026-08-30. - 2026-08-30 — backfilled the four missing `.sync/log/` files and closed the guard that was deferred with them, on maintainer instruction. The reasoning against doing this was that writing a rationale months later is reconstruction rather than record; what changed the answer is that the record turned out to still exist in full — both commits are readable on both sides, and three of the four fork commits carry unusually detailed messages that state the reasoning contemporaneously. So every log is derived from `git show` on the upstream commit and on ours, plus the fork commit's own message, and each says at the top that it was backfilled and from what. Nothing is recalled. Re-deriving them was worth more than the tidiness: it turned up a real omission nobody had recorded — `2799fa6f` added an autocomplete-mode row to upstream's InputMenu playground, the port dropped it, and **neither playground has one to this day**, so the mode cannot be exercised by hand. It also made two ports legible that the one-line summaries flattened. `631f5dc5` looks like a verbatim prop-list copy and is not: upstream's list contains `loadingIcon` and ours must not, because this fork does not carry that prop at all (`CommandPalette.vue:93`, `@memo not use loadingIcon`) — taking the list verbatim would have forwarded `undefined` silently. `007b136a` is the opposite of verbatim: upstream adds a popper cap to a `content` slot that already had `flex flex-col`, while here the equivalent cap was on `viewport`, so the port had to *move* it — keeping the fork's own ceilings rather than upstream's `15rem`, adding a `var(…, 100vh)` fallback that upstream's bare `max-h-(--reka-…)` form does not need (inside `min()`, an unset variable invalidates the whole declaration instead of merely not capping — which is exactly `Select` with `position="item-aligned"`, where reka never publishes the height), and fixing two transform-origin namespace bugs that were ours alone. That is also why it is marked breaking here and is not upstream. The guard now asserts the pairing in **both** directions plus a floor on file size: a missing log is the obvious failure, but an orphaned log is what a mistyped SHA in a filename looks like, and an empty file satisfies pairing while documenting nothing. Three mutations verified. Deliberately not guarded: the heading format, which varies across the 280 files (`# no-op — nuxt/ui@` and `# Port: ` both occur) and where a rule would be invented rather than enforced. Last reviewed: 2026-08-30. - 2026-09-01 — ports of `9c99bf16`…`20c1954d`, and a correction to the ledger guard added a day earlier in #515. That guard asserted "no entry carries `pending-merge`", which is a rule this file's own §6 step 4 contradicts: the entry is written in the same commit as the port, before its PR has a number or a squash SHA, and is reconciled by a later bookkeeping PR. It went red on the very next port — this one — and as written would have blocked the documented process rather than guarding it. The real invariant is ordering, since reconciliation happens in sequence: pending entries must form a **suffix**, and one sitting behind a reconciled entry means a bookkeeping PR was skipped. That is the failure worth catching, and it is the one that is otherwise invisible, because a stranded entry looks complete and says nothing false. Three mutations verified — a pending entry in the middle goes red, the current three-entry tail stays green, a fully reconciled ledger stays green. Worth generalising: a guard written while the tree happens to be in one state encodes that state rather than the rule, and the way to tell the difference is to ask what the next ordinary change looks like. Two method notes from the same run. **Counting installed packages with `find node_modules/.pnpm` is wrong** — the store keeps directories from earlier installs, so a version no longer resolved still appears; it reported an `@nuxtjs/mdc@0.22.2` the lockfile did not contain, and the conclusion had to be re-derived from `pnpm-lock.yaml`, which is the resolution. **And an upstream removal is not a reason to remove.** `9c99bf16` drops the `@nuxt/content>@nuxtjs/mdc` override, which upstream can do having moved to content 3.16.0; here removing it demonstrably resolves two mdc copies, because the root peer `@nuxt/content: ^3.0.0` — deliberately wide — sits at 3.14.0. The override stays. Its sibling removal, the spent `minimumReleaseAgeExclude` entry, was taken, on the opposite evidence: the supply-chain check passes without it. Last reviewed: 2026-09-01. +- 2026-09-07 — fix of #497 (PR pending): added the §2 **`FormField` announces the blocks it drew** invariant. Filed rather than fixed in August because the component and the composable were line-for-line identical to upstream; re-checked at upstream v4 HEAD (`970025f`) before touching anything, and they still are, so this is now a recorded divergence rather than a porting slip. Worth noting what "identical to upstream" bought: the issue's own measurements were taken in #496, the behaviour was pinned by six characterization tests written to fail when it was fixed, and that is exactly what happened — 66 failures per project on the first run, every one of them a test that existed to say "this is wrong on purpose". The seventh case was found while fixing: `error?: boolean | string`, and `error: true` marks the field invalid without a message, so the error block — which needs a string — never draws while the attribute named it anyway. Nothing covered that direction. Each of the four conditions in `describedBy` was mutated separately, because a guard checked at one value is not a guard checked: dropping the `hasLabel` term reds exactly one test, dropping the `!hasError` term reds two, replacing the whole list reds all seven. The `aria-invalid` half was measured and deliberately **not** changed — see the invariant for why. Docs and the `skills/` forms guideline both described the old behaviour in detail and were rewritten with it, including a section rename (`Slots do not replace their props` → `What is announced is what was drawn`) and its two in-page anchors. Last reviewed: 2026-09-07. diff --git a/docs/content/docs/2.components/form-field.md b/docs/content/docs/2.components/form-field.md index 07a7f67f4..6b5644c89 100644 --- a/docs/content/docs/2.components/form-field.md +++ b/docs/content/docs/2.components/form-field.md @@ -118,10 +118,12 @@ translation: ``` -`hint`, `description`, `help` and `error` take a slot the same way — and, like -`#label` above, each wants its prop set alongside it. [Slots do not replace -their props](#slots-do-not-replace-their-props) is why. The `required` asterisk -is drawn on the `