ReviewOS

stacks/stx

Button: a reactive :label serves a button with no accessible name, and slot content cannot be a fallback

#2034
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

<Button :label="expr()"> renders a button with no text content and no accessible name in the served HTML. Assistive technology that reaches it before hydration finds an unlabelled button, and for a destructive action that is the worst case.

There is no way to supply a server-rendered fallback, because slot content and the label span both render.

The mechanism

src/ui/button/Button.stx:

export const label = $props.label || ''
export const hasLabel = label !== ''
...
const labelText = useReactiveProp('label', {{ label }}, { parse: ... })
...
<slot />
<span :if="labelText()" @if(!hasLabel)x-cloak@endif :text="labelText()">{{ label }}</span>

A reactive :label is client-categorised, so $props.label is undefined during render. That makes label '', hasLabel false, and the span is emitted x-cloak with an empty . The served markup is effectively:

<button type="button" class="..."><span x-cloak></span></button>

No text, no aria-label — the component never emits one; aria-label appears nowhere in the file.

Why slot content is not a workaround

<slot /> renders unconditionally and the label span renders as well, so <Button :label="confirmLabel()">Confirm</Button> shows "Confirm" server-side and then "ConfirmDelete project" once labelText() resolves. The two mechanisms are additive, so an author has to pick between a server-rendered name and a reactive one.

Where this came up

An app has a destructive confirmation dialog whose button reads "Archive project" or "Delete project" depending on which action opened it. The markup is always in the document, hidden rather than conditionally rendered, so the unlabelled button is in every served response.

That app had already solved it before adopting the component, with a comment explaining the reasoning:

Static text as well as :text. The dialog markup is always in the document, and :text only fills this in once the runtime binds — so the HTML actually served carried a button with no content and no accessible name at all, which is what an assistive technology reads if it gets there first.

A plain <button :text="...">Confirm</button> gets this right: static content is the served name, and the binding takes over after hydration. <Button> cannot express it, so adopting the component here is a regression. That app reverted to the plain element.

Suggested

Make the slot the fallback rather than a sibling: render the label span only when labelText() differs from nothing, and suppress the slot when a label is in play — or equivalently, treat slot content as the server-side default for the label.

<slot :if="!labelText()" />
<span :if="labelText()" ...></span>

That would let <Button :label="confirmLabel()">Confirm</Button> serve "Confirm", then swap to the resolved label on hydration, which is the behaviour the plain element already has.

Failing that, forwarding aria-label would at least give the served button a name, though it would still be the wrong one once the label changes.

The same reasoning applies to any component that exposes a reactive text prop alongside a slot. Worth checking Badge, FilterChip and Tooltip for the same shape.

Sign in to comment on this issue.