Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .sync/PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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/<sha>.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@<sha>` and `# Port: <subject>` 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-08 — fix of #497 (PR #549): 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-08.
77 changes: 41 additions & 36 deletions docs/content/docs/2.components/form-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,12 @@ translation:
</B24FormField>
```

`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 `<label>` element, so it survives a custom `#label` slot.
`hint`, `description`, `help` and `error` take a slot the same way, and each
receives the prop it replaces — so setting the prop alongside the slot keeps
the text in one place. [What is announced is what was
drawn](#what-is-announced-is-what-was-drawn) covers what each block contributes
to the control's accessible description. The `required` asterisk is drawn on
the `<label>` element, so it survives a custom `#label` slot.

::caution
`#error` goes further than the others: the error block renders whenever an
Expand Down Expand Up @@ -156,10 +158,11 @@ Use the `#description` slot when the description needs markup — a link to the
policy the field refers to, a piece of emphasis, an inline code sample. It
replaces the content of the same `<p>` the prop fills.

**Keep the prop.** The block renders from either the prop or the slot, but
`aria-describedby` is built from the props alone — so a slot with no prop
beside it is a description on screen that no screen reader is told about. See
[Slots do not replace their props](#slots-do-not-replace-their-props).
The slot on its own is enough to be announced — `aria-describedby` names the
blocks that were actually drawn, whichever of the two drew them. Setting the
prop as well is still worth it: the slot receives it, so decorating the value
costs one interpolation instead of restating it. See [What is announced is what
was drawn](#what-is-announced-is-what-was-drawn).

::component-code
---
Expand Down Expand Up @@ -261,16 +264,18 @@ slots:

::note
The hint is a sibling of the `<label>`, not part of it, so its content does not
join the control's accessible name — unlike `#label`. With the `hint` prop set
it is announced through `aria-describedby` instead.
join the control's accessible name — unlike `#label`. It is announced through
`aria-describedby` instead.
::

::caution
**A hint needs a label**, and failing that requirement is silent in both
directions. The hint renders inside the label row, and that row is only drawn
when `label` or `#label` is present — so a `hint` on a label-less field renders
nothing at all. The control still advertises `aria-describedby="…-hint"`,
pointing at an element that was never drawn.
**A hint needs a label.** The hint renders inside the label row, and that row
is only drawn when `label` or `#label` is present — so a `hint` on a label-less
field renders nothing at all, and nothing is announced either. Silent, but at
least consistent: until
[#497](https://github.com/bitrix24/b24ui/issues/497) the control also
advertised `aria-describedby="…-hint"`, pointing at an element that was never
drawn.
::

### Help
Expand Down Expand Up @@ -317,30 +322,30 @@ slots:
:b24-input{placeholder="Enter your email" class="w-full"}
::

### Slots do not replace their props
### What is announced is what was drawn

Every one of the five blocks renders from `props.x || !!slots.x` — the prop or
the slot will do. The accessible wiring does not: `aria-describedby` is
assembled from the **props alone**, in `useFormField`, and never looks at what
was slotted.

The two halves disagree in both directions:
the slot will do. `aria-describedby` follows the same rule: it names the blocks
that ended up in the document, and only those.

| what you pass | block on screen | named in `aria-describedby` |
|---|---|---|
| `description` prop | yes | yes |
| `#description` slot, no prop | **yes** | **no** |
| `hint` prop, no `label` | **no** | **yes** |
| `#description` slot, no prop | yes | yes |
| `hint` prop, no `label` | no | no |
| `error` and `help` together | error only | error only |

So the rule for all five slots is the same, and it is the shape `#label` has
had since it was documented: **set the prop, and use the slot for markup.**
The slot receives the prop, so decorating it costs one interpolation and keeps
the two in step.
Two conditions are worth knowing, because both hide a block you asked for:
`hint` lives in the label row and needs a `label` or `#label` to exist at all,
and `help` is the `v-else-if` of the error branch, so a rendered error takes
its place. In both cases nothing is drawn and nothing is announced.

::note
This is upstream `nuxt/ui` behaviour, not a divergence in this fork — the
component and the composable are line-for-line identical at our sync cursor.
Tracked in [#497](https://github.com/bitrix24/b24ui/issues/497).
This is a deliberate divergence from `nuxt/ui`, which builds the attribute from
the props alone — so a slot with no prop went unannounced, and a prop whose
block never drew left the attribute naming an id that was not in the document.
Fixed here in [#497](https://github.com/bitrix24/b24ui/issues/497) and recorded
as an invariant in `.sync/PORTING.md` §2, so a later port does not revert it.
::

### Error and help slots
Expand All @@ -356,8 +361,9 @@ v-else-if="props.help || !!slots.help"
An `#error` slot alone satisfies that `v-if` **whether or not there is an
error**. So the block renders permanently, `help` — the `v-else-if` of the same
branch — never renders at all, and the control still reads
`aria-invalid="false"`. If `help` was set, `aria-describedby` still names it,
pointing at an element that is no longer in the document:
`aria-invalid="false"`: that attribute reports the field's error state, and
there is none. The block is described, since it was drawn, but nothing tells
assistive technology the field is invalid:

```vue
<!-- Don't: the message is always visible, `help` never is, and a screen
Expand Down Expand Up @@ -409,11 +415,10 @@ const message = computed<string | false>(() =>
| `message` | error block | help block | `aria-invalid` | `aria-describedby` |
|---|---|---|---|---|
| `false` | — | shown | `false` | `…-help` |
| `'Please enter…'` | your markup | — | `true` | `…-error …-help` |
| `'Please enter…'` | your markup | — | `true` | `…-error` |

The second row still names `…-help` while the help block is not rendered —
`help` and `error` are mutually exclusive on screen but not in the attribute.
Same defect as the table above, and the same ticket.
`help` and `error` are mutually exclusive on screen and in the attribute
alike — whichever block drew is the one named.

::note
Inside a [Form](/docs/components/form/) you do not set `error` at all — the
Expand Down
44 changes: 25 additions & 19 deletions skills/b24-ui-nuxt/references/guidelines/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,30 +84,36 @@ Each slot receives the prop it replaces, so a wrapper decorates the value
rather than restating it — which matters when the text comes from a schema or
a translation.

## Set the prop, use the slot for markup
## What is announced is what was drawn

Every block renders from `props.x || !!slots.x`. `aria-describedby` does not:
it is assembled from the **props alone**, in `useFormField`, and never looks at
what was slotted. The two disagree in both directions.
Every block renders from `props.x || !!slots.x`, and `aria-describedby` follows
the same rule: it names the blocks that ended up in the document, and only
those.

| what you pass | block on screen | named in `aria-describedby` |
|---|---|---|
| `description` prop | yes | yes |
| `#description` slot, no prop | **yes** | **no** |
| `hint` prop, no `label` | **no** | **yes** |

So always pass the prop alongside the slot. Upstream `nuxt/ui` behaves the same
way — the component and the composable are line-for-line identical at our sync
cursor — and it is tracked in b24ui#497.

`hint` needs one more thing: it renders inside the label row, and that row is
only drawn when there is a label. A `hint` on a field with no `label` renders
nothing at all, while still being named in `aria-describedby`.

`#error` goes further still. The error block renders whenever an `#error` slot
exists, with or without an error, and `help` is the `v-else-if` of that branch,
so supplying `#error` hides `help` entirely — while the control keeps
`aria-invalid="false"`.
| `#description` slot, no prop | yes | yes |
| `hint` prop, no `label` | no | no |
| `error` and `help` together | error only | error only |

Passing the prop alongside the slot is still the better habit — the slot
receives it, so a wrapper decorates the value instead of restating it — but it
is no longer what makes the block announced.

Two conditions hide a block you asked for. `hint` renders inside the label row,
which is only drawn when there is a label, so a `hint` on a field with no
`label` renders nothing at all. And `help` is the `v-else-if` of the error
branch, so a rendered error takes its place. In both cases nothing is drawn and
nothing is announced.

`#error` needs care for a different reason. The error block renders whenever an
`#error` slot exists, with or without an error, so supplying `#error` hides
`help` entirely — while the control keeps `aria-invalid="false"`, because that
attribute reports the field's error state and there is none.

This is a deliberate divergence from `nuxt/ui`, which still builds the
attribute from the props alone; fixed in b24ui#497.

Bind `error` to the message — or to `false` when there is none — and let the
slot supply markup only:
Expand Down
Loading