picocrank 1.25.2 → 1.27.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.
Files changed (51) hide show
  1. package/eslint.config.js +75 -0
  2. package/package.json +17 -4
  3. package/router.js +74 -0
  4. package/styles/dark-theme.css +20 -11
  5. package/styles/picocrank-extensions.css +123 -30
  6. package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
  7. package/supplemental-themes/dracula-alucard/theme.css +10 -24
  8. package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
  9. package/supplemental-themes/waffles/theme.css +2 -16
  10. package/vite.config.js +5 -0
  11. package/vue/components/Breadcrumbs.vue +192 -45
  12. package/vue/components/Calendar.vue +162 -145
  13. package/vue/components/DangerZone.vue +186 -0
  14. package/vue/components/FormField.spec.js +350 -0
  15. package/vue/components/FormField.vue +236 -32
  16. package/vue/components/FormLayout.spec.js +56 -0
  17. package/vue/components/Header.vue +22 -6
  18. package/vue/components/Login.vue +152 -41
  19. package/vue/components/NavigationGrid.vue +26 -180
  20. package/vue/components/NotificationBlock.vue +120 -0
  21. package/vue/components/NotificationPopup.vue +44 -41
  22. package/vue/components/Pagination.vue +5 -4
  23. package/vue/components/ReadOnlyTextArea.vue +1 -1
  24. package/vue/components/SidebarNavLink.vue +3 -3
  25. package/vue/components/Table.vue +107 -79
  26. package/vue/components/TableColumnOptionsPopover.vue +9 -0
  27. package/vue/components/Tabs.spec.js +101 -0
  28. package/vue/components/Tabs.vue +455 -81
  29. package/vue/components/TopBar.vue +2 -2
  30. package/vue/components/form-validation.spec.js +126 -0
  31. package/vue/composables/tableColumnPriorities.js +33 -0
  32. package/vue/composables/tableFilters.js +1 -1
  33. package/vue/data/navSectionHubs.js +196 -0
  34. package/vue/data/routeBreadcrumbs.js +90 -0
  35. package/vue/examples/Admin.vue +12 -12
  36. package/vue/examples/App.vue +27 -36
  37. package/vue/examples/CalendarExample.vue +32 -22
  38. package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
  39. package/vue/examples/FormExample.vue +76 -48
  40. package/vue/examples/Login.vue +5 -3
  41. package/vue/examples/LoginExample.vue +99 -15
  42. package/vue/examples/NavSectionHub.vue +36 -0
  43. package/vue/examples/PageStructure.vue +15 -12
  44. package/vue/examples/StatusExample.vue +248 -129
  45. package/vue/examples/TableExample.vue +72 -71
  46. package/vue/examples/TabsExample.vue +236 -257
  47. package/vue/examples/ThemePreview.vue +21 -23
  48. package/vue/examples/ViewItem.vue +32 -5
  49. package/vue/examples/Welcome.vue +3 -40
  50. package/vue/test/formTestUtils.js +279 -0
  51. package/vue/test/setup.js +5 -0
@@ -1,9 +1,34 @@
1
1
  <template>
2
- <div
3
- class="notification-popup notification show"
4
- :class="popup.class"
5
- role="status"
6
- >
2
+ <div class="notification-popup" role="status">
3
+ <NotificationBlock
4
+ :type="notificationType"
5
+ :label="popup.label || ''"
6
+ >
7
+ <span class="notification-popup-content">
8
+ <span class="notification-popup-message">{{ popup.message }}</span>
9
+
10
+ <RouterLink
11
+ v-if="popup.linkTo && !isExternalLink"
12
+ :to="popup.linkTo"
13
+ class="notification-popup-link"
14
+ @click="emit('dismiss', popup.id)"
15
+ >
16
+ {{ popup.linkLabel || 'View' }}
17
+ </RouterLink>
18
+
19
+ <a
20
+ v-else-if="popup.linkTo && isExternalLink"
21
+ :href="popup.linkTo"
22
+ class="notification-popup-link"
23
+ target="_blank"
24
+ rel="noopener noreferrer"
25
+ @click="emit('dismiss', popup.id)"
26
+ >
27
+ {{ popup.linkLabel || 'View' }}
28
+ </a>
29
+ </span>
30
+ </NotificationBlock>
31
+
7
32
  <button
