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.
@@ -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="isTabFocused(tab, index) ? 0 : -1"
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
- function getInitialTabId() {
108
- if (props.defaultTab !== null) {
109
- return props.defaultTab
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
- if (props.tabs.length > 0) {
112
- return getTabKey(props.tabs[0], 0)
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 null
152
+ return isTabFocused(tab, index) ? 0 : -1
115
153
  }
116
154
 
117
- const activeTabId = ref(getInitialTabId())
118
- const focusedTabId = ref(getInitialTabId())
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
- const normalized = (index + props.tabs.length) % props.tabs.length
170
- const tabKey = getTabKey(props.tabs[normalized], normalized)
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
- moveFocusToIndex(index + 1)
273
+ moveFocusByStep(index, 1)
208
274
  }
209
275
  break
210
276
  case 'ArrowLeft':
211
277
  if (!isVertical) {
212
278
  event.preventDefault()
213
- moveFocusToIndex(index - 1)
279
+ moveFocusByStep(index, -1)
214
280
  }
215
281
  break
216
282
  case 'ArrowDown':
217
283
  if (isVertical) {
218
284
  event.preventDefault()
219
- moveFocusToIndex(index + 1)
285
+ moveFocusByStep(index, 1)
220
286
  }
221
287
  break
222
288
  case 'ArrowUp':
223
289
  if (isVertical) {
224
290
  event.preventDefault()
225
- moveFocusToIndex(index - 1)
291
+ moveFocusByStep(index, -1)
226
292
  }
227
293
  break
228
294
  case 'Home':
229
295
  event.preventDefault()
230
- moveFocusToIndex(0)
296
+ moveFocusToIndex(getFirstEnabledIndex())
231
297
  break
232
298
  case 'End':
233
299
  event.preventDefault()
234
- moveFocusToIndex(props.tabs.length - 1)
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 activeStillExists = tabIndexForKey(activeTabId.value) >= 0
264
- if (!activeStillExists) {
265
- const firstKey = getTabKey(props.tabs[0], 0)
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
- if (tabIndexForKey(focusedTabId.value) < 0) {
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
  }
@@ -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
+ })
@@ -2,7 +2,8 @@
2
2
  <Navigation ref="navigation">
3
3
  <Navigation ref="topBarNavigation">
4
4
  <Header
5
- username="Guest"
5
+ :username="headerUsername"
6
+ :login-route="headerLoginRoute"
6
7
  @toggleSidebar="toggleSidebar"
7
8
  @logoClick="goToIndex"
8
9
  @userClick="goToUserDetails"
@@ -85,6 +86,8 @@
85
86
  const includeSupplementalThemes = ref(true);
86
87
  const topBarEnabled = ref(false);
87
88
  const breadcrumbsEnabled = ref(true);
89
+ const headerUsername = ref('tester');
90
+ const headerLoginRoute = { name: 'Login' };
88
91
 
89
92
  const { discoverThemes } = initCustomTheme({ includeSupplementalThemes: true });
90
93
 
@@ -134,6 +137,10 @@
134
137
  breadcrumbsEnabled.value = !breadcrumbsEnabled.value;
135
138
  }
136
139
 
140
+ function setHeaderUsername(username) {
141
+ headerUsername.value = username;
142
+ }
143
+
137
144
  async function dummyFetchResults(_query, { signal } = {}) {
138
145
  await new Promise((resolve, reject) => {
139
146
  const timer = setTimeout(resolve, 1500)
@@ -200,6 +207,8 @@
200
207
  provide('toggleTopBarEnabled', toggleTopBarEnabled);
201
208
  provide('breadcrumbsEnabled', breadcrumbsEnabled);
202
209
  provide('toggleBreadcrumbsEnabled', toggleBreadcrumbsEnabled);
210
+ provide('headerUsername', headerUsername);
211
+ provide('setHeaderUsername', setHeaderUsername);
203
212
 
204
213
  watch(quickSearchRef, (quickSearch) => {
205
214
  if (quickSearch) {
@@ -22,7 +22,7 @@
22
22
  />
23
23
  </FormField>
24
24
 
25
- <FormField label="Role" fake>
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" fake>
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
- fake
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
- fake
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" fake>
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
- description="Optional feedback or notes about this submission."
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
- const formData = ref({
258
- name: '',
259
- role: '',
260
- term: '',
261
- salary: '0',
262
- compensation: '',
263
- accessibility: [],
264
- email: '',
265
- isAdmin: false,
266
- website: '',
267
- favouriteFood: '',
268
- favouriteColour: '#dee3e7',
269
- favouriteNumber: 50,
270
- newsletter1: false,
271
- newsletter2: false,
272
- comments: '',
273
- readonlyText: 'This is a readonly textarea.',
274
- disabledText: 'This is a disabled textarea.',
275
- datetime: ''
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>
@@ -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