@imaginario27/air-ui-ds 1.13.4 → 1.13.5
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/CHANGELOG.md +11 -0
- package/components/accordions/AccordionItem.vue +19 -7
- package/components/alerts/Alert.vue +2 -1
- package/components/badges/Badge.vue +1 -0
- package/components/buttons/ActionIconButton.vue +2 -1
- package/components/buttons/options/OptionButtonGroup.vue +4 -1
- package/components/buttons/toggle/ToggleButton.vue +1 -0
- package/components/buttons/toggle/ToggleButtonGroup.vue +3 -1
- package/components/buttons/toggle/ToggleIconButton.vue +3 -0
- package/components/cards/specific/SelectableCard.vue +17 -8
- package/components/collapsibles/Collapsible.vue +18 -6
- package/components/dropdowns/DropdownMenuItem.vue +5 -0
- package/components/dropdowns/DropdownSelect.vue +14 -3
- package/components/forms/Checkbox.vue +5 -1
- package/components/forms/fields/SearchField.vue +2 -1
- package/components/forms/fields/SwitchField.vue +13 -9
- package/components/images/QRCode.vue +3 -0
- package/components/loaders/Loading.vue +4 -2
- package/components/modals/ModalDialog.vue +46 -2
- package/components/progress/ProgressBar.vue +10 -0
- package/components/rating/Rating.vue +37 -6
- package/components/spinners/Spinner.vue +3 -1
- package/components/tabs/Tab.vue +10 -1
- package/components/tabs/TabBar.vue +48 -7
- package/components/tabs/TabContent.vue +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this package are documented in this file.
|
|
|
5
5
|
Historical releases were reconstructed from git history (GitHub repository) and npm publish dates.
|
|
6
6
|
Future releases will include detailed entries generated with Changesets.
|
|
7
7
|
|
|
8
|
+
## 1.13.4 - 2026-05-26
|
|
9
|
+
|
|
10
|
+
Release type: patch.
|
|
11
|
+
Commits found in range: 1.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
1. make MetricCard description prop optional ([53937fb](https://github.com/imaginario27/air-ui/commit/53937fbca5cb1e3f350effdcb36a6a20bc9c50c5))
|
|
16
|
+
|
|
17
|
+
- Package: @imaginario27/air-ui-ds.
|
|
18
|
+
|
|
8
19
|
## 1.13.3 - 2026-05-21
|
|
9
20
|
|
|
10
21
|
Release type: patch.
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="w-full flex flex-col gap-2 py-3">
|
|
3
|
-
<
|
|
3
|
+
<button
|
|
4
|
+
type="button"
|
|
5
|
+
:id="headerId"
|
|
6
|
+
class="accordion-header w-full flex justify-between gap-4 hover:cursor-pointer text-left"
|
|
7
|
+
:aria-expanded="isOpen"
|
|
8
|
+
:aria-controls="panelId"
|
|
9
|
+
@click="toggle"
|
|
10
|
+
>
|
|
4
11
|
<span class="font-semibold mt-1">
|
|
5
12
|
{{ title }}
|
|
6
13
|
</span>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
<ActionIconButton
|
|
14
|
+
|
|
15
|
+
<ActionIconButton
|
|
10
16
|
:icon="isOpen ? 'mdi:minus' : 'mdi:plus'"
|
|
11
17
|
:styleType="ButtonStyleType.NEUTRAL_TRANSPARENT"
|
|
12
|
-
:size="ButtonSize.MD"
|
|
18
|
+
:size="ButtonSize.MD"
|
|
19
|
+
tabindex="-1"
|
|
20
|
+
aria-hidden="true"
|
|
13
21
|
/>
|
|
14
|
-
</
|
|
22
|
+
</button>
|
|
15
23
|
|
|
16
24
|
<VerticalExpansionTransition v-show="isOpen">
|
|
17
|
-
<p class="text-sm">
|
|
25
|
+
<p :id="panelId" role="region" :aria-labelledby="headerId" class="text-sm">
|
|
18
26
|
{{ content }}
|
|
19
27
|
</p>
|
|
20
28
|
</VerticalExpansionTransition>
|
|
@@ -34,6 +42,10 @@ defineProps({
|
|
|
34
42
|
}
|
|
35
43
|
})
|
|
36
44
|
|
|
45
|
+
// IDs
|
|
46
|
+
const headerId = useId()
|
|
47
|
+
const panelId = useId()
|
|
48
|
+
|
|
37
49
|
// Composables
|
|
38
50
|
const { isOpen, toggle } = useAccordion()
|
|
39
51
|
</script>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
...(actionType === ButtonActionType.ACTION ? { onClick: emitClick } : {})
|
|
18
18
|
}"
|
|
19
19
|
:disabled="disabled"
|
|
20
|
-
|
|
20
|
+
:aria-label="ariaLabel"
|
|
21
21
|
>
|
|
22
22
|
<Icon
|
|
23
23
|
:name="icon"
|
|
@@ -84,6 +84,7 @@ const props = defineProps({
|
|
|
84
84
|
default: false
|
|
85
85
|
},
|
|
86
86
|
id: String as PropType<string>,
|
|
87
|
+
ariaLabel: String as PropType<string>,
|
|
87
88
|
})
|
|
88
89
|
|
|
89
90
|
// Emits
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
role="group"
|
|
4
|
+
aria-label="Options"
|
|
3
5
|
:class="[
|
|
4
6
|
'flex',
|
|
5
7
|
'flex-wrap gap-2',
|
|
@@ -8,6 +10,7 @@
|
|
|
8
10
|
<OptionButton
|
|
9
11
|
v-for="(button, index) in displayButtons"
|
|
10
12
|
:key="index"
|
|
13
|
+
:aria-pressed="isButtonActive(button)"
|
|
11
14
|
:active="isButtonActive(button)"
|
|
12
15
|
:text="button.text"
|
|
13
16
|
:size
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<button
|
|
3
3
|
type="button"
|
|
4
|
+
:aria-pressed="active"
|
|
5
|
+
:aria-label="ariaLabel"
|
|
4
6
|
:disabled
|
|
5
7
|
:class="[
|
|
6
8
|
'flex items-center justify-center',
|
|
@@ -39,6 +41,7 @@ const props = defineProps({
|
|
|
39
41
|
type: Boolean as PropType<boolean>,
|
|
40
42
|
default: false,
|
|
41
43
|
},
|
|
44
|
+
ariaLabel: String as PropType<string>,
|
|
42
45
|
})
|
|
43
46
|
|
|
44
47
|
// Emits
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Card
|
|
2
|
+
<Card
|
|
3
3
|
:hasShadow
|
|
4
|
+
v-bind="selectMode === CardSelectionMode.CARD ? {
|
|
5
|
+
role: 'checkbox',
|
|
6
|
+
'aria-checked': modelValue,
|
|
7
|
+
tabindex: disabled ? -1 : 0,
|
|
8
|
+
} : {}"
|
|
4
9
|
:class="[
|
|
5
10
|
'lg:p-5',
|
|
6
|
-
selectMode === CardSelectionMode.CARD && isHoverable &&
|
|
11
|
+
selectMode === CardSelectionMode.CARD && isHoverable &&
|
|
7
12
|
'hover:border-border-neutral-hover cursor-pointer transition-shadow duration-300',
|
|
13
|
+
selectMode === CardSelectionMode.CARD && 'outline-none focus-visible:ring-2 focus-visible:ring-border-primary-brand-default',
|
|
8
14
|
modelValue && '!border-border-primary-brand-active',
|
|
9
15
|
disabled && 'opacity-disabled cursor-not-allowed',
|
|
10
16
|
]"
|
|
11
17
|
@click="handleCardClick"
|
|
18
|
+
@keydown.enter.prevent="handleCardClick($event as any)"
|
|
19
|
+
@keydown.space.prevent="handleCardClick($event as any)"
|
|
12
20
|
>
|
|
13
21
|
<CardHeader
|
|
14
22
|
:class="[
|
|
@@ -221,15 +229,16 @@ const props = defineProps({
|
|
|
221
229
|
const emit = defineEmits(['update:modelValue', 'buttonClick'])
|
|
222
230
|
|
|
223
231
|
// Methods
|
|
224
|
-
const handleCardClick = (event: MouseEvent) => {
|
|
232
|
+
const handleCardClick = (event: MouseEvent | KeyboardEvent) => {
|
|
225
233
|
if (props.selectMode !== CardSelectionMode.CARD) return
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
emit('update:modelValue', !props.modelValue)
|
|
235
|
+
if (event instanceof MouseEvent) {
|
|
236
|
+
const path = event.composedPath?.()
|
|
237
|
+
const isInsideButton = path?.some((el: any) => el?.tagName === 'BUTTON')
|
|
238
|
+
if (isInsideButton) return
|
|
232
239
|
}
|
|
240
|
+
|
|
241
|
+
emit('update:modelValue', !props.modelValue)
|
|
233
242
|
}
|
|
234
243
|
|
|
235
244
|
const handleButtonClick = (event: Event | undefined) => {
|
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="w-full flex flex-col gap-2 py-3">
|
|
3
|
-
<
|
|
4
|
-
|
|
3
|
+
<button
|
|
4
|
+
type="button"
|
|
5
|
+
:id="headerId"
|
|
6
|
+
class="collapsible-header w-full flex justify-between gap-4 hover:cursor-pointer text-left"
|
|
7
|
+
:aria-expanded="isOpen"
|
|
8
|
+
:aria-controls="panelId"
|
|
5
9
|
@click="toggle"
|
|
6
10
|
>
|
|
7
11
|
<span class="font-semibold mt-1">
|
|
8
12
|
{{ title }}
|
|
9
13
|
</span>
|
|
10
14
|
|
|
11
|
-
<ActionIconButton
|
|
15
|
+
<ActionIconButton
|
|
12
16
|
:icon="isOpen ? 'mdi:unfold-less-horizontal' : 'mdi:unfold-more-horizontal'"
|
|
13
17
|
:styleType="ButtonStyleType.NEUTRAL_OUTLINED"
|
|
14
|
-
:size="ButtonSize.MD"
|
|
18
|
+
:size="ButtonSize.MD"
|
|
19
|
+
tabindex="-1"
|
|
20
|
+
aria-hidden="true"
|
|
15
21
|
/>
|
|
16
|
-
</
|
|
22
|
+
</button>
|
|
17
23
|
|
|
18
24
|
<VerticalExpansionTransition v-show="isOpen">
|
|
19
|
-
<
|
|
25
|
+
<div :id="panelId" role="region" :aria-labelledby="headerId">
|
|
26
|
+
<slot />
|
|
27
|
+
</div>
|
|
20
28
|
</VerticalExpansionTransition>
|
|
21
29
|
</div>
|
|
22
30
|
</template>
|
|
23
31
|
|
|
24
32
|
<script setup lang="ts">
|
|
33
|
+
// IDs
|
|
34
|
+
const headerId = useId()
|
|
35
|
+
const panelId = useId()
|
|
36
|
+
|
|
25
37
|
// Props
|
|
26
38
|
const props = defineProps({
|
|
27
39
|
title: {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<component
|
|
3
3
|
:is="dynamicComponent"
|
|
4
|
+
role="menuitem"
|
|
5
|
+
:tabindex="disabled ? -1 : 0"
|
|
4
6
|
v-bind="{
|
|
5
7
|
...componentProps,
|
|
6
8
|
...$attrs,
|
|
@@ -12,12 +14,15 @@
|
|
|
12
14
|
'text-sm',
|
|
13
15
|
'hover:bg-background-neutral-hover-subtle hover:cursor-pointer',
|
|
14
16
|
'w-full',
|
|
17
|
+
'outline-none',
|
|
18
|
+
'focus-visible:bg-background-neutral-hover-subtle',
|
|
15
19
|
sizeClass,
|
|
16
20
|
typeClass,
|
|
17
21
|
hasSeparator ? 'border-b border-border-default' : undefined,
|
|
18
22
|
helpText ? 'py-2' : undefined,
|
|
19
23
|
disabled && 'opacity-disabled cursor-not-allowed pointer-events-none',
|
|
20
24
|
]"
|
|
25
|
+
@keydown.enter.prevent="actionType === DropdownActionType.ACTION && emitClick()"
|
|
21
26
|
>
|
|
22
27
|
<div class="flex items-center gap-3 w-full">
|
|
23
28
|
<Icon
|
|
@@ -17,8 +17,13 @@
|
|
|
17
17
|
<template #activator="{ isOpen }">
|
|
18
18
|
<!-- Select Box -->
|
|
19
19
|
<div
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
role="combobox"
|
|
21
|
+
:aria-expanded="isOpen"
|
|
22
|
+
aria-haspopup="listbox"
|
|
23
|
+
:aria-label="placeholder"
|
|
24
|
+
:tabindex="disabled ? -1 : 0"
|
|
25
|
+
:class="[
|
|
26
|
+
'select-box',
|
|
22
27
|
'flex items-center justify-between',
|
|
23
28
|
'w-full',
|
|
24
29
|
'px-3',
|
|
@@ -28,10 +33,14 @@
|
|
|
28
33
|
'border border-border-default',
|
|
29
34
|
'text-sm',
|
|
30
35
|
disabled ? 'text-text-neutral-disabled' : 'text-text-default',
|
|
36
|
+
'outline-none',
|
|
37
|
+
'focus-visible:ring-2 focus-visible:ring-border-primary-brand-default',
|
|
31
38
|
sizeClass,
|
|
32
39
|
selectBoxClass,
|
|
33
40
|
]"
|
|
34
41
|
@click="handleSelectBoxClick"
|
|
42
|
+
@keydown.enter.prevent="handleSelectBoxClick($event as any)"
|
|
43
|
+
@keydown.space.prevent="handleSelectBoxClick($event as any)"
|
|
35
44
|
>
|
|
36
45
|
<div v-if="multiple">
|
|
37
46
|
<template v-if="Array.isArray(selected) && selected.length">
|
|
@@ -96,11 +105,12 @@
|
|
|
96
105
|
|
|
97
106
|
<div class="flex gap-2 items-center">
|
|
98
107
|
<!-- Clear button -->
|
|
99
|
-
<ActionIconButton
|
|
108
|
+
<ActionIconButton
|
|
100
109
|
v-if="multiple && Array.isArray(selected) && selected.length"
|
|
101
110
|
:size="ButtonSize.SM"
|
|
102
111
|
:styleType="ButtonStyleType.NEUTRAL_TRANSPARENT_SUBTLE"
|
|
103
112
|
icon="mdi:close-circle"
|
|
113
|
+
ariaLabel="Clear selection"
|
|
104
114
|
@click="selected = []"
|
|
105
115
|
/>
|
|
106
116
|
|
|
@@ -135,6 +145,7 @@
|
|
|
135
145
|
<input
|
|
136
146
|
v-model="searchQuery"
|
|
137
147
|
type="text"
|
|
148
|
+
:aria-label="searchFieldPlaceholder"
|
|
138
149
|
:placeholder="searchFieldPlaceholder"
|
|
139
150
|
:class="[
|
|
140
151
|
'w-full',
|
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
:id="id"
|
|
5
5
|
type="checkbox"
|
|
6
6
|
:checked="modelValue"
|
|
7
|
-
class="
|
|
7
|
+
class="sr-only"
|
|
8
8
|
:disabled="disabled"
|
|
9
9
|
@change="handleNativeChange"
|
|
10
|
+
@keydown.space.prevent="toggleCheckbox"
|
|
10
11
|
>
|
|
11
12
|
|
|
12
13
|
<div
|
|
14
|
+
role="checkbox"
|
|
15
|
+
:aria-checked="modelValue"
|
|
16
|
+
:aria-label="id"
|
|
13
17
|
:class="[
|
|
14
18
|
'flex items-center justify-center',
|
|
15
19
|
controlFieldSizeClass,
|
|
@@ -66,11 +66,12 @@
|
|
|
66
66
|
>
|
|
67
67
|
|
|
68
68
|
<!-- Clear button -->
|
|
69
|
-
<ActionIconButton
|
|
69
|
+
<ActionIconButton
|
|
70
70
|
v-if="filled"
|
|
71
71
|
:size="ButtonSize.SM"
|
|
72
72
|
:styleType="ButtonStyleType.NEUTRAL_TRANSPARENT_SUBTLE"
|
|
73
73
|
icon="mdi:close-circle"
|
|
74
|
+
ariaLabel="Clear search"
|
|
74
75
|
@click="clearField"
|
|
75
76
|
/>
|
|
76
77
|
</div>
|
|
@@ -54,19 +54,23 @@
|
|
|
54
54
|
/>
|
|
55
55
|
</div>
|
|
56
56
|
|
|
57
|
-
<!--
|
|
58
|
-
<input
|
|
59
|
-
:id="id"
|
|
60
|
-
type="checkbox"
|
|
61
|
-
:checked="modelValue"
|
|
62
|
-
class="
|
|
57
|
+
<!-- Visually hidden native checkbox (remains accessible to screen readers) -->
|
|
58
|
+
<input
|
|
59
|
+
:id="id"
|
|
60
|
+
type="checkbox"
|
|
61
|
+
:checked="modelValue"
|
|
62
|
+
class="sr-only"
|
|
63
63
|
:disabled="disabled"
|
|
64
|
-
@change="handleChange"
|
|
64
|
+
@change="handleChange"
|
|
65
|
+
@keydown.space.prevent="toggleCheckbox"
|
|
65
66
|
>
|
|
66
67
|
|
|
67
68
|
<!-- Custom Switch -->
|
|
68
|
-
<div
|
|
69
|
-
|
|
69
|
+
<div
|
|
70
|
+
role="switch"
|
|
71
|
+
:aria-checked="modelValue"
|
|
72
|
+
:aria-label="label || legend || 'Toggle'"
|
|
73
|
+
:class="[
|
|
70
74
|
'relative flex items-center',
|
|
71
75
|
controlFieldSizeClass,
|
|
72
76
|
'rounded-full transition-colors',
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
+
role="img"
|
|
4
|
+
:aria-label="ariaLabel || `QR code: ${modelValue}`"
|
|
3
5
|
:class="[
|
|
4
6
|
'relative flex items-center justify-center',
|
|
5
7
|
hasBorder && 'border border-border-default rounded-md p-2',
|
|
@@ -98,6 +100,7 @@ const props = defineProps({
|
|
|
98
100
|
default: false,
|
|
99
101
|
},
|
|
100
102
|
containerClass: String as PropType<string>,
|
|
103
|
+
ariaLabel: String as PropType<string>,
|
|
101
104
|
})
|
|
102
105
|
|
|
103
106
|
// Computed
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
>
|
|
43
43
|
<div
|
|
44
44
|
v-show="modelValue"
|
|
45
|
+
ref="dialogRef"
|
|
46
|
+
role="dialog"
|
|
47
|
+
aria-modal="true"
|
|
48
|
+
:aria-labelledby="ariaLabelledby"
|
|
45
49
|
:class="[
|
|
46
50
|
'bg-background-surface rounded-lg shadow-xl',
|
|
47
51
|
'relative w-full my-8',
|
|
@@ -54,6 +58,7 @@
|
|
|
54
58
|
:styleType="ButtonStyleType.NEUTRAL_TRANSPARENT"
|
|
55
59
|
:size="ButtonSize.MD"
|
|
56
60
|
icon="mdi:close"
|
|
61
|
+
ariaLabel="Close"
|
|
57
62
|
class="absolute top-4 right-4 z-10"
|
|
58
63
|
@click="closeModal"
|
|
59
64
|
/>
|
|
@@ -70,6 +75,10 @@
|
|
|
70
75
|
</template>
|
|
71
76
|
|
|
72
77
|
<script setup lang="ts">
|
|
78
|
+
// Refs
|
|
79
|
+
const dialogRef = ref<HTMLElement | null>(null)
|
|
80
|
+
const previouslyFocusedElement = ref<HTMLElement | null>(null)
|
|
81
|
+
|
|
73
82
|
// State
|
|
74
83
|
const previousBodyOverflow = ref<string | null>(null)
|
|
75
84
|
const previousBodyPaddingRight = ref<string | null>(null)
|
|
@@ -95,6 +104,7 @@ const props = defineProps({
|
|
|
95
104
|
default: 'max-w-[600px]',
|
|
96
105
|
},
|
|
97
106
|
id: String as PropType<string>,
|
|
107
|
+
ariaLabelledby: String as PropType<string>,
|
|
98
108
|
})
|
|
99
109
|
|
|
100
110
|
// Emits
|
|
@@ -118,13 +128,39 @@ const handleEscKey = (event: KeyboardEvent) => {
|
|
|
118
128
|
}
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
const handleFocusTrap = (event: KeyboardEvent) => {
|
|
132
|
+
if (event.key !== 'Tab' || !dialogRef.value) return
|
|
133
|
+
|
|
134
|
+
const focusableElements = dialogRef.value.querySelectorAll<HTMLElement>(
|
|
135
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
136
|
+
)
|
|
137
|
+
if (focusableElements.length === 0) return
|
|
138
|
+
|
|
139
|
+
const first = focusableElements[0]!
|
|
140
|
+
const last = focusableElements[focusableElements.length - 1]!
|
|
141
|
+
|
|
142
|
+
if (event.shiftKey) {
|
|
143
|
+
if (document.activeElement === first) {
|
|
144
|
+
event.preventDefault()
|
|
145
|
+
last.focus()
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
if (document.activeElement === last) {
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
first.focus()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
121
155
|
// Event listeners
|
|
122
156
|
const addEscListener = () => {
|
|
123
157
|
globalThis.addEventListener('keydown', handleEscKey)
|
|
158
|
+
globalThis.addEventListener('keydown', handleFocusTrap)
|
|
124
159
|
}
|
|
125
160
|
|
|
126
161
|
const removeEscListener = () => {
|
|
127
162
|
globalThis.removeEventListener('keydown', handleEscKey)
|
|
163
|
+
globalThis.removeEventListener('keydown', handleFocusTrap)
|
|
128
164
|
}
|
|
129
165
|
|
|
130
166
|
const lockScroll = () => {
|
|
@@ -162,11 +198,19 @@ watch(
|
|
|
162
198
|
() => props.modelValue,
|
|
163
199
|
newValue => {
|
|
164
200
|
if (newValue) {
|
|
165
|
-
|
|
201
|
+
previouslyFocusedElement.value = document.activeElement as HTMLElement
|
|
202
|
+
addEscListener()
|
|
166
203
|
lockScroll()
|
|
204
|
+
nextTick(() => {
|
|
205
|
+
const firstFocusable = dialogRef.value?.querySelector<HTMLElement>(
|
|
206
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
207
|
+
)
|
|
208
|
+
firstFocusable?.focus()
|
|
209
|
+
})
|
|
167
210
|
} else {
|
|
168
|
-
removeEscListener()
|
|
211
|
+
removeEscListener()
|
|
169
212
|
unlockScroll()
|
|
213
|
+
previouslyFocusedElement.value?.focus()
|
|
170
214
|
}
|
|
171
215
|
},
|
|
172
216
|
{ immediate: true },
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
16
|
<div
|
|
17
|
+
role="progressbar"
|
|
18
|
+
:aria-valuenow="isIndeterminate ? undefined : normalizedProgress"
|
|
19
|
+
:aria-valuemin="min"
|
|
20
|
+
:aria-valuemax="max"
|
|
21
|
+
:aria-valuetext="isIndeterminate ? loadingText : undefined"
|
|
22
|
+
:aria-label="ariaLabel"
|
|
17
23
|
:class="[
|
|
18
24
|
'w-full',
|
|
19
25
|
'overflow-hidden',
|
|
@@ -114,6 +120,10 @@ const props = defineProps({
|
|
|
114
120
|
},
|
|
115
121
|
progressClass: String as PropType<string>,
|
|
116
122
|
progressLabelClass: String as PropType<string>,
|
|
123
|
+
ariaLabel: {
|
|
124
|
+
type: String as PropType<string>,
|
|
125
|
+
default: 'Progress',
|
|
126
|
+
},
|
|
117
127
|
})
|
|
118
128
|
|
|
119
129
|
// Computed
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
+
:role="isInteractive ? 'radiogroup' : 'img'"
|
|
4
|
+
:aria-label="isInteractive ? 'Rating' : `Rating: ${displayValue} out of 5`"
|
|
3
5
|
class="flex gap-1"
|
|
4
6
|
@mouseleave="hoverIndex = null"
|
|
7
|
+
@keydown="isInteractive && handleKeydown($event)"
|
|
5
8
|
>
|
|
6
|
-
<
|
|
9
|
+
<div
|
|
7
10
|
v-for="(icon, index) in items"
|
|
8
11
|
:key="index"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
v-bind="isInteractive ? {
|
|
13
|
+
role: 'radio',
|
|
14
|
+
'aria-checked': modelValue === index + 1,
|
|
15
|
+
'aria-label': `${index + 1} star${index + 1 > 1 ? 's' : ''}`,
|
|
16
|
+
tabindex: modelValue === index + 1 || (modelValue === 0 && index === 0) ? 0 : -1,
|
|
17
|
+
} : {
|
|
18
|
+
'aria-hidden': 'true',
|
|
19
|
+
}"
|
|
20
|
+
class="inline-flex"
|
|
13
21
|
@click="isInteractive && handleClick(index)"
|
|
14
22
|
@mouseenter="isInteractive && onMouseEnter(index)"
|
|
15
|
-
|
|
23
|
+
>
|
|
24
|
+
<RatingItem
|
|
25
|
+
:icon
|
|
26
|
+
:size
|
|
27
|
+
:color
|
|
28
|
+
:isInteractive
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
16
31
|
</div>
|
|
17
32
|
</template>
|
|
18
33
|
|
|
@@ -68,6 +83,22 @@ const onMouseEnter = (index: number) => {
|
|
|
68
83
|
}
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
87
|
+
let newValue = props.modelValue
|
|
88
|
+
|
|
89
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
newValue = Math.min(5, props.modelValue + 1)
|
|
92
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
newValue = Math.max(0, props.modelValue - 1)
|
|
95
|
+
} else {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
emit('update:modelValue', newValue)
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
const handleClick = (index: number) => {
|
|
72
103
|
const clickedValue = index + 1
|
|
73
104
|
|
package/components/tabs/Tab.vue
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
role="tab"
|
|
4
|
+
:aria-selected="active"
|
|
5
|
+
:tabindex="tabindex"
|
|
3
6
|
:class="[
|
|
4
7
|
'flex',
|
|
5
8
|
'items-center',
|
|
@@ -7,6 +10,8 @@
|
|
|
7
10
|
'px-3',
|
|
8
11
|
'hover:cursor-pointer',
|
|
9
12
|
'group',
|
|
13
|
+
'outline-none',
|
|
14
|
+
'focus-visible:ring-2 focus-visible:ring-border-primary-brand-default',
|
|
10
15
|
disabled && 'opacity-disabled cursor-not-allowed pointer-events-none',
|
|
11
16
|
styleClass,
|
|
12
17
|
]"
|
|
@@ -94,6 +99,10 @@ const props = defineProps({
|
|
|
94
99
|
type: Boolean as PropType<boolean>,
|
|
95
100
|
default: false,
|
|
96
101
|
},
|
|
102
|
+
tabindex: {
|
|
103
|
+
type: Number as PropType<number>,
|
|
104
|
+
default: 0,
|
|
105
|
+
},
|
|
97
106
|
})
|
|
98
107
|
|
|
99
108
|
// States
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
role="tablist"
|
|
3
4
|
:class="[
|
|
4
5
|
'flex flex-wrap',
|
|
5
6
|
hasContainer && 'border border-border-default',
|
|
@@ -7,19 +8,22 @@
|
|
|
7
8
|
hasContainer && isContainerFullWidth && 'w-full',
|
|
8
9
|
disabled && 'opacity-disabled cursor-not-allowed pointer-events-none',
|
|
9
10
|
]"
|
|
11
|
+
@keydown="handleKeydown"
|
|
10
12
|
>
|
|
11
|
-
<Tab
|
|
12
|
-
v-for="(tab, index) in tabs"
|
|
13
|
-
:key="index"
|
|
14
|
-
|
|
13
|
+
<Tab
|
|
14
|
+
v-for="(tab, index) in tabs"
|
|
15
|
+
:key="index"
|
|
16
|
+
ref="tabRefs"
|
|
17
|
+
:text="tab.text"
|
|
15
18
|
:icon="tab.icon"
|
|
16
|
-
:imgUrl="tab.imgUrl"
|
|
19
|
+
:imgUrl="tab.imgUrl"
|
|
17
20
|
:badgeValue="tab.badgeValue"
|
|
18
|
-
:active="index === activeIndex"
|
|
21
|
+
:active="index === activeIndex"
|
|
19
22
|
:disabled="disabled || tab.disabled"
|
|
20
23
|
:tabStyle
|
|
21
24
|
:size="tabSize"
|
|
22
25
|
:decoration
|
|
26
|
+
:tabindex="index === activeIndex ? 0 : -1"
|
|
23
27
|
@click="handleTabClick(index, tab.to)"
|
|
24
28
|
@pointerenter="handleTabPrefetch(tab.to)"
|
|
25
29
|
@focus="handleTabPrefetch(tab.to)"
|
|
@@ -73,6 +77,9 @@ const props = defineProps({
|
|
|
73
77
|
},
|
|
74
78
|
})
|
|
75
79
|
|
|
80
|
+
// Refs
|
|
81
|
+
const tabRefs = ref<ComponentPublicInstance[]>([])
|
|
82
|
+
|
|
76
83
|
// Local
|
|
77
84
|
const activeIndex = ref(props.modelValue)
|
|
78
85
|
|
|
@@ -112,6 +119,40 @@ const handleTabPrefetch = (to?: string) => {
|
|
|
112
119
|
// fire and forget - do not await to avoid blocking
|
|
113
120
|
preloadRouteComponents(to)
|
|
114
121
|
}
|
|
122
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
123
|
+
const tabCount = props.tabs.length
|
|
124
|
+
let newIndex = activeIndex.value
|
|
125
|
+
|
|
126
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
newIndex = (activeIndex.value + 1) % tabCount
|
|
129
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
130
|
+
event.preventDefault()
|
|
131
|
+
newIndex = (activeIndex.value - 1 + tabCount) % tabCount
|
|
132
|
+
} else if (event.key === 'Home') {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
newIndex = 0
|
|
135
|
+
} else if (event.key === 'End') {
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
newIndex = tabCount - 1
|
|
138
|
+
} else {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
while (props.tabs[newIndex]?.disabled && newIndex !== activeIndex.value) {
|
|
143
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === 'End') {
|
|
144
|
+
newIndex = (newIndex + 1) % tabCount
|
|
145
|
+
} else {
|
|
146
|
+
newIndex = (newIndex - 1 + tabCount) % tabCount
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
handleTabClick(newIndex, props.tabs[newIndex]?.to)
|
|
151
|
+
nextTick(() => {
|
|
152
|
+
(tabRefs.value[newIndex]?.$el as HTMLElement)?.focus()
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
115
156
|
// Watchers
|
|
116
157
|
watch(() => props.modelValue, (newVal) => {
|
|
117
158
|
activeIndex.value = newVal
|