picocrank 1.31.0 → 1.32.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.31.0",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
package/router.js CHANGED
@@ -15,6 +15,7 @@ import { StatusIcon } from '@hugeicons/core-free-icons';
15
15
  import { PaintBoardIcon } from '@hugeicons/core-free-icons';
16
16
  import { UserIcon } from '@hugeicons/core-free-icons';
17
17
  import { InformationCircleIcon } from '@hugeicons/core-free-icons';
18
+ import { Loading03Icon } from '@hugeicons/core-free-icons';
18
19
 
19
20
  import {
20
21
  hubChildBreadcrumbs,
@@ -176,6 +177,16 @@ const routes = [
176
177
  breadcrumbs: hubChildBreadcrumbs('NavFeedbackStatusHub', 'DialogExample', 'Dialog'),
177
178
  }
178
179
  },
180
+ {
181
+ name: 'LoadingAreaExample',
182
+ path: '/loading-area-example',
183
+ component: () => import('./vue/examples/LoadingAreaExample.vue'),
184
+ meta: {
185
+ title: 'Loading area',
186
+ icon: Loading03Icon,
187
+ breadcrumbs: hubChildBreadcrumbs('NavFeedbackStatusHub', 'LoadingAreaExample', 'Loading area'),
188
+ }
189
+ },
179
190
  {
180
191
  name: 'TabsExample',
181
192
  path: '/tabs-example',
@@ -91,7 +91,13 @@
91
91
  --danger-zone-warning-fg: var(--muted-text-color);
92
92
  --danger-zone-focus-outline: var(--karma-bad-border);
93
93
 
94
- --picocrank-fixed-header-offset: 4em;
94
+ /* Matches Femtocrank header min-height (logo 2em + .5em padding). */
95
+ --picocrank-fixed-header-offset: 3em;
96
+ }
97
+
98
+ /* No fixed header → no sidebar/main offset. */
99
+ html:not(:has(header.fixed)) {
100
+ --picocrank-fixed-header-offset: 0px;
95
101
  }
96
102
 
97
103
  table thead th {
@@ -172,9 +178,23 @@ select.field-invalid:focus-visible {
172
178
  }
173
179
 
174
180
  /*
175
- * Fixed header layout: pin the app header and offset sidebar + main content.
176
- * Apply .layout-fixed-header on a wrapper whose direct children are header and #layout.
181
+ * Fixed header layout: pin the app header and offset main content.
182
+ * Prefer Header's `fixed` prop (adds Femtocrank `.fixed` on <header>).
183
+ * Sidebar top/height use --picocrank-fixed-header-offset in Sidebar.vue
184
+ * (unlayered) so they are not overridden by stuck-mode defaults.
185
+ * Alternatively wrap with .layout-fixed-header when header and #layout are direct children.
177
186
  */
187
+ header.fixed {
188
+ top: 0;
189
+ left: 0;
190
+ z-index: 5;
191
+ }
192
+
193
+ header.fixed ~ aside,
194
+ header.fixed ~ #layout aside {
195
+ z-index: 3;
196
+ }
197
+
178
198
  .layout-fixed-header > header {
179
199
  position: fixed;
180
200
  top: 0;
@@ -183,15 +203,20 @@ select.field-invalid:focus-visible {
183
203
  z-index: 5;
184
204
  }
185
205
 
206
+ .layout-fixed-header > aside,
186
207
  .layout-fixed-header > #layout aside {
187
208
  z-index: 3;
188
- padding-top: var(--picocrank-fixed-header-offset);
209
+ }
189
210
  }
190
211
 
191
- .layout-fixed-header > #layout main {
212
+ /*
213
+ * Unlayered so this wins over @layer components (same issue as Sidebar offsets).
214
+ * Pads #content below a fixed header — clears the chrome and leaves a readable gap.
215
+ */
216
+ header.fixed ~ #layout > #content,
217
+ .layout-fixed-header > #layout > #content {
192
218
  padding-top: var(--picocrank-fixed-header-offset);
193
219
  }
194
- }
195
220
 
