@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 +1 -0
- package/package.json +1 -1
- package/src/components/Button/ButtonSlot.vue +86 -4
- package/src/components/DropdownMenu/DropdownDivider.vue +22 -0
- package/src/components/DropdownMenu/DropdownOption.vue +25 -1
- package/src/components/DropdownMenu/index.js +3 -1
- package/src/components/FormField/FormFieldCheckbox.vue +58 -5
- package/src/components/FormField/FormFieldCombobox.vue +11 -16
- package/src/components/FormField/FormFieldRadioGroup.vue +14 -12
- package/src/components/FormField/FormFieldSearchAsync.vue +9 -11
- package/src/components/FormField/FormFieldSelect.vue +10 -18
- package/src/components/FormField/FormFieldSlot.vue +18 -1
- package/src/components/FormField/FormFieldText.vue +3 -2
- package/src/components/FormField/FormFieldTextarea.vue +3 -2
- package/src/components/FormField/FormFieldTreeSelect.vue +11 -6
- package/src/components/Layout/PageHeader.vue +44 -2
- package/src/components/MetaInfo/MetaInfo.vue +12 -0
- package/src/components/Section/SectionHeader.vue +21 -1
- package/src/components/Table/Table.vue +130 -0
- package/src/components/Toast/Toast.vue +388 -0
- package/src/components/Toast/index.js +5 -0
- package/src/composables/useFormFieldA11y.js +72 -15
- package/src/helpers/slots.js +5 -2
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,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
|
-
<
|
|
209
|
+
<component :is="tag"
|
|
130
210
|
ref="buttonElementRef"
|
|
131
211
|
:class="buttonSlotClass"
|
|
132
|
-
|
|
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
|
-
</
|
|
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="
|
|
33
|
+
<div :class="optionClass"
|
|
10
34
|
role="menuitem"
|
|
11
35
|
tabindex="0"
|
|
12
36
|
@click="handleClick"
|
|
@@ -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="
|
|
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
|
-
|
|
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
|
|
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
|
|
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(() => `${
|
|
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="
|
|
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 ? `${
|
|
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="`${
|
|
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="
|
|
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="
|
|
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="`${
|
|
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="`${
|
|
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="`${
|
|
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,
|
|
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="
|
|
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
|
-
<
|
|
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 ? `${
|
|
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="`${
|
|
102
|
-
:name="$attrs.name ||
|
|
103
|
+
:id="`${inputId}-option-${index}`"
|
|
104
|
+
:name="$attrs.name || inputId"
|
|
103
105
|
:aria-describedby="ariaDescribedby"
|
|
104
106
|
>
|
|
105
107
|
<label
|
|
106
|
-
:for="`${
|
|
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
|
|
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="
|
|
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 ? `${
|
|
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="`${
|
|
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="
|
|
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="`${
|
|
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="`${
|
|
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="`${
|
|
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
|
|
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="
|
|
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 ? `${
|
|
241
|
-
:aria-controls="isOpen ? `${
|
|
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="
|
|
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 ?
|
|
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="`${
|
|
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="`${
|
|
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="`${
|
|
311
|
+
:id="`${inputId}-option-${index}`"
|
|
320
312
|
role="option"
|
|
321
313
|
:aria-selected="option.value === model"
|
|
322
314
|
:class="[
|