picocrank 1.26.0 → 1.28.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/eslint.config.js +12 -5
- package/package.json +8 -5
- package/styles/picocrank-extensions.css +46 -0
- package/vite.config.js +5 -0
- package/vue/components/Calendar.vue +125 -128
- package/vue/components/FormField.spec.js +350 -0
- package/vue/components/FormField.vue +236 -32
- package/vue/components/FormLayout.spec.js +56 -0
- package/vue/components/Header.spec.js +71 -0
- package/vue/components/Header.vue +27 -0
- package/vue/components/Login.vue +152 -41
- package/vue/components/Pagination.vue +5 -4
- package/vue/components/Tabs.spec.js +101 -0
- package/vue/components/Tabs.vue +115 -25
- package/vue/components/form-validation.spec.js +126 -0
- package/vue/examples/App.vue +10 -1
- package/vue/examples/FormExample.vue +76 -48
- package/vue/examples/Login.vue +6 -1
- package/vue/examples/LoginExample.vue +98 -2
- package/vue/examples/PageStructure.vue +37 -9
- package/vue/examples/TableExample.vue +5 -5
- package/vue/examples/TabsExample.vue +41 -7
- package/vue/test/formTestUtils.js +279 -0
- package/vue/test/setup.js +5 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
4
|
+
import Header from './Header.vue'
|
|
5
|
+
|
|
6
|
+
const router = createRouter({
|
|
7
|
+
history: createMemoryHistory(),
|
|
8
|
+
routes: [
|
|
9
|
+
{ path: '/login', name: 'Login', component: { template: '<div>Login</div>' } },
|
|
10
|
+
],
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const globalStubs = {
|
|
14
|
+
HugeiconsIcon: true,
|
|
15
|
+
Breadcrumbs: true,
|
|
16
|
+
TopBar: true,
|
|
17
|
+
Sidebar: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('Header loginRoute', () => {
|
|
21
|
+
it('shows a Login link when username is empty and loginRoute is set', async () => {
|
|
22
|
+
const wrapper = mount(Header, {
|
|
23
|
+
props: {
|
|
24
|
+
username: '',
|
|
25
|
+
loginRoute: { name: 'Login' },
|
|
26
|
+
sidebarEnabled: false,
|
|
27
|
+
},
|
|
28
|
+
global: {
|
|
29
|
+
plugins: [router],
|
|
30
|
+
stubs: globalStubs,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const loginLink = wrapper.find('a.login-link')
|
|
35
|
+
expect(loginLink.exists()).toBe(true)
|
|
36
|
+
expect(loginLink.text()).toBe('Login')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('shows the username control instead of Login when username is set', () => {
|
|
40
|
+
const wrapper = mount(Header, {
|
|
41
|
+
props: {
|
|
42
|
+
username: 'tester',
|
|
43
|
+
loginRoute: { name: 'Login' },
|
|
44
|
+
sidebarEnabled: false,
|
|
45
|
+
},
|
|
46
|
+
global: {
|
|
47
|
+
plugins: [router],
|
|
48
|
+
stubs: globalStubs,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(wrapper.find('a.login-link').exists()).toBe(false)
|
|
53
|
+
expect(wrapper.find('.user-info span').text()).toBe('tester')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows neither Login nor username when loginRoute is not set', () => {
|
|
57
|
+
const wrapper = mount(Header, {
|
|
58
|
+
props: {
|
|
59
|
+
username: '',
|
|
60
|
+
sidebarEnabled: false,
|
|
61
|
+
},
|
|
62
|
+
global: {
|
|
63
|
+
plugins: [router],
|
|
64
|
+
stubs: globalStubs,
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(wrapper.find('a.login-link').exists()).toBe(false)
|
|
69
|
+
expect(wrapper.find('.user-info').exists()).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -55,6 +55,13 @@
|
|
|
55
55
|
<HugeiconsIcon :icon="UserIcon" width="1em" height="1em" aria-hidden="true" />
|
|
56
56
|
<span>{{ username }}</span>
|
|
57
57
|
</button>
|
|
58
|
+
<RouterLink
|
|
59
|
+
v-else-if="showLoginLink"
|
|
60
|
+
:to="loginRoute"
|
|
61
|
+
class="user-info flex-row neutral login-link"
|
|
62
|
+
>
|
|
63
|
+
<span>Login</span>
|
|
64
|
+
</RouterLink>
|
|
58
65
|
</slot>
|
|
59
66
|
</div>
|
|
60
67
|
</header>
|
|
@@ -65,6 +72,7 @@
|
|
|
65
72
|
|
|
66
73
|
<script setup>
|
|
67
74
|
import { computed, ref } from "vue";
|
|
75
|
+
import { RouterLink } from "vue-router";
|
|
68
76
|
import { HugeiconsIcon } from "@hugeicons/vue";
|
|
69
77
|
import { BulbIcon, Menu01Icon, Moon02Icon, Sun01Icon, UserIcon } from "@hugeicons/core-free-icons";
|
|
70
78
|
|
|
@@ -110,6 +118,10 @@
|
|
|
110
118
|
type: String,
|
|
111
119
|
default: "",
|
|
112
120
|
},
|
|
121
|
+
loginRoute: {
|
|
122
|
+
type: [String, Object],
|
|
123
|
+
default: null,
|
|
124
|
+
},
|
|
113
125
|
title: {
|
|
114
126
|
type: String,
|
|
115
127
|
default: "Untitled",
|
|
@@ -155,6 +167,10 @@
|
|
|
155
167
|
() => props.breadcrumbs && !isMobile.value,
|
|
156
168
|
);
|
|
157
169
|
|
|
170
|
+
const showLoginLink = computed(
|
|
171
|
+
() => !props.username.trim() && props.loginRoute != null && props.loginRoute !== '',
|
|
172
|
+
);
|
|
173
|
+
|
|
158
174
|
function toggleNavSidebar() {
|
|
159
175
|
if (needsFallbackSidebar.value) {
|
|
160
176
|
fallbackSidebar.value?.toggle();
|
|
@@ -201,6 +217,11 @@
|
|
|
201
217
|
font-weight: normal;
|
|
202
218
|
}
|
|
203
219
|
|
|
220
|
+
.login-link {
|
|
221
|
+
text-decoration: none;
|
|
222
|
+
color: inherit;
|
|
223
|
+
}
|
|
224
|
+
|
|
204
225
|
.user-info span {
|
|
205
226
|
padding-right: 0;
|
|
206
227
|
font-weight: normal;
|
|
@@ -218,6 +239,7 @@
|
|
|
218
239
|
|
|
219
240
|
/* Match Femtocrank header chrome (same idea as #sidebar-button:hover) */
|
|
220
241
|
.header-actions > button,
|
|
242
|
+
.header-actions > a.login-link,
|
|
221
243
|
.header-actions :deep(button) {
|
|
222
244
|
border: 0;
|
|
223
245
|
border-radius: 0;
|
|
@@ -233,6 +255,7 @@
|
|
|
233
255
|
}
|
|
234
256
|
|
|
235
257
|
.header-actions > button:hover,
|
|
258
|
+
.header-actions > a.login-link:hover,
|
|
236
259
|
.header-actions :deep(button:hover),
|
|
237
260
|
.user-info:hover {
|
|
238
261
|
background-color: var(--header-hover-background-color);
|
|
@@ -298,4 +321,8 @@ header .search-trigger:hover {
|
|
|
298
321
|
header .user-info {
|
|
299
322
|
font-weight: normal;
|
|
300
323
|
}
|
|
324
|
+
|
|
325
|
+
header .login-link {
|
|
326
|
+
font-weight: normal;
|
|
327
|
+
}
|
|
301
328
|
</style>
|
package/vue/components/Login.vue
CHANGED
|
@@ -1,30 +1,50 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="login-container">
|
|
2
|
+
<div class="login-container" :class="{ 'local-only': localOnly }">
|
|
3
3
|
<Tabs :tabs="loginTabs" @tab-change="onTabChange">
|
|
4
4
|
<!-- Local Login Tab -->
|
|
5
5
|
<template #tab-local>
|
|
6
6
|
<div class="login-section">
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
7
|
+
<div
|
|
8
|
+
v-if="localLoginLoading"
|
|
9
|
+
class="local-login-loading"
|
|
10
|
+
role="status"
|
|
11
|
+
aria-live="polite"
|
|
12
|
+
>
|
|
13
|
+
<span class="local-login-spinner" aria-hidden="true" />
|
|
14
|
+
<p>{{ localLoginLoadingMessage }}</p>
|
|
15
|
+
</div>
|
|
16
|
+
<form v-else @submit.prevent="handleLocalLogin" class="local-login-form">
|
|
17
|
+
<FormField label="Username" for="username" label-skip v-model:error="usernameError">
|
|
18
|
+
<input
|
|
19
|
+
id="username"
|
|
20
|
+
type="text"
|
|
21
|
+
v-model="localLogin.username"
|
|
22
|
+
required
|
|
23
|
+
autocomplete="username"
|
|
24
|
+
placeholder="Username"
|
|
25
|
+
aria-label="Username"
|
|
26
|
+
/>
|
|
27
|
+
</FormField>
|
|
28
|
+
<FormField label="Password" for="password" label-skip v-model:error="passwordError">
|
|
29
|
+
<input
|
|
30
|
+
id="password"
|
|
31
|
+
type="password"
|
|
32
|
+
v-model="localLogin.password"
|
|
33
|
+
required
|
|
34
|
+
autocomplete="current-password"
|
|
35
|
+
placeholder="Password"
|
|
36
|
+
aria-label="Password"
|
|
37
|
+
/>
|
|
38
|
+
</FormField>
|
|
24
39
|
<button type="submit" class="good">Login</button>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
<NotificationBlock
|
|
41
|
+
v-if="formError"
|
|
42
|
+
class="login-form-error"
|
|
43
|
+
type="bad"
|
|
44
|
+
label="Error"
|
|
45
|
+
:message="formError"
|
|
46
|
+
role="alert"
|
|
47
|
+
/>
|
|
28
48
|
</form>
|
|
29
49
|
</div>
|
|
30
50
|
</template>
|
|
@@ -70,6 +90,8 @@
|
|
|
70
90
|
<script setup>
|
|
71
91
|
import { ref, reactive, watch, computed, useSlots } from 'vue';
|
|
72
92
|
import Tabs from './Tabs.vue';
|
|
93
|
+
import FormField from './FormField.vue';
|
|
94
|
+
import NotificationBlock from './NotificationBlock.vue';
|
|
73
95
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
74
96
|
|
|
75
97
|
const props = defineProps({
|
|
@@ -95,6 +117,15 @@ const props = defineProps({
|
|
|
95
117
|
showDefaultTabs: {
|
|
96
118
|
type: Boolean,
|
|
97
119
|
default: true
|
|
120
|
+
},
|
|
121
|
+
// Username & password only — no OAuth or custom tabs; hides the tab bar
|
|
122
|
+
localOnly: {
|
|
123
|
+
type: Boolean,
|
|
124
|
+
default: false
|
|
125
|
+
},
|
|
126
|
+
loadingMessage: {
|
|
127
|
+
type: String,
|
|
128
|
+
default: 'Signing in…'
|
|
98
129
|
}
|
|
99
130
|
});
|
|
100
131
|
|
|
@@ -119,8 +150,9 @@ const loginTabs = ref([]);
|
|
|
119
150
|
function initializeTabs() {
|
|
120
151
|
const tabs = [];
|
|
121
152
|
|
|
122
|
-
|
|
123
|
-
|
|
153
|
+
if (props.localOnly) {
|
|
154
|
+
tabs.push({ id: 'local', label: props.tabLabels.local || 'Username & Password' });
|
|
155
|
+
} else if (props.showDefaultTabs) {
|
|
124
156
|
tabs.push(
|
|
125
157
|
{ id: 'local', label: props.tabLabels.local || 'Username & Password' },
|
|
126
158
|
{ id: 'oauth', label: props.tabLabels.oauth || 'OAuth2' }
|
|
@@ -128,7 +160,7 @@ function initializeTabs() {
|
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
// Add custom tabs
|
|
131
|
-
if (props.customTabs && props.customTabs.length > 0) {
|
|
163
|
+
if (!props.localOnly && props.customTabs && props.customTabs.length > 0) {
|
|
132
164
|
tabs.push(...props.customTabs);
|
|
133
165
|
}
|
|
134
166
|
|
|
@@ -139,7 +171,7 @@ function initializeTabs() {
|
|
|
139
171
|
initializeTabs();
|
|
140
172
|
|
|
141
173
|
// Watch for changes to customTabs or showDefaultTabs
|
|
142
|
-
watch(() => [props.customTabs, props.showDefaultTabs], () => {
|
|
174
|
+
watch(() => [props.customTabs, props.showDefaultTabs, props.localOnly], () => {
|
|
143
175
|
initializeTabs();
|
|
144
176
|
}, { deep: true });
|
|
145
177
|
|
|
@@ -149,7 +181,26 @@ const localLogin = reactive({
|
|
|
149
181
|
password: ''
|
|
150
182
|
});
|
|
151
183
|
|
|
152
|
-
const
|
|
184
|
+
const usernameError = ref('');
|
|
185
|
+
const passwordError = ref('');
|
|
186
|
+
const formError = ref('');
|
|
187
|
+
const localLoginLoading = ref(false);
|
|
188
|
+
const localLoginLoadingMessage = ref('');
|
|
189
|
+
|
|
190
|
+
function clearLocalLoginErrors() {
|
|
191
|
+
usernameError.value = '';
|
|
192
|
+
passwordError.value = '';
|
|
193
|
+
formError.value = '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
watch(
|
|
197
|
+
() => [localLogin.username, localLogin.password],
|
|
198
|
+
() => {
|
|
199
|
+
if (formError.value) {
|
|
200
|
+
formError.value = '';
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
);
|
|
153
204
|
|
|
154
205
|
// OAuth providers - provided by parent component
|
|
155
206
|
const oauthProviders = ref(props.oauthProviders);
|
|
@@ -163,9 +214,10 @@ watch(() => props.oauthProviders, (newProviders) => {
|
|
|
163
214
|
|
|
164
215
|
// Handle local login
|
|
165
216
|
function handleLocalLogin() {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
217
|
+
clearLocalLoginErrors();
|
|
218
|
+
localLoginLoading.value = true;
|
|
219
|
+
localLoginLoadingMessage.value = props.loadingMessage;
|
|
220
|
+
|
|
169
221
|
emit('local-login', {
|
|
170
222
|
username: localLogin.username,
|
|
171
223
|
password: localLogin.password
|
|
@@ -176,7 +228,8 @@ function handleLocalLogin() {
|
|
|
176
228
|
function resetLocalForm() {
|
|
177
229
|
localLogin.username = '';
|
|
178
230
|
localLogin.password = '';
|
|
179
|
-
|
|
231
|
+
clearLocalLoginErrors();
|
|
232
|
+
localLoginLoading.value = false;
|
|
180
233
|
}
|
|
181
234
|
|
|
182
235
|
// Handle OAuth login
|
|
@@ -189,15 +242,28 @@ function handleOAuthLogin(provider) {
|
|
|
189
242
|
|
|
190
243
|
// Handle tab change
|
|
191
244
|
function onTabChange(tab, tabId) {
|
|
192
|
-
|
|
193
|
-
localLoginError.value = '';
|
|
245
|
+
clearLocalLoginErrors();
|
|
194
246
|
oauthError.value = '';
|
|
247
|
+
localLoginLoading.value = false;
|
|
195
248
|
emit('tab-change', tab, tabId);
|
|
196
249
|
}
|
|
197
250
|
|
|
198
|
-
|
|
251
|
+
function setLocalLoginErrors(errors = {}) {
|
|
252
|
+
localLoginLoading.value = false;
|
|
253
|
+
usernameError.value = errors.username || '';
|
|
254
|
+
passwordError.value = errors.password || '';
|
|
255
|
+
formError.value = errors.form || '';
|
|
256
|
+
}
|
|
257
|
+
|
|
199
258
|
function setLocalLoginError(message) {
|
|
200
|
-
|
|
259
|
+
setLocalLoginErrors({ form: message });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function setLocalLoginLoading(loading, message) {
|
|
263
|
+
localLoginLoading.value = loading;
|
|
264
|
+
if (loading) {
|
|
265
|
+
localLoginLoadingMessage.value = message ?? props.loadingMessage;
|
|
266
|
+
}
|
|
201
267
|
}
|
|
202
268
|
|
|
203
269
|
function setOAuthError(message) {
|
|
@@ -214,6 +280,8 @@ defineExpose({
|
|
|
214
280
|
addOAuthProvider,
|
|
215
281
|
resetLocalForm,
|
|
216
282
|
setLocalLoginError,
|
|
283
|
+
setLocalLoginErrors,
|
|
284
|
+
setLocalLoginLoading,
|
|
217
285
|
setOAuthError,
|
|
218
286
|
localLogin
|
|
219
287
|
});
|
|
@@ -225,6 +293,15 @@ defineExpose({
|
|
|
225
293
|
margin: 2rem auto;
|
|
226
294
|
}
|
|
227
295
|
|
|
296
|
+
.login-container.local-only {
|
|
297
|
+
margin-top: 0;
|
|
298
|
+
margin-bottom: 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.login-container.local-only :deep(.tabs-header) {
|
|
302
|
+
display: none;
|
|
303
|
+
}
|
|
304
|
+
|
|
228
305
|
.login-section {
|
|
229
306
|
display: flex;
|
|
230
307
|
flex-direction: column;
|
|
@@ -239,17 +316,46 @@ defineExpose({
|
|
|
239
316
|
width: 100%;
|
|
240
317
|
}
|
|
241
318
|
|
|
242
|
-
.local-login-form
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
319
|
+
.local-login-form :deep(.form-field-control) {
|
|
320
|
+
width: 100%;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.local-login-form :deep(input) {
|
|
246
324
|
width: 100%;
|
|
247
325
|
box-sizing: border-box;
|
|
248
326
|
}
|
|
249
327
|
|
|
250
|
-
.local-login-
|
|
251
|
-
|
|
252
|
-
|
|
328
|
+
.local-login-loading {
|
|
329
|
+
display: flex;
|
|
330
|
+
flex-direction: column;
|
|
331
|
+
align-items: center;
|
|
332
|
+
justify-content: center;
|
|
333
|
+
gap: 1rem;
|
|
334
|
+
width: 100%;
|
|
335
|
+
min-height: 9rem;
|
|
336
|
+
padding: 1rem 0;
|
|
337
|
+
color: var(--login-muted-fg);
|
|
338
|
+
text-align: center;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.local-login-loading p {
|
|
342
|
+
margin: 0;
|
|
343
|
+
font-size: 0.95em;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.local-login-spinner {
|
|
347
|
+
width: 2rem;
|
|
348
|
+
height: 2rem;
|
|
349
|
+
border: 3px solid var(--login-spinner-track);
|
|
350
|
+
border-top-color: var(--login-spinner-fg);
|
|
351
|
+
border-radius: 50%;
|
|
352
|
+
animation: local-login-spin 0.75s linear infinite;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@keyframes local-login-spin {
|
|
356
|
+
to {
|
|
357
|
+
transform: rotate(360deg);
|
|
358
|
+
}
|
|
253
359
|
}
|
|
254
360
|
|
|
255
361
|
.local-login-form button {
|
|
@@ -266,6 +372,11 @@ defineExpose({
|
|
|
266
372
|
opacity: 0.9;
|
|
267
373
|
}
|
|
268
374
|
|
|
375
|
+
.local-login-form :deep(.login-form-error) {
|
|
376
|
+
width: 100%;
|
|
377
|
+
font-size: 0.9em;
|
|
378
|
+
}
|
|
379
|
+
|
|
269
380
|
.oauth-providers {
|
|
270
381
|
display: flex;
|
|
271
382
|
flex-direction: column;
|
|
@@ -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 {
|
|
@@ -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
|
+
})
|