@imaginario27/air-ui-ds 1.13.3 → 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 CHANGED
@@ -5,6 +5,28 @@ 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
+
19
+ ## 1.13.3 - 2026-05-21
20
+
21
+ Release type: patch.
22
+ Commits found in range: 1.
23
+
24
+ ### Added
25
+
26
+ 1. merge InteractiveRating into Rating and add disabled option to DropdownSelect ([15a4b1c](https://github.com/imaginario27/air-ui/commit/15a4b1c8bdec9f54d5e733c0ab834aba40a1c50b))
27
+
28
+ - Package: @imaginario27/air-ui-ds.
29
+
8
30
  ## 1.13.2 - 2026-05-20
9
31
 
10
32
  Release type: patch.
@@ -1,20 +1,28 @@
1
1
  <template>
2
2
  <div class="w-full flex flex-col gap-2 py-3">
3
- <div class="accordion-header flex justify-between gap-4 hover:cursor-pointer" @click="toggle">
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
- <!-- This button does not have click event because the toggle is being controlled by the accordeon header div-->
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
- </div>
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>
@@ -1,5 +1,6 @@
1
1
  <template>
2
- <div
2
+ <div
3
+ role="alert"
3
4
  :class="[
4
5
  'w-full',
5
6
  'flex gap-3',
@@ -49,6 +49,7 @@
49
49
  class="ml-1 h-[24px] flex items-center"
50
50
  >
51
51
  <button
52
+ aria-label="Remove"
52
53
  class="flex items-center justify-center h-full w-[20px]"
53
54
  :class="[
54
55
  textClass,
@@ -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,7 @@
1
1
  <template>
2
2
  <button
3
3
  type="button"
4
+ :aria-pressed="active"
4
5
  :disabled
5
6
  :class="[
6
7
  'flex items-center justify-center',
@@ -1,5 +1,7 @@
1
1
  <template>
2
- <div
2
+ <div
3
+ role="group"
4
+ aria-label="Toggle options"
3
5
  :class="[
4
6
  'flex',
5
7
  groupStyle === ToggleButtonGroupStyle.GROUPED ? 'border border-border-default' : 'flex-wrap gap-3',
@@ -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
@@ -50,7 +50,10 @@
50
50
  {{ featuredDescription }}
51
51
  </p>
52
52
 
53
- <p :class="['text-sm', styleType === DashboardMetricCardStyle.DEFAULT ? 'text-text-neutral-subtle' : textColorClass]">
53
+ <p
54
+ v-if="description"
55
+ :class="['text-sm', styleType === DashboardMetricCardStyle.DEFAULT ? 'text-text-neutral-subtle' : textColorClass]"
56
+ >
54
57
  {{ description }}
55
58
  </p>
56
59
  </div>
@@ -106,10 +109,7 @@ const props = defineProps({
106
109
  },
107
110
  unit: String as PropType<string>,
108
111
  featuredDescription: String as PropType<string>,
109
- description: {
110
- type: String as PropType<string>,
111
- default: "Metric description",
112
- },
112
+ description: String as PropType<string>,
113
113
  trend: String as PropType<string>,
114
114
  trendDirection: {
115
115
  type: String as PropType<DashboardMetricTrendDirection>,
@@ -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
- const path = event.composedPath?.()
228
- const isInsideButton = path?.some((el: any) => el?.tagName === 'BUTTON')
229
-
230
- if (!isInsideButton) {
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
- <div
4
- class="collapsible-header flex justify-between gap-4 hover:cursor-pointer"
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
- </div>
22
+ </button>
17
23
 
18
24
  <VerticalExpansionTransition v-show="isOpen">
19
- <slot />
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
- :class="[
21
- 'select-box', // Class identifier for unit test
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="hidden"
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
- <!-- Hidden native checkbox -->
58
- <input
59
- :id="id"
60
- type="checkbox"
61
- :checked="modelValue"
62
- class="hidden"
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
- :class="[
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
@@ -1,6 +1,8 @@
1
1
  <template>
2
- <div
3
- v-if="isLoading"
2
+ <div
3
+ v-if="isLoading"
4
+ role="status"
5
+ aria-live="polite"
4
6
  class="flex flex-col items-center gap-4"
5
7
  >
6
8
  <Spinner :class="[spinnerSizeClass, spinnerClass]" />
@@ -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
- addEscListener() // Add Esc key listener
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() // Remove Esc key listener
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
- <RatingItem
9
+ <div
7
10
  v-for="(icon, index) in items"
8
11
  :key="index"
9
- :icon
10
- :size
11
- :color
12
- :isInteractive
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
 
@@ -1,5 +1,7 @@
1
1
  <template>
2
- <div
2
+ <div
3
+ role="status"
4
+ aria-label="Loading"
3
5
  :class="[
4
6
  'animate-spin',
5
7
  'rounded-full',
@@ -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
- :text="tab.text"
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
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="flex flex-col gap-6 w-full">
2
+ <div role="tabpanel" class="flex flex-col gap-6 w-full">
3
3
  <slot />
4
4
  </div>
5
5
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imaginario27/air-ui-ds",
3
- "version": "1.13.3",
3
+ "version": "1.13.5",
4
4
  "author": "imaginario27",
5
5
  "type": "module",
6
6
  "homepage": "https://air-ui.netlify.app/",