picocrank 1.31.1 → 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 +1 -1
- package/router.js +11 -0
- package/styles/picocrank-extensions.css +65 -0
- package/vue/components/Calendar.vue +41 -63
- package/vue/components/LoadingArea.spec.js +72 -0
- package/vue/components/LoadingArea.vue +176 -0
- package/vue/components/Table.spec.js +253 -0
- package/vue/components/Table.vue +379 -2
- package/vue/components/TableColumnFilterPopover.vue +56 -8
- package/vue/components/TableRowContextMenu.vue +267 -0
- package/vue/data/navSectionHubs.js +5 -0
- package/vue/examples/LoadingAreaExample.vue +175 -0
- package/vue/examples/TableExample.vue +156 -4
package/package.json
CHANGED
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',
|
|
@@ -252,3 +252,68 @@ header.fixed ~ #layout > #content,
|
|
|
252
252
|
@container responsive-cols (max-width: 22rem) {
|
|
253
253
|
:is(th, td).col-priority-5 { display: none; }
|
|
254
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?.
|
|
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
|
-
<
|
|
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
|
-
<
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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>
|
|
@@ -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>
|