8
33
  type="button"
9
34
  class="notification-popup-dismiss"
@@ -12,33 +37,6 @@
12
37
  >
13
38
  <HugeiconsIcon :icon="Cancel01Icon" width="1em" height="1em" />
14
39
  </button>
15
-
16
- <div class="notification-popup-body">
17
- <p class="notification-popup-message">
18
- <strong v-if="popup.label">{{ popup.label }}:</strong>
19
- {{ popup.message }}
20
- </p>
21
-
22
- <RouterLink
23
- v-if="popup.linkTo && !isExternalLink"
24
- :to="popup.linkTo"
25
- class="notification-popup-link"
26
- @click="emit('dismiss', popup.id)"
27
- >
28
- {{ popup.linkLabel || 'View' }}
29
- </RouterLink>
30
-
31
- <a
32
- v-else-if="popup.linkTo && isExternalLink"
33
- :href="popup.linkTo"
34
- class="notification-popup-link"
35
- target="_blank"
36
- rel="noopener noreferrer"
37
- @click="emit('dismiss', popup.id)"
38
- >
39
- {{ popup.linkLabel || 'View' }}
40
- </a>
41
- </div>
42
40
  </div>
43
41
  </template>
44
42
 
@@ -47,6 +45,7 @@ import { computed } from 'vue'
47
45
  import { RouterLink } from 'vue-router'
48
46
  import { HugeiconsIcon } from '@hugeicons/vue'
49
47
  import { Cancel01Icon } from '@hugeicons/core-free-icons'
48
+ import NotificationBlock from './NotificationBlock.vue'
50
49
 
