Le Truc 2.6.0

Accessibility#

Reflect ARIA semantics from a signal with bindAria(). Content attributes and ElementInternals reflection are two complementary channels, not competitors — the consumer owns one, the component owns the other.

ARIA Reflection#

ARIA semantics have two channels. Content attributes face the consumer. Reflection properties (internals.ariaExpanded, trigger.ariaLabel) are the component's own defaults. The bindAria() helper drives the reflection channel from a signal:

js

defineComponent('my-disclosure', ({ first, host, internals, on, watch }) => {
  const trigger = first('button', 'Add a native button as the trigger.')

  expose({ expanded: false })

  on(trigger, 'click', () => ({ expanded: !host.expanded }))
  // Host default semantics: invisible in markup, consumer can still override
  watch('expanded', bindAria(internals, 'ariaExpanded'))
})

bindAria(target, name) accepts any ARIAMixin target:

  • internals — on the factory context of every component. It is null only if attachInternals() failed, and every handler degrades to a no-op. The write sets default semantics on the host. No attribute appears in markup. No consumer framework rewriting attributes can overwrite the value.
  • A native element — the write mirrors into the content attribute. CSS selectors and getAttribute() still see the value.

The handler assigns values the way the reflection API expects:

Watched valueAssigned
boolean'true' or 'false'
numberDecimal string
string, Element, Element[]Unchanged
null / undefinednull — clears the reflection

bindAria() also has the map form:

js

watch(
  () => ({ ariaValueNow: `${degree}`, ariaValueText: `${degree} degrees` }),
  bindAria(internals, ['ariaValueNow', 'ariaValueText']),
)

Set static ARIA directly in the factory. internals.role = 'slider' is shorter than any helper call.

The server-rendered attribute is the initial value

A host content attribute overrides the reflection value in the accessibility tree. A stale aria-expanded="false" from the server would silence every later update through internals. bindAria() removes the shadowing attribute for you — once per property, at the first value the binding applies. After that, the component owns the property. A consumer who sets the attribute after connect still overrides it. Never write both channels for the same property on the same element.

Choose the Channel#

ConcernChannel
Initial state in server-rendered HTMLContent attribute — a Parser reads it at connect time
Consumer overrides component semanticsContent attribute — the platform guarantees the host attribute wins
Component-owned state on the host (role, aria-expanded, aria-valuenow)internals.aria* via bindAria()
Component-internal relationships (label, description, controls, active descendant)Element references via bindAria()
Relationships the consumer authorsContent attribute (IDREF) — the component only reads it
State that CSS must select onContent attribute — internals values are invisible to CSS

axe-core 4.13 and later can see internals.role on every Le Truc component. Le Truc registers each component's internals in the ElementInternals declaration registry for you. The tooling reach is partial: only the attribute-validity rules (aria-allowed-attr, aria-prohibited-attr) act on an internals-only role. The nesting rules (aria-required-parent, aria-required-children) inspect only elements with a role attribute. Keep structural roles (list, table, menu, and their required children) on the attribute channel, or keep the native element.

Do not use ariaOwnsElements. Chromium does not implement it, and its aria-owns semantics are problematic on their own. Le Truc components own their internal structure and never need it.