From 42ab8b428918fe361fea5d90a18ff75b2701170d Mon Sep 17 00:00:00 2001 From: Jay George Date: Fri, 2 Oct 2026 15:56:17 +0100 Subject: [PATCH 1/2] Associate ui/Field labels with nested controls Field now generates an id for its label and lets the first nested control claim it, so screens like login get a proper for/id association without call-site changes. Explicit Field ids (Publish) are not injected, avoiding duplicate ids inside fieldtypes. --- .../js/components/ui/Combobox/Combobox.vue | 11 +- resources/js/components/ui/Field.vue | 20 ++- resources/js/components/ui/Input/Input.vue | 9 +- resources/js/components/ui/Label.vue | 3 + resources/js/components/ui/Slider/Slider.vue | 11 +- resources/js/components/ui/Switch.vue | 9 +- resources/js/components/ui/Textarea.vue | 7 +- resources/js/composables/ui-field-id.js | 49 ++++++ resources/js/tests/components/Field.test.js | 150 ++++++++++++++++++ 9 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 resources/js/composables/ui-field-id.js diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue index 6832897ef5b..c84067a6b12 100644 --- a/resources/js/components/ui/Combobox/Combobox.vue +++ b/resources/js/components/ui/Combobox/Combobox.vue @@ -12,7 +12,7 @@ import { ComboboxVirtualizer, FocusScope } from 'reka-ui'; -import { computed, nextTick, ref, useAttrs, useTemplateRef, watch } from 'vue'; +import { computed, nextTick, ref, toRef, useAttrs, useTemplateRef, watch } from 'vue'; import { twMerge } from 'tailwind-merge'; import Button from '../Button/Button.vue'; import Icon from '../Icon/Icon.vue'; @@ -20,6 +20,7 @@ import Badge from '../Badge.vue'; import fuzzysort from 'fuzzysort'; import DOMPurify from 'dompurify'; import { SortableList } from '@/components/sortable/Sortable.js'; +import { useUiFieldId } from '@/composables/ui-field-id.js'; const emit = defineEmits(['update:modelValue', 'search', 'selected', 'added']); @@ -37,8 +38,8 @@ const props = defineProps({ discreteFocusOutline: { type: Boolean, default: false }, /** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */ icon: { type: String, default: null }, - /** ID attribute for the input element */ - id: { type: String }, + /** ID attribute for the input element. Inherits from parent `Field` when omitted. */ + id: { type: String, default: null }, /** When `true`, the Combobox will avoid filtering options, allowing you to handle filtering yourself by listening to the `search` event and updating the `options` prop. */ ignoreFilter: { type: Boolean, default: false }, /** When `true`, the option labels will be rendered with `v-html` instead of `v-text`. */ @@ -79,6 +80,8 @@ defineOptions({ inheritAttrs: false, }); +const { id, labelId } = useUiFieldId(toRef(props, 'id')); + const attrs = useAttrs(); const wrapperClasses = computed(() => twMerge('w-full min-w-0', attrs.class)); @@ -397,6 +400,8 @@ defineExpose({ as="div" ref="trigger" v-bind="triggerAttrs" + :id="shouldShowInput ? undefined : id" + :aria-labelledby="shouldShowInput ? undefined : labelId" :class="triggerClasses" data-ui-combobox-trigger @keydown.enter="openDropdown" diff --git a/resources/js/components/ui/Field.vue b/resources/js/components/ui/Field.vue index 6cf910f224e..452f7f6f756 100644 --- a/resources/js/components/ui/Field.vue +++ b/resources/js/components/ui/Field.vue @@ -1,11 +1,12 @@