From 0c576bf7e5370687379a737eb7fdb1be02ae7097 Mon Sep 17 00:00:00 2001 From: Nerd Date: Wed, 12 Aug 2026 14:10:07 -0700 Subject: [PATCH 1/2] [6.x] Fix Checkbox & Radio label click capturing and useId prop binding --- .../fieldtypes/CheckboxesFieldtype.vue | 1 + .../js/components/fieldtypes/RadioFieldtype.vue | 1 + resources/js/components/ui/Checkbox/Item.vue | 16 ++++++++-------- resources/js/components/ui/Radio/Item.vue | 12 ++++++------ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/resources/js/components/fieldtypes/CheckboxesFieldtype.vue b/resources/js/components/fieldtypes/CheckboxesFieldtype.vue index 50293c191e9..b6503d18e6d 100644 --- a/resources/js/components/fieldtypes/CheckboxesFieldtype.vue +++ b/resources/js/components/fieldtypes/CheckboxesFieldtype.vue @@ -4,6 +4,7 @@ -import { CheckboxIndicator, CheckboxRoot, useId } from 'reka-ui'; -import { computed, useAttrs } from 'vue'; +import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'; +import { computed, useAttrs, useId } from 'vue'; import { cva } from 'cva'; import { twMerge } from 'tailwind-merge'; import { injectCheckboxContext } from './Group.vue'; @@ -10,6 +10,8 @@ defineOptions({ inheritAttrs: false }); const attrs = useAttrs(); const props = defineProps({ + /** Optional ID for the checkbox input */ + id: { type: String, default: () => useId() }, /** Controls the vertical alignment of the checkbox with its label. Options: `start`, `center` */ align: { type: String, default: 'start', validator: (value) => ['start', 'center'].includes(value) }, /** Description text to display below the label */ @@ -37,8 +39,6 @@ const emit = defineEmits(['update:modelValue', 'keydown']); const { appearance } = injectCheckboxContext() ?? { appearance: computed(() => 'default') }; -const id = useId(); - const handleKeydown = (event) => { emit('keydown', event); @@ -97,7 +97,7 @@ const conditionalProps = computed(() => { // Only add aria-describedby if description exists AND it's not a solo checkbox if (props.description && !props.solo) { - props_obj['aria-describedby'] = `${id}-description`; + props_obj['aria-describedby'] = `${props.id}-description`; } if (props.solo && (props.label || props.value)) { @@ -112,7 +112,7 @@ const conditionalProps = computed(() => {
{
-
diff --git a/resources/js/components/ui/Radio/Item.vue b/resources/js/components/ui/Radio/Item.vue index 4c97b488145..5f47e260f89 100644 --- a/resources/js/components/ui/Radio/Item.vue +++ b/resources/js/components/ui/Radio/Item.vue @@ -4,6 +4,8 @@ import { RadioGroupIndicator, RadioGroupItem } from 'reka-ui'; import { injectRadioContext } from './Group.vue'; const props = defineProps({ + /** Optional ID for the radio button */ + id: { type: String, default: () => useId() }, /** Description text to display below the label */ description: { type: String, default: null }, disabled: { type: Boolean, default: false }, @@ -15,8 +17,6 @@ const props = defineProps({ }); const { appearance } = injectRadioContext() ?? { appearance: computed(() => 'default') }; - -const id = useId(); From 70b8c26c956884523df6496dc561ce54410e8f45 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 14 Aug 2026 13:27:33 -0400 Subject: [PATCH 2/2] Add tests for Checkbox and Radio id/label association Covers default vs. custom id prop, and that for/aria-describedby correctly track it. Co-Authored-By: Claude Sonnet 5 --- .../js/tests/components/CheckboxItem.test.js | 32 ++++++++++++++++ .../js/tests/components/RadioItem.test.js | 37 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 resources/js/tests/components/CheckboxItem.test.js create mode 100644 resources/js/tests/components/RadioItem.test.js diff --git a/resources/js/tests/components/CheckboxItem.test.js b/resources/js/tests/components/CheckboxItem.test.js new file mode 100644 index 00000000000..97b9e0be517 --- /dev/null +++ b/resources/js/tests/components/CheckboxItem.test.js @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import Checkbox from '@/components/ui/Checkbox/Item.vue'; + +test('the label targets the auto-generated id when no id prop is given', () => { + const wrapper = mount(Checkbox, { + props: { label: 'Subscribe' }, + }); + + const id = wrapper.find('[role="checkbox"]').attributes('id'); + + expect(id).toBeTruthy(); + expect(wrapper.find('label').attributes('for')).toBe(id); +}); + +test('a custom id prop is honored on the control and the label', () => { + const wrapper = mount(Checkbox, { + props: { label: 'Subscribe', id: 'custom-checkbox-id' }, + }); + + expect(wrapper.find('[role="checkbox"]').attributes('id')).toBe('custom-checkbox-id'); + expect(wrapper.find('label').attributes('for')).toBe('custom-checkbox-id'); +}); + +test('aria-describedby and the description element share the custom id', () => { + const wrapper = mount(Checkbox, { + props: { label: 'Subscribe', id: 'custom-checkbox-id', description: 'Receive occasional emails' }, + }); + + expect(wrapper.find('[role="checkbox"]').attributes('aria-describedby')).toBe('custom-checkbox-id-description'); + expect(wrapper.find('p').attributes('id')).toBe('custom-checkbox-id-description'); +}); diff --git a/resources/js/tests/components/RadioItem.test.js b/resources/js/tests/components/RadioItem.test.js new file mode 100644 index 00000000000..bca198cf07e --- /dev/null +++ b/resources/js/tests/components/RadioItem.test.js @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import { h } from 'vue'; +import RadioGroup from '@/components/ui/Radio/Group.vue'; +import Radio from '@/components/ui/Radio/Item.vue'; + +// RadioGroupItem requires a RadioGroupRoot ancestor for its reka-ui context injection. +function mountRadio(props) { + return mount(RadioGroup, { + slots: { + default: () => h(Radio, { label: 'Yes', value: 'yes', ...props }), + }, + }); +} + +test('the label targets the auto-generated id when no id prop is given', () => { + const wrapper = mountRadio({}); + + const id = wrapper.find('[role="radio"]').attributes('id'); + + expect(id).toBeTruthy(); + expect(wrapper.find('label').attributes('for')).toBe(id); +}); + +test('a custom id prop is honored on the control and the label', () => { + const wrapper = mountRadio({ id: 'custom-radio-id' }); + + expect(wrapper.find('[role="radio"]').attributes('id')).toBe('custom-radio-id'); + expect(wrapper.find('label').attributes('for')).toBe('custom-radio-id'); +}); + +test('aria-describedby and the description element share the custom id', () => { + const wrapper = mountRadio({ id: 'custom-radio-id', description: 'This cannot be undone' }); + + expect(wrapper.find('[role="radio"]').attributes('aria-describedby')).toBe('custom-radio-id-description'); + expect(wrapper.find('span').attributes('id')).toBe('custom-radio-id-description'); +});