Skip to content

a11y(FormField): aria-describedby is built from props while the blocks render from props-or-slots #497

Description

@IgorShevchik

Environment

main. Found while writing the slot documentation in #496, which documents the behaviour rather than changing it — see Why this is filed rather than fixed below.

Description

FormField decides what to render from the prop or the slot:

<span v-if="props.hint || !!slots.hint" :id="`${ariaId}-hint`" …>
<p    v-if="props.description || !!slots.description" :id="`${ariaId}-description`" …>
<div  v-else-if="props.help || !!slots.help" :id="`${ariaId}-help`" …>

and what to announce from the props alone:

// src/runtime/composables/useFormField.ts:127
const descriptiveAttrs = ['error', 'hint', 'description', 'help']
  .filter(type => formField?.value?.[type])          // ← the props, via provide()
  .map(type => `${formField?.value.ariaId}-${type}`)

Slots are not in formField.value at all. The two conditions therefore disagree, in both directions.

Measured

Mounted B24FormField with B24Input, useId stubbed to v-0-0:

what is passed block rendered aria-describedby
hint prop (with a label) v-0-0-hint v-0-0-hint
#hint slot, no prop v-0-0-hint absent
hint prop, no label none v-0-0-hint
description prop v-0-0-description v-0-0-description
#description slot, no prop v-0-0-description absent
help prop v-0-0-help v-0-0-help
#help slot, no prop v-0-0-help absent

Two distinct failures:

  1. Slot without prop — text on screen, nothing announced. The block has an id; the control never names it.
  2. Prop without a rendered block — a dangling reference. hint needs a label (the hint lives inside the label row, which is only drawn when label or #label is present), so hint alone renders nothing while aria-describedby still names it. The same happens whenever error and help are both set: help is the v-else-if of the error branch, so it is not rendered, and aria-describedby reads …-error …-help.

The #error slot makes the second failure worse, because its v-if is satisfied by the slot's mere existence:

props.error !== false && ((typeof error === 'string' && error) || !!slots.error)

With an #error slot and no error value: a red error block on screen, help gone, aria-invalid="false", and aria-describedby="…-help" pointing at an element that is not in the document. A visible error that assistive technology is told nothing about, over a broken reference.

Impact

WCAG 4.1.2 (Name, Role, Value) — aria-invalid does not reflect the state, and aria-describedby names ids that do not resolve. Indirectly 1.3.1 (Info and Relationships): the association between a message and its control is not exposed programmatically.

This is a Bitrix24 component library; the fields in question are the ones every form in every app is built out of.

Why this is filed rather than fixed

Checked against upstream at our sync cursor (.sync/PORTING.md §7): FormField.vue's conditions and useFormField.ts's ariaAttrs are line-for-line identical to nuxt/ui, modulo the uib24ui rename. So this is not a porting slip, and fixing it locally is a deliberate divergence — one that a later port would silently revert unless it is recorded in §2 first. That decision is worth making on purpose rather than as a side effect of a documentation PR.

Note that PORTING.md is explicit that matching upstream is not itself a defence: "Upstream is the control, not the authority: matching it answers where the mistake happened … and settles nothing about whether it is a mistake."

Pinned, not left loose

#496 adds characterization tests so the current behaviour cannot change unnoticed, each pointing here:

  • renders the #hint/#description/#help slot with no prop, and announces nothing
  • names the hint in aria-describedby even when no hint was drawn
  • renders #error with no error, and leaves aria pointing at nothing

When this is fixed those tests fail, which is the intended signal — they also mark the documentation that has to change with it.

Options

  1. Build aria-describedby from what rendered. Pass slot presence into the injected context, or compute the attribute in FormField.vue where both are in scope. Fixes both directions. Diverges from upstream; needs a §2 entry.
  2. Report upstream first and port the fix back. Slower, no divergence, and the same bug is presumably live for every nuxt/ui consumer.
  3. Documentation only — the current state after docs(FormField): document the four remaining slots, and what #error actually does #496: "set the prop, use the slot for markup", with the failure modes spelled out.

3 is in place. 1 or 2 is the actual repair.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions