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.
@@ -1,6 +1,33 @@
1
1
  <template>
2
- <Section title="Login Example" subtitle="A reusable login form component with local and OAuth2 authentication">
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" fake>
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" fake>
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" fake :disabled="!quickSearchEnabled">
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" fake>
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" fake>
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" fake>
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" fake>
95
+ <FormField label="Breadcrumbs" component-has-label>
96
96
  <RadioGroup
97
97
  name="ps-breadcrumbs"
98
98
  variant="boolean"
@@ -102,12 +102,29 @@
102
102
  @update:model-value="setBreadcrumbsEnabled"
103
103
  />
104
104
  </FormField>
105
+
106
+ <FormField label="Header username" component-has-label>
107
+ <div>
108
+ <RadioGroup
109
+ name="ps-header-username"
110
+ variant="list"
111
+ aria-label="Header username"
112
+ :model-value="headerUsername"
113
+ :options="headerUsernameOptions"
114
+ @update:model-value="setHeaderUsernameValue"
115
+ />
116
+ <p class="subtle">
117
+ When no username is set, the header shows a Login link if
118
+ <code>loginRoute</code> is configured.
119
+ </p>
120
+ </div>
121
+ </FormField>
105
122
  </FormLayout>
106
123
  </Section>
107
124
 
108
125
  <Section title="Sidebar" subtitle="Primary navigation panel">
109
126
  <FormLayout @submit.prevent>
110
- <FormField label="Sidebar navigation" fake>
127
+ <FormField label="Sidebar navigation" component-has-label>
111
128
  <div>
112
129
  <RadioGroup
113
130
  name="ps-sidebar"
@@ -124,7 +141,7 @@
124
141
  </div>
125
142
  </FormField>
126
143
 
127
- <FormField label="Example link states" fake>
144
+ <FormField label="Example link states" component-has-label>
128
145
  <div>
129
146
  <RadioGroup
130
147
  name="ps-example-links"
@@ -174,6 +191,11 @@ const onOffOptions = [
174
191
  { label: 'Off', value: false },
175
192
  ]
176
193
 
194
+ const headerUsernameOptions = [
195
+ { label: 'No username', value: '' },
196
+ { label: 'tester', value: 'tester' },
197
+ ]
198
+
177
199
  const sidebarEnabled = inject('sidebarEnabled')
178
200
  const toggleSidebarEnabled = inject('toggleSidebarEnabled')
179
201
  const brandingEnabled = inject('brandingEnabled')
@@ -190,6 +212,8 @@ const topBarEnabled = inject('topBarEnabled')
190
212
  const toggleTopBarEnabled = inject('toggleTopBarEnabled')
191
213
  const breadcrumbsEnabled = inject('breadcrumbsEnabled')
192
214
  const toggleBreadcrumbsEnabled = inject('toggleBreadcrumbsEnabled')
215
+ const headerUsername = inject('headerUsername')
216
+ const setHeaderUsername = inject('setHeaderUsername')
193
217
  const navigation = inject('navigation', null)
194
218
 
195
219
  const exampleLinksEnabled = ref(false)
@@ -228,6 +252,10 @@ function setBreadcrumbsEnabled(want) {
228
252
  setFlag(breadcrumbsEnabled, toggleBreadcrumbsEnabled, want)
229
253
  }
230
254
 
255
+ function setHeaderUsernameValue(username) {
256
+ setHeaderUsername(username)
257
+ }
258
+
231
259
  function setSidebarEnabled(want) {
232
260
  setFlag(sidebarEnabled, toggleSidebarEnabled, want)
233
261
  }
@@ -50,7 +50,7 @@
50
50
  </template>
51
51
 
52
52
  <FormLayout>
53
- <FormField label="Data" fake description="Connect or disconnect row data for the table above. Saved column layouts persist in localStorage for this table.">
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
- fake
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" fake description="Toggle core table behaviour.">
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
- fake
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
- fake
96
+ component-has-label
97
97
  description="Pin the first columns while scrolling horizontally."
98
98
  >
99
99
  <RadioGroup
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <Section
3
3
  title="Horizontal Tabs"