196
221
  /*
197
222
  * Responsive column priorities: classes col-priority-1 … col-priority-5 on th/td only
@@ -227,3 +252,68 @@ select.field-invalid:focus-visible {
227
252
  @container responsive-cols (max-width: 22rem) {
228
253
  :is(th, td).col-priority-5 { display: none; }
229
254
  }
255
+
256
+ /*
257
+ * Floating context menu: reuse Femtocrank nav.menu dropdown chrome, forced open
258
+ * for cursor-positioned / teleported menus (table rows, calendar days, etc.).
259
+ */
260
+ nav.menu.context-menu {
261
+ position: fixed;
262
+ z-index: 1000;
263
+ margin: 0;
264
+ }
265
+
266
+ nav.menu.context-menu:focus,
267
+ nav.menu.context-menu:focus-visible {
268
+ outline: none;
269
+ }
270
+
271
+ nav.menu.context-menu > ul {
272
+ margin: 0;
273
+ padding: 0;
274
+ }
275
+
276
+ nav.menu.context-menu > ul > li {
277
+ display: block;
278
+ margin: 0;
279
+ padding: 0;
280
+ border-radius: 0;
281
+ background: transparent;
282
+ }
283
+
284
+ nav.menu.context-menu > ul > li:hover,
285
+ nav.menu.context-menu > ul > li:focus-within {
286
+ background: transparent;
287
+ color: inherit;
288
+ }
289
+
290
+ nav.menu.context-menu > ul > li > div {
291
+ display: block;
292
+ position: static;
293
+ top: auto;
294
+ left: auto;
295
+ min-width: 12em;
296
+ padding: var(--menu-dropdown-padding);
297
+ border-radius: var(--menu-dropdown-radius);
298
+ background-color: var(--menu-dropdown-background-color);
299
+ box-shadow: var(--menu-dropdown-shadow);
300
+ }
301
+
302
+ nav.menu.context-menu > ul > li > div::before {
303
+ content: none;
304
+ }
305
+
306
+ nav.menu.context-menu > ul div ul button.inline-icon {
307
+ display: inline-flex;
308
+ align-items: center;
309
+ gap: 0.45em;
310
+ }
311
+
312
+ nav.menu.context-menu > ul div ul li[role="separator"] {
313
+ height: 1px;
314
+ margin: 0.25em 0.35em;
315
+ padding: 0;
316
+ background: var(--menu-dropdown-border-color, var(--border-color));
317
+ border-radius: 0;
318
+ pointer-events: none;
319
+ }
@@ -641,7 +641,7 @@ function onDayContextMenu(date, mouseEvent) {
641
641
 
642
642
  nextTick(() => {
643
643
  positionDayContextMenu(mouseEvent.clientX, mouseEvent.clientY)
644
- dayContextMenuRef.value?.querySelector('button')?.focus()
644
+ dayContextMenuRef.value?.focus({ preventScroll: true })
645
645
  })
646
646
  }
647
647
 
@@ -1067,43 +1067,53 @@ watch([viewMonth, viewYear], () => {
1067
1067
  </div>
1068
1068
 
1069
1069
  <Teleport to="body">
1070
- <div
1070
+ <nav
1071
1071
  v-if="dayContextMenuOpen"
1072
1072
  ref="dayContextMenuRef"
1073
- class="calendar-day-context-menu"
1073
+ class="menu context-menu calendar-day-context-menu"
1074
1074
  role="menu"
1075
+ tabindex="-1"
1075
1076
  :style="dayContextMenuStyle"
1077
+ aria-label="Day view options"
1076
1078
  @click.stop
1077
1079
  @contextmenu.prevent
1078
1080
  >
1079
- <button
1080
- v-if="internalViewMode !== 'day'"
1081
- type="button"
1082
- role="menuitem"
1083
- class="calendar-day-context-menu-item"
1084
- @click="showOnlyThisDay"
1085
- >
1086
- Show only this day
1087
- </button>
1088
- <button
1089
- v-if="internalViewMode !== 'week'"
1090
- type="button"
1091
- role="menuitem"
1092
- class="calendar-day-context-menu-item"
1093
- @click="showOnlyThisWeek"
1094
- >
1095
- Show only this week
1096
- </button>
1097
- <button
1098
- v-if="internalViewMode !== 'month'"
1099
- type="button"
1100
- role="menuitem"
1101
- class="calendar-day-context-menu-item"
1102
- @click="showFullMonth"
1103
- >
1104
- Show full month
1105
- </button>
1106
- </div>
1081
+ <ul class="noListStyle">
1082
+ <li>
1083
+ <div>
1084
+ <ul>
1085
+ <li v-if="internalViewMode !== 'day'">
1086
+ <button
1087
+ type="button"
1088
+ role="menuitem"
1089
+ @click="showOnlyThisDay"
1090
+ >
1091
+ Show only this day
1092
+ </button>
1093
+ </li>
1094
+ <li v-if="internalViewMode !== 'week'">
1095
+ <button
1096
+ type="button"
1097
+ role="menuitem"
1098
+ @click="showOnlyThisWeek"
1099
+ >
1100
+ Show only this week
1101
+ </button>
1102
+ </li>
1103
+ <li v-if="internalViewMode !== 'month'">
1104
+ <button
1105
+ type="button"
1106
+ role="menuitem"
1107
+ @click="showFullMonth"
1108
+ >
1109
+ Show full month
1110
+ </button>
1111
+ </li>
1112
+ </ul>
1113
+ </div>
1114
+ </li>
1115
+ </ul>
1116
+ </nav>
1107
1117
  </Teleport>
1108
1118
  </div>
1109
1119
  </template>
@@ -1522,35 +1532,3 @@ watch([viewMonth, viewYear], () => {
1522
1532
  }
1523
1533
  }
1524
1534
  </style>
1525
-
1526
- <style>
1527
- .calendar-day-context-menu {
1528
- position: fixed;
1529
- z-index: 1000;
1530
- min-width: 12rem;
1531
- padding: 0.35rem;
1532
- border: 1px solid var(--table-popover-border);
1533
- border-radius: 8px;
1534
- background: var(--table-popover-bg);
1535
- box-shadow: var(--table-popover-shadow);
1536
- }
1537
-
1538
- .calendar-day-context-menu-item {
1539
- display: block;
1540
- width: 100%;
1541
- padding: 0.5rem 0.75rem;
1542
- border: none;
1543
- border-radius: 4px;
1544
- background: transparent;
1545
- color: var(--table-popover-fg);
1546
- font: inherit;
1547
- text-align: left;
1548
- cursor: pointer;
1549
- }
1550
-
1551
- .calendar-day-context-menu-item:hover,
1552
- .calendar-day-context-menu-item:focus-visible {
1553
- background: var(--table-popover-item-bg);
1554
- outline: none;
1555
- }
1556
- </style>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <header>
2
+ <header :class="{ fixed }">
3
3
  <div
4
4
  :id = "showSidebarChrome ? 'sidebar-button' : null"
5
5
  :class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding, 'logo-home-link': !showSidebarChrome && showBranding }]"
@@ -146,6 +146,10 @@
146
146
  type: Boolean,
147
147
  default: false,
148
148
  },
