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.
@@ -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>
@@ -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
- <form @submit.prevent="handleLocalLogin" class="local-login-form">
8
- <input
9
- id="username"
10
- type="text"
11
- v-model="localLogin.username"
12
- required
13
- autocomplete="username"
14
- placeholder="Username"
15
- />
16
- <input
17
- id="password"
18
- type="password"
19
- v-model="localLogin.password"
20
- required
21
- autocomplete="current-password"
22
- placeholder="Password"
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
- <div v-if="localLoginError" class="error-message">
26
- {{ localLoginError }}
27
- </div>
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
- // Add default tabs if enabled
123
- if (props.showDefaultTabs) {
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 localLoginError = ref('');
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
- localLoginError.value = '';
167
-
168
- // Emit event for parent component to handle
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
- localLoginError.value = '';
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
- // Clear errors when switching tabs
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
- // Method to set error messages
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
- localLoginError.value = message;
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 input {
243
- padding: 0.75rem;
244
- border-radius: 4px;
245
- font-size: 1em;
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-form input:focus-visible {
251
- outline: 2px solid var(--login-input-focus-outline);
252
- outline-offset: 2px;
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
+ })