picocrank 1.21.5 → 1.22.1
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 +8 -8
- package/public/themes/README.md +29 -0
- package/public/themes/catppuccin/theme.css +195 -0
- package/public/themes/dracula/theme.css +181 -0
- package/public/themes/index.json +7 -0
- package/public/themes/waffles/theme.css +143 -0
- package/router.js +20 -0
- package/styles/dark-theme.css +15 -1
- package/styles.css +18 -2
- package/vite.config.js +57 -0
- package/vue/components/CheckGroup.vue +156 -0
- package/vue/components/FormField.vue +14 -2
- package/vue/components/Header.vue +139 -33
- package/vue/components/QuickSearch.vue +352 -351
- package/vue/components/RadioGroup.vue +191 -0
- package/vue/components/Sidebar.vue +226 -262
- package/vue/components/SidebarNavLink.vue +161 -0
- package/vue/composables/useCustomTheme.js +162 -0
- package/vue/data/examplePeople.js +45 -0
- package/vue/views/App.vue +85 -15
- package/vue/views/FormExample.vue +64 -29
- package/vue/views/PageStructure.vue +168 -95
- package/vue/views/TableExample.vue +2 -76
- package/vue/views/ThemePreview.vue +172 -0
- package/vue/views/UserDetails.vue +39 -0
- package/vue/views/Welcome.vue +9 -6
package/vite.config.js
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
import { defineConfig } from 'vite'
|
|
2
2
|
import vue from '@vitejs/plugin-vue'
|
|
3
3
|
import Components from 'unplugin-vue-components/vite'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const themesDir = path.join(__dirname, 'public', 'themes')
|
|
10
|
+
|
|
11
|
+
function scanThemeNames() {
|
|
12
|
+
if (!fs.existsSync(themesDir)) {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return fs
|
|
17
|
+
.readdirSync(themesDir, { withFileTypes: true })
|
|
18
|
+
.filter((entry) => entry.isDirectory())
|
|
19
|
+
.filter((entry) => fs.existsSync(path.join(themesDir, entry.name, 'theme.css')))
|
|
20
|
+
.map((entry) => entry.name)
|
|
21
|
+
.sort((a, b) => a.localeCompare(b))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeThemesIndex() {
|
|
25
|
+
fs.mkdirSync(themesDir, { recursive: true })
|
|
26
|
+
const themes = scanThemeNames()
|
|
27
|
+
const indexPath = path.join(themesDir, 'index.json')
|
|
28
|
+
fs.writeFileSync(indexPath, `${JSON.stringify({ themes }, null, 2)}\n`)
|
|
29
|
+
return themes
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function themesIndexPlugin() {
|
|
33
|
+
return {
|
|
34
|
+
name: 'picocrank-themes-index',
|
|
35
|
+
buildStart() {
|
|
36
|
+
writeThemesIndex()
|
|
37
|
+
},
|
|
38
|
+
configureServer(server) {
|
|
39
|
+
writeThemesIndex()
|
|
40
|
+
|
|
41
|
+
const refreshIndex = (filePath) => {
|
|
42
|
+
if (!filePath) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const normalized = path.resolve(filePath)
|
|
46
|
+
if (!normalized.startsWith(themesDir)) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
writeThemesIndex()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
server.watcher.add(themesDir)
|
|
53
|
+
server.watcher.on('add', refreshIndex)
|
|
54
|
+
server.watcher.on('unlink', refreshIndex)
|
|
55
|
+
server.watcher.on('addDir', refreshIndex)
|
|
56
|
+
server.watcher.on('unlinkDir', refreshIndex)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
4
60
|
|
|
5
61
|
export default defineConfig({
|
|
6
62
|
base: '/',
|
|
@@ -36,6 +92,7 @@ export default defineConfig({
|
|
|
36
92
|
},
|
|
37
93
|
},
|
|
38
94
|
plugins: [
|
|
95
|
+
themesIndexPlugin(),
|
|
39
96
|
Components({
|
|
40
97
|
dirs: "./vue/",
|
|
41
98
|
extensions: ['vue'],
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="['multiple-checklist', { disabled }]"
|
|
4
|
+
role="group"
|
|
5
|
+
:aria-labelledby="ariaLabelledby || undefined"
|
|
6
|
+
:aria-label="ariaLabel || undefined"
|
|
7
|
+
:aria-disabled="disabled ? 'true' : undefined"
|
|
8
|
+
>
|
|
9
|
+
<label
|
|
10
|
+
v-for="option in normalizedOptions"
|
|
11
|
+
:key="option.key"
|
|
12
|
+
:class="{ disabled: disabled || option.disabled }"
|
|
13
|
+
>
|
|
14
|
+
<input
|
|
15
|
+
type="checkbox"
|
|
16
|
+
:name="groupName"
|
|
17
|
+
:value="option.key"
|
|
18
|
+
:checked="isSelected(option)"
|
|
19
|
+
:disabled="disabled || option.disabled"
|
|
20
|
+
@change="toggle(option)"
|
|
21
|
+
/>
|
|
22
|
+
<span>{{ option.label }}</span>
|
|
23
|
+
</label>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup>
|
|
28
|
+
import { computed, useId } from 'vue'
|
|
29
|
+
|
|
30
|
+
const props = defineProps({
|
|
31
|
+
modelValue: {
|
|
32
|
+
type: Array,
|
|
33
|
+
default: () => [],
|
|
34
|
+
},
|
|
35
|
+
options: {
|
|
36
|
+
type: Array,
|
|
37
|
+
required: true,
|
|
38
|
+
validator: (options) => Array.isArray(options) && options.every((option) => {
|
|
39
|
+
if (typeof option === 'string' || typeof option === 'number') {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
return option
|
|
43
|
+
&& typeof option === 'object'
|
|
44
|
+
&& 'value' in option
|
|
45
|
+
&& typeof option.label === 'string'
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
name: {
|
|
49
|
+
type: String,
|
|
50
|
+
default: '',
|
|
51
|
+
},
|
|
52
|
+
// Accepted for API parity with RadioGroup; CheckGroup has a single Femtocrank layout.
|
|
53
|
+
variant: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: 'default',
|
|
56
|
+
validator: (value) => ['default', 'boolean', 'list'].includes(value),
|
|
57
|
+
},
|
|
58
|
+
disabled: {
|
|
59
|
+
type: Boolean,
|
|
60
|
+
default: false,
|
|
61
|
+
},
|
|
62
|
+
ariaLabelledby: {
|
|
63
|
+
type: String,
|
|
64
|
+
default: '',
|
|
65
|
+
},
|
|
66
|
+
ariaLabel: {
|
|
67
|
+
type: String,
|
|
68
|
+
default: '',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const emit = defineEmits(['update:modelValue', 'change'])
|
|
73
|
+
|
|
74
|
+
const generatedName = useId()
|
|
75
|
+
|
|
76
|
+
const groupName = computed(() => props.name || generatedName)
|
|
77
|
+
|
|
78
|
+
const selectedValues = computed(() => (
|
|
79
|
+
Array.isArray(props.modelValue) ? props.modelValue : []
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
const normalizedOptions = computed(() => props.options.map((option, index) => {
|
|
83
|
+
if (typeof option === 'string' || typeof option === 'number') {
|
|
84
|
+
return {
|
|
85
|
+
key: String(index),
|
|
86
|
+
label: String(option),
|
|
87
|
+
value: option,
|
|
88
|
+
disabled: false,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
key: String(index),
|
|
94
|
+
label: option.label,
|
|
95
|
+
value: option.value,
|
|
96
|
+
disabled: Boolean(option.disabled),
|
|
97
|
+
}
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
function isSelected(option) {
|
|
101
|
+
return selectedValues.value.includes(option.value)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toggle(option) {
|
|
105
|
+
if (props.disabled || option.disabled) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const next = isSelected(option)
|
|
110
|
+
? selectedValues.value.filter((value) => value !== option.value)
|
|
111
|
+
: [...selectedValues.value, option.value]
|
|
112
|
+
|
|
113
|
+
emit('update:modelValue', next)
|
|
114
|
+
emit('change', next)
|
|
115
|
+
}
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<style scoped>
|
|
119
|
+
.multiple-checklist label {
|
|
120
|
+
display: flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
box-sizing: border-box;
|
|
123
|
+
gap: 0.5em;
|
|
124
|
+
padding-inline: 0.75em;
|
|
125
|
+
margin: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.multiple-checklist input[type="checkbox"] {
|
|
129
|
+
margin: 0;
|
|
130
|
+
flex-shrink: 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.multiple-checklist.disabled {
|
|
134
|
+
opacity: 0.7;
|
|
135
|
+
cursor: not-allowed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.multiple-checklist label.disabled,
|
|
139
|
+
.multiple-checklist.disabled label {
|
|
140
|
+
background-color: var(--disabled-background-color);
|
|
141
|
+
color: var(--disabled-text-color);
|
|
142
|
+
cursor: not-allowed;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.multiple-checklist label.disabled:hover,
|
|
146
|
+
.multiple-checklist label.disabled:has(input:focus-visible),
|
|
147
|
+
.multiple-checklist label.disabled:has(input:checked),
|
|
148
|
+
.multiple-checklist label.disabled:has(input:checked):hover,
|
|
149
|
+
.multiple-checklist.disabled label:hover,
|
|
150
|
+
.multiple-checklist.disabled label:has(input:focus-visible),
|
|
151
|
+
.multiple-checklist.disabled label:has(input:checked),
|
|
152
|
+
.multiple-checklist.disabled label:has(input:checked):hover {
|
|
153
|
+
background-color: var(--disabled-background-color);
|
|
154
|
+
color: var(--disabled-text-color);
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<label v-if="!fake" :for="controlId">{{ label }}</label>
|
|
3
|
-
<span v-else class="fake-label">{{ label }}</span>
|
|
2
|
+
<label v-if="!fake" :for="controlId" :class="{ disabled }">{{ label }}</label>
|
|
3
|
+
<span v-else class="fake-label" :class="{ disabled }">{{ label }}</span>
|
|
4
4
|
<slot />
|
|
5
5
|
</template>
|
|
6
6
|
|
|
@@ -24,7 +24,19 @@ const props = defineProps({
|
|
|
24
24
|
type: Boolean,
|
|
25
25
|
default: false,
|
|
26
26
|
},
|
|
27
|
+
disabled: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
27
31
|
})
|
|
28
32
|
|
|
29
33
|
const controlId = computed(() => props.htmlFor || props.for)
|
|
30
34
|
</script>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
label.disabled,
|
|
38
|
+
.fake-label.disabled {
|
|
39
|
+
color: var(--disabled-text-color);
|
|
40
|
+
cursor: not-allowed;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
<div class = "fg1" />
|
|
12
12
|
|
|
13
|
-
<button
|
|
14
|
-
|
|
13
|
+
<button
|
|
14
|
+
v-if="showSidebarChrome"
|
|
15
|
+
id="sidebar-toggler-button"
|
|
16
|
+
type="button"
|
|
17
|
+
class="neutral"
|
|
18
|
+
aria-expanded="false"
|
|
19
|
+
aria-controls="picocrank-sidebar"
|
|
20
|
+
aria-label="Open sidebar navigation"
|
|
21
|
+
@click.stop="toggleNavSidebar"
|
|
22
|
+
>
|
|
23
|
+
<HugeiconsIcon :icon="Menu01Icon" width="1em" height="1em" :strokeWidth="3" aria-hidden="true" />
|
|
15
24
|
</button>
|
|
16
25
|
</div>
|
|
17
26
|
|
|
@@ -19,26 +28,35 @@
|
|
|
19
28
|
|
|
20
29
|
<Breadcrumbs v-if="breadcrumbs" />
|
|
21
30
|
|
|
22
|
-
<slot name="toolbar" />
|
|
23
|
-
|
|
24
31
|
<div class = "fg1"></div>
|
|
25
32
|
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
<div class="header-actions flex-row">
|
|
34
|
+
<button
|
|
35
|
+
v-if="themeToggleEnabled"
|
|
36
|
+
type="button"
|
|
37
|
+
class="theme-toggle neutral"
|
|
38
|
+
:aria-label="themeToggleLabel"
|
|
39
|
+
:title="themeToggleTitle"
|
|
40
|
+
@click="toggleTheme"
|
|
41
|
+
>
|
|
42
|
+
<HugeiconsIcon :icon="themeToggleIcon" width="1em" height="1em" />
|
|
43
|
+
</button>
|
|
44
|
+
|
|
45
|
+
<slot name="toolbar" />
|
|
46
|
+
|
|
47
|
+
<slot name = "user-info">
|
|
48
|
+
<button
|
|
49
|
+
v-if="username"
|
|
50
|
+
type="button"
|
|
51
|
+
class="user-info flex-row neutral"
|
|
52
|
+
:aria-label="`View profile for ${username}`"
|
|
53
|
+
@click="emit('userClick')"
|
|
54
|
+
>
|
|
55
|
+
<HugeiconsIcon :icon="UserIcon" width="1em" height="1em" aria-hidden="true" />
|
|
56
|
+
<span>{{ username }}</span>
|
|
57
|
+
</button>
|
|
58
|
+
</slot>
|
|
59
|
+
</div>
|
|
42
60
|
</header>
|
|
43
61
|
|
|
44
62
|
<!-- Top-bar-only mobile: Header owns the fallback sidebar so consumers need no extra wiring. -->
|
|
@@ -48,7 +66,7 @@
|
|
|
48
66
|
<script setup>
|
|
49
67
|
import { computed, ref } from "vue";
|
|
50
68
|
import { HugeiconsIcon } from "@hugeicons/vue";
|
|
51
|
-
import { BulbIcon, Menu01Icon, Moon02Icon, Sun01Icon } from "@hugeicons/core-free-icons";
|
|
69
|
+
import { BulbIcon, Menu01Icon, Moon02Icon, Sun01Icon, UserIcon } from "@hugeicons/core-free-icons";
|
|
52
70
|
|
|
53
71
|
import Breadcrumbs from "./Breadcrumbs.vue";
|
|
54
72
|
import TopBar from "./TopBar.vue";
|
|
@@ -81,7 +99,7 @@
|
|
|
81
99
|
return `Theme ${current.toLowerCase()}. Switch to ${next.toLowerCase()} mode`;
|
|
82
100
|
});
|
|
83
101
|
|
|
84
|
-
const emit = defineEmits(["toggleSidebar", "logoClick"]);
|
|
102
|
+
const emit = defineEmits(["toggleSidebar", "logoClick", "userClick"]);
|
|
85
103
|
|
|
86
104
|
const props = defineProps({
|
|
87
105
|
breadcrumbs: {
|
|
@@ -147,15 +165,6 @@
|
|
|
147
165
|
</script>
|
|
148
166
|
|
|
149
167
|
<style scoped>
|
|
150
|
-
button {
|
|
151
|
-
border: 0;
|
|
152
|
-
color: #fff;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
button:hover {
|
|
156
|
-
background-color: transparent;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
168
|
#sidebar-button.disabled-branding {
|
|
160
169
|
width: auto;
|
|
161
170
|
}
|
|
@@ -169,11 +178,108 @@ button:hover {
|
|
|
169
178
|
color: var(--header-hover-text-color);
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
.user-info {
|
|
182
|
+
gap: 0.4rem;
|
|
183
|
+
align-self: stretch;
|
|
184
|
+
padding: 0 0.6em;
|
|
185
|
+
border-radius: 0;
|
|
186
|
+
font-weight: normal;
|
|
187
|
+
}
|
|
188
|
+
|
|
172
189
|
.user-info span {
|
|
173
|
-
padding-right: 0
|
|
190
|
+
padding-right: 0;
|
|
191
|
+
font-weight: normal;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.header-actions {
|
|
195
|
+
align-self: stretch;
|
|
196
|
+
gap: 0.25rem;
|
|
174
197
|
}
|
|
175
198
|
|
|
176
199
|
.theme-toggle {
|
|
177
|
-
margin-right: 0
|
|
200
|
+
margin-right: 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* Match Femtocrank header chrome (same idea as #sidebar-button:hover) */
|
|
204
|
+
.header-actions > button,
|
|
205
|
+
.header-actions :deep(button) {
|
|
206
|
+
border: 0;
|
|
207
|
+
border-radius: 0;
|
|
208
|
+
color: #fff;
|
|
209
|
+
background-color: transparent;
|
|
210
|
+
align-self: stretch;
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
padding-top: 0;
|
|
214
|
+
padding-bottom: 0;
|
|
215
|
+
padding-left: 0.6em;
|
|
216
|
+
padding-right: 0.6em;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.header-actions > button:hover,
|
|
220
|
+
.header-actions :deep(button:hover),
|
|
221
|
+
.user-info:hover {
|
|
222
|
+
background-color: var(--header-hover-background-color);
|
|
223
|
+
color: var(--header-hover-text-color);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Sidebar toggler keeps existing full-height chrome */
|
|
227
|
+
#sidebar-toggler-button {
|
|
228
|
+
border: 0;
|
|
229
|
+
border-radius: 0;
|
|
230
|
+
color: #fff;
|
|
231
|
+
background-color: transparent;
|
|
232
|
+
align-self: stretch;
|
|
233
|
+
display: inline-flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
padding-top: 0;
|
|
236
|
+
padding-bottom: 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#sidebar-toggler-button:hover {
|
|
240
|
+
background-color: var(--header-hover-background-color);
|
|
241
|
+
color: var(--header-hover-text-color);
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
244
|
+
|
|
245
|
+
<!--
|
|
246
|
+
Unscoped descendant selector: the search trigger lives inside QuickSearch in the
|
|
247
|
+
toolbar slot, so scoped / :slotted / :deep cannot fully restyle it alone.
|
|
248
|
+
-->
|
|
249
|
+
<style>
|
|
250
|
+
header .header-actions {
|
|
251
|
+
gap: 0.25rem;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
header .quick-search {
|
|
255
|
+
align-self: stretch;
|
|
256
|
+
display: inline-flex;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
header .search-trigger,
|
|
260
|
+
header .search-trigger.neutral {
|
|
261
|
+
border: 0;
|
|
262
|
+
border-radius: 0;
|
|
263
|
+
color: #fff;
|
|
264
|
+
background-color: transparent;
|
|
265
|
+
align-self: stretch;
|
|
266
|
+
height: 100%;
|
|
267
|
+
display: inline-flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
padding-top: 0;
|
|
270
|
+
padding-bottom: 0;
|
|
271
|
+
padding-left: 0.6em;
|
|
272
|
+
padding-right: 0.6em;
|
|
273
|
+
margin-right: 0;
|
|
274
|
+
font-weight: normal;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
header .search-trigger:hover {
|
|
278
|
+
background-color: var(--header-hover-background-color);
|
|
279
|
+
color: var(--header-hover-text-color);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
header .user-info {
|
|
283
|
+
font-weight: normal;
|
|
178
284
|
}
|
|
179
285
|
</style>
|