Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/js/components/fieldtypes/CheckboxesFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<Checkbox
v-for="(option, index) in options"
:disabled="config.disabled"
:id="`${id}_${index}`"
:key="index"
:label="option.label || option.value"
:read-only="isReadOnly"
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/fieldtypes/RadioFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Radio
v-for="(option, index) in options"
:disabled="config.disabled"
:id="`${id}_${index}`"
:key="index"
:label="option.label || option.value"
:read-only="isReadOnly"
Expand Down
16 changes: 8 additions & 8 deletions resources/js/components/ui/Checkbox/Item.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup>
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';
Expand All @@ -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 */
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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)) {
Expand All @@ -112,7 +112,7 @@ const conditionalProps = computed(() => {
<div :class="containerClasses" data-ui-checkbox-item>
<CheckboxRoot
:disabled="readOnly || disabled"
:id
:id="props.id"
:name="name"
:value="value"
v-bind="conditionalProps"
Expand All @@ -132,10 +132,10 @@ const conditionalProps = computed(() => {
</span>
</CheckboxRoot>
<div class="flex flex-col" v-if="!solo">
<label class="text-sm font-normal antialiased cursor-pointer dark:text-gray-200 before:absolute before:inset-0 before:content-['']" :for="id">
<label class="text-sm font-normal antialiased cursor-pointer dark:text-gray-200 before:absolute before:inset-0 before:content-['']" :for="props.id">
<slot>{{ label || value }}</slot>
</label>
<p v-if="description" :id="`${id}-description`" class="mt-0.5 block text-xs leading-snug text-gray-500 dark:text-gray-200">{{ description }}</p>
<p v-if="description" :id="`${props.id}-description`" class="mt-0.5 block text-xs leading-snug text-gray-500 dark:text-gray-200">{{ description }}</p>
</div>
</div>
</template>
12 changes: 6 additions & 6 deletions resources/js/components/ui/Radio/Item.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -15,8 +17,6 @@ const props = defineProps({
});

const { appearance } = injectRadioContext() ?? { appearance: computed(() => 'default') };

const id = useId();
</script>

<template>
Expand All @@ -26,10 +26,10 @@ const id = useId();
data-ui-radio-item
>
<RadioGroupItem
:id
:id="props.id"
:value="value"
:disabled="readOnly || disabled"
:aria-describedby="description ? `${id}-description` : undefined"
:aria-describedby="description ? `${props.id}-description` : undefined"
class="
shadow-ui-xs mt-0.5 size-4 cursor-default rounded-full
focus:focus-outline border border-gray-400/75 bg-white with-contrast:border-gray-100
Expand All @@ -48,10 +48,10 @@ const id = useId();
/>
</RadioGroupItem>
<div class="flex flex-col" :class="{ 'opacity-50': disabled }">
<label class="text-sm font-normal antialiased cursor-pointer dark:text-gray-200 before:absolute before:inset-0 before:content-['']" :for="id">
<label class="text-sm font-normal antialiased cursor-pointer dark:text-gray-200 before:absolute before:inset-0 before:content-['']" :for="props.id">
<slot>{{ label || value }}</slot>
</label>
<span v-if="description" :id="`${id}-description`" class="mt-0.5 block text-xs leading-snug text-gray-500">{{ description }}</span>
<span v-if="description" :id="`${props.id}-description`" class="mt-0.5 block text-xs leading-snug text-gray-500">{{ description }}</span>
</div>
</div>
</template>
32 changes: 32 additions & 0 deletions resources/js/tests/components/CheckboxItem.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
37 changes: 37 additions & 0 deletions resources/js/tests/components/RadioItem.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading