@redseed/redseed-ui-vue3 10.1.0 → 10.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-vue3",
3
- "version": "10.1.0",
3
+ "version": "10.3.1",
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="[
@@ -15,6 +15,21 @@ const props = defineProps({
15
15
  type: Boolean,
16
16
  default: false,
17
17
  },
18
+ /**
19
+ * Set by a field component to say "the id I handed you is already on my own control".
20
+ *
21
+ * It is what lets `useFormFieldA11y()` tell two identical-looking nestings apart. A
22
+ * field renders this slot as its own root and puts the id on its own input, so anything
23
+ * appearing in the slots below is a SEPARATE control and must mint its own id. A bare
24
+ * FormFieldSlot written by a consumer owns no control, so whatever it wraps is the
25
+ * control this label belongs to and should adopt the id.
26
+ *
27
+ * Injection alone cannot distinguish those — both cases simply see a provider above.
28
+ */
29
+ idClaimed: {
30
+ type: Boolean,
31
+ default: false,
32
+ },
18
33
  })
19
34
 
20
35
  defineOptions({
@@ -39,11 +54,13 @@ const ariaDescribedby = computed(() => {
39
54
  // aria-invalid when error slot is present
40
55
  const ariaInvalid = computed(() => slots.error ? true : undefined)
41
56
 
42
- // Provide a11y values for child components to inject
57
+ // Provide a11y values for child components to inject. `idClaimed` travels with them so a
58
+ // nested field can tell whether the id above it is already spoken for.
43
59
  provide(FormFieldA11yKey, {
44
60
  inputId,
45
61
  ariaDescribedby,
46
62
  ariaInvalid,
63
+ idClaimed: computed(() => props.idClaimed),
47
64
  })
48
65
 
49
66
  const formFieldSlotClass = computed(() => [
@@ -23,7 +23,8 @@ defineExpose({
23
23
  </script>
24
24
  <template>
25
25
  <FormFieldSlot
26
- :id="$attrs.id"
26
+ :id="inputId"
27
+ id-claimed
27
28
  :class="[$attrs.class, 'rsui-form-field-text']"
28
29
  :required="$attrs.required"
29
30
  :showAsterisk="$attrs.showAsterisk"
@@ -47,7 +48,7 @@ defineExpose({
47
48
  :autocomplete="$attrs.autocomplete"
48
49
  :autofocus="$attrs.autofocus"
49
50
  :disabled="$attrs.disabled"
50
- :id="inputId || $attrs.id"
51
+ :id="inputId"
51
52
  :inputmode="$attrs.inputmode"
52
53
  :max="$attrs.max"
53
54
  :maxlength="$attrs.maxlength"
@@ -50,7 +50,8 @@ defineExpose({
50
50
  </script>
51
51
  <template>
52
52
  <FormFieldSlot
53
- :id="$attrs.id"
53
+ :id="inputId"
54
+ id-claimed
54
55
  :class="[$attrs.class, 'rsui-form-field-textarea']"
55
56
  :required="$attrs.required"
56
57
  :showAsterisk="$attrs.showAsterisk"
@@ -69,7 +70,7 @@ defineExpose({
69
70
  :autocomplete="$attrs.autocomplete"
70
71
  :autofocus="$attrs.autofocus"
71
72
  :disabled="$attrs.disabled"
72
- :id="inputId || $attrs.id"
73
+ :id="inputId"
73
74
  :maxlength="$attrs.maxlength"
74
75
  :minlength="$attrs.minlength"
75
76
  :name="$attrs.name"
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, useAttrs } from 'vue'
2
+ import { ref, computed } from 'vue'
3
3
  import { useFuse } from '@vueuse/integrations/useFuse'
4
4
  import FormFieldSlot from './FormFieldSlot.vue'
5
5
  import FormFieldSearch from './FormFieldSearch.vue'
@@ -49,7 +49,6 @@ const props = defineProps({
49
49
 
50
50
  const emit = defineEmits(['change'])
51
51
 
52
- const attrs = useAttrs()
53
52
  const { inputId, ariaDescribedby, ariaInvalid } = useFormFieldA11y()
54
53
 
55
54
  const model = defineModel({
@@ -57,8 +56,6 @@ const model = defineModel({
57
56
  validator: () => true,
58
57
  })
59
58
 
60
- const effectiveId = computed(() => inputId.value || attrs.id)
61
-
62
59
  const show = ref(false)
63
60
  function openModal() {
64
61
  if (props.disabled) return
@@ -181,7 +178,8 @@ function deselectOption(node) {
181
178
 
182
179
  <template>
183
180
  <FormFieldSlot
184
- :id="$attrs.id"
181
+ :id="inputId"
182
+ id-claimed
185
183
  :class="[$attrs.class, 'rsui-form-field-tree-select']"
186
184
  :required="$attrs.required"
187
185
  :showAsterisk="$attrs.showAsterisk"
@@ -199,7 +197,7 @@ function deselectOption(node) {
199
197
  { 'rsui-form-field-tree-select__trigger--full': fullWidth },
200
198
  { 'rsui-form-field-tree-select__trigger--disabled': disabled },
201
199
  ]"
202
- :id="effectiveId"
200
+ :id="inputId"
203
201
  :aria-describedby="ariaDescribedby"
204
202
  :aria-invalid="ariaInvalid"
205
203
  :aria-required="$attrs.required || undefined"
@@ -222,7 +220,14 @@ function deselectOption(node) {
222
220
  </template>
223
221
 
224
222
  <div class="rsui-form-field-tree-select__search">
223
+ <!--
224
+ Its own id. This search box lives inside the tree select's own
225
+ FormFieldSlot, so without one it would adopt the outer field's id and two
226
+ controls would answer to it — the trigger and this input. `id-claimed`
227
+ above makes it mint its own, and naming it explicitly keeps it legible.
228
+ -->
225
229
  <FormFieldSearch
230
+ :id="`${inputId}-search`"
226
231
  v-model="searchInput"
227
232
  :placeholder="searchPlaceholder"
228
233
  ></FormFieldSearch>
@@ -46,6 +46,26 @@ const props = defineProps({
46
46
  type: Boolean,
47
47
  default: false,
48
48
  },
49
+ /*
50
+ * The heading level of the title.
51
+ *
52
+ * Defaults to 1, which is right for a real page and was the hardcoded behaviour.
53
+ * It is wrong everywhere else, and there was no way to say so: a PageHeader in a
54
+ * drawer, a preview or a docs page emitted a second <h1> that no care in the
55
+ * surrounding markup could correct. Any page that demonstrates the component more than
56
+ * once emits one <h1> per instance — the Storybook autodocs page for PageHeader
57
+ * renders 21 stories, so 21 of them on one page.
58
+ *
59
+ * Same shape and validator as SectionHeader.headingLevel, deliberately — the two
60
+ * header components disagreeing about whether their level is the consumer's business
61
+ * is what #337 started closing and this finishes. `null` renders a <span>, for a
62
+ * title that is styled as one but is not a heading at all.
63
+ */
64
+ headingLevel: {
65
+ type: [Number, String],
66
+ default: 1,
67
+ validator: (value) => value === null || ['1', '2', '3', '4', '5', '6'].includes(String(value)),
68
+ },
49
69
  })
50
70
 
51
71
  /**
@@ -108,6 +128,28 @@ onBeforeUnmount(() => {
108
128
  metaObserver = null
109
129
  })
110
130
 
131
+ /*
132
+ * Shared with the tag below but NOT with the validator in defineProps, which is forced to
133
+ * repeat the list: defineProps() is hoisted outside setup() and cannot close over a local.
134
+ * A test asserts the two agree.
135
+ */
136
+ const HEADING_LEVELS = ['1', '2', '3', '4', '5', '6']
137
+
138
+ /*
139
+ * Falls back to a span for anything that is not a real heading level, rather than
140
+ * interpolating whatever it was handed.
141
+ *
142
+ * The validator only warns, and Vue strips validators from production builds — so before
143
+ * this, `heading-level="null"` (the string, an easy slip when the correct form needs a
144
+ * binding) rendered <hnull>, and `:heading-level="7"` rendered <h7>. Both are invalid
145
+ * elements, both shipped silently, and both LOOKED correct because the styling hook is a
146
+ * class rather than the tag. Rendered and verified, not assumed.
147
+ *
148
+ * A span is the safe direction: it never corrupts the document outline, where <h7> is
149
+ * simply broken markup.
150
+ */
151
+ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLevel)) ? `h${props.headingLevel}` : 'span')
152
+
111
153
  const pageHeaderClass = computed(() => [
112
154
  'rsui-page-header',
113
155
  {
@@ -143,9 +185,9 @@ const pageHeaderClass = computed(() => [
143
185
  </div>
144
186
 
145
187
  <div class="rsui-page-header__title-text-container">
146
- <h1>
188
+ <component :is="titleTag" class="rsui-page-header__heading">
147
189
  <slot name="title"></slot>
148
- </h1>
190
+ </component>
149
191
  <div v-if="$slots.status" class="rsui-page-header__status">
150
192
  <slot name="status"></slot>
151
193
  </div>
@@ -85,5 +85,17 @@ const emit = defineEmits(['click'])
85
85
  <div class="rsui-meta-info__value">
86
86
  <slot></slot>
87
87
  </div>
88
+
89
+ <!--
90
+ A second, subordinate line UNDER the value — "Repeats weekly on Tuesday" beneath
91
+ a date, rather than a fourth field beside it.
92
+
93
+ A sibling of __value rather than a child of it: __value is a flex ROW (it lines an
94
+ avatar up beside its text), so a subtitle dropped inside it lands next to the value
95
+ instead of below. The root is the column, and its gap-y already spaces this.
96
+ -->
97
+ <div v-if="$slots.subtitle" class="rsui-meta-info__subtitle">
98
+ <slot name="subtitle"></slot>
99
+ </div>
88
100
  </div>
89
101
  </template>
@@ -139,7 +139,27 @@ function showsToolbar() {
139
139
  * Element used to render the title — a real heading when a level is supplied,
140
140
  * otherwise a non-heading <span>, which now requires passing null explicitly.
141
141
  */
142
- const titleTag = computed(() => props.headingLevel ? `h${props.headingLevel}` : 'span')
142
+ /*
143
+ * Shared with the tag below but NOT with the validator in defineProps, which is forced to
144
+ * repeat the list: defineProps() is hoisted outside setup() and cannot close over a local.
145
+ * A test asserts the two agree.
146
+ */
147
+ const HEADING_LEVELS = ['1', '2', '3', '4', '5', '6']
148
+
149
+ /*
150
+ * Falls back to a span for anything that is not a real heading level, rather than
151
+ * interpolating whatever it was handed.
152
+ *
153
+ * The validator only warns, and Vue strips validators from production builds — so before
154
+ * this, `heading-level="null"` (the string, an easy slip when the correct form needs a
155
+ * binding) rendered <hnull>, and `:heading-level="7"` rendered <h7>. Both are invalid
156
+ * elements, both shipped silently, and both LOOKED correct because the styling hook is a
157
+ * class rather than the tag. Rendered and verified, not assumed.
158
+ *
159
+ * A span is the safe direction: it never corrupts the document outline, where <h7> is
160
+ * simply broken markup.
161
+ */
162
+ const titleTag = computed(() => HEADING_LEVELS.includes(String(props.headingLevel)) ? `h${props.headingLevel}` : 'span')
143
163
  </script>
144
164
  <template>
145
165
  <div ref="sectionHeaderElement"
@@ -5,6 +5,7 @@ import Tr from './Tr.vue'
5
5
  import Th from './Th.vue'
6
6
  import Td from './Td.vue'
7
7
  import ColumnPicker from './ColumnPicker.vue'
8
+ import FormFieldCheckbox from '../FormField/FormFieldCheckbox.vue'
8
9
 
9
10
  const titleId = _.uniqueId('table-title-')
10
11
 
@@ -58,8 +59,96 @@ const props = defineProps({
58
59
  type: Boolean,
59
60
  default: false,
60
61
  },
62
+ /**
63
+ * Row selection: a checkbox column, and a select-all in the header.
64
+ *
65
+ * Opt-in, so no existing table changes. Use with `v-model:selected`, which holds the
66
+ * ids of the selected rows.
67
+ *
68
+ * Exists because the alternative was consumers hand-rolling it, and the LMS shows what
69
+ * that costs: bare `<input type="checkbox">` elements with the select-all reconciled
70
+ * through `document.getElementById`, no indeterminate state, no accessible names, and
71
+ * the select-all stranded in the `#title` slot because there was no header cell to put
72
+ * it in — which is why it landed on its own line on a phone (#390).
73
+ */
74
+ selectable: {
75
+ type: Boolean,
76
+ default: false,
77
+ },
78
+ /**
79
+ * Accessible name for the select-all checkbox.
80
+ *
81
+ * A prop rather than a slot because it is an accessible name, not content — there is
82
+ * nothing to render, and a slot would invite markup into a `<th>` that holds only a
83
+ * control.
84
+ */
85
+ selectAllLabel: {
86
+ type: String,
87
+ default: 'Select all rows',
88
+ },
89
+ })
90
+
91
+ /*
92
+ * v-model:selected — the ids of the selected rows.
93
+ *
94
+ * The consumer owns the array, which is the whole point: the LMS version kept the truth in
95
+ * the DOM and wrote `.checked` directly, so two tables on one page collided on the id and
96
+ * nothing could read the selection back out.
97
+ */
98
+ const selected = defineModel('selected', {
99
+ type: Array,
100
+ default: () => [],
61
101
  })
62
102
 
103
+ const selectableRowIds = computed(() => props.rows.map((row) => row.id))
104
+
105
+ const isRowSelected = (row) => selected.value.includes(row.id)
106
+
107
+ /*
108
+ * Every row, none, or some. The third is the state a select-all exists to report and the
109
+ * one the hand-rolled version could not: with a plain boolean, a partial selection renders
110
+ * identically to an empty one.
111
+ */
112
+ const allSelected = computed(() =>
113
+ selectableRowIds.value.length > 0
114
+ && selectableRowIds.value.every((id) => selected.value.includes(id))
115
+ )
116
+
117
+ const someSelected = computed(() =>
118
+ selectableRowIds.value.some((id) => selected.value.includes(id))
119
+ )
120
+
121
+ function toggleRow(row) {
122
+ // Rebuilt rather than mutated, so a consumer holding the array in a readonly store or
123
+ // watching it shallowly still sees the change.
124
+ selected.value = isRowSelected(row)
125
+ ? selected.value.filter((id) => id !== row.id)
126
+ : [...selected.value, row.id]
127
+ }
128
+
129
+ /*
130
+ * Select-all acts on THESE rows, not on everything the consumer might have.
131
+ *
132
+ * A paginated or filtered table only knows the page it was handed, so "all" can only
133
+ * honestly mean "all of these". Clearing removes just this page's ids and leaves any
134
+ * selection made on another page intact.
135
+ */
136
+ function toggleAll() {
137
+ selected.value = allSelected.value
138
+ ? selected.value.filter((id) => !selectableRowIds.value.includes(id))
139
+ : [...new Set([...selected.value, ...selectableRowIds.value])]
140
+ }
141
+
142
+ /*
143
+ * Names the row, not the control. A column of boxes all called "Select row" is a column of
144
+ * identical announcements with nothing to tell them apart, which is what the LMS shipped.
145
+ * `row.selectionLabel` wins; `row.ariaLabel` is the fallback because rows already carry it
146
+ * for the clickable case, so most consumers get a real name for free.
147
+ */
148
+ function rowSelectionLabel(row) {
149
+ return row.selectionLabel ?? row.ariaLabel ?? 'Select row'
150
+ }
151
+
63
152
  // v-model:visibleKeys — undefined means "uncontrolled" / use internal default (all visible).
64
153
  const visibleKeys = defineModel('visibleKeys', {
65
154
  type: Array,
@@ -242,6 +331,29 @@ watch([() => props.rows, visibleColumns], () => nextTick(updateScrollable), { de
242
331
  <caption v-if="!showHeader && $slots.title"><slot name="title"></slot></caption>
243
332
  <thead v-if="visibleColumns.length">
244
333
  <Tr>
334
+ <!--
335
+ A real header cell for the select-all, which is the point.
336
+ It used to have nowhere to go but the #title slot, and
337
+ CardHeader stacks title and actions on separate rows when
338
+ narrow — so on a phone it was stranded on a line of its own
339
+ above the search (#390).
340
+
341
+ scope="col" like any other header cell; the checkbox carries
342
+ the accessible name, so the cell needs no text of its own.
343
+ -->
344
+ <Th v-if="selectable"
345
+ scope="col"
346
+ :fixed="fixedColumns"
347
+ class="rsui-table__select-cell"
348
+ >
349
+ <FormFieldCheckbox sm
350
+ :model-value="allSelected"
351
+ :indeterminate="someSelected && !allSelected"
352
+ :aria-label="selectAllLabel"
353
+ @update:model-value="toggleAll"
354
+ ></FormFieldCheckbox>
355
+ </Th>
356
+
245
357
  <Th v-for="column in visibleColumns"
246
358
  :key="column.key"
247
359
  scope="col"
@@ -268,6 +380,24 @@ watch([() => props.rows, visibleColumns], () => nextTick(updateScrollable), { de
268
380
  :aria-label="row.ariaLabel"
269
381
  @click="$emit('click:row', row)"
270
382
  >
383
+ <!--
384
+ @click.stop so ticking a box on a clickable row does not also
385
+ open the row. Selecting and navigating are different intents,
386
+ and the whole reason to select is to act on rows without
387
+ visiting them.
388
+ -->
389
+ <Td v-if="selectable"
390
+ :fixed="fixedColumns"
391
+ class="rsui-table__select-cell"
392
+ @click.stop
393
+ >
394
+ <FormFieldCheckbox sm
395
+ :model-value="isRowSelected(row)"
396
+ :aria-label="rowSelectionLabel(row)"
397
+ @update:model-value="toggleRow(row)"
398
+ ></FormFieldCheckbox>
399
+ </Td>
400
+
271
401
  <Td v-for="column in visibleColumns"
272
402
  :key="column.key"
273
403
  :alignment="column?.alignment"
@@ -1,28 +1,85 @@
1
- import { inject, ref } from 'vue'
1
+ import { computed, inject, useAttrs, useSlots } from 'vue'
2
2
 
3
- // Injection key used by FormFieldSlot to provide a11y values
3
+ // Injection key used by FormFieldSlot to hand these values to anything nested deeper.
4
4
  export const FormFieldA11yKey = Symbol('FormFieldA11y')
5
5
 
6
6
  /**
7
- * Consumer side of a provide/inject pattern for WCAG a11y attributes.
7
+ * The three WCAG wiring values every field control needs:
8
8
  *
9
- * FormFieldSlot (the wrapper) provides three reactive values under FormFieldA11yKey:
10
- * - inputId: a unique ID (from _.uniqueId()) for <label for> / <input id> association
11
- * - ariaDescribedby: computed string linking to help/error text element IDs
12
- * - ariaInvalid: computed boolean, true when an error slot is present
9
+ * - inputId the id on the control, and the target of the label's `for`
10
+ * - ariaDescribedby points at the help and error text, when either is present
11
+ * - ariaInvalid true while an error slot is rendered
13
12
  *
14
- * Child input components (FormFieldText, Checkbox, Select, Toggle, etc.) call this
15
- * composable to inject those values and bind them to their <input> elements.
13
+ * THE ID IS GENERATED HERE, by the field component, and handed DOWN to FormFieldSlot.
14
+ * That direction is the whole point, and it used to run the other way (#342).
16
15
  *
17
- * Safe to use outside FormFieldSlot — returns ref(null) fallbacks so ARIA attributes
18
- * simply won't render when there's no wrapper providing help/error context.
16
+ * FormFieldSlot generates an id too, and provides it under FormFieldA11yKey — but
17
+ * FormFieldSlot is the field's CHILD: it is the root element of FormFieldText's template,
18
+ * not its wrapper. provide/inject only ever flows parent to child, so a field injecting
19
+ * what its own child provides gets nothing and silently fell back to ref(null). Measured
20
+ * before the fix, with no consumer id passed:
21
+ *
22
+ * label[for] form-field-1
23
+ * input id undefined
24
+ *
25
+ * The label pointed at an element that did not exist, and aria-describedby never rendered,
26
+ * so help and error text did not reach the control either. WCAG 3.3.2 and 1.3.1, on every
27
+ * field in the library that did not pass an id by hand.
28
+ *
29
+ * Five components had already worked around it by recomputing a local `effectiveId`, and
30
+ * in four of those the workaround was itself broken: the control took the local id while
31
+ * the label took FormFieldSlot's separate one, so the two still disagreed. Those are gone;
32
+ * this is the single place an id is minted.
33
+ *
34
+ * A consumer-supplied `id` still wins, so nothing changes for a call site that passes one.
35
+ *
36
+ * The inject is kept for the genuinely nested case — a control rendered *inside* a
37
+ * FormFieldSlot's slots rather than being its parent. When there is a provider above, its
38
+ * values win, which keeps a nested control on the same id as the label above it.
19
39
  */
20
40
  export function useFormFieldA11y() {
21
- const a11y = inject(FormFieldA11yKey, null)
41
+ const provided = inject(FormFieldA11yKey, null)
42
+
43
+ /*
44
+ * Adopt an injected id only when no control owns it yet.
45
+ *
46
+ * A provider above means one of two things, and they look identical from here:
47
+ *
48
+ * a consumer's bare FormFieldSlot wrapping this control — one field, and this IS its
49
+ * control, so it should take the outer id or the label points at nothing;
50
+ *
51
+ * another FIELD's FormFieldSlot, because this control sits in its #prefix, #suffix or
52
+ * default slot — two controls, and taking the outer id puts the same id on both.
53
+ *
54
+ * The second was a real regression: `<FormFieldText><template #prefix><FormFieldText/>`
55
+ * rendered two inputs sharing one id, so `label[for]` resolved to whichever the browser
56
+ * picked. WCAG 4.1.1, and silent — the markup reads correctly and nothing warns.
57
+ *
58
+ * `idClaimed` is the discriminator. A field sets it when it puts the id on its own
59
+ * control; a bare FormFieldSlot leaves it false because it owns no control.
60
+ */
61
+ if (provided && !provided.idClaimed?.value) return provided
62
+
63
+ const attrs = useAttrs()
64
+ const slots = useSlots()
65
+
66
+ const autoId = _.uniqueId('form-field-')
67
+ const inputId = computed(() => attrs.id || autoId)
68
+
69
+ const ariaDescribedby = computed(() => {
70
+ const ids = []
71
+
72
+ if (slots.help) ids.push(`${inputId.value}-help`)
73
+ if (slots.error) ids.push(`${inputId.value}-error`)
74
+
75
+ return ids.length > 0 ? ids.join(' ') : undefined
76
+ })
77
+
78
+ const ariaInvalid = computed(() => slots.error ? true : undefined)
22
79
 
23
80
  return {
24
- inputId: a11y?.inputId ?? ref(null),
25
- ariaDescribedby: a11y?.ariaDescribedby ?? ref(null),
26
- ariaInvalid: a11y?.ariaInvalid ?? ref(null),
81
+ inputId,
82
+ ariaDescribedby,
83
+ ariaInvalid,
27
84
  }
28
85
  }