4
- subtitle="A flexible tabbed control component with support for multiple tab panes"
4
+ subtitle="A flexible tabbed control component with support for multiple tab panes and disabled tabs"
5
5
  :padding="horizontalTabsSectionPadding"
6
6
  >
7
7
  <template #toolbar>
@@ -13,6 +13,10 @@
13
13
  <input v-model="horizontalTabsPadding" type="checkbox" />
14
14
  Tab padding
15
15
  </label>
16
+ <label class="tabs-padding-toggle">
17
+ <input v-model="detailsTabEnabled" type="checkbox" />
18
+ Enable Details tab
19
+ </label>
16
20
  </template>
17
21
 
18
22
  <Tabs
@@ -26,6 +30,8 @@
26
30
  <p>
27
31
  This is the Overview tab. It contains general information about the current topic.
28
32
  You can place any content here, including forms, tables, images, or other components.
33
+ The <strong>Details</strong> tab starts disabled — use the toolbar checkbox to
34
+ enable it when elevated permissions are granted.
29
35
  </p>
30
36
  <ul>
31
37
  <li>First item in the overview</li>
@@ -97,7 +103,7 @@
97
103
 
98
104
  <Section
99
105
  title="Vertical Tabs"
100
- subtitle="Side tab strip with arrow-key navigation up and down"
106
+ subtitle="Side tab strip with arrow-key navigation up and down; disabled tabs are skipped"
101
107
  :padding="verticalTabsSectionPadding"
102
108
  >
103
109
  <template #toolbar>
@@ -109,6 +115,10 @@
109
115
  <input v-model="verticalTabsPadding" type="checkbox" />
110
116
  Tab padding
111
117
  </label>
118
+ <label class="tabs-padding-toggle">
119
+ <input v-model="advancedTabEnabled" type="checkbox" />
120
+ Enable Advanced tab
121
+ </label>
112
122
  </template>
113
123
 
114
124
  <Tabs
@@ -123,6 +133,7 @@
123
133
  <p>
124
134
  Vertical tabs place the tab list beside the panel. Use them when labels are
125
135
  longer or when the layout mirrors a settings sidebar.
136
+ <strong>Advanced</strong> starts disabled — use the toolbar checkbox to enable it.
126
137
  </p>
127
138
  </div>
128
139
  </template>
@@ -186,24 +197,47 @@
186
197
  </template>
187
198
 
188
199
  <script setup>
189
- import { ref, reactive } from 'vue';
200
+ import { computed, ref, reactive } from 'vue';
190
201
  import { HomeIcon, Settings01Icon, UserIcon, ViewIcon } from '@hugeicons/core-free-icons';
191
202
  import Section from '../components/Section.vue';
192
203
  import Tabs from '../components/Tabs.vue';
193
204
  import FormLayout from '../components/FormLayout.vue';
194
205
  import FormField from '../components/FormField.vue';
195
206
 
