@redseed/redseed-ui-vue3 10.0.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -48,5 +48,6 @@ export * from './src/components/Switcher'
48
48
  export * from './src/components/TabSlider'
49
49
  export * from './src/components/Table'
50
50
  export * from './src/components/Toggle'
51
+ export * from './src/components/Toast'
51
52
  export * from './src/components/Tooltip'
52
53
  export * from './src/helpers'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "10.0.0",
3
+ "version": "10.3.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, onMounted, watchEffect } from 'vue'
2
+ import { ref, computed, onMounted, watchEffect, useAttrs } from 'vue'
3
3
 
4
4
  const props = defineProps({
5
5
  xs: {
@@ -51,10 +51,90 @@ const props = defineProps({
51
51
  type: Boolean,
52
52
  default: false,
53
53
  },
54
+ /*
55
+ * Render a real link that looks like a button.
56
+ *
57
+ * Material, Polaris and Carbon all do this, and the absence of it has already cost
58
+ * something in the LMS: because the button family could not navigate, navigation
59
+ * controls were built from LinkPrimary instead — and LinkPrimary then got used for
60
+ * things that do NOT navigate. TeamMemberOverviewTab still renders eight LinkPrimarys
61
+ * carrying only a @click, which are links that go nowhere: our own clickable standards
62
+ * broken in the product they were written for. (The homepage sections did the same
63
+ * until lms#5012 replaced them, so the pattern is real and recurring, not a one-off.)
64
+ *
65
+ * Declared here rather than on each wrapper. A fallthrough attr that matches a
66
+ * declared prop on the child is bound as that prop, so `<ButtonPrimary href="/x">`
67
+ * reaches this — covered by a test, because it is the kind of thing that works
68
+ * until someone adds `inheritAttrs: false` to a wrapper.
69
+ */
70
+ href: {
71
+ type: String,
72
+ default: null,
73
+ },
74
+ /*
75
+ * Declared rather than left to fall through, which is a change of mechanism and not of
76
+ * behaviour for a button.
77
+ *
78
+ * A fallthrough attr is merged onto the root element and beats an explicit binding
79
+ * there, so while `disabled` fell through there was no way to keep it off the anchor —
80
+ * and `disabled` is not a property of HTMLAnchorElement, so Vue wrote it as a literal
81
+ * attribute. Every enabled link button rendered `<a href="/x" disabled="false">`:
82
+ * invalid HTML, inert in practice, but it trips `[disabled]` selectors and reads as a
83
+ * disabled control to anything inspecting the DOM.
84
+ *
85
+ * `type: null` accepts any value and coerces none, so a consumer passing a string, a
86
+ * number or nothing gets exactly what they got before — see isDisabled below, which
87
+ * decides what those mean.
88
+ */
89
+ disabled: {
90
+ type: null,
91
+ default: undefined,
92
+ },
54
93
  })
55
94
 
56
95
  defineEmits(['click'])
57
96
 
97
+ const attrs = useAttrs()
98
+
99
+ /*
100
+ * A link when it navigates, a button when it acts — and a button when it is disabled,
101
+ * whatever else it is.
102
+ *
103
+ * `disabled` does not exist on an anchor. A disabled <a href> still navigates on click
104
+ * and still follows on Enter, and every variant's disabled styling is written with the
105
+ * `disabled:` pseudo-class, which never matches an anchor — so a disabled link would
106
+ * have looked enabled AND worked. Falling back to a real <button disabled> makes it
107
+ * genuinely inert and correctly styled, with no new CSS.
108
+ *
109
+ * `disabled` arrives as a fallthrough attr rather than a prop, so it is read from attrs.
110
+ * Vue renders `disabled="false"` as absent, but a consumer writing the string "false"
111
+ * means it to be truthy-looking and is wrong either way; only absent and `false` count
112
+ * as enabled.
113
+ */
114
+ /*
115
+ * Matches what the DOM will actually do, which `!== undefined && !== false` did not.
116
+ *
117
+ * `:disabled="null"` and `:disabled="0"` are ordinary bindings — `:disabled="user.lockedAt"`
118
+ * is null when unlocked, `:disabled="count"` is 0 when empty. Both were treated as disabled
119
+ * here, so the href was withheld and the tag flipped to <button>; but Vue renders a nullish
120
+ * or zero `disabled` as ABSENT, so the button came out fully enabled. The result was an
121
+ * ordinary-looking button that navigated nowhere and fired a handler nobody wrote.
122
+ *
123
+ * Measured across every input: undefined/false/null/0 enable, "" and the string "false"
124
+ * disable — the last one deliberately, because an author writing disabled="false" means it
125
+ * to bind, and a disabled control is the safe way to be wrong.
126
+ */
127
+ const isDisabled = computed(() =>
128
+ props.disabled !== undefined
129
+ && props.disabled !== null
130
+ && props.disabled !== false
131
+ && props.disabled !== 0
132
+ )
133
+
134
+ const isLink = computed(() => Boolean(props.href) && !isDisabled.value)
135
+
136
+ const tag = computed(() => isLink.value ? 'a' : 'button')
137
+
58
138
  // button element ref
59
139
  const buttonElementRef = ref(null)
60
140
 
@@ -126,10 +206,12 @@ onMounted(() => {
126
206
  })
127
207
  </script>
128
208
  <template>
129
- <button
209
+ <component :is="tag"
130
210
  ref="buttonElementRef"
131
211
  :class="buttonSlotClass"
132
- type="button"
212
+ :href="isLink ? href : undefined"
213
+ :type="isLink ? undefined : 'button'"
214
+ :disabled="isLink ? undefined : (isDisabled || undefined)"
133
215
  @click="$emit('click', $event)"
134
216
  >
135
217
  <span v-if="$slots.icon" aria-hidden="true">
@@ -137,5 +219,5 @@ onMounted(() => {
137
219
  </span>
138
220
 
139
221
  <slot></slot>
140
- </button>
222
+ </component>
141
223
  </template>
@@ -0,0 +1,22 @@
1
+ <script setup>
2
+ /**
3
+ * A rule between groups of options in a DropdownMenu.
4
+ *
5
+ * `role="separator"` rather than `menuitem`, and deliberately not focusable.
6
+ * `DropdownMenu.handleMenuKeydown()` collects `[role="menuitem"]` for arrow-key
7
+ * navigation, so a separator carrying any other role is stepped over for free —
8
+ * the divider never becomes a stop on the way to the option below it.
9
+ *
10
+ * A component rather than a documented `<hr>`, because a consuming app writing
11
+ * its own would have nothing to style it with (apps pass no `class` or `style`)
12
+ * and could easily reach for `<hr tabindex="0">`, which lands in the tab order
13
+ * between two options.
14
+ *
15
+ * No orientation prop: the menu container is `flex-col`, so a separator in it is
16
+ * always horizontal. `role="separator"` defaults to `aria-orientation="horizontal"`,
17
+ * which is already correct.
18
+ */
19
+ </script>
20
+ <template>
21
+ <div class="rsui-dropdown-divider" role="separator"></div>
22
+ </template>
@@ -1,4 +1,28 @@
1
1
  <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ /**
6
+ * A destructive option — Delete, Remove, Revoke.
7
+ *
8
+ * Presentation only. The option still announces as a plain `menuitem`, because
9
+ * "this one deletes something" is not a role and there is no ARIA for it. Colour
10
+ * must therefore not be the only signal (WCAG 1.4.1) — pair it with a trash icon
11
+ * and an explicit verb ("Delete meeting", not "Delete").
12
+ */
13
+ danger: {
14
+ type: Boolean,
15
+ default: false,
16
+ },
17
+ })
18
+
19
+ const optionClass = computed(() => [
20
+ 'rsui-dropdown-option',
21
+ {
22
+ 'rsui-dropdown-option--danger': props.danger,
23
+ },
24
+ ])
25
+
2
26
  const emit = defineEmits(['click'])
3
27
 
4
28
  function handleClick() {
@@ -6,7 +30,7 @@ function handleClick() {
6
30
  }
7
31
  </script>
8
32
  <template>
9
- <div class="rsui-dropdown-option"
33
+ <div :class="optionClass"
10
34
  role="menuitem"
11
35
  tabindex="0"
12
36
  @click="handleClick"
@@ -1,7 +1,9 @@
1
1
  import DropdownMenu from './DropdownMenu.vue'
2
2
  import DropdownOption from './DropdownOption.vue'
3
+ import DropdownDivider from './DropdownDivider.vue'
3
4
 
4
5
  export {
5
6
  DropdownMenu,
6
7
  DropdownOption,
7
- }
8
+ DropdownDivider,
9
+ }
@@ -1,7 +1,7 @@
1
1
  <script setup>
2
- import { ref, watch, computed } from 'vue'
2
+ import { ref, watch, computed, onMounted } from 'vue'
3
3
  import FormFieldSlot from './FormFieldSlot.vue'
4
- import { CheckIcon } from '@heroicons/vue/24/outline'
4
+ import { CheckIcon, MinusIcon } from '@heroicons/vue/24/outline'
5
5
  import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
6
6
 
7
7
  defineOptions({
@@ -19,6 +19,26 @@ const props = defineProps({
19
19
  type: Boolean,
20
20
  default: false,
21
21
  },
22
+ /**
23
+ * Some but not all of the things this box stands for are selected.
24
+ *
25
+ * The state a select-all exists to show, and the one a plain boolean cannot: without
26
+ * it, "3 of 12 rows selected" renders identically to "none selected", so the control
27
+ * reports the opposite of the truth.
28
+ *
29
+ * Presentation AND semantics, but not value — an indeterminate box is still unchecked
30
+ * as far as `v-model` goes. Clicking it checks it, which is the platform behaviour and
31
+ * what every consumer expects: the next click selects everything.
32
+ *
33
+ * Ignored while `checked`, because a box cannot be both. That ordering is deliberate:
34
+ * a consumer computing `:indeterminate="some && !all"` alongside `v-model="all"` can
35
+ * leave both true for a tick during an update, and the checked state is the safer of
36
+ * the two to show.
37
+ */
38
+ indeterminate: {
39
+ type: Boolean,
40
+ default: false,
41
+ },
22
42
  })
23
43
 
24
44
  // Resolve to a single size modifier so only one applies at a time.
@@ -36,6 +56,27 @@ const emit = defineEmits(['input'])
36
56
 
37
57
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
38
58
 
59
+ /*
60
+ * `indeterminate` is a DOM PROPERTY, not an attribute — there is no `indeterminate=""` to
61
+ * bind, and setting one does nothing. It has to be written to the element, which is why
62
+ * this needs a ref and a watch rather than a binding in the template.
63
+ *
64
+ * It is also what makes a screen reader announce "mixed" rather than "unchecked", so this
65
+ * is the accessible state and not only the glyph.
66
+ */
67
+ const inputElement = ref(null)
68
+
69
+ const isIndeterminate = computed(() => props.indeterminate && !checked.value)
70
+
71
+ function syncIndeterminate() {
72
+ if (!inputElement.value) return
73
+
74
+ inputElement.value.indeterminate = isIndeterminate.value
75
+ }
76
+
77
+ watch(isIndeterminate, syncIndeterminate)
78
+ onMounted(syncIndeterminate)
79
+
39
80
  function check(event) {
40
81
  checked.value = !checked.value
41
82
  model.value = checked.value
@@ -44,7 +85,8 @@ function check(event) {
44
85
  </script>
45
86
  <template>
46
87
  <FormFieldSlot
47
- :id="$attrs.id"
88
+ :id="inputId"
89
+ id-claimed
48
90
  :required="$attrs.required"
49
91
  :showAsterisk="false"
50
92
  class="rsui-form-field-checkbox"
@@ -53,15 +95,26 @@ function check(event) {
53
95
  <div class="rsui-form-field-checkbox__checkbox">
54
96
  <div :class="['rsui-form-field-checkbox__check', size && `rsui-form-field-checkbox__check--${size}`]">
55
97
  <CheckIcon v-if="checked" aria-hidden="true"></CheckIcon>
56
- <input
98
+ <!--
99
+ A dash, not a tick. No v-else: each branch states its own condition, so
100
+ the mutual exclusion is visible rather than implied.
101
+ -->
102
+ <MinusIcon v-if="isIndeterminate" aria-hidden="true"></MinusIcon>
103
+ <!--
104
+ aria-label is bound explicitly because inheritAttrs is false, so it
105
+ would otherwise land nowhere. A checkbox used without a visible #label
106
+ — a table's selection column — has no accessible name without it.
107
+ -->
108
+ <input ref="inputElement"
57
109
  :checked="checked"
58
110
  type="checkbox"
59
111
  :aria-describedby="ariaDescribedby"
60
112
  :aria-invalid="ariaInvalid"
113
+ :aria-label="$attrs['aria-label']"
61
114
  :aria-required="$attrs.required || undefined"
62
115
  :autofocus="$attrs.autofocus"
63
116
  :disabled="$attrs.disabled"
64
- :id="inputId || $attrs.id"
117
+ :id="inputId"
65
118
  :name="$attrs.name"
66
119
  :required="$attrs.required"
67
120
  @change="check"
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, onMounted, onUnmounted, watch, useAttrs, useId } from 'vue'
2
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
3
3
  import { onClickOutside } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
@@ -58,7 +58,6 @@ const props = defineProps({
58
58
 
59
59
  const emit = defineEmits(['input', 'change', 'keyup-enter', 'navigate', 'reject'])
60
60
 
61
- const attrs = useAttrs()
62
61
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
63
62
 
64
63
  const isOpen = ref(false)
@@ -80,11 +79,6 @@ const searchError = ref(null)
80
79
  const asyncOptions = ref([])
81
80
  const debounceTimeout = ref(null)
82
81
 
83
- // Stable fallback so the listbox/datalist/option IDs (and the native <input
84
- // list>/<datalist id> association) never collapse to `undefined-...` when a
85
- // consumer supplies neither an injected inputId nor an explicit id attr.
86
- const fallbackId = useId()
87
- const effectiveId = computed(() => inputId.value || attrs.id || fallbackId)
88
82
 
89
83
  // Engage the native <datalist> path only when opted in, on a touch device, and
90
84
  // for the plain synchronous-options case a <datalist> can actually represent
@@ -96,7 +90,7 @@ const useNativeMobile = computed(() =>
96
90
  && !props.searchFunction
97
91
  && !props.navigable
98
92
  )
99
- const datalistId = computed(() => `${effectiveId.value}-datalist`)
93
+ const datalistId = computed(() => `${inputId.value}-datalist`)
100
94
 
101
95
  // Live region announcement for screen readers
102
96
  const liveAnnouncement = computed(() => {
@@ -472,7 +466,8 @@ defineExpose({
472
466
  <template>
473
467
  <FormFieldSlot
474
468
  ref="comboboxElement"
475
- :id="$attrs.id"
469
+ :id="inputId"
470
+ id-claimed
476
471
  :class="[$attrs.class, 'rsui-form-field-combobox']"
477
472
  :required="$attrs.required"
478
473
  :showAsterisk="$attrs.showAsterisk"
@@ -495,10 +490,10 @@ defineExpose({
495
490
  v-if="!useNativeMobile"
496
491
  ref="inputElement"
497
492
  role="combobox"
498
- :aria-activedescendant="isOpen && dropdownContent === 'options' && highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
493
+ :aria-activedescendant="isOpen && dropdownContent === 'options' && highlightedIndex >= 0 ? `${inputId}-option-${highlightedIndex}` : undefined"
499
494
  :aria-autocomplete="'list'"
500
495
  :aria-busy="isLoading || undefined"
501
- :aria-controls="`${effectiveId}-listbox`"
496
+ :aria-controls="`${inputId}-listbox`"
502
497
  :aria-describedby="ariaDescribedby"
503
498
  :aria-expanded="isOpen"
504
499
  :aria-haspopup="'listbox'"
@@ -508,7 +503,7 @@ defineExpose({
508
503
  :autocomplete="$attrs.autocomplete"
509
504
  :autofocus="$attrs.autofocus"
510
505
  :disabled="$attrs.disabled"
511
- :id="effectiveId"
506
+ :id="inputId"
512
507
  :name="$attrs.name"
513
508
  :placeholder="$slots.placeholder ? '' : 'Type to search or select...'"
514
509
  :required="$attrs.required"
@@ -533,7 +528,7 @@ defineExpose({
533
528
  :autocomplete="$attrs.autocomplete"
534
529
  :autofocus="$attrs.autofocus"
535
530
  :disabled="$attrs.disabled"
536
- :id="effectiveId"
531
+ :id="inputId"
537
532
  :name="$attrs.name"
538
533
  :placeholder="$slots.placeholder ? '' : 'Type to search or select...'"
539
534
  :required="$attrs.required"
@@ -571,7 +566,7 @@ defineExpose({
571
566
  <div
572
567
  ref="dropdownElement"
573
568
  v-show="isOpen"
574
- :id="`${effectiveId}-listbox`"
569
+ :id="`${inputId}-listbox`"
575
570
  role="listbox"
576
571
  :class="[
577
572
  'rsui-form-field-combobox__options',
@@ -660,7 +655,7 @@ defineExpose({
660
655
  <div
661
656
  v-for="option in group.options"
662
657
  :key="option.value"
663
- :id="`${effectiveId}-option-${filteredOptions.indexOf(option)}`"
658
+ :id="`${inputId}-option-${filteredOptions.indexOf(option)}`"
664
659
  role="option"
665
660
  :aria-selected="option.value === model"
666
661
  :class="[
@@ -687,7 +682,7 @@ defineExpose({
687
682
  <div
688
683
  v-for="(option, index) in filteredOptions"
689
684
  :key="option.value"
690
- :id="`${effectiveId}-option-${index}`"
685
+ :id="`${inputId}-option-${index}`"
691
686
  role="option"
692
687
  :aria-selected="option.value === model"
693
688
  :class="[
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, useAttrs, useSlots } from 'vue'
2
+ import { computed, useSlots } from 'vue'
3
3
  import FormFieldSlot from './FormFieldSlot.vue'
4
4
  import { useFormFieldA11y } from '../../composables/useFormFieldA11y.js'
5
5
 
@@ -26,11 +26,8 @@ const props = defineProps({
26
26
 
27
27
  const emit = defineEmits(['input'])
28
28
 
29
- const attrs = useAttrs()
30
29
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
31
30
 
32
- const effectiveId = computed(() => inputId.value || attrs.id)
33
-
34
31
  function setValue(value) {
35
32
  model.value = value
36
33
  emit('input', value)
@@ -60,21 +57,26 @@ const itemsClass = computed(() => [
60
57
  </script>
61
58
  <template>
62
59
  <FormFieldSlot
63
- :id="$attrs.id"
60
+ :id="inputId"
61
+ id-claimed
64
62
  :class="[$attrs.class, 'rsui-form-field-radio-group']"
65
63
  :required="$attrs.required"
66
64
  :showAsterisk="$attrs.showAsterisk"
67
65
  :compact="$attrs.compact"
68
66
  :lg="$attrs.lg"
69
67
  >
68
+ <!--
69
+ No id-carrying wrapper here. FormFieldSlot's own <label> already carries
70
+ `${inputId}-label`, so a span with the same id inside it put that id in the
71
+ document twice and left aria-labelledby resolving to whichever the browser
72
+ picked (WCAG 4.1.1). The group points at FormFieldSlot's label instead.
73
+ -->
70
74
  <template #label v-if="$slots.label">
71
- <span :id="`${effectiveId}-label`">
72
- <slot name="label"></slot>
73
- </span>
75
+ <slot name="label"></slot>
74
76
  </template>
75
77
  <div :class="itemsClass"
76
78
  role="radiogroup"
77
- :aria-labelledby="$slots.label ? `${effectiveId}-label` : undefined"
79
+ :aria-labelledby="$slots.label ? `${inputId}-label` : undefined"
78
80
  :aria-describedby="ariaDescribedby"
79
81
  :aria-invalid="ariaInvalid"
80
82
  :aria-required="$attrs.required || undefined"
@@ -98,12 +100,12 @@ const itemsClass = computed(() => [
98
100
  type="radio"
99
101
  :value="option.value"
100
102
  v-model="model"
101
- :id="`${effectiveId}-option-${index}`"
102
- :name="$attrs.name || effectiveId"
103
+ :id="`${inputId}-option-${index}`"
104
+ :name="$attrs.name || inputId"
103
105
  :aria-describedby="ariaDescribedby"
104
106
  >
105
107
  <label
106
- :for="`${effectiveId}-option-${index}`"
108
+ :for="`${inputId}-option-${index}`"
107
109
  :class="['rsui-form-field-radio-group__item-label', {'rsui-form-field-radio-group__item-label--disabled': option.disabled}]"
108
110
  >
109
111
  {{ option.label }}
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, watch, useAttrs } from 'vue'
2
+ import { ref, computed, watch } from 'vue'
3
3
  import { onClickOutside } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
@@ -36,7 +36,6 @@ const props = defineProps({
36
36
 
37
37
  const emit = defineEmits(['input', 'query-change', 'navigate', 'keyup-enter'])
38
38
 
39
- const attrs = useAttrs()
40
39
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
41
40
 
42
41
  const query = ref('')
@@ -53,8 +52,6 @@ const results = ref([])
53
52
  const debounceTimeout = ref(null)
54
53
  let latestRequestId = 0
55
54
 
56
- const effectiveId = computed(() => inputId.value || attrs.id)
57
-
58
55
  const liveAnnouncement = computed(() => {
59
56
  if (!isOpen.value) return ''
60
57
  if (isLoading.value) return 'Searching'
@@ -274,7 +271,8 @@ defineExpose({
274
271
  <template>
275
272
  <FormFieldSlot
276
273
  ref="rootElement"
277
- :id="$attrs.id"
274
+ :id="inputId"
275
+ id-claimed
278
276
  :class="[$attrs.class, 'rsui-form-field-search-async', 'rsui-form-field-search', 'rsui-form-field-text']"
279
277
  :required="$attrs.required"
280
278
  :showAsterisk="$attrs.showAsterisk"
@@ -297,10 +295,10 @@ defineExpose({
297
295
  ref="inputElement"
298
296
  role="combobox"
299
297
  class="rsui-form-field-search"
300
- :aria-activedescendant="isOpen && dropdownContent === 'results' && highlightedIndex >= 0 ? `${effectiveId}-result-${highlightedIndex}` : undefined"
298
+ :aria-activedescendant="isOpen && dropdownContent === 'results' && highlightedIndex >= 0 ? `${inputId}-result-${highlightedIndex}` : undefined"
301
299
  :aria-autocomplete="'list'"
302
300
  :aria-busy="isLoading || undefined"
303
- :aria-controls="`${effectiveId}-listbox`"
301
+ :aria-controls="`${inputId}-listbox`"
304
302
  :aria-describedby="ariaDescribedby"
305
303
  :aria-expanded="isOpen"
306
304
  :aria-haspopup="'listbox'"
@@ -310,7 +308,7 @@ defineExpose({
310
308
  :autocomplete="$attrs.autocomplete || 'off'"
311
309
  :autofocus="$attrs.autofocus"
312
310
  :disabled="$attrs.disabled"
313
- :id="effectiveId"
311
+ :id="inputId"
314
312
  :name="$attrs.name"
315
313
  :placeholder="$attrs.placeholder || 'Search...'"
316
314
  :required="$attrs.required"
@@ -333,7 +331,7 @@ defineExpose({
333
331
  <div
334
332
  ref="dropdownElement"
335
333
  v-show="isOpen"
336
- :id="`${effectiveId}-listbox`"
334
+ :id="`${inputId}-listbox`"
337
335
  role="listbox"
338
336
  :class="[
339
337
  'rsui-form-field-combobox__options',
@@ -416,7 +414,7 @@ defineExpose({
416
414
  <div
417
415
  v-for="{ result, flatIndex } in group.items"
418
416
  :key="result.value"
419
- :id="`${effectiveId}-result-${flatIndex}`"
417
+ :id="`${inputId}-result-${flatIndex}`"
420
418
  role="option"
421
419
  aria-selected="false"
422
420
  :class="[
@@ -446,7 +444,7 @@ defineExpose({
446
444
  <div
447
445
  v-for="(result, index) in results"
448
446
  :key="result.value"
449
- :id="`${effectiveId}-result-${index}`"
447
+ :id="`${inputId}-result-${index}`"
450
448
  role="option"
451
449
  aria-selected="false"
452
450
  :class="[
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, watch, onMounted, onUnmounted, useAttrs } from 'vue'
2
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
3
3
  import { onClickOutside } from '@vueuse/core'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import { ChevronDownIcon, CheckIcon } from '@heroicons/vue/24/outline'
@@ -21,21 +21,12 @@ const props = defineProps({
21
21
 
22
22
  const emit = defineEmits(['change'])
23
23
 
24
- const attrs = useAttrs()
25
24
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
26
25
 
27
26
  const isOpen = ref(false)
28
27
  const isMobileDevice = ref(false)
29
28
  const highlightedIndex = ref(-1)
30
29
 
31
- // Fall back to a locally-generated unique id when this Select isn't wrapped by
32
- // a FormFieldSlot (no injected inputId) and no id attr is supplied. Without it,
33
- // effectiveId is undefined and the listbox/option ids — and the trigger's
34
- // aria-controls — interpolate the literal string "undefined" (e.g.
35
- // "undefined-listbox"), a dangling reference and a duplicate id across
36
- // instances. Mirrors FormFieldSlot's _.uniqueId('form-field-') mechanism.
37
- const autoId = _.uniqueId('form-field-')
38
- const effectiveId = computed(() => inputId.value || attrs.id || autoId)
39
30
 
40
31
  function toggleOptions() {
41
32
  isOpen.value = !isOpen.value
@@ -212,7 +203,8 @@ defineExpose({
212
203
  <template>
213
204
  <FormFieldSlot
214
205
  ref="formFieldSelectElement"
215
- :id="effectiveId"
206
+ :id="inputId"
207
+ id-claimed
216
208
  :class="[
217
209
  $attrs.class,
218
210
  'rsui-form-field-select',
@@ -237,15 +229,15 @@ defineExpose({
237
229
  { 'rsui-form-field-select__trigger--invalid': isNativeInvalid }
238
230
  ]"
239
231
  role="combobox"
240
- :aria-activedescendant="highlightedIndex >= 0 ? `${effectiveId}-option-${highlightedIndex}` : undefined"
241
- :aria-controls="isOpen ? `${effectiveId}-listbox` : undefined"
232
+ :aria-activedescendant="highlightedIndex >= 0 ? `${inputId}-option-${highlightedIndex}` : undefined"
233
+ :aria-controls="isOpen ? `${inputId}-listbox` : undefined"
242
234
  :aria-describedby="ariaDescribedby"
243
235
  :aria-expanded="isOpen"
244
236
  :aria-haspopup="'listbox'"
245
237
  :aria-invalid="ariaInvalid || isNativeInvalid || undefined"
246
238
  :aria-required="$attrs.required || undefined"
247
239
  :disabled="$attrs.disabled"
248
- :id="effectiveId"
240
+ :id="inputId"
249
241
  @click="toggleOptions"
250
242
  @keydown="handleKeydown"
251
243
  >
@@ -263,7 +255,7 @@ defineExpose({
263
255
  :autocomplete="$attrs.autocomplete"
264
256
  :autofocus="isMobileDevice ? $attrs.autofocus : undefined"
265
257
  :disabled="$attrs.disabled"
266
- :id="isMobileDevice ? effectiveId : undefined"
258
+ :id="isMobileDevice ? inputId : undefined"
267
259
  :name="$attrs.name"
268
260
  :required="$attrs.required"
269
261
  @change.prevent="nativeChoose"
@@ -295,7 +287,7 @@ defineExpose({
295
287
  >
296
288
  <div ref="dropdownElement"
297
289
  v-show="isOpen"
298
- :id="`${effectiveId}-listbox`"
290
+ :id="`${inputId}-listbox`"
299
291
  role="listbox"
300
292
  :class="[
301
293
  'rsui-form-field-select__options',
@@ -303,7 +295,7 @@ defineExpose({
303
295
  ]"
304
296
  >
305
297
  <div class="rsui-form-field-select__option rsui-form-field-select__option--disabled"
306
- :id="`${effectiveId}-option-placeholder`"
298
+ :id="`${inputId}-option-placeholder`"
307
299
  role="option"
308
300
  aria-selected="false"
309
301
  aria-disabled="true"
@@ -316,7 +308,7 @@ defineExpose({
316
308
  </div>
317
309
  <div v-for="(option, index) in options"
318
310
  :key="option.value"
319
- :id="`${effectiveId}-option-${index}`"
311
+ :id="`${inputId}-option-${index}`"
320
312
  role="option"
321
313
  :aria-selected="option.value === model"
322
314
  :class="[