From the June 2026 accessibility audit (verified findings). Latent correctness issues across the form controls with no current consumer-visible impact, parked as one bundle. Notes: ariaDescribedby was since declared on textarea/select/slider (#39); aria-describedby/aria-invalid field wiring landed in #39; the cat-select icon aria-hidden landed in #34. The findings below remain.
input.vue (line 19), textarea.vue (line 16), and select.vue (line 14) bind v-bind="$attrs" onto the native element but do not set defineOptions({ inheritAttrs: false }), so Vue also applies every fallthrough attribute to the root element. Verified by mounting cat-input with id="dup-check": both the root
and the inner received id="dup-check" (and autocomplete="email" landed on the div). Since the div precedes the input in document order, a consumer's resolves to the non-labelable div, silently breaking label association; any aria-* passed as an attr is likewise duplicated onto a role-less div.
Recommendation: Add defineOptions({ inheritAttrs: false }) to input.vue, textarea.vue, and select.vue. Caveat the reviewer missed: this also moves consumer class/style fallthrough from the root .control wrapper onto the native element (via the existing v-bind="$attrs"). Check playground/consumer usage; if class-on-root must be preserved, split with useAttrs() (bind class/style on the root, the rest on the native element). Add a regression test per component asserting a fallthrough id/aria-* attr appears only on the native form element and not on the root wrapper.
readonly is implemented as disabled, removing the select from the tab order [moderate]
Location: src/controls/select.vue:12
select.vue:12 binds :disabled="disabled || readonly". A readonly select therefore becomes unfocusable: keyboard and screen-reader users cannot Tab to it to read its current value, and it is announced as dimmed/unavailable rather than read-only. The prop doc (line 64) says 'readonly (not editable)', which implies the value should remain perceivable.
Recommendation: In select.vue: bind :disabled="disabled" only; add :aria-readonly="readonly ? 'true' : undefined". In handleChange, when props.readonly, reset target.value to String(props.modelValue ?? '') (and re-sync selectedOptions for multiple) and return without emitting; add @mousedown="readonly && $event.preventDefault()" and a keydown guard for value-changing keys (arrows, Home/End, typeahead, Space/Enter to open). Note: removing :disabled also removes Bulma's dimmed styling, so add a cat-select is-readonly class with appropriate styling (e.g. reduced opacity on the arrow, default cursor) so the state stays visually distinguishable. Add a test: readonly select is focusable, has aria-readonly, and emits nothing on change.
Optional name prop means radios are not a native radio group; library demo never sets it [moderate]
Location: src/controls/radio.vue:36
radio.vue declares name as optional with default undefined (lines 36-37, 49). Without a shared name, each is its own group of one: every radio becomes a separate Tab stop, arrow keys do not move selection within the set, and screen readers report wrong group position (e.g. '1 of 1'). The playground demo (playground/app/pages/controls/radio.vue:13-21 and all subsequent groups) never passes name, so the documented usage ships this broken keyboard behavior; v-model masks it for mouse users only.
Recommendation: Two-part fix. (1) Grouping by default: in fieldset.vue, provide a generated name (const groupName = useId(); provide(RadioGroupNameKey, groupName) with a new InjectionKey in types.ts); in radio.vue, inject it and bind :name="name ?? injectedGroupName". Emit a dev-only console.warn when a cat-radio resolves to no name at all. (2) Fix the docs that ship the bug: add name to every group in playground/app/pages/controls/radio.vue (basic, variants demo can stay single, disabledDemo, contentDemo, payment, shipping, satisfaction, rating) and to the fieldset.vue JSDoc example. Add a test mounting cat-fieldset with three cat-radios asserting all three inputs share one name attribute.
No id override prop on cat-input/cat-textarea: duplicate ids when one labeled cat-field wraps multiple controls [moderate]
Location: src/controls/input.vue:4
cat-field provides a single fieldId (field.vue:50) and explicitly supports grouped/addons layouts containing multiple controls (field.vue:29). Every cat-input (input.vue:4,49) and cat-textarea (textarea.vue:4,27) inside that field injects the same id, producing duplicate DOM ids and an ambiguous label target. cat-select already has the documented escape hatch — an explicit id prop 'Use this when a composite control contains multiple selects so ids stay unique' (select.vue:48-49) — but cat-input and cat-textarea have no equivalent prop (and the $attrs fallthrough workaround currently triggers the duplication bug above). This is the same class of bug the datepicker had.
Recommendation: Add to input.vue, textarea.vue, and slider.vue an id?: string prop with the same JSDoc as select.vue:48 ('Explicit id for the native element. Overrides the id injected by a wrapping cat-field...'), default undefined, bound as :id="id ?? fieldId". Extend field.test.ts with a grouped cat-field containing two cat-inputs given explicit distinct ids, asserting no duplicate ids and that the label's for matches the intended control.
Missing ariaLabel/ariaDescribedby props (breaks strictTemplates convention for unlabeled use) [moderate]
Location: src/controls/textarea.vue:30
The Props interface (textarea.vue:30-111) declares no ariaLabel or ariaDescribedby, unlike cat-input (input.vue:79-81) and cat-select (select.vue:51). Under the repo's strictTemplates convention, consumers cannot typecheck an aria-label on a cat-textarea used without a labeled cat-field, so unlabeled textareas end up with no accessible name.
Recommendation: Add ariaLabel and ariaDescribedby props to textarea.vue with the same JSDoc wording as input.vue:78-81, defaults undefined, bound on the <textarea> as :aria-label="ariaLabel" and :aria-describedby="ariaDescribedby" (merging with the injected field message id once the cat-field describedby fix lands).
Select All / Select None active state is visual-only (is-selected class, no aria-pressed) [minor]
Location: src/controls/checkbox-group.vue:19
The header buttons indicate which mode is active purely via the is-${variant} is-selected class (checkbox-group.vue:19 and :30) driven by selectAllState. Screen-reader users hear two plain buttons with no indication of whether 'Select All' or 'Select None' is currently in effect.
Recommendation: In checkbox-group.vue add :aria-pressed="selectAllState === 'all' ? 'true' : 'false'" to the Select All button (line 16-26) and :aria-pressed="selectAllState === 'none' ? 'true' : 'false'" to the Select None button (line 27-37); both report false in the 'some' state, matching the visual. Add assertions to checkbox-group.test.ts for the all/none/some states.
Redundant aria-checked on a native checkbox can contradict the real state [minor]
Location: src/controls/switch.vue:7
switch.vue:7 binds :aria-checked="isChecked" on the native checkbox. With role="switch" on a checkbox, the native checked property already maps to the switch's checked state. The explicit attribute is derived from modelValue, so the moment a user toggles the control, the native state flips immediately while aria-checked only updates after the v-model round-trip — and if a consumer ever fails to update modelValue, aria-checked permanently contradicts the actual state.
Recommendation: Remove :aria-checked="isChecked" from switch.vue:7, keeping :checked. In switch.test.ts, rewrite the two aria-checked tests (lines 15-37) to assert (input.element as HTMLInputElement).checked for both the boolean and the custom trueValue/falseValue cases, and optionally assert aria-checked is absent.
checkbox/radio/switch have no ariaLabel prop and fallthrough attrs land on the wrapping [minor]
Location: src/controls/checkbox.vue:2
cat-checkbox (checkbox.vue:2-11), cat-radio (radio.vue:2-12), and cat-switch (switch.vue:2-15) are rooted in a wrapping , so default attribute inheritance puts any consumer-passed aria-label on the label element, where it has no effect (label has no role). None of the three declares an ariaLabel prop. A switch or checkbox used without visible text (e.g. a per-row toggle in a table) therefore cannot be given an accessible name through the typed API; the only workaround is hand-rolled sr-only slot content.
Recommendation: Add an ariaLabel?: string prop to checkbox.vue, radio.vue, and switch.vue (JSDoc: 'Accessible label for use when the control has no visible label text', matching input.vue:78), bound as :aria-label="ariaLabel" on the inner . Document in each component's JSDoc that it replaces slot/label text for AT when set.
Labeled cat-field around checkbox/radio/switch yields an orphan label[for] [minor]
Location: src/controls/field.vue:6
field.vue:6 (and :11) always renders when a label is given, but checkbox.vue, radio.vue, and switch.vue never inject FieldIdKey, so nothing in the slot carries that id. The result is a visible label whose for attribute references a nonexistent element: clicking it does nothing and it is not associated with the control (the control is named only by its own inner slot text).
Recommendation: Split the fix by control. (1) checkbox.vue and switch.vue: inject FieldIdKey and bind :id="fieldId" on the inner (typically one control per labeled field; an explicit for-label plus the implicit wrapping label is valid and both contribute to the name; checkbox-group's per-option unlabeled cat-field wrappers stay safe since each provides a unique id). (2) radio.vue: do NOT wire FieldIdKey — a field label over a radio set is a group label, not a control label. Instead update field.vue's JSDoc to state that radio groups (and multi-checkbox sets) should use cat-fieldset/legend, and migrate the labeled radio examples in playground/app/pages/controls/radio.vue (payment, shipping) from cat-field label to cat-fieldset.
No ariaLabel/ariaDescribedby props — standalone slider has no typecheckable way to get an accessible name [serious]
Location: src/controls/slider.vue:41-88
The Props interface declares no ariaLabel or ariaDescribedby. The slider only gets a name when wrapped in cat-field (FieldIdKey at slider.vue:4,39). Used standalone, a consumer must rely on $attrs fallthrough (slider.vue:14), which fails strictTemplates typechecking per the repo convention that consumer-passed ARIA attributes are declared as props (cf. input.vue:13-14,79). Result: standalone usage tends toward an unnamed range input. There is also no declared way to pass aria-valuetext when raw numbers are not meaningful (e.g. minutes, percent).
Recommendation: In slider.vue Props add: ariaLabel?: string (doc: 'Accessible name when not wrapped in cat-field') and ariaDescribedby?: string, with withDefaults entries of undefined, and bind :aria-label="ariaLabel" :aria-describedby="ariaDescribedby" on the BEFORE v-bind="$attrs" (matching input.vue:13-14 ordering). Also add ariaValuetext?: string bound as :aria-valuetext for unit-bearing values per the APG slider pattern (a static prop is sufficient since consumers can compute it from their v-model). Then wrap the playground's standalone examples in cat-field or pass aria-label.
inheritAttrs not disabled — fallthrough attrs land on both the wrapper div and the input [moderate]
Location: src/controls/slider.vue:2,14
The template explicitly binds v-bind="$attrs" on the inner (line 14), but the component never sets defineOptions({ inheritAttrs: false }), so Vue's default behavior also applies every fallthrough attribute to the single root
(line 2). A consumer passing id, aria-label, or data-* gets it duplicated: a duplicate id in the DOM (axe duplicate-id) and an aria-label on a non-interactive generic div.
Recommendation: Add defineOptions({ inheritAttrs: false }) at the top of slider.vue's <script setup> (before defineProps), matching button.vue:44. No other change needed — class/style passed by consumers will then land on the input rather than the wrapper, which is acceptable since sliderClasses already targets the input; if wrapper-level class passthrough is desired, keep class on the wrapper via :class="$attrs.class" is NOT needed for a11y and can be skipped. Consider filing the same one-line fix for input.vue, which shares the pattern.
Tick buttons stay focusable and silently inert when the slider is disabled [moderate]
Location: src/controls/slider-tick.vue:3-7 (with guard at src/controls/slider.vue:143-147)
When cat-slider has disabled=true, the provided setValue function guards against emitting (slider.vue:144), but the tick elements remain fully focusable, hover-styled, and clickable. A keyboard or screen-reader user tabs to a tick, activates it, and nothing happens, with no disabled state announced — the disabled state is not exposed on the only part of the composite that still receives focus.
Recommendation: In slider.vue add: provide('sliderDisabled', computed(() => props.disabled)) next to the existing provide (line 149). In slider-tick.vue inject it: const sliderDisabled = inject<ComputedRef | undefined>('sliderDisabled', undefined), and on the bind :disabled="setValue && sliderDisabled?.value ? true : undefined" (only valid when rendering as button; the :is already guarantees that when setValue exists). Add a button.cat-slider-tick:disabled style (opacity 0.5, cursor not-allowed) mirroring the input's disabled CSS. Prefer typed InjectionKey symbols in types.ts (like FieldIdKey at types.ts:133) for both 'sliderSetValue' and the new key while touching this code.
Value tooltip shows only for pointer interaction, never for keyboard focus, and is not hidden from AT [minor]
Location: src/controls/slider.vue:16-19,21-27
showTooltip is toggled only on mousedown/mouseup/touchstart/touchend (lines 16-19), so when tooltip=true a sighted keyboard user adjusting the slider with arrow keys never sees the current-value tooltip that pointer users get. Additionally, the tooltip div (lines 21-27) renders the value as plain text without aria-hidden, so during a drag a screen reader can encounter the value twice (aria-valuenow on the input plus the tooltip text).
Recommendation: Track two state refs and compute visibility: const pointerActive = ref(false); const focused = ref(false); const showTooltip = computed(() => pointerActive.value || focused.value). Bind @Focus="focused = true" @blur="focused = false" on the input alongside the existing pointer handlers (do NOT just reuse the single showTooltip ref — mouseup would hide the tooltip while the input is still focused, creating inconsistent state). Add aria-hidden="true" to the .cat-slider-tooltip div since aria-valuenow on the native input already exposes the value. Note: focus-following tooltip means it shows whenever the slider is focused, including after a click — that is acceptable and matches common slider tooltip behavior.
Clickable tick buttons are named only by their bare value with no action context [minor]
Location: src/controls/slider-tick.vue:2-10
When ticks are interactive, each button's accessible name is just its slot text (e.g. "0", "10", "60" in playground/app/pages/controls/slider.vue:42-62). Tabbing through them, a screen-reader user hears a series of bare numbers ("10, button") with no indication these set the adjacent slider's value, since the cat-field label is associated only with the input via FieldIdKey, not with the buttons.
Recommendation: Add an optional ariaLabel?: string prop to slider-tick.vue and bind on the component: :aria-label="setValue ? (ariaLabel ?? Set to ${value}) : undefined". Since aria-label overrides element content on a button, the visible slot text needs no aria-hidden wrapper. Document the prop so consumers can supply unit-bearing labels (e.g. 'Set to 10 minutes') when the visible tick text is formatted ('10 min') and the bare-number default would mismatch. Leave the non-interactive div rendering unlabeled — it is presentational tick text.
No slider.test.ts — slider is the only audited-adjacent control without an axe/a11y test [minor]
Location: src/controls/slider.vue:1 (no slider.test.ts or slider-tick.test.ts exists alongside)
Sibling controls (button, checkbox-group, datepicker, dropdown, field, input, modal, select, switch) all have *.test.ts using the in-house axe wrapper (src/testutil/axe.ts), but there is no test for slider.vue or slider-tick.vue, so regressions in label association (FieldIdKey), disabled behavior, or tick button semantics are unguarded.
Recommendation: Add src/controls/slider.test.ts using src/testutil/axe.ts and component-helpers.ts, covering: (1) axe pass when wrapped in cat-field (label/for association via FieldIdKey) and standalone with the new ariaLabel prop; (2) axe FAILURE expectation removed once finding 1 lands — assert the input has an accessible name in both modes; (3) input event emits update:modelValue with a number; (4) ticks render as
when inside cat-slider and clicking one emits update:modelValue with the tick value; (5) disabled=true emits nothing on tick click and (after finding 3) renders ticks with the disabled attribute; (6) only-attrs-on-input assertion once inheritAttrs: false lands (pass data-testid and assert it appears once).
Filed from the catenary widget accessibility audit session, 2026-06-10 (context: interline-io/calact-network-analysis-tool#390).
From the June 2026 accessibility audit (verified findings). Latent correctness issues across the form controls with no current consumer-visible impact, parked as one bundle. Notes:
ariaDescribedbywas since declared on textarea/select/slider (#39);aria-describedby/aria-invalidfield wiring landed in #39; the cat-select icon aria-hidden landed in #34. The findings below remain.Fallthrough attributes are duplicated onto the root wrapper (duplicate ids, ARIA on a plain div) [serious]
Location:
src/controls/input.vue:19input.vue (line 19), textarea.vue (line 16), and select.vue (line 14) bind v-bind="$attrs" onto the native element but do not set defineOptions({ inheritAttrs: false }), so Vue also applies every fallthrough attribute to the root element. Verified by mounting cat-input with id="dup-check": both the root
Recommendation: Add defineOptions({ inheritAttrs: false }) to input.vue, textarea.vue, and select.vue. Caveat the reviewer missed: this also moves consumer class/style fallthrough from the root .control wrapper onto the native element (via the existing v-bind="$attrs"). Check playground/consumer usage; if class-on-root must be preserved, split with useAttrs() (bind class/style on the root, the rest on the native element). Add a regression test per component asserting a fallthrough id/aria-* attr appears only on the native form element and not on the root wrapper.
readonly is implemented as disabled, removing the select from the tab order [moderate]
Location:
src/controls/select.vue:12select.vue:12 binds :disabled="disabled || readonly". A readonly select therefore becomes unfocusable: keyboard and screen-reader users cannot Tab to it to read its current value, and it is announced as dimmed/unavailable rather than read-only. The prop doc (line 64) says 'readonly (not editable)', which implies the value should remain perceivable.
Recommendation: In select.vue: bind :disabled="disabled" only; add :aria-readonly="readonly ? 'true' : undefined". In handleChange, when props.readonly, reset target.value to String(props.modelValue ?? '') (and re-sync selectedOptions for multiple) and return without emitting; add @mousedown="readonly && $event.preventDefault()" and a keydown guard for value-changing keys (arrows, Home/End, typeahead, Space/Enter to open). Note: removing :disabled also removes Bulma's dimmed styling, so add a cat-select is-readonly class with appropriate styling (e.g. reduced opacity on the arrow, default cursor) so the state stays visually distinguishable. Add a test: readonly select is focusable, has aria-readonly, and emits nothing on change.
Optional name prop means radios are not a native radio group; library demo never sets it [moderate]
Location:
src/controls/radio.vue:36radio.vue declares name as optional with default undefined (lines 36-37, 49). Without a shared name, each is its own group of one: every radio becomes a separate Tab stop, arrow keys do not move selection within the set, and screen readers report wrong group position (e.g. '1 of 1'). The playground demo (playground/app/pages/controls/radio.vue:13-21 and all subsequent groups) never passes name, so the documented usage ships this broken keyboard behavior; v-model masks it for mouse users only.
Recommendation: Two-part fix. (1) Grouping by default: in fieldset.vue, provide a generated name (const groupName = useId(); provide(RadioGroupNameKey, groupName) with a new InjectionKey in types.ts); in radio.vue, inject it and bind :name="name ?? injectedGroupName". Emit a dev-only console.warn when a cat-radio resolves to no name at all. (2) Fix the docs that ship the bug: add name to every group in playground/app/pages/controls/radio.vue (basic, variants demo can stay single, disabledDemo, contentDemo, payment, shipping, satisfaction, rating) and to the fieldset.vue JSDoc example. Add a test mounting cat-fieldset with three cat-radios asserting all three inputs share one name attribute.
No id override prop on cat-input/cat-textarea: duplicate ids when one labeled cat-field wraps multiple controls [moderate]
Location:
src/controls/input.vue:4cat-field provides a single fieldId (field.vue:50) and explicitly supports grouped/addons layouts containing multiple controls (field.vue:29). Every cat-input (input.vue:4,49) and cat-textarea (textarea.vue:4,27) inside that field injects the same id, producing duplicate DOM ids and an ambiguous label target. cat-select already has the documented escape hatch — an explicit id prop 'Use this when a composite control contains multiple selects so ids stay unique' (select.vue:48-49) — but cat-input and cat-textarea have no equivalent prop (and the $attrs fallthrough workaround currently triggers the duplication bug above). This is the same class of bug the datepicker had.
Recommendation: Add to input.vue, textarea.vue, and slider.vue an
id?: stringprop with the same JSDoc as select.vue:48 ('Explicit id for the native element. Overrides the id injected by a wrapping cat-field...'), default undefined, bound as :id="id ?? fieldId". Extend field.test.ts with a grouped cat-field containing two cat-inputs given explicit distinct ids, asserting no duplicate ids and that the label's for matches the intended control.Missing ariaLabel/ariaDescribedby props (breaks strictTemplates convention for unlabeled use) [moderate]
Location:
src/controls/textarea.vue:30The Props interface (textarea.vue:30-111) declares no ariaLabel or ariaDescribedby, unlike cat-input (input.vue:79-81) and cat-select (select.vue:51). Under the repo's strictTemplates convention, consumers cannot typecheck an aria-label on a cat-textarea used without a labeled cat-field, so unlabeled textareas end up with no accessible name.
Recommendation: Add ariaLabel and ariaDescribedby props to textarea.vue with the same JSDoc wording as input.vue:78-81, defaults undefined, bound on the <textarea> as :aria-label="ariaLabel" and :aria-describedby="ariaDescribedby" (merging with the injected field message id once the cat-field describedby fix lands).
Select All / Select None active state is visual-only (is-selected class, no aria-pressed) [minor]
Location:
src/controls/checkbox-group.vue:19The header buttons indicate which mode is active purely via the
is-${variant} is-selectedclass (checkbox-group.vue:19 and :30) driven by selectAllState. Screen-reader users hear two plain buttons with no indication of whether 'Select All' or 'Select None' is currently in effect.Recommendation: In checkbox-group.vue add :aria-pressed="selectAllState === 'all' ? 'true' : 'false'" to the Select All button (line 16-26) and :aria-pressed="selectAllState === 'none' ? 'true' : 'false'" to the Select None button (line 27-37); both report false in the 'some' state, matching the visual. Add assertions to checkbox-group.test.ts for the all/none/some states.
Redundant aria-checked on a native checkbox can contradict the real state [minor]
Location:
src/controls/switch.vue:7switch.vue:7 binds :aria-checked="isChecked" on the native checkbox. With role="switch" on a checkbox, the native checked property already maps to the switch's checked state. The explicit attribute is derived from modelValue, so the moment a user toggles the control, the native state flips immediately while aria-checked only updates after the v-model round-trip — and if a consumer ever fails to update modelValue, aria-checked permanently contradicts the actual state.
Recommendation: Remove :aria-checked="isChecked" from switch.vue:7, keeping :checked. In switch.test.ts, rewrite the two aria-checked tests (lines 15-37) to assert (input.element as HTMLInputElement).checked for both the boolean and the custom trueValue/falseValue cases, and optionally assert aria-checked is absent.
checkbox/radio/switch have no ariaLabel prop and fallthrough attrs land on the wrapping [minor]
Location:
src/controls/checkbox.vue:2cat-checkbox (checkbox.vue:2-11), cat-radio (radio.vue:2-12), and cat-switch (switch.vue:2-15) are rooted in a wrapping , so default attribute inheritance puts any consumer-passed aria-label on the label element, where it has no effect (label has no role). None of the three declares an ariaLabel prop. A switch or checkbox used without visible text (e.g. a per-row toggle in a table) therefore cannot be given an accessible name through the typed API; the only workaround is hand-rolled sr-only slot content.
Recommendation: Add an
ariaLabel?: stringprop to checkbox.vue, radio.vue, and switch.vue (JSDoc: 'Accessible label for use when the control has no visible label text', matching input.vue:78), bound as :aria-label="ariaLabel" on the inner . Document in each component's JSDoc that it replaces slot/label text for AT when set.Labeled cat-field around checkbox/radio/switch yields an orphan label[for] [minor]
Location:
src/controls/field.vue:6field.vue:6 (and :11) always renders when a label is given, but checkbox.vue, radio.vue, and switch.vue never inject FieldIdKey, so nothing in the slot carries that id. The result is a visible label whose for attribute references a nonexistent element: clicking it does nothing and it is not associated with the control (the control is named only by its own inner slot text).
Recommendation: Split the fix by control. (1) checkbox.vue and switch.vue: inject FieldIdKey and bind :id="fieldId" on the inner (typically one control per labeled field; an explicit for-label plus the implicit wrapping label is valid and both contribute to the name; checkbox-group's per-option unlabeled cat-field wrappers stay safe since each provides a unique id). (2) radio.vue: do NOT wire FieldIdKey — a field label over a radio set is a group label, not a control label. Instead update field.vue's JSDoc to state that radio groups (and multi-checkbox sets) should use cat-fieldset/legend, and migrate the labeled radio examples in playground/app/pages/controls/radio.vue (payment, shipping) from cat-field label to cat-fieldset.
No ariaLabel/ariaDescribedby props — standalone slider has no typecheckable way to get an accessible name [serious]
Location:
src/controls/slider.vue:41-88The Props interface declares no ariaLabel or ariaDescribedby. The slider only gets a name when wrapped in cat-field (FieldIdKey at slider.vue:4,39). Used standalone, a consumer must rely on $attrs fallthrough (slider.vue:14), which fails strictTemplates typechecking per the repo convention that consumer-passed ARIA attributes are declared as props (cf. input.vue:13-14,79). Result: standalone usage tends toward an unnamed range input. There is also no declared way to pass aria-valuetext when raw numbers are not meaningful (e.g. minutes, percent).
Recommendation: In slider.vue Props add: ariaLabel?: string (doc: 'Accessible name when not wrapped in cat-field') and ariaDescribedby?: string, with withDefaults entries of undefined, and bind :aria-label="ariaLabel" :aria-describedby="ariaDescribedby" on the BEFORE v-bind="$attrs" (matching input.vue:13-14 ordering). Also add ariaValuetext?: string bound as :aria-valuetext for unit-bearing values per the APG slider pattern (a static prop is sufficient since consumers can compute it from their v-model). Then wrap the playground's standalone examples in cat-field or pass aria-label.
inheritAttrs not disabled — fallthrough attrs land on both the wrapper div and the input [moderate]
Location:
src/controls/slider.vue:2,14The template explicitly binds v-bind="$attrs" on the inner (line 14), but the component never sets defineOptions({ inheritAttrs: false }), so Vue's default behavior also applies every fallthrough attribute to the single root
Recommendation: Add defineOptions({ inheritAttrs: false }) at the top of slider.vue's <script setup> (before defineProps), matching button.vue:44. No other change needed — class/style passed by consumers will then land on the input rather than the wrapper, which is acceptable since sliderClasses already targets the input; if wrapper-level class passthrough is desired, keep class on the wrapper via :class="$attrs.class" is NOT needed for a11y and can be skipped. Consider filing the same one-line fix for input.vue, which shares the pattern.
Tick buttons stay focusable and silently inert when the slider is disabled [moderate]
Location:
src/controls/slider-tick.vue:3-7 (with guard at src/controls/slider.vue:143-147)When cat-slider has disabled=true, the provided setValue function guards against emitting (slider.vue:144), but the tick elements remain fully focusable, hover-styled, and clickable. A keyboard or screen-reader user tabs to a tick, activates it, and nothing happens, with no disabled state announced — the disabled state is not exposed on the only part of the composite that still receives focus.
Recommendation: In slider.vue add: provide('sliderDisabled', computed(() => props.disabled)) next to the existing provide (line 149). In slider-tick.vue inject it: const sliderDisabled = inject<ComputedRef | undefined>('sliderDisabled', undefined), and on the bind :disabled="setValue && sliderDisabled?.value ? true : undefined" (only valid when rendering as button; the :is already guarantees that when setValue exists). Add a button.cat-slider-tick:disabled style (opacity 0.5, cursor not-allowed) mirroring the input's disabled CSS. Prefer typed InjectionKey symbols in types.ts (like FieldIdKey at types.ts:133) for both 'sliderSetValue' and the new key while touching this code.
Value tooltip shows only for pointer interaction, never for keyboard focus, and is not hidden from AT [minor]
Location:
src/controls/slider.vue:16-19,21-27showTooltip is toggled only on mousedown/mouseup/touchstart/touchend (lines 16-19), so when tooltip=true a sighted keyboard user adjusting the slider with arrow keys never sees the current-value tooltip that pointer users get. Additionally, the tooltip div (lines 21-27) renders the value as plain text without aria-hidden, so during a drag a screen reader can encounter the value twice (aria-valuenow on the input plus the tooltip text).
Recommendation: Track two state refs and compute visibility: const pointerActive = ref(false); const focused = ref(false); const showTooltip = computed(() => pointerActive.value || focused.value). Bind @Focus="focused = true" @blur="focused = false" on the input alongside the existing pointer handlers (do NOT just reuse the single showTooltip ref — mouseup would hide the tooltip while the input is still focused, creating inconsistent state). Add aria-hidden="true" to the .cat-slider-tooltip div since aria-valuenow on the native input already exposes the value. Note: focus-following tooltip means it shows whenever the slider is focused, including after a click — that is acceptable and matches common slider tooltip behavior.
Clickable tick buttons are named only by their bare value with no action context [minor]
Location:
src/controls/slider-tick.vue:2-10When ticks are interactive, each button's accessible name is just its slot text (e.g. "0", "10", "60" in playground/app/pages/controls/slider.vue:42-62). Tabbing through them, a screen-reader user hears a series of bare numbers ("10, button") with no indication these set the adjacent slider's value, since the cat-field label is associated only with the input via FieldIdKey, not with the buttons.
Recommendation: Add an optional ariaLabel?: string prop to slider-tick.vue and bind on the component: :aria-label="setValue ? (ariaLabel ??
Set to ${value}) : undefined". Since aria-label overrides element content on a button, the visible slot text needs no aria-hidden wrapper. Document the prop so consumers can supply unit-bearing labels (e.g. 'Set to 10 minutes') when the visible tick text is formatted ('10 min') and the bare-number default would mismatch. Leave the non-interactive div rendering unlabeled — it is presentational tick text.No slider.test.ts — slider is the only audited-adjacent control without an axe/a11y test [minor]
Location:
src/controls/slider.vue:1 (no slider.test.ts or slider-tick.test.ts exists alongside)Sibling controls (button, checkbox-group, datepicker, dropdown, field, input, modal, select, switch) all have *.test.ts using the in-house axe wrapper (src/testutil/axe.ts), but there is no test for slider.vue or slider-tick.vue, so regressions in label association (FieldIdKey), disabled behavior, or tick button semantics are unguarded.
Recommendation: Add src/controls/slider.test.ts using src/testutil/axe.ts and component-helpers.ts, covering: (1) axe pass when wrapped in cat-field (label/for association via FieldIdKey) and standalone with the new ariaLabel prop; (2) axe FAILURE expectation removed once finding 1 lands — assert the input has an accessible name in both modes; (3) input event emits update:modelValue with a number; (4) ticks render as
when inside cat-slider and clicking one emits update:modelValue with the tick value; (5) disabled=true emits nothing on tick click and (after finding 3) renders ticks with the disabled attribute; (6) only-attrs-on-input assertion once inheritAttrs: false lands (pass data-testid and assert it appears once).Filed from the catenary widget accessibility audit session, 2026-06-10 (context: interline-io/calact-network-analysis-tool#390).