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/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 v-if="showSidebarChrome" id = "sidebar-toggler-button" aria-label = "Open sidebar navigation" aria-pressed = "false" aria-haspopup = "menu" class = "neutral">
14
- <HugeiconsIcon :icon = "Menu01Icon" width = "1em" height = "1em" :strokeWidth = 3 />
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
- <button
27
- v-if="themeToggleEnabled"
28
- type="button"
29
- class="theme-toggle neutral"
30
- :aria-label="themeToggleLabel"
31
- :title="themeToggleTitle"
32
- @click="toggleTheme"
33
- >
34
- <HugeiconsIcon :icon="themeToggleIcon" width="1em" height="1em" />
35
- </button>
36
-
37
- <slot name = "user-info">
38
- <div class = "user-info">
39
- <span v-if="username">{{ username }}</span>
40
- </div>
41
- </slot>
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.5rem;
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.25rem;
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>