51
50
  const props = defineProps({
52
51
  popup: {
@@ -57,6 +56,8 @@ const props = defineProps({
57
56
 
58
57
  const emit = defineEmits(['dismiss'])
59
58
 
59
+ const notificationType = computed(() => props.popup.class || 'info')
60
+
60
61
  const isExternalLink = computed(() =>
61
62
  typeof props.popup.linkTo === 'string' && /^https?:\/\//.test(props.popup.linkTo)
62
63
  )
@@ -67,30 +68,32 @@ const isExternalLink = computed(() =>
67
68
  position: relative;
68
69
  width: min(22rem, calc(100vw - 2rem));
69
70
  margin: 0;
70
- padding: 0.75rem 2.25rem 0.75rem 0.75rem;
71
- cursor: default;
72
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
71
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--shadow-color) 60%, transparent);
73
72
  }
74
73
 
75
- .notification-popup-message {
74
+ .notification-popup :deep(.notification) {
76
75
  margin: 0;
77
76
  }
78
77
 
79
- .notification-popup-body {
78
+ .notification-popup-content {
80
79
  display: flex;
81
80
  flex-direction: column;
82
81
  gap: 0.5rem;
83
82
  }
84
83
 
84
+ .notification-popup-message {
85
+ display: block;
86
+ }
87
+
85
88
  .notification-popup-link {
86
- color: inherit;
89
+ color: var(--link-color);
87
90
  font-weight: bold;
88
91
  text-decoration: underline;
89
92
  align-self: flex-start;
90
93
  }
91
94
 
92
95
  .notification-popup-link:hover {
93
- opacity: 0.85;
96
+ text-decoration-thickness: 2px;
94
97
  }
95
98
 
96
99
  .notification-popup-dismiss {
@@ -106,13 +109,13 @@ const isExternalLink = computed(() =>
106
109
  border: 0;
107
110
  border-radius: 0.35rem;
108
111
  background: transparent;
109
- color: inherit;
112
+ color: var(--text-color);
110
113
  cursor: pointer;
111
114
  opacity: 0.75;
112
115
  }
113
116
 
114
117
  .notification-popup-dismiss:hover {
115
118
  opacity: 1;
116
- background: rgba(0, 0, 0, 0.08);
119
+ background: var(--hover-background-color);
117
120
  }
118
121
  </style>
@@ -185,19 +185,20 @@ watch(() => props.pageSize, (newSize) => {
185
185
 
186
186
  .button:disabled {
187
187
  background: transparent;
188
+ color: var(--button-transparent-disabled-fg);
188
189
  cursor: not-allowed;
189
190
  }
190
191
 
192
+ .button.active:disabled {
193
+ color: var(--text-color);
194
+ }
195
+
191
196
  #page-size {
192
197
  background: transparent;
193
198
  margin-left: 0.5rem;
194
199
  margin-right: 0.5rem;
195
200
  }
196
201
 
197
- option {
198
- background: #545f69;
199
- }
200
-
201
202
  /* Responsive design */
202
203
  @media (max-width: 768px) {
203
204
  .pagination {
@@ -144,7 +144,7 @@ async function copy() {
144
144
  await navigator.clipboard.writeText(text)
145
145
  markCopied()
146
146
  emit('copy', text)
147
- } catch (error) {
147
+ } catch (_error) {
148
148
  try {
149
149
  textareaRef.value?.select()
150
150
  document.execCommand('copy')
@@ -121,7 +121,7 @@ a.active {
121
121
  width: 0.5rem;
122
122
  height: 0.5rem;
123
123
  border-radius: 50%;
124
- background: var(--indicator-color, #dc3545);
124
+ background: var(--karma-bad-tint);
125
125
  flex-shrink: 0;
126
126
  }
127
127
 
@@ -129,8 +129,8 @@ a.active {
129
129
  min-width: 1.25rem;
130
130
  padding: 0.1rem 0.4rem;
131
131
  border-radius: 999px;
132
- background: var(--indicator-color, #dc3545);
133
- color: #fff;
132
+ background: var(--karma-bad-tint);
133
+ color: var(--text-color);
134
134
  font-size: 0.75em;
135
135
  font-weight: 600;
136
136
  line-height: 1.2;
@@ -37,27 +37,24 @@
37
37
  v-if="showColumnOptionsButton"
38
38
  class="actions table-column-options-header"
39
39
  >
40
- <div class="table-column-options-header-content">
41
- <span v-if="activeLayoutLabel" class="table-layout-label">{{ activeLayoutLabel }}</span>
42
- <span class="actions-menu-trigger">
43
- <button
44
- ref="columnOptionsButtonRef"
45
- type="button"
46
- aria-haspopup="dialog"
47
- aria-label="Column options"
48
- :aria-expanded="columnOptionsOpen ? 'true' : 'false'"
49
- @click="openColumnOptions"
50
- >
51
- <HugeiconsIcon
52
- :icon="LayoutGridIcon"
53
- width="0.95em"
54
- height="0.95em"
55
- :strokeWidth="2"
56
- aria-hidden="true"
57
- />
58
- </button>
59
- </span>
60
- </div>
40
+ <span class="actions-menu-trigger">
41
+ <button
42
+ ref="columnOptionsButtonRef"
43
+ type="button"
44
+ aria-haspopup="dialog"
45
+ aria-label="Column options"
46
+ :aria-expanded="columnOptionsOpen ? 'true' : 'false'"
47
+ @click.stop="openColumnOptions"
48
+ >
49
+ <HugeiconsIcon
50
+ :icon="LayoutGridIcon"
51
+ width="0.95em"
52
+ height="0.95em"
53
+ :strokeWidth="2"
54
+ aria-hidden="true"
55
+ />
56
+ </button>
57
+ </span>
61
58
  </th>
62
59
  </tr>
63
60
  </thead>
@@ -71,7 +68,12 @@
71
68
  <td
72
69
  v-for="(header, cellIndex) in visibleHeaders"
73
70
  :key="header.key || cellIndex"
74
- :class="[cellClasses(header), header.class]"
71
+ :colspan="bodyColumnColspan(cellIndex) || undefined"
72
+ :class="[
73
+ cellClasses(header),
74
+ header.class,
75
+ isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
76
+ ]"
75
77
  >
76
78
  <slot
77
79
  v-if="slots[`cell-${header.key}`]"
@@ -89,11 +91,6 @@
89
91
  {{ row[header.key] }}
90
92
  </span>
91
93
  </td>
92
- <td
93
- v-if="showColumnOptionsButton"
94
- class="actions table-column-options-cell"
95
- aria-hidden="true"
96
- />
97
94
  </tr>
98
95
  </tbody>
99
96
  </table>
@@ -135,6 +132,7 @@
135
132
  :column-keys="resolvedColumnOrder"
136
133
  :visible-keys="visibleColumnKeys"
137
134
  :column-priorities="resolvedColumnPriorities"
135
+ :default-column-priorities="defaultColumnPriorities"
138
136
  :filters="activeFilters"
139
137
  :rows="props.data"
140
138
  :table-id="props.tableId"
@@ -161,11 +159,9 @@ import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
161
159
  import TableColumnOptionsPopover from './TableColumnOptionsPopover.vue'
162
160
  import { useLongPress } from '../composables/useLongPress.js'
163
161
  import {
164
- applyFilters,
165
162
  buildSelectOptions,
166
163
  cloneColumnFilterEntries,
167
164
  cloneFilters,
168
- countActiveFilters,
169
165
  isColumnFiltered,
170
166
  isFilterEntryActive,
171
167
  normalizeColumnFilterEntries,
@@ -186,6 +182,10 @@ import {
186
182
  sanitizePresetState,
187
183
  layoutStatesEqual,
188
184
  } from '../composables/tableColumnPresets.js'
185
+ import {
186
+ defaultColumnPriorityForIndex,
187
+ isValidColPriority,
188
+ } from '../composables/tableColumnPriorities.js'
189
189
 
190
190
  const DEFAULT_PAGE_SIZE = 10
191
191
 
@@ -231,6 +231,10 @@ const props = defineProps({
231
231
  type: Boolean,
232
232
  default: false,
233
233
  },
234
+ responsiveColumns: {
235
+ type: Boolean,
236
+ default: true,
237
+ },
234
238
  stickyCols: {
235
239
  type: Number,
236
240
  default: null,
@@ -286,6 +290,8 @@ const emit = defineEmits([
286
290
  'column-order-change',
287
291
  'update:columnPriorities',
288
292
  'column-priorities-change',
293
+ 'update:layoutLabel',
294
+ 'layout-label-change',
289
295
  ])
290
296
 
291
297
  const attrs = useAttrs()
@@ -368,6 +374,18 @@ const showColumnOptionsButton = computed(() =>
368
374
  props.columnOptions && manageableColumnKeys.value.length > 0,
369
375
  )
370
376
 
377
+ function isLastVisibleColumn(index) {
378
+ return index === visibleHeaders.value.length - 1
379
+ }
380
+
381
+ function isLastColumnWithOptions(index) {
382
+ return showColumnOptionsButton.value && isLastVisibleColumn(index)
383
+ }
384
+
385
+ function bodyColumnColspan(index) {
386
+ return isLastColumnWithOptions(index) ? 2 : null
387
+ }
388
+
371
389
  function isHeaderHidden(header) {
372
390
  if (header.hidden) {
373
391
  return true
@@ -386,9 +404,13 @@ const visibleColumnKeys = computed(() =>
386
404
  .map((header) => header.key),
387
405
  )
388
406
 
389
- function defaultColumnPriority(header) {
390
- return isValidColPriority(header?.colPriority) ? header.colPriority : null
391
- }
407
+ const hasHorizontalScroll = computed(() =>
408
+ props.horizontalScroll || (props.stickyCols >= 1 && props.stickyCols <= 3),
409
+ )
410
+
411
+ const useResponsiveColumns = computed(() =>
412
+ props.responsiveColumns && !hasHorizontalScroll.value,
413
+ )
392
414
 
393
415
  function resolvedColumnPriority(header) {
394
416
  if (!header?.key) {
@@ -404,9 +426,35 @@ function resolvedColumnPriority(header) {
404
426
  return isValidColPriority(override) ? override : null
405
427
  }
406
428
 
407
- return defaultColumnPriority(header)
429
+ if (!useResponsiveColumns.value) {
430
+ return null
431
+ }
432
+
433
+ const index = visibleHeaders.value.findIndex((item) => item.key === header.key)
434
+ if (index < 0) {
435
+ return null
436
+ }
437
+
438
+ return defaultColumnPriorityForIndex(header, index, useResponsiveColumns.value)
408
439
  }
409
440
 
441
+ const defaultColumnPriorities = computed(() => {
442
+ if (!useResponsiveColumns.value) {
443
+ return Object.fromEntries(
444
+ visibleHeaders.value
445
+ .filter((header) => isValidColPriority(header?.colPriority))
446
+ .map((header) => [header.key, header.colPriority]),
447
+ )
448
+ }
449
+
450
+ return Object.fromEntries(
451
+ visibleHeaders.value.flatMap((header, index) => {
452
+ const priority = defaultColumnPriorityForIndex(header, index, true)
453
+ return isValidColPriority(priority) ? [[header.key, priority]] : []
454
+ }),
455
+ )
456
+ })
457
+
410
458
  const resolvedColumnPriorities = computed(() =>
411
459
  Object.fromEntries(
412
460
  manageableColumnKeys.value.map((key) => {
@@ -420,10 +468,6 @@ const hasColPriorities = computed(() =>
420
468
  orderedHeaders.value.some((header) => isValidColPriority(resolvedColumnPriority(header))),
421
469
  )
422
470
 
423
- const hasHorizontalScroll = computed(() =>
424
- props.horizontalScroll || (props.stickyCols >= 1 && props.stickyCols <= 3),
425
- )
426
-
427
471
  const needsTableWrapper = computed(() => hasColPriorities.value || hasHorizontalScroll.value)
428
472
 
429
473
  const tableWrapperClasses = computed(() => {
@@ -528,10 +572,6 @@ const queryParams = computed(() => {
528
572
  return params
529
573
  })
530
574
 
531
- function isValidColPriority(priority) {
532
- return Number.isInteger(priority) && priority >= 1 && priority <= 5
533
- }
534
-
535
575
  function colPriorityClass(priority) {
536
576
  return isValidColPriority(priority) ? `col-priority-${priority}` : null
537
577
  }
@@ -1105,6 +1145,11 @@ const activeLayoutLabel = computed(() => {
1105
1145
  return 'Custom'
1106
1146
  })
1107
1147
 
1148
+ function emitLayoutLabelChange(label) {
1149
+ emit('update:layoutLabel', label)
1150
+ emit('layout-label-change', label)
1151
+ }
1152
+
1108
1153
  function loadDeveloperDefaults() {
1109
1154
  setFilters({})
1110
1155
  sortBy.value = null
@@ -1313,6 +1358,10 @@ watch(
1313
1358
  { deep: true },
1314
1359
  )
1315
1360
 
1361
+ watch(activeLayoutLabel, (label) => {
1362
+ emitLayoutLabelChange(label)
1363
+ }, { immediate: true })
1364
+
1316
1365
  watch(sortedItems, () => {
1317
1366
  if (!isRemote.value) {
1318
1367
  page.value = 1
@@ -1356,6 +1405,11 @@ onBeforeUnmount(() => {
1356
1405
  clearTimeout(fetchDebounceTimer.value)
1357
1406
  }
1358
1407
  })
1408
+
1409
+ defineExpose({
1410
+ layoutLabel: activeLayoutLabel,
1411
+ loadDeveloperDefaults,
1412
+ })
1359
1413
  </script>
1360
1414
 
1361
1415
  <style scoped>
@@ -1443,41 +1497,19 @@ tbody tr.row-clickable {
1443
1497
  margin: 0;
1444
1498
  }
1445
1499
 
1446
- th.table-column-options-header,
1447
- td.table-column-options-cell {
1500
+ th.table-column-options-header {
1448
1501
  padding-right: 1rem;
1449
1502
  text-align: right;
1450
1503
  vertical-align: middle;
1451
- }
1452
-
1453
- th.table-column-options-header {
1454
1504
  width: auto;
1455
1505
  min-width: 2.5rem;
1456
1506
  max-width: none;
1457
1507
  white-space: nowrap;
1458
1508
  }
1459
1509
 
1460
- td.table-column-options-cell {
1461
- width: 2.5rem;
1462
- min-width: 2.5rem;
1463
- max-width: 2.5rem;
1464
- }
1465
-
1466
- .table-column-options-header-content {
1467
- display: inline-flex;
1468
- align-items: center;
1469
- justify-content: flex-end;
1470
- gap: 0.5rem;
1471
- width: 100%;
1472
- }
1473
-
1474
- .table-layout-label {
1475
- font-size: 0.85rem;
1476
- color: var(--table-muted-fg);
1477
- max-width: 10rem;
1478
- overflow: hidden;
1479
- text-overflow: ellipsis;
1480
- white-space: nowrap;
1510
+ td.table-last-column-with-options {
1511
+ padding-right: 1rem;
1512
+ vertical-align: middle;
1481
1513
  }
1482
1514
 
1483
1515
  th.table-column-options-header .actions-menu-trigger {
@@ -1486,24 +1518,20 @@ th.table-column-options-header .actions-menu-trigger {
1486
1518
  flex: 0 0 auto;
1487
1519
  }
1488
1520
 
1489
- .table-scroll th.table-column-options-header,
1490
- .table-scroll td.table-column-options-cell {
1521
+ .table-scroll {
1522
+ min-width: 0;
1523
+ max-width: 100%;
1524
+ contain: inline-size;
1525
+ }
1526
+
1527
+ .table-scroll th.table-column-options-header {
1491
1528
  position: sticky;
1492
1529
  right: 0;
1493
- z-index: 1;
1530
+ z-index: 2;
1494
1531
  background-color: var(--table-sticky-bg);
1495
1532
  box-shadow: -1px 0 0 var(--border-color);
1496
1533
  }
1497
1534
 
1498
- .table-scroll thead th.table-column-options-header {
1499
- z-index: 2;
1500
- }
1501
-
1502
- .table-scroll table.row-hover tbody tr:hover td.table-column-options-cell,
1503
- .table-scroll table.row-hover tbody tr:focus-within td.table-column-options-cell {
1504
- background-color: var(--table-sticky-hover-bg);
1505
- }
1506
-
1507
1535
  th.actions .actions-menu-trigger button {
1508
1536
  display: inline-flex;
1509
1537
  align-items: center;
@@ -264,6 +264,10 @@ const props = defineProps({
264
264
  type: Object,
265
265
  default: () => ({}),
266
266
  },
267
+ defaultColumnPriorities: {
268
+ type: Object,
269
+ default: () => ({}),
270
+ },
267
271
  anchorEl: {
268
272
  type: Object,
269
273
  default: null,
@@ -389,6 +393,11 @@ function isValidColPriority(priority) {
389
393
  }
390
394
 
391
395
  function defaultColumnPriority(key) {
396
+ if (Object.prototype.hasOwnProperty.call(props.defaultColumnPriorities, key)) {
397
+ const value = props.defaultColumnPriorities[key]
398
+ return isValidColPriority(value) ? value : null
399
+ }
400
+
392
401
  const header = headerByKey.value.get(key)
393
402
  return isValidColPriority(header?.colPriority) ? header.colPriority : null
394
403
  }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import Tabs from './Tabs.vue'
5
+
6
+ const globalStubs = {
7
+ HugeiconsIcon: true,
8
+ }
9
+
10
+ describe('Tabs disabled support', () => {
11
+ const tabs = [
12
+ { id: 'first', label: 'First' },
13
+ { id: 'second', label: 'Second', disabled: true },
14
+ { id: 'third', label: 'Third' },
15
+ ]
16
+
17
+ function mountTabs(overrides = {}) {
18
+ return mount(Tabs, {
19
+ attachTo: document.body,
20
+ props: {
21
+ tabs,
22
+ defaultTab: 'first',
23
+ ...overrides.props,
24
+ },
25
+ slots: {
26
+ 'tab-first': '<p>First panel</p>',
27
+ 'tab-second': '<p>Second panel</p>',
28
+ 'tab-third': '<p>Third panel</p>',
29
+ },
30
+ global: {
31
+ stubs: globalStubs,
32
+ },
33
+ })
34
+ }
35
+
36
+ it('marks disabled tabs with disabled and aria-disabled', () => {
37
+ const wrapper = mountTabs()
38
+
39
+ const disabledTab = wrapper.find('#tab-second')
40
+ expect(disabledTab.attributes('disabled')).toBeDefined()
41
+ expect(disabledTab.attributes('aria-disabled')).toBe('true')
42
+ })
43
+
44
+ it('does not select a disabled tab on click', async () => {
45
+ const wrapper = mountTabs()
46
+
47
+ await wrapper.find('#tab-second').trigger('click')
48
+ await nextTick()
49
+
50
+ expect(wrapper.find('#tab-first').attributes('aria-selected')).toBe('true')
51
+ expect(wrapper.emitted('tab-change')).toBeUndefined()
52
+ })
53
+
54
+ it('skips disabled tabs when moving focus with arrow keys', async () => {
55
+ const wrapper = mountTabs()
56
+
57
+ await wrapper.find('#tab-first').trigger('keydown', { key: 'ArrowRight' })
58
+ await nextTick()
59
+
60
+ expect(wrapper.find('#tab-third').attributes('tabindex')).toBe('0')
61
+ expect(wrapper.find('#tab-third').attributes('aria-selected')).toBe('true')
62
+ })
63
+
64
+ it('selects the first enabled tab when defaultTab is disabled', async () => {
65
+ const wrapper = mountTabs({
66
+ props: {
67
+ defaultTab: 'second',
68
+ },
69
+ })
70
+
71
+ await nextTick()
72
+
73
+ expect(wrapper.find('#tab-first').attributes('aria-selected')).toBe('true')
74
+ })
75
+
76
+ it('moves off a tab when it becomes disabled', async () => {
77
+ const wrapper = mountTabs({
78
+ props: {
79
+ tabs: [
80
+ { id: 'first', label: 'First' },
81
+ { id: 'second', label: 'Second' },
82
+ ],
83
+ defaultTab: 'second',
84
+ },
85
+ slots: {
86
+ 'tab-first': '<p>First panel</p>',
87
+ 'tab-second': '<p>Second panel</p>',
88
+ },
89
+ })
90
+
91
+ await wrapper.setProps({
92
+ tabs: [
93
+ { id: 'first', label: 'First' },
94
+ { id: 'second', label: 'Second', disabled: true },
95
+ ],
96
+ })
97
+ await nextTick()
98
+
99
+ expect(wrapper.find('#tab-first').attributes('aria-selected')).toBe('true')
100
+ })
101
+ })