diff --git a/resources/js/components/Reveal.js b/resources/js/components/Reveal.js index 83a903246bf..7914e539139 100644 --- a/resources/js/components/Reveal.js +++ b/resources/js/components/Reveal.js @@ -2,6 +2,16 @@ import { nextTick, onMounted, onBeforeUnmount } from 'vue'; const registry = new WeakMap(); +const FOCUSABLE_CONTROL_SELECTORS = [ + 'input:not([disabled]):not([type="hidden"]):not([aria-disabled="true"])', + 'textarea:not([disabled]):not([aria-disabled="true"])', + 'select:not([disabled]):not([aria-disabled="true"])', + '[role="switch"]:not([disabled]):not([aria-disabled="true"]):not([data-disabled])', + '[role="combobox"]:not([disabled]):not([aria-disabled="true"]):not([data-disabled])', + '[role="slider"]:not([disabled]):not([aria-disabled="true"])', + 'button[data-ui-control]:not([disabled])', +]; + class Reveal { use(ref, callback) { onMounted(() => this.mount(ref.value, callback)); @@ -29,11 +39,36 @@ class Reveal { }); } + focusableControl(root) { + if (!root) return null; + + for (const selector of FOCUSABLE_CONTROL_SELECTORS) { + const control = root.querySelector(selector); + if (!control) continue; + if (control.tabIndex < 0 && control.getAttribute('role') === 'combobox') continue; + return control; + } + + return null; + } + invalid() { nextTick(() => { const el = document.querySelector('[data-ui-field-has-errors]:not(:has([data-ui-field-has-errors]))'); if (!el) return; + + const fieldId = el.id || el.querySelector('[data-ui-label]')?.htmlFor || null; this.element(el); + + nextTick(() => { + const current = + document.querySelector('[data-ui-field-has-errors]:not(:has([data-ui-field-has-errors]))') || el; + const control = + this.focusableControl(current) || + (fieldId ? document.getElementById(fieldId) : null); + + control?.focus?.({ preventScroll: true }); + }); }); } } diff --git a/resources/js/components/ui/Combobox/Combobox.vue b/resources/js/components/ui/Combobox/Combobox.vue index 6832897ef5b..0c2f88e099c 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, describedBy, invalid } = useUiFieldId(toRef(props, 'id')); + const attrs = useAttrs(); const wrapperClasses = computed(() => twMerge('w-full min-w-0', attrs.class)); @@ -397,6 +400,10 @@ defineExpose({ as="div" ref="trigger" v-bind="triggerAttrs" + :id="shouldShowInput ? undefined : id" + :aria-labelledby="shouldShowInput ? undefined : labelId" + :aria-describedby="shouldShowInput ? undefined : describedBy" + :aria-invalid="!shouldShowInput && invalid ? 'true' : undefined" :class="triggerClasses" data-ui-combobox-trigger @keydown.enter="openDropdown" @@ -407,6 +414,8 @@ defineExpose({ v-if="shouldShowInput" :id :placeholder + :aria-describedby="describedBy" + :aria-invalid="invalid ? 'true' : undefined" ref="search" class="w-full bg-transparent text-gray-900 dark:text-gray-300 opacity-100 focus:outline-none placeholder-gray-500 dark:placeholder-gray-400 [&::-webkit-search-cancel-button]:hidden cursor-pointer" :class="{ diff --git a/resources/js/components/ui/Description.vue b/resources/js/components/ui/Description.vue index b7b9c67b00a..aef9a222773 100644 --- a/resources/js/components/ui/Description.vue +++ b/resources/js/components/ui/Description.vue @@ -5,13 +5,19 @@ const slots = useSlots(); const hasDefaultSlot = !!slots.default; const props = defineProps({ + /** Optional id so the description can be referenced by aria-describedby */ + id: { type: String, default: null }, /** The description text to display */ text: { type: [String, Number, Boolean, null], default: null }, });