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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
flushControlState,
|
|
4
|
+
MIN_COMMENTS_LENGTH,
|
|
5
|
+
mountLoginValidationForm,
|
|
6
|
+
mountValidationForm,
|
|
7
|
+
} from '../test/formTestUtils.js'
|
|
8
|
+
|
|
9
|
+
describe('form validation integration', () => {
|
|
10
|
+
it('blocks submit and surfaces native required validation for empty email', async () => {
|
|
11
|
+
const { wrapper, submitted } = mountValidationForm()
|
|
12
|
+
|
|
13
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
14
|
+
await flushControlState()
|
|
15
|
+
|
|
16
|
+
expect(submitted.value).toBe(false)
|
|
17
|
+
expect(wrapper.find('#email').element.validationMessage).not.toBe('')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows computed email format errors as the user types', async () => {
|
|
21
|
+
const { wrapper } = mountValidationForm()
|
|
22
|
+
|
|
23
|
+
await wrapper.find('#email').setValue('not-an-email')
|
|
24
|
+
await flushControlState()
|
|
25
|
+
|
|
26
|
+
expect(wrapper.find('#email').element.getAttribute('aria-invalid')).toBe('true')
|
|
27
|
+
expect(wrapper.text()).toContain('Enter a valid email address.')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('clears email errors when a valid address is entered', async () => {
|
|
31
|
+
const { wrapper } = mountValidationForm()
|
|
32
|
+
|
|
33
|
+
await wrapper.find('#email').setValue('not-an-email')
|
|
34
|
+
await flushControlState()
|
|
35
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
36
|
+
await flushControlState()
|
|
37
|
+
|
|
38
|
+
expect(wrapper.find('#email').element.hasAttribute('aria-invalid')).toBe(false)
|
|
39
|
+
expect(wrapper.findAll('.form-field-error')).toHaveLength(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('shows comments length validation only when comments are non-empty', async () => {
|
|
43
|
+
const { wrapper } = mountValidationForm()
|
|
44
|
+
|
|
45
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
46
|
+
await wrapper.find('#comments').setValue('Too short')
|
|
47
|
+
await flushControlState()
|
|
48
|
+
|
|
49
|
+
expect(wrapper.text()).toContain(`Comments must be at least ${MIN_COMMENTS_LENGTH} characters.`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('allows submit when email is valid and comments are empty', async () => {
|
|
53
|
+
const { wrapper, submitted } = mountValidationForm()
|
|
54
|
+
|
|
55
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
56
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
57
|
+
await flushControlState()
|
|
58
|
+
|
|
59
|
+
expect(submitted.value).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('blocks submit for conflicting accessibility selections via validate()', async () => {
|
|
63
|
+
const { wrapper, submitted, formData } = mountValidationForm()
|
|
64
|
+
|
|
65
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
66
|
+
formData.value.accessibility = ['high-contrast', 'low-contrast']
|
|
67
|
+
await flushControlState()
|
|
68
|
+
|
|
69
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
70
|
+
await flushControlState()
|
|
71
|
+
|
|
72
|
+
expect(submitted.value).toBe(false)
|
|
73
|
+
expect(wrapper.text()).toContain('High contrast and low contrast UI cannot both be selected.')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('submits when accessibility selections are compatible', async () => {
|
|
77
|
+
const { wrapper, submitted, formData } = mountValidationForm()
|
|
78
|
+
|
|
79
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
80
|
+
formData.value.accessibility = ['high-contrast']
|
|
81
|
+
await flushControlState()
|
|
82
|
+
|
|
83
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
84
|
+
await flushControlState()
|
|
85
|
+
|
|
86
|
+
expect(submitted.value).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('Login-style server validation', () => {
|
|
91
|
+
it('maps password errors to the password field', async () => {
|
|
92
|
+
const { wrapper } = mountLoginValidationForm()
|
|
93
|
+
|
|
94
|
+
wrapper.vm.applyServerErrors({ password: 'Incorrect password.' })
|
|
95
|
+
await flushControlState()
|
|
96
|
+
|
|
97
|
+
expect(wrapper.find('#password').element.getAttribute('aria-invalid')).toBe('true')
|
|
98
|
+
expect(wrapper.text()).toContain('Incorrect password.')
|
|
99
|
+
expect(wrapper.find('.login-form-error').exists()).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('maps server failures to a form-level NotificationBlock', async () => {
|
|
103
|
+
const { wrapper } = mountLoginValidationForm()
|
|
104
|
+
|
|
105
|
+
wrapper.vm.applyServerErrors({ form: 'Unable to reach authentication service.' })
|
|
106
|
+
await flushControlState()
|
|
107
|
+
|
|
108
|
+
expect(wrapper.find('.login-form-error').exists()).toBe(true)
|
|
109
|
+
expect(wrapper.find('.login-form-error').attributes('role')).toBe('alert')
|
|
110
|
+
expect(wrapper.text()).toContain('Unable to reach authentication service.')
|
|
111
|
+
expect(wrapper.find('#username').element.hasAttribute('aria-invalid')).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('clears field errors when the user edits the control', async () => {
|
|
115
|
+
const { wrapper, passwordError } = mountLoginValidationForm()
|
|
116
|
+
|
|
117
|
+
wrapper.vm.applyServerErrors({ password: 'Incorrect password.' })
|
|
118
|
+
await flushControlState()
|
|
119
|
+
|
|
120
|
+
await wrapper.find('#password').setValue('new-secret')
|
|
121
|
+
await flushControlState()
|
|
122
|
+
|
|
123
|
+
expect(passwordError.value).toBe('')
|
|
124
|
+
expect(wrapper.find('#password').element.hasAttribute('aria-invalid')).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
/>
|
|
23
23
|
</FormField>
|
|
24
24
|
|
|
25
|
-
<FormField label="Role"
|
|
25
|
+
<FormField label="Role" component-has-label>
|
|
26
26
|
<RadioGroup
|
|
27
27
|
v-model="formData.role"
|
|
28
28
|
name="role"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
/>
|
|
31
31
|
</FormField>
|
|
32
32
|
|
|
33
|
-
<FormField label="Term"
|
|
33
|
+
<FormField label="Term" component-has-label>
|
|
34
34
|
<RadioGroup
|
|
35
35
|
v-model="formData.term"
|
|
36
36
|
name="term"
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
|
|
53
53
|
<FormField
|
|
54
54
|
label="Compensation"
|
|
55
|
-
|
|
55
|
+
component-has-label
|
|
56
|
+
label-required
|
|
56
57
|
description-above
|
|
57
58
|
description="Select the incentive plan that applies to this role."
|
|
58
59
|
docs-url="https://github.com/jamesread/femtocrank"
|
|
@@ -68,11 +69,13 @@
|
|
|
68
69
|
|
|
69
70
|
<FormField
|
|
70
71
|
label="Accessibility requirements"
|
|
71
|
-
|
|
72
|
+
component-has-label
|
|
72
73
|
description-above
|
|
73
74
|
description="Choose any accommodations needed for this role."
|
|
74
75
|
docs-url="https://www.w3.org/WAI/fundamentals/accessibility-intro/"
|
|
75
76
|
docs-url-title="Web accessibility introduction"
|
|
77
|
+
:validate="validateAccessibilityRequirements"
|
|
78
|
+
:validate-deps="[formData.accessibility]"
|
|
76
79
|
>
|
|
77
80
|
<CheckGroup
|
|
78
81
|
v-model="formData.accessibility"
|
|
@@ -84,6 +87,7 @@
|
|
|
84
87
|
<FormField
|
|
85
88
|
label="Email"
|
|
86
89
|
for="email"
|
|
90
|
+
:error="emailError"
|
|
87
91
|
description="We'll use this address for account notifications only."
|
|
88
92
|
docs-url="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email"
|
|
89
93
|
docs-url-title="HTML email input reference"
|
|
@@ -149,7 +153,7 @@
|
|
|
149
153
|
/>
|
|
150
154
|
</FormField>
|
|
151
155
|
|
|
152
|
-
<FormField label="Newsletters"
|
|
156
|
+
<FormField label="Newsletters" component-has-label>
|
|
153
157
|
<div>
|
|
154
158
|
<label>
|
|
155
159
|
<input
|
|
@@ -174,7 +178,8 @@
|
|
|
174
178
|
<FormField
|
|
175
179
|
label="Comments"
|
|
176
180
|
for="comments"
|
|
177
|
-
|
|
181
|
+
:error="commentsError"
|
|
182
|
+
description="Optional feedback or notes about this submission (minimum 50 characters when provided)."
|
|
178
183
|
>
|
|
179
184
|
<textarea
|
|
180
185
|
id="comments"
|
|
@@ -222,13 +227,39 @@
|
|
|
222
227
|
</template>
|
|
223
228
|
|
|
224
229
|
<script setup>
|
|
225
|
-
import { ref } from 'vue'
|
|
230
|
+
import { computed, ref } from 'vue'
|
|
226
231
|
import Section from '../components/Section.vue'
|
|
227
232
|
import FormLayout from '../components/FormLayout.vue'
|
|
228
233
|
import FormField from '../components/FormField.vue'
|
|
229
234
|
import RadioGroup from '../components/RadioGroup.vue'
|
|
230
235
|
import CheckGroup from '../components/CheckGroup.vue'
|
|
231
236
|
|
|
237
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
238
|
+
const MIN_COMMENTS_LENGTH = 50
|
|
239
|
+
|
|
240
|
+
function createDefaultFormData() {
|
|
241
|
+
return {
|
|
242
|
+
name: '',
|
|
243
|
+
role: '',
|
|
244
|
+
term: '',
|
|
245
|
+
salary: '0',
|
|
246
|
+
compensation: 'sales-target-incentive',
|
|
247
|
+
accessibility: ['screen-reader', 'high-contrast'],
|
|
248
|
+
email: 'not-an-email',
|
|
249
|
+
isAdmin: false,
|
|
250
|
+
website: '',
|
|
251
|
+
favouriteFood: '',
|
|
252
|
+
favouriteColour: '#dee3e7',
|
|
253
|
+
favouriteNumber: 50,
|
|
254
|
+
newsletter1: false,
|
|
255
|
+
newsletter2: false,
|
|
256
|
+
comments: 'Too short.',
|
|
257
|
+
readonlyText: 'This is a readonly textarea.',
|
|
258
|
+
disabledText: 'This is a disabled textarea.',
|
|
259
|
+
datetime: '',
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
232
263
|
// From femtocrank/tests/simple.html — Role (default), Term (boolean), Compensation (list)
|
|
233
264
|
const roleOptions = [
|
|
234
265
|
{ label: 'Sales', value: 'sales' },
|
|
@@ -251,36 +282,52 @@ const accessibilityOptions = [
|
|
|
251
282
|
{ label: 'Screen reader support', value: 'screen-reader' },
|
|
252
283
|
{ label: 'Keyboard navigation', value: 'keyboard-navigation' },
|
|
253
284
|
{ label: 'High contrast', value: 'high-contrast' },
|
|
285
|
+
{ label: 'Low contrast UI', value: 'low-contrast' },
|
|
254
286
|
{ label: 'Captions / transcripts', value: 'captions' },
|
|
255
287
|
]
|
|
256
288
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
289
|
+
function validateAccessibilityRequirements() {
|
|
290
|
+
const selected = formData.value.accessibility
|
|
291
|
+
if (selected.includes('high-contrast') && selected.includes('low-contrast')) {
|
|
292
|
+
return 'High contrast and low contrast UI cannot both be selected.'
|
|
293
|
+
}
|
|
294
|
+
return ''
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const formData = ref(createDefaultFormData())
|
|
298
|
+
|
|
299
|
+
const emailError = computed(() => {
|
|
300
|
+
const email = formData.value.email.trim()
|
|
301
|
+
if (!email) {
|
|
302
|
+
return 'Email is required.'
|
|
303
|
+
}
|
|
304
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
305
|
+
return 'Enter a valid email address.'
|
|
306
|
+
}
|
|
307
|
+
return ''
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const commentsError = computed(() => {
|
|
311
|
+
const comments = formData.value.comments.trim()
|
|
312
|
+
if (comments.length > 0 && comments.length < MIN_COMMENTS_LENGTH) {
|
|
313
|
+
return `Comments must be at least ${MIN_COMMENTS_LENGTH} characters.`
|
|
314
|
+
}
|
|
315
|
+
return ''
|
|
276
316
|
})
|
|
277
317
|
|
|
278
318
|
const submitted = ref(false)
|
|
279
319
|
|
|
280
|
-
function handleSubmit() {
|
|
320
|
+
function handleSubmit(event) {
|
|
321
|
+
if (!event.target.reportValidity()) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (validateAccessibilityRequirements()) {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
281
329
|
submitted.value = true
|
|
282
330
|
console.log('Form submitted:', formData.value)
|
|
283
|
-
// In a real application, you would send this data to a server
|
|
284
331
|
alert('Form submitted! Check the console and the form result below.')
|
|
285
332
|
}
|
|
286
333
|
|
|
@@ -290,26 +337,7 @@ function handleCancel() {
|
|
|
290
337
|
}
|
|
291
338
|
|
|
292
339
|
function resetForm() {
|
|
293
|
-
formData.value =
|
|
294
|
-
name: '',
|
|
295
|
-
role: '',
|
|
296
|
-
term: '',
|
|
297
|
-
salary: '0',
|
|
298
|
-
compensation: '',
|
|
299
|
-
accessibility: [],
|
|
300
|
-
email: '',
|
|
301
|
-
isAdmin: false,
|
|
302
|
-
website: '',
|
|
303
|
-
favouriteFood: '',
|
|
304
|
-
favouriteColour: '#dee3e7',
|
|
305
|
-
favouriteNumber: 50,
|
|
306
|
-
newsletter1: false,
|
|
307
|
-
newsletter2: false,
|
|
308
|
-
comments: '',
|
|
309
|
-
readonlyText: 'This is a readonly textarea.',
|
|
310
|
-
disabledText: 'This is a disabled textarea.',
|
|
311
|
-
datetime: ''
|
|
312
|
-
}
|
|
340
|
+
formData.value = createDefaultFormData()
|
|
313
341
|
submitted.value = false
|
|
314
342
|
}
|
|
315
343
|
</script>
|
package/vue/examples/Login.vue
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Section title="Login" classes="">
|
|
3
|
-
<Login
|
|
3
|
+
<Login
|
|
4
|
+
ref="loginRef"
|
|
4
5
|
@local-login="handleLocalLogin"
|
|
5
6
|
@oauth-login="handleOAuthLogin"
|
|
6
7
|
@tab-change="onTabChange"
|
|
@@ -9,9 +10,12 @@
|
|
|
9
10
|
</template>
|
|
10
11
|
|
|
11
12
|
<script setup>
|
|
13
|
+
import { ref } from 'vue';
|
|
12
14
|
import Section from '../components/Section.vue';
|
|
13
15
|
import Login from '../components/Login.vue';
|
|
14
16
|
|
|
17
|
+
const loginRef = ref(null);
|
|
18
|
+
|
|
15
19
|
// Handle local login
|
|
16
20
|
function handleLocalLogin(credentials) {
|
|
17
21
|
console.log('Local login attempt:', {
|
|
@@ -41,6 +45,7 @@ function handleLocalLogin(credentials) {
|
|
|
41
45
|
|
|
42
46
|
// Placeholder success
|
|
43
47
|
alert(`Local login would authenticate: ${credentials.username}`);
|
|
48
|
+
loginRef.value?.setLocalLoginLoading(false);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
// Handle OAuth login
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Section title="
|
|
3
|
-
<Login
|
|
2
|
+
<Section title="Simple login" subtitle="Username and password only" classes="narrow">
|
|
3
|
+
<Login ref="simpleLoginRef" local-only @local-login="handleSimpleLocalLogin" />
|
|
4
|
+
</Section>
|
|
5
|
+
|
|
6
|
+
<Section
|
|
7
|
+
title="Simulated server response"
|
|
8
|
+
subtitle="Choose what the server returns when you submit the simple login form above"
|
|
9
|
+
classes="narrow"
|
|
10
|
+
>
|
|
11
|
+
<FormField label="Response" component-has-label description="Field errors appear on username or password; server and network errors appear below the form.">
|
|
12
|
+
<RadioGroup
|
|
13
|
+
v-model="simulatedResponse"
|
|
14
|
+
name="login-sim-response"
|
|
15
|
+
variant="list"
|
|
16
|
+
:options="simulatedResponseOptions"
|
|
17
|
+
/>
|
|
18
|
+
</FormField>
|
|
19
|
+
|
|
20
|
+
<FormField label="Timing" component-has-label>
|
|
21
|
+
<label class="simulate-delay-label">
|
|
22
|
+
<input v-model="simulateDelay" type="checkbox" />
|
|
23
|
+
<span>Simulate slow response (1.5s)</span>
|
|
24
|
+
</label>
|
|
25
|
+
</FormField>
|
|
26
|
+
</Section>
|
|
27
|
+
|
|
28
|
+
<Section title="Login example" subtitle="Local, OAuth2, and custom authentication tabs" classes="narrow">
|
|
29
|
+
<Login
|
|
30
|
+
ref="fullLoginRef"
|
|
4
31
|
:oauth-providers="oauthProviders"
|
|
5
32
|
:custom-tabs="customTabs"
|
|
6
33
|
@local-login="handleLocalLogin"
|
|
@@ -56,6 +83,67 @@
|
|
|
56
83
|
import { ref } from 'vue';
|
|
57
84
|
import Section from '../components/Section.vue';
|
|
58
85
|
import Login from '../components/Login.vue';
|
|
86
|
+
import FormField from '../components/FormField.vue';
|
|
87
|
+
import RadioGroup from '../components/RadioGroup.vue';
|
|
88
|
+
|
|
89
|
+
const simpleLoginRef = ref(null);
|
|
90
|
+
const fullLoginRef = ref(null);
|
|
91
|
+
const simulatedResponse = ref('success');
|
|
92
|
+
const simulateDelay = ref(false);
|
|
93
|
+
|
|
94
|
+
const simulatedResponseOptions = [
|
|
95
|
+
{ value: 'success', label: 'Success' },
|
|
96
|
+
{ value: 'wrong-password', label: 'Wrong password' },
|
|
97
|
+
{ value: 'username-not-found', label: 'Username not found' },
|
|
98
|
+
{ value: 'account-locked', label: 'Account locked' },
|
|
99
|
+
{ value: 'server-error', label: 'Server error (500)' },
|
|
100
|
+
{ value: 'network-error', label: 'Network unavailable' },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const simulatedResponseMessages = {
|
|
104
|
+
'wrong-password': 'Incorrect password.',
|
|
105
|
+
'username-not-found': 'No account found for that username.',
|
|
106
|
+
'account-locked': 'This account is locked. Contact support.',
|
|
107
|
+
'server-error': 'Sign-in failed. Please try again later.',
|
|
108
|
+
'network-error': 'Could not reach the server. Check your connection.',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const SIMULATED_DELAY_MS = 1500;
|
|
112
|
+
|
|
113
|
+
function resolveSimulatedLoginErrors(responseKey) {
|
|
114
|
+
const message = simulatedResponseMessages[responseKey];
|
|
115
|
+
if (!message) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (responseKey === 'wrong-password') {
|
|
119
|
+
return { password: message };
|
|
120
|
+
}
|
|
121
|
+
if (responseKey === 'username-not-found') {
|
|
122
|
+
return { username: message };
|
|
123
|
+
}
|
|
124
|
+
return { form: message };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleSimpleLocalLogin(credentials) {
|
|
128
|
+
try {
|
|
129
|
+
if (simulateDelay.value) {
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, SIMULATED_DELAY_MS));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const errors = resolveSimulatedLoginErrors(simulatedResponse.value);
|
|
134
|
+
if (errors) {
|
|
135
|
+
simpleLoginRef.value?.setLocalLoginErrors(errors);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
simpleLoginRef.value?.resetLocalForm();
|
|
140
|
+
alert(`Signed in as ${credentials.username}`);
|
|
141
|
+
} catch {
|
|
142
|
+
simpleLoginRef.value?.setLocalLoginErrors({
|
|
143
|
+
form: 'Sign-in failed. Please try again later.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
59
147
|
|
|
60
148
|
// Custom tabs configuration
|
|
61
149
|
const customTabs = ref([
|
|
@@ -151,6 +239,7 @@ function handleLocalLogin(credentials) {
|
|
|
151
239
|
|
|
152
240
|
// Placeholder success
|
|
153
241
|
alert(`Local login would authenticate: ${credentials.username}`);
|
|
242
|
+
fullLoginRef.value?.setLocalLoginLoading(false);
|
|
154
243
|
}
|
|
155
244
|
|
|
156
245
|
// Handle OAuth login
|
|
@@ -220,6 +309,13 @@ function handleSSOLogin(provider) {
|
|
|
220
309
|
</script>
|
|
221
310
|
|
|
222
311
|
<style scoped>
|
|
312
|
+
.simulate-delay-label {
|
|
313
|
+
display: flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.5em;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
|
|
223
319
|
.magic-link-form,
|
|
224
320
|
.sso-providers {
|
|
225
321
|
display: flex;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<Section title="Header" subtitle="Branding, search, and top navigation">
|
|
13
13
|
<FormLayout @submit.prevent>
|
|
14
|
-
<FormField label="Logo and title"
|
|
14
|
+
<FormField label="Logo and title" component-has-label>
|
|
15
15
|
<RadioGroup
|
|
16
16
|
name="ps-branding"
|
|
17
17
|
variant="boolean"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
/>
|
|
23
23
|
</FormField>
|
|
24
24
|
|
|
25
|
-
<FormField label="QuickSearch"
|
|
25
|
+
<FormField label="QuickSearch" component-has-label>
|
|
26
26
|
<RadioGroup
|
|
27
27
|
name="ps-quicksearch"
|
|
28
28
|
variant="boolean"
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
/>
|
|
34
34
|
</FormField>
|
|
35
35
|
|
|
36
|
-
<FormField label="Dummy background search"
|
|
36
|
+
<FormField label="Dummy background search" component-has-label :disabled="!quickSearchEnabled">
|
|
37
37
|
<div>
|
|
38
38
|
<RadioGroup
|
|
39
39
|
name="ps-dummy-search"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
</div>
|
|
52
52
|
</FormField>
|
|
53
53
|
|
|
54
|
-
<FormField label="Dark/Light mode button"
|
|
54
|
+
<FormField label="Dark/Light mode button" component-has-label>
|
|
55
55
|
<RadioGroup
|
|
56
56
|
name="ps-theme-toggle"
|
|
57
57
|
variant="boolean"
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
/>
|
|
63
63
|
</FormField>
|
|
64
64
|
|
|
65
|
-
<FormField label="Theme switcher"
|
|
65
|
+
<FormField label="Theme switcher" component-has-label>
|
|
66
66
|
<RadioGroup
|
|
67
67
|
name="ps-theme-switcher"
|
|
68
68
|
variant="boolean"
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
/>
|
|
74
74
|
</FormField>
|
|
75
75
|
|
|
76
|
-
<FormField label="Top bar"
|
|
76
|
+
<FormField label="Top bar" component-has-label>
|
|
77
77
|
<div>
|
|
78
78
|
<RadioGroup
|
|
79
79
|
name="ps-topbar"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
</div>
|
|
93
93
|
</FormField>
|
|
94
94
|
|
|
95
|
-
<FormField label="Breadcrumbs"
|
|
95
|
+
<FormField label="Breadcrumbs" component-has-label>
|
|
96
96
|
<RadioGroup
|
|
97
97
|
name="ps-breadcrumbs"
|
|
98
98
|
variant="boolean"
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
|
|
108
108
|
<Section title="Sidebar" subtitle="Primary navigation panel">
|
|
109
109
|
<FormLayout @submit.prevent>
|
|
110
|
-
<FormField label="Sidebar navigation"
|
|
110
|
+
<FormField label="Sidebar navigation" component-has-label>
|
|
111
111
|
<div>
|
|
112
112
|
<RadioGroup
|
|
113
113
|
name="ps-sidebar"
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
</div>
|
|
125
125
|
</FormField>
|
|
126
126
|
|
|
127
|
-
<FormField label="Example link states"
|
|
127
|
+
<FormField label="Example link states" component-has-label>
|
|
128
128
|
<div>
|
|
129
129
|
<RadioGroup
|
|
130
130
|
name="ps-example-links"
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</template>
|
|
51
51
|
|
|
52
52
|
<FormLayout>
|
|
53
|
-
<FormField label="Data"
|
|
53
|
+
<FormField label="Data" component-has-label description="Connect or disconnect row data for the table above. Saved column layouts persist in localStorage for this table.">
|
|
54
54
|
<RadioGroup
|
|
55
55
|
v-model="dataConnected"
|
|
56
56
|
name="table-data-connection"
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
|
|
62
62
|
<FormField
|
|
63
63
|
label="Wide table"
|
|
64
|
-
|
|
64
|
+
component-has-label
|
|
65
65
|
description="Adds extra columns (email, department, role, status) to the table."
|
|
66
66
|
>
|
|
67
67
|
<label>
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
</label>
|
|
71
71
|
</FormField>
|
|
72
72
|
|
|
73
|
-
<FormField label="Features"
|
|
73
|
+
<FormField label="Features" component-has-label description="Toggle core table behaviour.">
|
|
74
74
|
<CheckGroup
|
|
75
75
|
v-model="enabledFeatures"
|
|
76
76
|
name="table-features"
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
|
|
81
81
|
<FormField
|
|
82
82
|
label="Responsive layout"
|
|
83
|
-
|
|
83
|
+
component-has-label
|
|
84
84
|
description="Column priorities hide lower-priority columns as the table narrows. Horizontal scroll keeps every column visible with optional sticky leading columns. Browser layout leaves column sizing to the viewport."
|
|
85
85
|
>
|
|
86
86
|
<RadioGroup
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
<FormField
|
|
94
94
|
v-if="layout === 'scroll'"
|
|
95
95
|
label="Sticky columns"
|
|
96
|
-
|
|
96
|
+
component-has-label
|
|
97
97
|
description="Pin the first columns while scrolling horizontally."
|
|
98
98
|
>
|
|
99
99
|
<RadioGroup
|