picocrank 1.26.0 → 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.
- 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/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/FormExample.vue +76 -48
- package/vue/examples/Login.vue +6 -1
- package/vue/examples/LoginExample.vue +98 -2
- package/vue/examples/PageStructure.vue +9 -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
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
|
+
})
|
package/vue/components/Tabs.vue
CHANGED
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
v-for="(tab, index) in tabs"
|
|
19
19
|
:key="getTabKey(tab, index)"
|
|
20
20
|
:ref="(el) => setTabRef(getTabKey(tab, index), el)"
|
|
21
|
-
:class="['tab-button', { active: isTabSelected(tab, index) }]"
|
|
21
|
+
:class="['tab-button', { active: isTabSelected(tab, index), disabled: isTabDisabled(tab) }]"
|
|
22
|
+
:disabled="isTabDisabled(tab)"
|
|
22
23
|
:aria-selected="isTabSelected(tab, index) ? 'true' : 'false'"
|
|
24
|
+
:aria-disabled="isTabDisabled(tab) ? 'true' : undefined"
|
|
23
25
|
:aria-controls="`tab-panel-${getTabKey(tab, index)}`"
|
|
24
26
|
:id="`tab-${getTabKey(tab, index)}`"
|
|
25
|
-
:tabindex="
|
|
27
|
+
:tabindex="tabTabIndex(tab, index)"
|
|
28
|
+
:title="tab.disabledTitle || undefined"
|
|
26
29
|
role="tab"
|
|
27
30
|
type="button"
|
|
28
31
|
@click="onTabClick(getTabKey(tab, index))"
|
|
@@ -104,18 +107,66 @@ const emit = defineEmits(['tab-change'])
|
|
|
104
107
|
|
|
105
108
|
const tabRefs = new Map()
|
|
106
109
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
const activeTabId = ref(null)
|
|
111
|
+
const focusedTabId = ref(null)
|
|
112
|
+
|
|
113
|
+
function isTabDisabled(tab) {
|
|
114
|
+
return tab.disabled === true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getFirstEnabledIndex() {
|
|
118
|
+
return props.tabs.findIndex((tab) => !isTabDisabled(tab))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getLastEnabledIndex() {
|
|
122
|
+
for (let index = props.tabs.length - 1; index >= 0; index -= 1) {
|
|
123
|
+
if (!isTabDisabled(props.tabs[index])) {
|
|
124
|
+
return index
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return -1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getFirstEnabledTabKey() {
|
|
131
|
+
const index = getFirstEnabledIndex()
|
|
132
|
+
if (index < 0) {
|
|
133
|
+
return null
|
|
110
134
|
}
|
|
111
|
-
|
|
112
|
-
|
|
135
|
+
return getTabKey(props.tabs[index], index)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveSelectableTabId(tabKey) {
|
|
139
|
+
if (tabKey !== null && tabKey !== undefined) {
|
|
140
|
+
const index = props.tabs.findIndex((tab, i) => getTabKey(tab, i) === tabKey)
|
|
141
|
+
if (index >= 0 && !isTabDisabled(props.tabs[index])) {
|
|
142
|
+
return tabKey
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return getFirstEnabledTabKey()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tabTabIndex(tab, index) {
|
|
149
|
+
if (isTabDisabled(tab)) {
|
|
150
|
+
return -1
|
|
113
151
|
}
|
|
114
|
-
return
|
|
152
|
+
return isTabFocused(tab, index) ? 0 : -1
|
|
115
153
|
}
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
const
|
|
155
|
+
function findNextEnabledIndex(fromIndex, step) {
|
|
156
|
+
const length = props.tabs.length
|
|
157
|
+
if (length === 0) {
|
|
158
|
+
return fromIndex
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (let offset = 1; offset <= length; offset += 1) {
|
|
162
|
+
const index = (fromIndex + step * offset + length * 100) % length
|
|
163
|
+
if (!isTabDisabled(props.tabs[index])) {
|
|
164
|
+
return index
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return fromIndex
|
|
169
|
+
}
|
|
119
170
|
|
|
120
171
|
function getTabKey(tab, index) {
|
|
121
172
|
return tab.id ?? index
|
|
@@ -146,10 +197,14 @@ function focusTab(tabKey) {
|
|
|
146
197
|
}
|
|
147
198
|
|
|
148
199
|
function selectTab(tabKey, { emitChange = true } = {}) {
|
|
200
|
+
const index = tabIndexForKey(tabKey)
|
|
201
|
+
if (index >= 0 && isTabDisabled(props.tabs[index])) {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
149
205
|
if (activeTabId.value !== tabKey) {
|
|
150
206
|
activeTabId.value = tabKey
|
|
151
207
|
if (emitChange) {
|
|
152
|
-
const index = tabIndexForKey(tabKey)
|
|
153
208
|
const activeTab = index >= 0 ? props.tabs[index] : null
|
|
154
209
|
emit('tab-change', activeTab, tabKey)
|
|
155
210
|
}
|
|
@@ -166,8 +221,11 @@ function moveFocusToIndex(index, { activate = props.activation === 'auto' } = {}
|
|
|
166
221
|
return
|
|
167
222
|
}
|
|
168
223
|
|
|
169
|
-
|
|
170
|
-
|
|
224
|
+
if (index < 0 || index >= props.tabs.length || isTabDisabled(props.tabs[index])) {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tabKey = getTabKey(props.tabs[index], index)
|
|
171
229
|
focusedTabId.value = tabKey
|
|
172
230
|
|
|
173
231
|
if (activate) {
|
|
@@ -177,6 +235,14 @@ function moveFocusToIndex(index, { activate = props.activation === 'auto' } = {}
|
|
|
177
235
|
nextTick(() => focusTab(tabKey))
|
|
178
236
|
}
|
|
179
237
|
|
|
238
|
+
function moveFocusByStep(fromIndex, step, { activate = props.activation === 'auto' } = {}) {
|
|
239
|
+
const nextIndex = findNextEnabledIndex(fromIndex, step)
|
|
240
|
+
if (nextIndex === fromIndex && isTabDisabled(props.tabs[fromIndex])) {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
moveFocusToIndex(nextIndex, { activate })
|
|
244
|
+
}
|
|
245
|
+
|
|
180
246
|
function tabKeyFromElement(tabEl) {
|
|
181
247
|
const suffix = tabEl.id.slice('tab-'.length)
|
|
182
248
|
const index = props.tabs.findIndex((tab, i) => String(getTabKey(tab, i)) === suffix)
|
|
@@ -204,34 +270,34 @@ function onTabListKeydown(event) {
|
|
|
204
270
|
case 'ArrowRight':
|
|
205
271
|
if (!isVertical) {
|
|
206
272
|
event.preventDefault()
|
|
207
|
-
|
|
273
|
+
moveFocusByStep(index, 1)
|
|
208
274
|
}
|
|
209
275
|
break
|
|
210
276
|
case 'ArrowLeft':
|
|
211
277
|
if (!isVertical) {
|
|
212
278
|
event.preventDefault()
|
|
213
|
-
|
|
279
|
+
moveFocusByStep(index, -1)
|
|
214
280
|
}
|
|
215
281
|
break
|
|
216
282
|
case 'ArrowDown':
|
|
217
283
|
if (isVertical) {
|
|
218
284
|
event.preventDefault()
|
|
219
|
-
|
|
285
|
+
moveFocusByStep(index, 1)
|
|
220
286
|
}
|
|
221
287
|
break
|
|
222
288
|
case 'ArrowUp':
|
|
223
289
|
if (isVertical) {
|
|
224
290
|
event.preventDefault()
|
|
225
|
-
|
|
291
|
+
moveFocusByStep(index, -1)
|
|
226
292
|
}
|
|
227
293
|
break
|
|
228
294
|
case 'Home':
|
|
229
295
|
event.preventDefault()
|
|
230
|
-
moveFocusToIndex(
|
|
296
|
+
moveFocusToIndex(getFirstEnabledIndex())
|
|
231
297
|
break
|
|
232
298
|
case 'End':
|
|
233
299
|
event.preventDefault()
|
|
234
|
-
moveFocusToIndex(
|
|
300
|
+
moveFocusToIndex(getLastEnabledIndex())
|
|
235
301
|
break
|
|
236
302
|
case ' ':
|
|
237
303
|
case 'Enter':
|
|
@@ -260,20 +326,25 @@ function syncTabIdsAfterListChange() {
|
|
|
260
326
|
return
|
|
261
327
|
}
|
|
262
328
|
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
329
|
+
const activeIndex = tabIndexForKey(activeTabId.value)
|
|
330
|
+
const activeIsValid = activeIndex >= 0 && !isTabDisabled(props.tabs[activeIndex])
|
|
331
|
+
if (!activeIsValid) {
|
|
332
|
+
const firstKey = getFirstEnabledTabKey()
|
|
266
333
|
activeTabId.value = firstKey
|
|
267
334
|
focusedTabId.value = firstKey
|
|
268
335
|
return
|
|
269
336
|
}
|
|
270
337
|
|
|
271
|
-
|
|
338
|
+
const focusedIndex = tabIndexForKey(focusedTabId.value)
|
|
339
|
+
const focusedIsValid = focusedIndex >= 0 && !isTabDisabled(props.tabs[focusedIndex])
|
|
340
|
+
if (!focusedIsValid) {
|
|
272
341
|
focusedTabId.value = activeTabId.value
|
|
273
342
|
}
|
|
274
343
|
}
|
|
275
344
|
|
|
276
345
|
onMounted(() => {
|
|
346
|
+
activeTabId.value = resolveSelectableTabId(props.defaultTab)
|
|
347
|
+
focusedTabId.value = activeTabId.value
|
|
277
348
|
syncTabIdsAfterListChange()
|
|
278
349
|
})
|
|
279
350
|
|
|
@@ -357,7 +428,7 @@ defineExpose({
|
|
|
357
428
|
position: relative;
|
|
358
429
|
}
|
|
359
430
|
|
|
360
|
-
.tab-button:hover:not(.active) {
|
|
431
|
+
.tab-button:hover:not(.active):not(:disabled) {
|
|
361
432
|
color: var(--tab-hover-fg);
|
|
362
433
|
background-color: var(--tab-hover-bg);
|
|
363
434
|
}
|
|
@@ -368,6 +439,25 @@ defineExpose({
|
|
|
368
439
|
border-bottom-color: transparent;
|
|
369
440
|
}
|
|
370
441
|
|
|
442
|
+
.tab-button:disabled,
|
|
443
|
+
.tab-button.disabled {
|
|
444
|
+
cursor: not-allowed;
|
|
445
|
+
color: var(--disabled-text-color);
|
|
446
|
+
opacity: 0.72;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.tab-button:disabled:hover,
|
|
450
|
+
.tab-button.disabled:hover {
|
|
451
|
+
color: var(--disabled-text-color);
|
|
452
|
+
background-color: transparent;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.tab-button:disabled.active,
|
|
456
|
+
.tab-button.disabled.active {
|
|
457
|
+
background-color: var(--tab-active-bg);
|
|
458
|
+
color: var(--disabled-text-color);
|
|
459
|
+
}
|
|
460
|
+
|
|
371
461
|
.tabs-vertical .tab-button {
|
|
372
462
|
width: 100%;
|
|
373
463
|
justify-content: flex-start;
|
|
@@ -426,7 +516,7 @@ defineExpose({
|
|
|
426
516
|
z-index: 1;
|
|
427
517
|
}
|
|
428
518
|
|
|
429
|
-
.tab-button:hover:not(.active):focus-visible {
|
|
519
|
+
.tab-button:hover:not(.active):not(:disabled):focus-visible {
|
|
430
520
|
color: var(--tab-hover-fg);
|
|
431
521
|
background-color: var(--tab-hover-bg);
|
|
432
522
|
}
|