196
- const basicTabs = ref([
207
+ const detailsTabEnabled = ref(false);
208
+ const advancedTabEnabled = ref(false);
209
+
210
+ const basicTabs = computed(() => [
197
211
  { id: 0, label: 'Overview', icon: HomeIcon },
198
- { id: 1, label: 'Details', icon: ViewIcon },
212
+ {
213
+ id: 1,
214
+ label: 'Details',
215
+ icon: ViewIcon,
216
+ ...(detailsTabEnabled.value
217
+ ? {}
218
+ : {
219
+ disabled: true,
220
+ disabledTitle: 'Details requires elevated permissions',
221
+ }),
222
+ },
199
223
  { id: 2, label: 'Settings', icon: Settings01Icon },
200
224
  ]);
201
225
 
202
- const verticalTabs = ref([
226
+ const verticalTabs = computed(() => [
203
227
  { id: 'general', label: 'General', icon: HomeIcon },
204
228
  { id: 'appearance', label: 'Appearance', icon: Settings01Icon },
205
229
  { id: 'notifications', label: 'Notifications', icon: UserIcon },
206
- { id: 'advanced', label: 'Advanced', icon: Settings01Icon },
230
+ {
231
+ id: 'advanced',
232
+ label: 'Advanced',
233
+ icon: Settings01Icon,
234
+ ...(advancedTabEnabled.value
235
+ ? {}
236
+ : {
237
+ disabled: true,
238
+ disabledTitle: 'Advanced settings are not available for this account',
239
+ }),
240
+ },
207
241
  ]);
208
242
 
209
243
  const horizontalTabsSectionPadding = ref(true);
@@ -0,0 +1,279 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { computed, defineComponent, ref } from 'vue'
3
+ import FormField from '../components/FormField.vue'
4
+ import FormLayout from '../components/FormLayout.vue'
5
+ import NotificationBlock from '../components/NotificationBlock.vue'
6
+
7
+ export const routerLinkStub = {
8
+ name: 'RouterLink',
9
+ template: '<a><slot /></a>',
10
+ }
11
+
12
+ const defaultMountOptions = {
13
+ attachTo: document.body,
14
+ }
15
+
16
+ export function mountFormField(options = {}) {
17
+ const {
18
+ props = {},
19
+ slot = '<input id="test-input" />',
20
+ global = {},
21
+ attachTo = document.body,
22
+ ...rest
23
+ } = options
24
+
25
+ return mount(FormField, {
26
+ ...defaultMountOptions,
27
+ attachTo,
28
+ props: {
29
+ label: 'Test Field',
30
+ for: 'test-input',
31
+ ...props,
32
+ },
33
+ slots: {
34
+ default: slot,
35
+ },
36
+ global: {
37
+ stubs: {
38
+ RouterLink: routerLinkStub,
39
+ ...global.stubs,
40
+ },
41
+ ...global,
42
+ },
43
+ ...rest,
44
+ })
45
+ }
46
+
47
+ export function mountFormFieldWithError(initialError = '') {
48
+ const error = ref(initialError)
49
+
50
+ const wrapper = mount(
51
+ defineComponent({
52
+ components: { FormField },
53
+ setup() {
54
+ return { error }
55
+ },
56
+ template: `
57
+ <FormField label="Email" for="email" v-model:error="error">
58
+ <input id="email" />
59
+ </FormField>
60
+ `,
61
+ }),
62
+ {
63
+ attachTo: document.body,
64
+ global: {
65
+ stubs: {
66
+ RouterLink: routerLinkStub,
67
+ },
68
+ },
69
+ },
70
+ )
71
+
72
+ return { wrapper, error }
73
+ }
74
+
75
+ export async function flushControlState() {
76
+ const { nextTick } = await import('vue')
77
+ await nextTick()
78
+ await nextTick()
79
+ }
80
+
81
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
82
+ export const MIN_COMMENTS_LENGTH = 50
83
+
84
+ export function mountValidationForm() {
85
+ const formData = ref({
86
+ email: '',
87
+ comments: '',
88
+ accessibility: [],
89
+ })
90
+
91
+ const emailError = computed(() => {
92
+ const email = formData.value.email.trim()
93
+ if (!email) {
94
+ return 'Email is required.'
95
+ }
96
+ if (!EMAIL_PATTERN.test(email)) {
97
+ return 'Enter a valid email address.'
98
+ }
99
+ return ''
100
+ })
101
+
102
+ const commentsError = computed(() => {
103
+ const comments = formData.value.comments.trim()
104
+ if (comments.length > 0 && comments.length < MIN_COMMENTS_LENGTH) {
105
+ return `Comments must be at least ${MIN_COMMENTS_LENGTH} characters.`
106
+ }
107
+ return ''
108
+ })
109
+
110
+ function validateAccessibilityRequirements() {
111
+ const selected = formData.value.accessibility
112
+ if (selected.includes('high-contrast') && selected.includes('low-contrast')) {
113
+ return 'High contrast and low contrast UI cannot both be selected.'
114
+ }
115
+ return ''
116
+ }
117
+
118
+ const submitted = ref(false)
119
+
120
+ const wrapper = mount(
121
+ defineComponent({
122
+ components: { FormField, FormLayout },
123
+ setup() {
124
+ function handleSubmit(event) {
125
+ if (!event.target.reportValidity()) {
126
+ return
127
+ }
128
+ if (validateAccessibilityRequirements()) {
129
+ return
130
+ }
131
+ submitted.value = true
132
+ }
133
+
134
+ return {
135
+ formData,
136
+ emailError,
137
+ commentsError,
138
+ validateAccessibilityRequirements,
139
+ submitted,
140
+ handleSubmit,
141
+ }
142
+ },
143
+ template: `
144
+ <FormLayout @submit.prevent="handleSubmit">
145
+ <FormField
146
+ label="Email"
147
+ for="email"
148
+ :error="emailError"
149
+ >
150
+ <input
151
+ id="email"
152
+ v-model="formData.email"
153
+ type="email"
154
+ required
155
+ />
156
+ </FormField>
157
+
158
+ <FormField
159
+ label="Comments"
160
+ for="comments"
161
+ :error="commentsError"
162
+ >
163
+ <textarea id="comments" v-model="formData.comments" />
164
+ </FormField>
165
+
166
+ <FormField
167
+ label="Accessibility requirements"
168
+ component-has-label
169
+ :validate="validateAccessibilityRequirements"
170
+ :validate-deps="[formData.accessibility]"
171
+ >
172
+ <div role="group" aria-label="Accessibility requirements">
173
+ <label>
174
+ <input
175
+ type="checkbox"
176
+ value="high-contrast"
177
+ v-model="formData.accessibility"
178
+ />
179
+ High contrast
180
+ </label>
181
+ <label>
182
+ <input
183
+ type="checkbox"
184
+ value="low-contrast"
185
+ v-model="formData.accessibility"
186
+ />
187
+ Low contrast
188
+ </label>
189
+ </div>
190
+ </FormField>
191
+
192
+ <template #actions>
193
+ <button type="submit">Submit</button>
194
+ </template>
195
+ </FormLayout>
196
+ `,
197
+ }),
198
+ {
199
+ global: {
200
+ stubs: {
201
+ RouterLink: routerLinkStub,
202
+ },
203
+ },
204
+ attachTo: document.body,
205
+ },
206
+ )
207
+
208
+ return { wrapper, formData, submitted }
209
+ }
210
+
211
+ export function mountLoginValidationForm() {
212
+ const usernameError = ref('')
213
+ const passwordError = ref('')
214
+ const formError = ref('')
215
+
216
+ const wrapper = mount(
217
+ defineComponent({
218
+ components: { FormField, NotificationBlock },
219
+ setup() {
220
+ const credentials = ref({ username: '', password: '' })
221
+
222
+ function applyServerErrors(errors) {
223
+ usernameError.value = errors.username || ''
224
+ passwordError.value = errors.password || ''
225
+ formError.value = errors.form || ''
226
+ }
227
+
228
+ function handleSubmit() {
229
+ usernameError.value = ''
230
+ passwordError.value = ''
231
+ formError.value = ''
232
+ }
233
+
234
+ return {
235
+ credentials,
236
+ usernameError,
237
+ passwordError,
238
+ formError,
239
+ applyServerErrors,
240
+ handleSubmit,
241
+ }
242
+ },
243
+ template: `
244
+ <form @submit.prevent="handleSubmit">
245
+ <FormField label="Username" for="username" label-skip v-model:error="usernameError">
246
+ <input id="username" v-model="credentials.username" required aria-label="Username" />
247
+ </FormField>
248
+ <FormField label="Password" for="password" label-skip v-model:error="passwordError">
249
+ <input id="password" v-model="credentials.password" type="password" required aria-label="Password" />
250
+ </FormField>
251
+ <button type="submit">Login</button>
252
+ <NotificationBlock
253
+ v-if="formError"
254
+ class="login-form-error"
255
+ type="bad"
256
+ label="Error"
257
+ :message="formError"
258
+ role="alert"
259
+ />
260
+ </form>
261
+ `,
262
+ }),
263
+ {
264
+ global: {
265
+ stubs: {
266
+ RouterLink: routerLinkStub,
267
+ },
268
+ },
269
+ attachTo: document.body,
270
+ },
271
+ )
272
+
273
+ return {
274
+ wrapper,
275
+ usernameError,
276
+ passwordError,
277
+ formError,
278
+ }
279
+ }
@@ -0,0 +1,5 @@
1
+ import { afterEach } from 'vitest'
2
+
3
+ afterEach(() => {
4
+ document.body.innerHTML = ''
5
+ })