picocrank 1.25.2 → 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 +75 -0
- package/package.json +17 -4
- package/router.js +74 -0
- package/styles/dark-theme.css +20 -11
- package/styles/picocrank-extensions.css +123 -30
- package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
- package/supplemental-themes/dracula-alucard/theme.css +10 -24
- package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
- package/supplemental-themes/waffles/theme.css +2 -16
- package/vite.config.js +5 -0
- package/vue/components/Breadcrumbs.vue +192 -45
- package/vue/components/Calendar.vue +162 -145
- package/vue/components/DangerZone.vue +186 -0
- 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/Header.vue +22 -6
- package/vue/components/Login.vue +152 -41
- package/vue/components/NavigationGrid.vue +26 -180
- package/vue/components/NotificationBlock.vue +120 -0
- package/vue/components/NotificationPopup.vue +44 -41
- package/vue/components/Pagination.vue +5 -4
- package/vue/components/ReadOnlyTextArea.vue +1 -1
- package/vue/components/SidebarNavLink.vue +3 -3
- package/vue/components/Table.vue +107 -79
- package/vue/components/TableColumnOptionsPopover.vue +9 -0
- package/vue/components/Tabs.spec.js +101 -0
- package/vue/components/Tabs.vue +455 -81
- package/vue/components/TopBar.vue +2 -2
- package/vue/components/form-validation.spec.js +126 -0
- package/vue/composables/tableColumnPriorities.js +33 -0
- package/vue/composables/tableFilters.js +1 -1
- package/vue/data/navSectionHubs.js +196 -0
- package/vue/data/routeBreadcrumbs.js +90 -0
- package/vue/examples/Admin.vue +12 -12
- package/vue/examples/App.vue +27 -36
- package/vue/examples/CalendarExample.vue +32 -22
- package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
- package/vue/examples/FormExample.vue +76 -48
- package/vue/examples/Login.vue +5 -3
- package/vue/examples/LoginExample.vue +99 -15
- package/vue/examples/NavSectionHub.vue +36 -0
- package/vue/examples/PageStructure.vue +15 -12
- package/vue/examples/StatusExample.vue +248 -129
- package/vue/examples/TableExample.vue +72 -71
- package/vue/examples/TabsExample.vue +236 -257
- package/vue/examples/ThemePreview.vue +21 -23
- package/vue/examples/ViewItem.vue +32 -5
- package/vue/examples/Welcome.vue +3 -40
- package/vue/test/formTestUtils.js +279 -0
- package/vue/test/setup.js +5 -0
|
@@ -53,6 +53,20 @@ import {
|
|
|
53
53
|
AlertCircleIcon,
|
|
54
54
|
} from '@hugeicons/core-free-icons'
|
|
55
55
|
|
|
56
|
+
/** Static event palette — background + accessible dark foreground for light pastels in any theme. */
|
|
57
|
+
const EVENT_COLORS = {
|
|
58
|
+
weekend: { color: '#e8d5f5', foregroundColor: '#4a2d6b' },
|
|
59
|
+
teamMeeting: { color: '#d1fae5', foregroundColor: '#14532d' },
|
|
60
|
+
clientCall: { color: '#e0f2fe', foregroundColor: '#075985' },
|
|
61
|
+
projectReview: { color: '#ede9fe', foregroundColor: '#5b21b6' },
|
|
62
|
+
lunchBreak: { color: '#ffedd5', foregroundColor: '#9a3412' },
|
|
63
|
+
codeReview: { color: '#ccfbf1', foregroundColor: '#115e59' },
|
|
64
|
+
conference: { color: '#d6e8ff', foregroundColor: '#1e40af' },
|
|
65
|
+
vacation: { color: '#fff0d6', foregroundColor: '#92400e' },
|
|
66
|
+
holiday: { color: '#ffe0e0', foregroundColor: '#991b1b' },
|
|
67
|
+
deadline: { color: '#fde8e8', foregroundColor: '#7f1d1d' },
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
const events = ref([])
|
|
57
71
|
const loading = ref(false)
|
|
58
72
|
const error = ref(null)
|
|
@@ -99,30 +113,32 @@ function generateDummyEventsForMonth(monthIndex, year) {
|
|
|
99
113
|
date: new Date(year, monthIndex, day, 10, 0),
|
|
100
114
|
description: 'Relaxing weekend activity',
|
|
101
115
|
icon: PartyIcon,
|
|
102
|
-
|
|
116
|
+
...EVENT_COLORS.weekend,
|
|
103
117
|
})
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
// Add some random events
|
|
107
121
|
if (random() > 0.7) {
|
|
108
122
|
const eventTypes = [
|
|
109
|
-
{ title: 'Team Meeting', time: [9, 0], duration: 1 },
|
|
110
|
-
{ title: 'Client Call', time: [14, 30], duration: 0.5 },
|
|
111
|
-
{ title: 'Project Review', time: [16, 0], duration: 2 },
|
|
112
|
-
{ title: 'Lunch Break', time: [12, 0], duration: 1 },
|
|
113
|
-
{ title: 'Code Review', time: [10, 0], duration: 1.5 }
|
|
123
|
+
{ title: 'Team Meeting', time: [9, 0], duration: 1, ...EVENT_COLORS.teamMeeting },
|
|
124
|
+
{ title: 'Client Call', time: [14, 30], duration: 0.5, ...EVENT_COLORS.clientCall },
|
|
125
|
+
{ title: 'Project Review', time: [16, 0], duration: 2, ...EVENT_COLORS.projectReview },
|
|
126
|
+
{ title: 'Lunch Break', time: [12, 0], duration: 1, ...EVENT_COLORS.lunchBreak },
|
|
127
|
+
{ title: 'Code Review', time: [10, 0], duration: 1.5, ...EVENT_COLORS.codeReview },
|
|
114
128
|
]
|
|
115
129
|
|
|
116
130
|
const eventType = eventTypes[Math.floor(random() * eventTypes.length)]
|
|
117
|
-
const
|
|
131
|
+
const { title, time, color, foregroundColor } = eventType
|
|
132
|
+
const [hours, minutes] = time
|
|
118
133
|
|
|
119
134
|
dummyEvents.push({
|
|
120
135
|
id: `event-${year}-${monthIndex}-${day}-${Math.random().toString(36).substr(2, 9)}`,
|
|
121
|
-
title
|
|
136
|
+
title,
|
|
122
137
|
date: new Date(year, monthIndex, day, hours, minutes),
|
|
123
|
-
description: `Scheduled ${
|
|
124
|
-
icon:
|
|
125
|
-
color
|
|
138
|
+
description: `Scheduled ${title.toLowerCase()}`,
|
|
139
|
+
icon: title === 'Team Meeting' ? MeetingRoomIcon : undefined,
|
|
140
|
+
color,
|
|
141
|
+
foregroundColor,
|
|
126
142
|
})
|
|
127
143
|
}
|
|
128
144
|
}
|
|
@@ -135,7 +151,7 @@ function generateDummyEventsForMonth(monthIndex, year) {
|
|
|
135
151
|
endDate: new Date(year, monthIndex, 7, 17, 0),
|
|
136
152
|
description: 'Annual technology conference',
|
|
137
153
|
icon: Calendar01Icon,
|
|
138
|
-
|
|
154
|
+
...EVENT_COLORS.conference,
|
|
139
155
|
})
|
|
140
156
|
|
|
141
157
|
dummyEvents.push({
|
|
@@ -145,7 +161,7 @@ function generateDummyEventsForMonth(monthIndex, year) {
|
|
|
145
161
|
endDate: new Date(year, monthIndex, 18, 23, 59),
|
|
146
162
|
description: 'Family vacation time',
|
|
147
163
|
icon: BeachIcon,
|
|
148
|
-
|
|
164
|
+
...EVENT_COLORS.vacation,
|
|
149
165
|
})
|
|
150
166
|
|
|
151
167
|
// Add some all-day events
|
|
@@ -155,7 +171,7 @@ function generateDummyEventsForMonth(monthIndex, year) {
|
|
|
155
171
|
date: new Date(year, monthIndex, 1, 0, 0),
|
|
156
172
|
description: 'National holiday - office closed',
|
|
157
173
|
icon: PartyIcon,
|
|
158
|
-
|
|
174
|
+
...EVENT_COLORS.holiday,
|
|
159
175
|
})
|
|
160
176
|
|
|
161
177
|
if (daysInMonth >= 25) {
|
|
@@ -165,7 +181,7 @@ function generateDummyEventsForMonth(monthIndex, year) {
|
|
|
165
181
|
date: new Date(year, monthIndex, 25, 0, 0),
|
|
166
182
|
description: 'Important project deadline',
|
|
167
183
|
icon: AlertCircleIcon,
|
|
168
|
-
|
|
184
|
+
...EVENT_COLORS.deadline,
|
|
169
185
|
})
|
|
170
186
|
}
|
|
171
187
|
|
|
@@ -204,12 +220,6 @@ function getEventsForSurroundingMonths(monthIndex, year) {
|
|
|
204
220
|
]
|
|
205
221
|
}
|
|
206
222
|
|
|
207
|
-
// Generate dummy events for the current month (backward compatibility wrapper)
|
|
208
|
-
function generateDummyEvents() {
|
|
209
|
-
const now = new Date()
|
|
210
|
-
return getEventsForMonth(now.getMonth(), now.getFullYear())
|
|
211
|
-
}
|
|
212
|
-
|
|
213
223
|
function handleEventClick(event) {
|
|
214
224
|
console.log('Event clicked:', event)
|
|
215
225
|
alert(`Event: ${event.title}\nDescription: ${event.description || 'No description'}`)
|
|
@@ -232,7 +242,7 @@ function handleDateRangeSelect(start, end) {
|
|
|
232
242
|
function handleEventMoveRequest({ respond, event, sourceDate, targetDate }) {
|
|
233
243
|
window.setTimeout(() => {
|
|
234
244
|
const ok = window.confirm(
|
|
235
|
-
`Move "${event.title}" to ${targetDate.toLocaleDateString()}? (Cancel = decline)`
|
|
245
|
+
`Move "${event.title}" from ${sourceDate.toLocaleDateString()} to ${targetDate.toLocaleDateString()}? (Cancel = decline)`
|
|
236
246
|
)
|
|
237
247
|
respond(ok)
|
|
238
248
|
}, 50)
|
|
@@ -56,7 +56,7 @@ header .header-actions .theme-switcher--compact select {
|
|
|
56
56
|
border: 0;
|
|
57
57
|
border-radius: 0;
|
|
58
58
|
background-color: transparent;
|
|
59
|
-
color: var(--header-fg-color
|
|
59
|
+
color: var(--header-fg-color);
|
|
60
60
|
padding-top: 0;
|
|
61
61
|
padding-bottom: 0;
|
|
62
62
|
padding-left: 0.6em;
|
|
@@ -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,11 +10,11 @@
|
|
|
9
10
|
</template>
|
|
10
11
|
|
|
11
12
|
<script setup>
|
|
12
|
-
import {
|
|
13
|
+
import { ref } from 'vue';
|
|
13
14
|
import Section from '../components/Section.vue';
|
|
14
15
|
import Login from '../components/Login.vue';
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
+
const loginRef = ref(null);
|
|
17
18
|
|
|
18
19
|
// Handle local login
|
|
19
20
|
function handleLocalLogin(credentials) {
|
|
@@ -44,6 +45,7 @@ function handleLocalLogin(credentials) {
|
|
|
44
45
|
|
|
45
46
|
// Placeholder success
|
|
46
47
|
alert(`Local login would authenticate: ${credentials.username}`);
|
|
48
|
+
loginRef.value?.setLocalLoginLoading(false);
|
|
47
49
|
}
|
|
48
50
|
|
|
49
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"
|
|
@@ -8,7 +35,7 @@
|
|
|
8
35
|
@tab-change="onTabChange"
|
|
9
36
|
>
|
|
10
37
|
<!-- Custom tab: Magic Link Login -->
|
|
11
|
-
<template #tab-magic-link
|
|
38
|
+
<template #tab-magic-link>
|
|
12
39
|
<div class="login-section">
|
|
13
40
|
<form @submit.prevent="handleMagicLinkLogin" class="magic-link-form">
|
|
14
41
|
<input
|
|
@@ -30,7 +57,7 @@
|
|
|
30
57
|
</template>
|
|
31
58
|
|
|
32
59
|
<!-- Custom tab: SSO Login -->
|
|
33
|
-
<template #tab-sso
|
|
60
|
+
<template #tab-sso>
|
|
34
61
|
<div class="login-section">
|
|
35
62
|
<div class="sso-providers">
|
|
36
63
|
<button
|
|
@@ -54,11 +81,69 @@
|
|
|
54
81
|
|
|
55
82
|
<script setup>
|
|
56
83
|
import { ref } from 'vue';
|
|
57
|
-
import { useRouter } from 'vue-router';
|
|
58
84
|
import Section from '../components/Section.vue';
|
|
59
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
|
+
}
|
|
60
126
|
|
|
61
|
-
|
|
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
|
+
}
|
|
62
147
|
|
|
63
148
|
// Custom tabs configuration
|
|
64
149
|
const customTabs = ref([
|
|
@@ -154,6 +239,7 @@ function handleLocalLogin(credentials) {
|
|
|
154
239
|
|
|
155
240
|
// Placeholder success
|
|
156
241
|
alert(`Local login would authenticate: ${credentials.username}`);
|
|
242
|
+
fullLoginRef.value?.setLocalLoginLoading(false);
|
|
157
243
|
}
|
|
158
244
|
|
|
159
245
|
// Handle OAuth login
|
|
@@ -223,6 +309,13 @@ function handleSSOLogin(provider) {
|
|
|
223
309
|
</script>
|
|
224
310
|
|
|
225
311
|
<style scoped>
|
|
312
|
+
.simulate-delay-label {
|
|
313
|
+
display: flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.5em;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
|
|
226
319
|
.magic-link-form,
|
|
227
320
|
.sso-providers {
|
|
228
321
|
display: flex;
|
|
@@ -232,19 +325,10 @@ function handleSSOLogin(provider) {
|
|
|
232
325
|
}
|
|
233
326
|
|
|
234
327
|
.magic-link-form input {
|
|
235
|
-
padding: 0.75rem;
|
|
236
|
-
border-radius: 4px;
|
|
237
|
-
font-size: 1em;
|
|
238
328
|
width: 100%;
|
|
239
329
|
box-sizing: border-box;
|
|
240
330
|
}
|
|
241
331
|
|
|
242
|
-
.magic-link-form input:focus {
|
|
243
|
-
outline: none;
|
|
244
|
-
border-color: var(--primary-color, #007bff);
|
|
245
|
-
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
332
|
.magic-link-form button,
|
|
249
333
|
.sso-button {
|
|
250
334
|
width: 100%;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Section v-if="hub" :title="hub.title" :subtitle="hub.subtitle">
|
|
3
|
+
<Navigation ref="localNavigation">
|
|
4
|
+
<NavigationGrid />
|
|
5
|
+
</Navigation>
|
|
6
|
+
</Section>
|
|
7
|
+
<Section
|
|
8
|
+
v-else
|
|
9
|
+
title="Unknown section"
|
|
10
|
+
subtitle="This navigation hub is not configured."
|
|
11
|
+
>
|
|
12
|
+
<p class="subtle">
|
|
13
|
+
Return to the welcome page and pick an example category from the top bar or sidebar.
|
|
14
|
+
</p>
|
|
15
|
+
<router-link :to="{ name: 'Welcome' }" class="button neutral">
|
|
16
|
+
Back to welcome
|
|
17
|
+
</router-link>
|
|
18
|
+
</Section>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup>
|
|
22
|
+
import { ref, onMounted, computed } from 'vue'
|
|
23
|
+
import { useRoute } from 'vue-router'
|
|
24
|
+
import Section from '../components/Section.vue'
|
|
25
|
+
import Navigation from '../components/Navigation.vue'
|
|
26
|
+
import NavigationGrid from '../components/NavigationGrid.vue'
|
|
27
|
+
import { navSectionHubs, registerHubNavigation } from '../data/navSectionHubs.js'
|
|
28
|
+
|
|
29
|
+
const route = useRoute()
|
|
30
|
+
const localNavigation = ref(null)
|
|
31
|
+
const hub = computed(() => navSectionHubs[route.name])
|
|
32
|
+
|
|
33
|
+
onMounted(() => {
|
|
34
|
+
registerHubNavigation(localNavigation.value, route.name)
|
|
35
|
+
})
|
|
36
|
+
</script>
|
|
@@ -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"
|
|
@@ -84,13 +84,15 @@
|
|
|
84
84
|
@update:model-value="setTopBarEnabled"
|
|
85
85
|
/>
|
|
86
86
|
<p class="subtle">
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
When enabled, example categories appear in the header; each opens a hub page with a
|
|
88
|
+
navigation grid of related examples. On narrow screens: if both sidebar and top bar are
|
|
89
|
+
on, the sidebar takes precedence and the top bar is hidden. If only the top bar is on,
|
|
90
|
+
Header collapses it into a mobile sidebar automatically.
|
|
89
91
|
</p>
|
|
90
92
|
</div>
|
|
91
93
|
</FormField>
|
|
92
94
|
|
|
93
|
-
<FormField label="Breadcrumbs"
|
|
95
|
+
<FormField label="Breadcrumbs" component-has-label>
|
|
94
96
|
<RadioGroup
|
|
95
97
|
name="ps-breadcrumbs"
|
|
96
98
|
variant="boolean"
|
|
@@ -105,7 +107,7 @@
|
|
|
105
107
|
|
|
106
108
|
<Section title="Sidebar" subtitle="Primary navigation panel">
|
|
107
109
|
<FormLayout @submit.prevent>
|
|
108
|
-
<FormField label="Sidebar navigation"
|
|
110
|
+
<FormField label="Sidebar navigation" component-has-label>
|
|
109
111
|
<div>
|
|
110
112
|
<RadioGroup
|
|
111
113
|
name="ps-sidebar"
|
|
@@ -116,12 +118,13 @@
|
|
|
116
118
|
@update:model-value="setSidebarEnabled"
|
|
117
119
|
/>
|
|
118
120
|
<p class="subtle">
|
|
119
|
-
When enabled, use the menu button in the header to open and pin the sidebar.
|
|
121
|
+
When enabled, use the menu button in the header to open and pin the sidebar. The sidebar
|
|
122
|
+
lists every example directly — hub routes are for top-bar navigation only.
|
|
120
123
|
</p>
|
|
121
124
|
</div>
|
|
122
125
|
</FormField>
|
|
123
126
|
|
|
124
|
-
<FormField label="Example link states"
|
|
127
|
+
<FormField label="Example link states" component-has-label>
|
|
125
128
|
<div>
|
|
126
129
|
<RadioGroup
|
|
127
130
|
name="ps-example-links"
|