149
+ fixed: {
150
+ type: Boolean,
151
+ default: true,
152
+ },
149
153
  navigation: {
150
154
  type: Object,
151
155
  default: null,
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import LoadingArea from './LoadingArea.vue'
5
+
6
+ const globalStubs = {
7
+ HugeiconsIcon: true,
8
+ }
9
+
10
+ describe('LoadingArea', () => {
11
+ it('shows the placeholder by default', () => {
12
+ const wrapper = mount(LoadingArea, {
13
+ global: { stubs: globalStubs },
14
+ })
15
+
16
+ expect(wrapper.find('[role="status"]').exists()).toBe(true)
17
+ expect(wrapper.text()).toContain('Loading…')
18
+ expect(wrapper.attributes('aria-busy')).toBe('true')
19
+ })
20
+
21
+ it('renders default-slot content when not loading', async () => {
22
+ const wrapper = mount(LoadingArea, {
23
+ props: {
24
+ loading: false,
25
+ },
26
+ slots: {
27
+ default: '<p class="ready">Ready</p>',
28
+ },
29
+ global: { stubs: globalStubs },
30
+ })
31
+
32
+ await nextTick()
33
+
34
+ expect(wrapper.find('[role="status"]').exists()).toBe(false)
35
+ expect(wrapper.find('.ready').text()).toBe('Ready')
36
+ expect(wrapper.attributes('aria-busy')).toBe('false')
37
+ })
38
+
39
+ it('keeps content mounted in overlay mode while loading', async () => {
40
+ const wrapper = mount(LoadingArea, {
41
+ props: {
42
+ loading: true,
43
+ overlay: true,
44
+ },
45
+ slots: {
46
+ default: '<p class="ready">Ready</p>',
47
+ },
48
+ global: { stubs: globalStubs },
49
+ })
50
+
51
+ await nextTick()
52
+
53
+ expect(wrapper.find('[role="status"]').exists()).toBe(true)
54
+ expect(wrapper.find('.ready').exists()).toBe(true)
55
+ expect(wrapper.classes()).toContain('is-overlay')
56
+ })
57
+
58
+ it('supports a custom placeholder slot', () => {
59
+ const wrapper = mount(LoadingArea, {
60
+ props: {
61
+ loading: true,
62
+ },
63
+ slots: {
64
+ placeholder: '<span class="custom">Please wait</span>',
65
+ },
66
+ global: { stubs: globalStubs },
67
+ })
68
+
69
+ expect(wrapper.find('.custom').text()).toBe('Please wait')
70
+ expect(wrapper.text()).not.toContain('Loading…')
71
+ })
72
+ })
@@ -0,0 +1,176 @@
1
+ <template>
2
+ <div
3
+ class="loading-area"
4
+ :class="{
5
+ 'is-loading': loading,
6
+ 'is-overlay': loading && overlay && hasContent,
7
+ }"
8
+ :style="rootStyle"
9
+ :aria-busy="loading ? 'true' : 'false'"
10
+ >
11
+ <div
12
+ v-if="loading"
13
+ class="loading-area-placeholder"
14
+ role="status"
15
+ aria-live="polite"
16
+ >
17
+ <slot name="placeholder">
18
+ <HugeiconsIcon
19
+ :icon="resolvedIcon"
20
+ class="loading-area-spinner"
21
+ :size="resolvedIconSize"
22
+ :strokeWidth="2"
23
+ aria-hidden="true"
24
+ />
25
+ <p v-if="message" class="loading-area-message">{{ message }}</p>
26
+ </slot>
27
+ </div>
28
+
29
+ <div
30
+ v-if="hasContent"
31
+ v-show="!loading || overlay"
32
+ class="loading-area-content"
33
+ :aria-hidden="loading && overlay ? 'true' : undefined"
34
+ >
35
+ <slot />
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup>
41
+ import { computed, useSlots } from 'vue'
42
+ import { HugeiconsIcon } from '@hugeicons/vue'
43
+ import { Loading03Icon } from '@hugeicons/core-free-icons'
44
+
45
+ const props = defineProps({
46
+ /**
47
+ * When true, shows the loading placeholder (and optionally overlays content).
48
+ */
49
+ loading: {
50
+ type: Boolean,
51
+ default: true,
52
+ },
53
+ /**
54
+ * Status text announced to assistive tech and shown under the spinner.
55
+ * Pass an empty string to hide the message.
56
+ */
57
+ message: {
58
+ type: String,
59
+ default: 'Loading…',
60
+ },
61
+ /**
62
+ * Keep default-slot content in the layout and cover it with the placeholder.
63
+ */
64
+ overlay: {
65
+ type: Boolean,
66
+ default: false,
67
+ },
68
+ /**
69
+ * Hugeicons icon definition. Defaults to Loading03Icon.
70
+ */
71
+ icon: {
72
+ type: [Object, Array, Function],
73
+ default: null,
74
+ },
75
+ /**
76
+ * Spinner size in px, or a CSS length string (e.g. "1.5em").
77
+ */
78
+ iconSize: {
79
+ type: [Number, String],
80
+ default: 28,
81
+ },
82
+ /**
83
+ * Minimum height of the loading area (number = px, or any CSS length).
84
+ */
85
+ minHeight: {
86
+ type: [Number, String],
87
+ default: null,
88
+ },
89
+ })
90
+
91
+ const slots = useSlots()
92
+
93
+ const hasContent = computed(() => typeof slots.default === 'function')
94
+
95
+ const resolvedIcon = computed(() => props.icon || Loading03Icon)
96
+
97
+ const resolvedIconSize = computed(() => {
98
+ if (typeof props.iconSize === 'number') {
99
+ return props.iconSize
100
+ }
101
+ const parsed = Number.parseFloat(props.iconSize)
102
+ return Number.isFinite(parsed) ? parsed : 28
103
+ })
104
+
105
+ const rootStyle = computed(() => {
106
+ if (props.minHeight == null || props.minHeight === '') {
107
+ return undefined
108
+ }
109
+ const value = typeof props.minHeight === 'number'
110
+ ? `${props.minHeight}px`
111
+ : props.minHeight
112
+ return { minHeight: value }
113
+ })
114
+ </script>
115
+
116
+ <style scoped>
117
+ .loading-area {
118
+ position: relative;
119
+ display: block;
120
+ min-width: 0;
121
+ }
122
+
123
+ .loading-area-placeholder {
124
+ display: flex;
125
+ flex-direction: column;
126
+ align-items: center;
127
+ justify-content: center;
128
+ gap: 0.75rem;
129
+ padding: 2rem 1rem;
130
+ text-align: center;
131
+ color: var(--table-muted-fg, var(--muted-text-color));
132
+ }
133
+
134
+ .loading-area.is-overlay > .loading-area-placeholder {
135
+ position: absolute;
136
+ inset: 0;
137
+ z-index: 1;
138
+ box-sizing: border-box;
139
+ background-color: color-mix(in srgb, var(--section-bg-color, #fff) 78%, transparent);
140
+ }
141
+
142
+ .loading-area-spinner {
143
+ display: block;
144
+ flex: 0 0 auto;
145
+ animation: loading-area-spin 0.8s linear infinite;
146
+ }
147
+
148
+ .loading-area-message {
149
+ margin: 0;
150
+ font-size: 0.95em;
151
+ }
152
+
153
+ .loading-area-content {
154
+ min-width: 0;
155
+ }
156
+
157
+ .loading-area.is-overlay > .loading-area-content {
158
+ pointer-events: none;
159
+ user-select: none;
160
+ }
161
+
162
+ @keyframes loading-area-spin {
163
+ from {
164
+ transform: rotate(0deg);
165
+ }
166
+ to {
167
+ transform: rotate(360deg);
168
+ }
169
+ }
170
+
171
+ @media (prefers-reduced-motion: reduce) {
172
+ .loading-area-spinner {
173
+ animation: none;
174
+ }
175
+ }
176
+ </style>
@@ -401,6 +401,34 @@ defineExpose({
401
401
  </script>
402
402
 
403
403
  <style scoped>
404
+ .sidebar {
405
+ display: flex;
406
+ flex-direction: column;
407
+ box-sizing: border-box;
408
+ overflow-x: hidden;
409
+ overflow-y: auto;
410
+ overscroll-behavior: contain;
411
+ /*
412
+ * Sit fully below a fixed Header (Navigation, pin, and scrollbar).
413
+ * Offset is 0 when no header.fixed is present (see picocrank-extensions).
414
+ */
415
+ top: var(--picocrank-fixed-header-offset);
416
+ height: calc(100vh - var(--picocrank-fixed-header-offset));
417
+ max-height: calc(100vh - var(--picocrank-fixed-header-offset));
418
+ }
419
+
420
+ /*
421
+ * Stuck mode: same viewport band as overlay mode; scroll the whole contents
422
+ * instead of growing with content (Femtocrank defaults to height: auto).
423
+ */
424
+ .sidebar.stuck {
425
+ align-self: flex-start;
426
+ position: sticky;
427
+ top: var(--picocrank-fixed-header-offset);
428
+ height: calc(100vh - var(--picocrank-fixed-header-offset));
429
+ max-height: calc(100vh - var(--picocrank-fixed-header-offset));
430
+ }
431
+
404
432
  h2 {
405
433
  padding: .75em;
406
434
  }