picocrank 1.9.1 → 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.9.1",
3
+ "version": "1.11.0",
4
4
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
5
5
  "main": "vite.config.js",
6
6
  "scripts": {
@@ -18,14 +18,14 @@
18
18
  "url": "https://github.com/jamesread/picocrank/issues"
19
19
  },
20
20
  "dependencies": {
21
- "@hugeicons/core-free-icons": "^2.0.0",
22
- "@hugeicons/vue": "^1.0.3",
23
- "@vitejs/plugin-vue": "^6.0.2",
24
- "femtocrank": "^2.4.11",
21
+ "@hugeicons/core-free-icons": "^3.1.0",
22
+ "@hugeicons/vue": "^1.0.4",
23
+ "@vitejs/plugin-vue": "^6.0.3",
24
+ "femtocrank": "^2.4.12",
25
25
  "unplugin-vue-components": "^30.0.0",
26
- "vite": "^7.2.4",
27
- "vue": "^3.5.24",
28
- "vue-router": "^4.6.3"
26
+ "vite": "^7.3.0",
27
+ "vue": "^3.5.26",
28
+ "vue-router": "^4.6.4"
29
29
  },
30
30
  "devDependencies": {
31
31
  "semantic-release": "^25.0.2"
package/router.js CHANGED
@@ -5,6 +5,8 @@ import { TableIcon } from '@hugeicons/core-free-icons';
5
5
  import { ViewIcon } from '@hugeicons/core-free-icons';
6
6
  import { SecurityValidationIcon } from '@hugeicons/core-free-icons';
7
7
  import { CalendarIcon } from '@hugeicons/core-free-icons';
8
+ import { EditIcon } from '@hugeicons/core-free-icons';
9
+ import { Menu01Icon } from '@hugeicons/core-free-icons';
8
10
 
9
11
  const routes = [
10
12
  {
@@ -54,6 +56,51 @@ const routes = [
54
56
  title: 'Calendar Example',
55
57
  icon: CalendarIcon,
56
58
  }
59
+ },
60
+ {
61
+ name: 'Login',
62
+ path: '/login',
63
+ component: () => import('./vue/views/Login.vue'),
64
+ meta: {
65
+ title: 'Login',
66
+ icon: SecurityValidationIcon,
67
+ }
68
+ },
69
+ {
70
+ name: 'FormExample',
71
+ path: '/form-example',
72
+ component: () => import('./vue/views/FormExample.vue'),
73
+ meta: {
74
+ title: 'Form Example',
75
+ icon: EditIcon,
76
+ }
77
+ },
78
+ {
79
+ name: 'TabsExample',
80
+ path: '/tabs-example',
81
+ component: () => import('./vue/views/TabsExample.vue'),
82
+ meta: {
83
+ title: 'Tabs Example',
84
+ icon: Menu01Icon,
85
+ }
86
+ },
87
+ {
88
+ name: 'UserControlPanel',
89
+ path: '/user-control-panel',
90
+ component: () => import('./vue/views/UserControlPanel.vue'),
91
+ meta: {
92
+ title: 'User Control Panel',
93
+ icon: SecurityValidationIcon,
94
+ }
95
+ },
96
+ {
97
+ name: 'LoginExample',
98
+ path: '/login-example',
99
+ component: () => import('./vue/views/LoginExample.vue'),
100
+ meta: {
101
+ title: 'Login Example',
102
+ icon: SecurityValidationIcon,
103
+ }
57
104
  }
58
105
  ];
59
106
 
@@ -66,4 +66,8 @@ button {
66
66
  button:hover {
67
67
  background-color: transparent;
68
68
  }
69
+
70
+ .user-info span {
71
+ padding-right: 0.5rem;
72
+ }
69
73
  </style>
@@ -0,0 +1,279 @@
1
+ <template>
2
+ <div class="login-container">
3
+ <Tabs :tabs="loginTabs" @tab-change="onTabChange">
4
+ <!-- Local Login Tab -->
5
+ <template #tab-local>
6
+ <div class="login-section">
7
+ <form @submit.prevent="handleLocalLogin" class="local-login-form">
8
+ <input
9
+ id="username"
10
+ v-model="localLogin.username"
11
+ required
12
+ autocomplete="username"
13
+ placeholder="Username"
14
+ />
15
+ <input
16
+ id="password"
17
+ type="password"
18
+ v-model="localLogin.password"
19
+ required
20
+ autocomplete="current-password"
21
+ placeholder="Password"
22
+ />
23
+ <button type="submit" class="good">Login</button>
24
+ <div v-if="localLoginError" class="error-message">
25
+ {{ localLoginError }}
26
+ </div>
27
+ </form>
28
+ </div>
29
+ </template>
30
+
31
+ <!-- OAuth 2 Login Tab -->
32
+ <template #tab-oauth>
33
+ <div class="login-section">
34
+ <div v-if="oauthProviders.length > 0" class="oauth-providers">
35
+ <button
36
+ v-for="provider in oauthProviders"
37
+ :key="provider.id"
38
+ class="oauth-button"
39
+ :class="provider.class || 'neutral'"
40
+ @click="handleOAuthLogin(provider)"
41
+ :title="`Sign in with ${provider.name}`"
42
+ >
43
+ <HugeiconsIcon
44
+ v-if="provider.icon"
45
+ :icon="provider.icon"
46
+ width="1.2em"
47
+ height="1.2em"
48
+ />
49
+ <span>{{ provider.name }}</span>
50
+ </button>
51
+ </div>
52
+ <div v-else class="no-providers">
53
+ <p>No OAuth providers configured.</p>
54
+ </div>
55
+ <div v-if="oauthError" class="error-message">
56
+ {{ oauthError }}
57
+ </div>
58
+ </div>
59
+ </template>
60
+ </Tabs>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup>
65
+ import { ref, reactive, watch } from 'vue';
66
+ import Tabs from './Tabs.vue';
67
+ import { HugeiconsIcon } from '@hugeicons/vue';
68
+
69
+ const props = defineProps({
70
+ // Custom OAuth providers (optional)
71
+ oauthProviders: {
72
+ type: Array,
73
+ default: () => []
74
+ },
75
+ // Custom tab labels (optional)
76
+ tabLabels: {
77
+ type: Object,
78
+ default: () => ({
79
+ local: 'Username & Password',
80
+ oauth: 'OAuth2'
81
+ })
82
+ }
83
+ });
84
+
85
+ const emit = defineEmits(['local-login', 'oauth-login', 'tab-change']);
86
+
87
+ // Login tabs configuration
88
+ const loginTabs = ref([
89
+ { id: 'local', label: props.tabLabels.local || 'Username & Password' },
90
+ { id: 'oauth', label: props.tabLabels.oauth || 'OAuth2' }
91
+ ]);
92
+
93
+ // Local login state
94
+ const localLogin = reactive({
95
+ username: '',
96
+ password: ''
97
+ });
98
+
99
+ const localLoginError = ref('');
100
+
101
+ // OAuth providers - provided by parent component
102
+ const oauthProviders = ref(props.oauthProviders);
103
+ const oauthError = ref('');
104
+
105
+ // Watch for changes to oauthProviders prop
106
+ watch(() => props.oauthProviders, (newProviders) => {
107
+ oauthProviders.value = newProviders;
108
+ }, { deep: true });
109
+
110
+ // Handle local login
111
+ function handleLocalLogin() {
112
+ localLoginError.value = '';
113
+
114
+ // Emit event for parent component to handle
115
+ emit('local-login', {
116
+ username: localLogin.username,
117
+ password: localLogin.password
118
+ });
119
+ }
120
+
121
+ // Reset local login form
122
+ function resetLocalForm() {
123
+ localLogin.username = '';
124
+ localLogin.password = '';
125
+ localLoginError.value = '';
126
+ }
127
+
128
+ // Handle OAuth login
129
+ function handleOAuthLogin(provider) {
130
+ oauthError.value = '';
131
+
132
+ // Emit event for parent component to handle
133
+ emit('oauth-login', provider);
134
+ }
135
+
136
+ // Handle tab change
137
+ function onTabChange(tab, tabId) {
138
+ // Clear errors when switching tabs
139
+ localLoginError.value = '';
140
+ oauthError.value = '';
141
+ emit('tab-change', tab, tabId);
142
+ }
143
+
144
+ // Method to set error messages
145
+ function setLocalLoginError(message) {
146
+ localLoginError.value = message;
147
+ }
148
+
149
+ function setOAuthError(message) {
150
+ oauthError.value = message;
151
+ }
152
+
153
+ // Method to add custom OAuth providers programmatically
154
+ function addOAuthProvider(provider) {
155
+ oauthProviders.value.push(provider);
156
+ }
157
+
158
+ // Expose methods for parent components
159
+ defineExpose({
160
+ addOAuthProvider,
161
+ resetLocalForm,
162
+ setLocalLoginError,
163
+ setOAuthError,
164
+ localLogin
165
+ });
166
+ </script>
167
+
168
+ <style scoped>
169
+ .login-container {
170
+ max-width: 500px;
171
+ margin: 2rem auto;
172
+ }
173
+
174
+ .login-section {
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 1rem;
178
+ align-items: center;
179
+ }
180
+
181
+ .local-login-form {
182
+ display: flex;
183
+ flex-direction: column;
184
+ gap: 1rem;
185
+ width: 100%;
186
+ }
187
+
188
+ .local-login-form input {
189
+ padding: 0.75rem;
190
+ border: 1px solid var(--border-color, #ccc);
191
+ border-radius: 4px;
192
+ font-size: 1em;
193
+ background-color: var(--input-bg, #fff);
194
+ color: var(--text-color, #000);
195
+ width: 100%;
196
+ }
197
+
198
+ .local-login-form input:focus {
199
+ outline: none;
200
+ border-color: var(--primary-color, #007bff);
201
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
202
+ }
203
+
204
+ .local-login-form button {
205
+ width: 100%;
206
+ padding: 0.75rem 1.5rem;
207
+ font-size: 1em;
208
+ cursor: pointer;
209
+ border: none;
210
+ border-radius: 4px;
211
+ transition: opacity 0.2s;
212
+ }
213
+
214
+ .local-login-form button:hover {
215
+ opacity: 0.9;
216
+ }
217
+
218
+ .oauth-providers {
219
+ display: flex;
220
+ flex-direction: column;
221
+ gap: 0.75rem;
222
+ width: 100%;
223
+ }
224
+
225
+ .oauth-button {
226
+ display: flex;
227
+ align-items: center;
228
+ justify-content: center;
229
+ gap: 0.75rem;
230
+ padding: 0.75rem 1.5rem;
231
+ font-size: 1em;
232
+ cursor: pointer;
233
+ border: none;
234
+ border-radius: 4px;
235
+ transition: opacity 0.2s, transform 0.1s;
236
+ width: 100%;
237
+ }
238
+
239
+ .oauth-button:hover {
240
+ opacity: 0.9;
241
+ transform: translateY(-1px);
242
+ }
243
+
244
+ .oauth-button:active {
245
+ transform: translateY(0);
246
+ }
247
+
248
+ .oauth-button span {
249
+ font-weight: 500;
250
+ }
251
+
252
+ .error-message {
253
+ padding: 0.75rem;
254
+ background-color: var(--error-bg, #fee);
255
+ color: var(--error-color, #c33);
256
+ border-radius: 4px;
257
+ font-size: 0.9em;
258
+ margin-top: 0.5rem;
259
+ width: 100%;
260
+ }
261
+
262
+ .no-providers {
263
+ padding: 2rem;
264
+ text-align: center;
265
+ color: var(--text-muted, #666);
266
+ }
267
+
268
+ @media (min-width: 768px) {
269
+ .login-container {
270
+ max-width: 600px;
271
+ }
272
+
273
+ .oauth-providers {
274
+ display: grid;
275
+ grid-template-columns: repeat(2, 1fr);
276
+ gap: 0.75rem;
277
+ }
278
+ }
279
+ </style>
@@ -0,0 +1,167 @@
1
+ <template>
2
+ <!-- Navigation component has no visual output, it only manages navigation state -->
3
+ <slot />
4
+ </template>
5
+
6
+ <script setup>
7
+ import { ref, provide } from 'vue'
8
+ import { useRoute, useRouter } from 'vue-router'
9
+ import { PinIcon } from '@hugeicons/core-free-icons'
10
+
11
+ const navigationLinks = ref([])
12
+ const route = useRoute()
13
+ const router = useRouter()
14
+
15
+ /**
16
+ * Add a router link to the navigation
17
+ * @param {string} link - Route name
18
+ * @param {string|null} altTitle - Optional alternative title
19
+ * @param {object} options - Additional options
20
+ * @param {object} options.params - Route params to pass (e.g., { id: 1 })
21
+ * @param {object} options.props - Props to pass to router-link (e.g., { replace: true, custom: true })
22
+ * @param {object|string} options.to - Override the 'to' prop (if provided, takes precedence over params)
23
+ */
24
+ function addRouterLink(link, altTitle = null, options = {}) {
25
+ const foundRoute = router.getRoutes().find(r => r.name === link)
26
+
27
+ if (!foundRoute) {
28
+ console.warn(`Route "${link}" not found`)
29
+ return
30
+ }
31
+
32
+ const title = altTitle || foundRoute.meta?.title || foundRoute.name
33
+
34
+ // Determine the 'to' value: use options.to if provided, otherwise construct from params, or fall back to path
35
+ let toValue = foundRoute.path
36
+ if (options.to) {
37
+ toValue = options.to
38
+ } else if (options.params) {
39
+ toValue = { name: link, params: options.params }
40
+ }
41
+
42
+ const routeLink = {
43
+ name: link,
44
+ title: title,
45
+ path: foundRoute.path,
46
+ to: toValue,
47
+ icon: foundRoute.meta?.icon || PinIcon,
48
+ type: 'route',
49
+ props: options.props || {},
50
+ description: options.description || foundRoute.meta?.description || null
51
+ }
52
+
53
+ addNavigationLink(routeLink)
54
+ }
55
+
56
+ function addNavigationLink(link) {
57
+ const existingIndex = navigationLinks.value.findIndex(l => l.name === link.name)
58
+ if (existingIndex >= 0) {
59
+ navigationLinks.value[existingIndex] = { ...link }
60
+ } else {
61
+ navigationLinks.value.push({ ...link })
62
+ }
63
+ }
64
+
65
+ function addCallback(title, callback, options = {}) {
66
+ const callbackLink = {
67
+ name: options.name || title.toLowerCase().replace(/\s+/g, '-'),
68
+ type: 'callback',
69
+ icon: options.icon || PinIcon,
70
+ callback: callback || (() => {}),
71
+ title: title,
72
+ description: options.description || null
73
+ }
74
+
75
+ addNavigationLink(callbackLink)
76
+ }
77
+
78
+ function addSeparator(id) {
79
+ const separator = {
80
+ name: id || `nav-separator-${Date.now()}`,
81
+ type: 'separator',
82
+ title: 'Separator'
83
+ }
84
+ addNavigationLink(separator)
85
+ }
86
+
87
+ function addHtml(html, options = {}) {
88
+ const htmlLink = {
89
+ name: options.name || `html-item-${Date.now()}`,
90
+ type: 'html',
91
+ html: html,
92
+ title: options.title || 'HTML Item'
93
+ }
94
+ addNavigationLink(htmlLink)
95
+ }
96
+
97
+ function removeNavigationLink(linkId) {
98
+ navigationLinks.value = navigationLinks.value.filter(link => link.id !== linkId)
99
+ }
100
+
101
+ function clearNavigationLinks() {
102
+ navigationLinks.value = []
103
+ }
104
+
105
+ function getNavigationLinks() {
106
+ return [...navigationLinks.value]
107
+ }
108
+
109
+ function isActive(link) {
110
+ // If link has a 'to' object with a name, compare route name and params
111
+ if (link.to && typeof link.to === 'object' && link.to.name) {
112
+ // Compare route names first
113
+ if (route.name !== link.to.name) {
114
+ return false
115
+ }
116
+
117
+ // If params are specified in the link, all specified params must match
118
+ if (link.to.params && Object.keys(link.to.params).length > 0) {
119
+ const linkParams = link.to.params
120
+ const routeParams = route.params
121
+
122
+ // Check if all params in link.to.params match route.params
123
+ for (const key in linkParams) {
124
+ if (String(routeParams[key]) !== String(linkParams[key])) {
125
+ return false
126
+ }
127
+ }
128
+ }
129
+
130
+ // Route name matches, and either no params specified or all params match
131
+ return true
132
+ }
133
+
134
+ // Fall back to path comparison for string paths
135
+ const linkPath = typeof link.to === 'string' ? link.to : link.path
136
+ return route.path === linkPath
137
+ }
138
+
139
+ // Provide navigation API for child components
140
+ provide('navigation', {
141
+ navigationLinks,
142
+ addRouterLink,
143
+ addNavigationLink,
144
+ addCallback,
145
+ addSeparator,
146
+ addHtml,
147
+ removeNavigationLink,
148
+ clearNavigationLinks,
149
+ getNavigationLinks,
150
+ isActive
151
+ })
152
+
153
+ // Expose API for direct ref access
154
+ defineExpose({
155
+ navigationLinks,
156
+ addRouterLink,
157
+ addNavigationLink,
158
+ addCallback,
159
+ addSeparator,
160
+ addHtml,
161
+ removeNavigationLink,
162
+ clearNavigationLinks,
163
+ getNavigationLinks,
164
+ isActive
165
+ })
166
+ </script>
167
+