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 +1 -1
- package/router.js +11 -0
- package/styles/picocrank-extensions.css +96 -6
- package/vue/components/Calendar.vue +41 -63
- package/vue/components/Header.vue +5 -1
- package/vue/components/LoadingArea.spec.js +72 -0
- package/vue/components/LoadingArea.vue +176 -0
- package/vue/components/Sidebar.vue +28 -0
- package/vue/components/StatusCard.vue +211 -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/Admin.vue +148 -53
- package/vue/examples/LoadingAreaExample.vue +175 -0
- package/vue/examples/TableExample.vue +156 -4
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<nav
|
|
4
|
+
v-if="open"
|
|
5
|
+
ref="menuRef"
|
|
6
|
+
class="menu context-menu table-row-context-menu"
|
|
7
|
+
role="menu"
|
|
8
|
+
tabindex="-1"
|
|
9
|
+
:style="style"
|
|
10
|
+
aria-label="Row actions"
|
|
11
|
+
@click.stop
|
|
12
|
+
@contextmenu.prevent
|
|
13
|
+
@keydown="onMenuKeyDown"
|
|
14
|
+
>
|
|
15
|
+
<ul class="noListStyle">
|
|
16
|
+
<li>
|
|
17
|
+
<div>
|
|
18
|
+
<ul>
|
|
19
|
+
<template v-for="(item, index) in items" :key="itemKey(item, index)">
|
|
20
|
+
<li
|
|
21
|
+
v-if="item.divider"
|
|
22
|
+
role="separator"
|
|
23
|
+
/>
|
|
24
|
+
<li v-else>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
role="menuitem"
|
|
28
|
+
:class="{
|
|
29
|
+
'inline-icon': Boolean(item.icon),
|
|
30
|
+
bad: item.danger,
|
|
31
|
+
}"
|
|
32
|
+
:disabled="item.disabled"
|
|
33
|
+
@click="onSelect(item)"
|
|
34
|
+
>
|
|
35
|
+
<HugeiconsIcon
|
|
36
|
+
v-if="item.icon"
|
|
37
|
+
:icon="item.icon"
|
|
38
|
+
width="1em"
|
|
39
|
+
height="1em"
|
|
40
|
+
:strokeWidth="2.5"
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
/>
|
|
43
|
+
<span>{{ itemLabel(item) }}</span>
|
|
44
|
+
</button>
|
|
45
|
+
</li>
|
|
46
|
+
</template>
|
|
47
|
+
</ul>
|
|
48
|
+
</div>
|
|
49
|
+
</li>
|
|
50
|
+
</ul>
|
|
51
|
+
</nav>
|
|
52
|
+
</Teleport>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<script setup>
|
|
56
|
+
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
57
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
58
|
+
|
|
59
|
+
const props = defineProps({
|
|
60
|
+
open: {
|
|
61
|
+
type: Boolean,
|
|
62
|
+
default: false,
|
|
63
|
+
},
|
|
64
|
+
items: {
|
|
65
|
+
type: Array,
|
|
66
|
+
default: () => [],
|
|
67
|
+
},
|
|
68
|
+
clientX: {
|
|
69
|
+
type: Number,
|
|
70
|
+
default: 0,
|
|
71
|
+
},
|
|
72
|
+
clientY: {
|
|
73
|
+
type: Number,
|
|
74
|
+
default: 0,
|
|
75
|
+
},
|
|
76
|
+
targetCount: {
|
|
77
|
+
type: Number,
|
|
78
|
+
default: 1,
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits(['update:open', 'select', 'cancel'])
|
|
83
|
+
|
|
84
|
+
const MENU_MARGIN_PX = 8
|
|
85
|
+
const menuRef = ref(null)
|
|
86
|
+
const style = ref({ top: '0px', left: '0px' })
|
|
87
|
+
|
|
88
|
+
function itemKey(item, index) {
|
|
89
|
+
if (item.divider) {
|
|
90
|
+
return `divider-${index}`
|
|
91
|
+
}
|
|
92
|
+
return item.id ?? item.label ?? index
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function itemLabel(item) {
|
|
96
|
+
if (typeof item.label === 'function') {
|
|
97
|
+
return item.label({ count: props.targetCount })
|
|
98
|
+
}
|
|
99
|
+
const label = item.label ?? ''
|
|
100
|
+
if (props.targetCount > 1 && item.showCount !== false) {
|
|
101
|
+
return `${label} (${props.targetCount})`
|
|
102
|
+
}
|
|
103
|
+
return label
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function positionMenu() {
|
|
107
|
+
const menu = menuRef.value
|
|
108
|
+
if (!menu) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rect = menu.getBoundingClientRect()
|
|
113
|
+
const margin = MENU_MARGIN_PX
|
|
114
|
+
let top = props.clientY
|
|
115
|
+
let left = props.clientX
|
|
116
|
+
|
|
117
|
+
if (left + rect.width > window.innerWidth - margin) {
|
|
118
|
+
left = window.innerWidth - rect.width - margin
|
|
119
|
+
}
|
|
120
|
+
if (top + rect.height > window.innerHeight - margin) {
|
|
121
|
+
top = window.innerHeight - rect.height - margin
|
|
122
|
+
}
|
|
123
|
+
if (left < margin) {
|
|
124
|
+
left = margin
|
|
125
|
+
}
|
|
126
|
+
if (top < margin) {
|
|
127
|
+
top = margin
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
style.value = {
|
|
131
|
+
top: `${top}px`,
|
|
132
|
+
left: `${left}px`,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function close() {
|
|
137
|
+
emit('update:open', false)
|
|
138
|
+
emit('cancel')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onSelect(item) {
|
|
142
|
+
if (item.disabled) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
emit('select', item)
|
|
146
|
+
emit('update:open', false)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function enabledMenuItems() {
|
|
150
|
+
if (!menuRef.value) {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
return [...menuRef.value.querySelectorAll('[role="menuitem"]:not([disabled])')]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function onMenuKeyDown(event) {
|
|
157
|
+
const items = enabledMenuItems()
|
|
158
|
+
const active = document.activeElement
|
|
159
|
+
const currentIndex = items.indexOf(active)
|
|
160
|
+
const focusIsOnMenuShell = active === menuRef.value
|
|
161
|
+
|
|
162
|
+
if (event.key === 'Escape') {
|
|
163
|
+
event.preventDefault()
|
|
164
|
+
close()
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (event.key === 'ArrowDown') {
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
|
|
171
|
+
items[nextIndex]?.focus()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (event.key === 'ArrowUp') {
|
|
176
|
+
event.preventDefault()
|
|
177
|
+
const nextIndex = currentIndex < 0
|
|
178
|
+
? items.length - 1
|
|
179
|
+
: (currentIndex - 1 + items.length) % items.length
|
|
180
|
+
items[nextIndex]?.focus()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (event.key === 'Home') {
|
|
185
|
+
event.preventDefault()
|
|
186
|
+
items[0]?.focus()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (event.key === 'End') {
|
|
191
|
+
event.preventDefault()
|
|
192
|
+
items[items.length - 1]?.focus()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (event.key !== 'Tab' || items.length === 0) {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Tab through items; leave (and close) rather than jumping to the rest of the page.
|
|
201
|
+
if (event.shiftKey) {
|
|
202
|
+
if (focusIsOnMenuShell || currentIndex <= 0) {
|
|
203
|
+
event.preventDefault()
|
|
204
|
+
close()
|
|
205
|
+
}
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (currentIndex === items.length - 1) {
|
|
210
|
+
event.preventDefault()
|
|
211
|
+
close()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function onDocumentPointerDown(event) {
|
|
216
|
+
if (!props.open) {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (menuRef.value?.contains(event.target)) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
close()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function onDocumentKeyDown(event) {
|
|
226
|
+
if (!props.open) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (event.key === 'Escape') {
|
|
230
|
+
close()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function attachListeners() {
|
|
235
|
+
window.addEventListener('pointerdown', onDocumentPointerDown, true)
|
|
236
|
+
window.addEventListener('keydown', onDocumentKeyDown)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function detachListeners() {
|
|
240
|
+
window.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
|
241
|
+
window.removeEventListener('keydown', onDocumentKeyDown)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
watch(
|
|
245
|
+
() => props.open,
|
|
246
|
+
async (isOpen) => {
|
|
247
|
+
detachListeners()
|
|
248
|
+
if (!isOpen) {
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
style.value = {
|
|
252
|
+
top: `${props.clientY}px`,
|
|
253
|
+
left: `${props.clientX}px`,
|
|
254
|
+
}
|
|
255
|
+
await nextTick()
|
|
256
|
+
positionMenu()
|
|
257
|
+
// Focus the menu shell so keyboard users can Tab/arrow into items,
|
|
258
|
+
// without visually selecting the first action on open.
|
|
259
|
+
menuRef.value?.focus({ preventScroll: true })
|
|
260
|
+
attachListeners()
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
onBeforeUnmount(() => {
|
|
265
|
+
detachListeners()
|
|
266
|
+
})
|
|
267
|
+
</script>
|
|
@@ -112,6 +112,11 @@ export const navSectionHubs = {
|
|
|
112
112
|
title: 'Dialog',
|
|
113
113
|
options: { description: 'Modal dialogs and confirmations' },
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
name: 'LoadingAreaExample',
|
|
117
|
+
title: 'Loading area',
|
|
118
|
+
options: { description: 'Async placeholder with spinner' },
|
|
119
|
+
},
|
|
115
120
|
],
|
|
116
121
|
},
|
|
117
122
|
NavPatternsHub: {
|
package/vue/examples/Admin.vue
CHANGED
|
@@ -8,67 +8,99 @@
|
|
|
8
8
|
<div class="admin-section">
|
|
9
9
|
<h3>System Overview</h3>
|
|
10
10
|
<p>Welcome to the administrative panel. This is a placeholder for administrative functionality.</p>
|
|
11
|
+
|
|
12
|
+
<FormLayout @submit.prevent>
|
|
13
|
+
<FormField
|
|
14
|
+
label="Grid"
|
|
15
|
+
component-has-label
|
|
16
|
+
description="How many StatusCard columns to show."
|
|
17
|
+
>
|
|
18
|
+
<RadioGroup
|
|
19
|
+
v-model="gridLayout"
|
|
20
|
+
name="status-card-grid"
|
|
21
|
+
variant="boolean"
|
|
22
|
+
:options="gridLayoutOptions"
|
|
23
|
+
/>
|
|
24
|
+
</FormField>
|
|
25
|
+
|
|
26
|
+
<FormField label="Content align" component-has-label>
|
|
27
|
+
<RadioGroup
|
|
28
|
+
v-model="cardAlign"
|
|
29
|
+
name="status-card-align"
|
|
30
|
+
variant="boolean"
|
|
31
|
+
:options="alignOptions"
|
|
32
|
+
/>
|
|
33
|
+
</FormField>
|
|
34
|
+
|
|
35
|
+
<FormField label="Bar labels" component-has-label>
|
|
36
|
+
<RadioGroup
|
|
37
|
+
v-model="showBarLabels"
|
|
38
|
+
name="status-card-bar-labels"
|
|
39
|
+
variant="boolean"
|
|
40
|
+
:options="showHideOptions"
|
|
41
|
+
/>
|
|
42
|
+
</FormField>
|
|
43
|
+
|
|
44
|
+
<FormField label="Compact" component-has-label>
|
|
45
|
+
<RadioGroup
|
|
46
|
+
v-model="cardCompact"
|
|
47
|
+
name="status-card-compact"
|
|
48
|
+
variant="boolean"
|
|
49
|
+
:options="onOffOptions"
|
|
50
|
+
/>
|
|
51
|
+
</FormField>
|
|
52
|
+
</FormLayout>
|
|
11
53
|
|
|
12
|
-
<div class="
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<h4>System Load</h4>
|
|
27
|
-
<span class="stat">67%</span>
|
|
28
|
-
<span class="subtle">Moderate</span>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
|
-
<div class="stat-display good">
|
|
32
|
-
<h4>Storage Used</h4>
|
|
33
|
-
<span class="stat">2.4 GB</span>
|
|
34
|
-
<span class="subtle">45% of total</span>
|
|
35
|
-
</div>
|
|
54
|
+
<div :class="gridClass">
|
|
55
|
+
<StatusCard
|
|
56
|
+
v-for="card in overviewCards"
|
|
57
|
+
:key="card.title"
|
|
58
|
+
:karma="card.karma"
|
|
59
|
+
:bar-label="showBarLabels ? card.barLabel : ''"
|
|
60
|
+
:align="cardAlign"
|
|
61
|
+
:compact="cardCompact"
|
|
62
|
+
:to="card.to"
|
|
63
|
+
>
|
|
64
|
+
<h4>{{ card.title }}</h4>
|
|
65
|
+
<span class="stat">{{ card.stat }}</span>
|
|
66
|
+
<span class="subtle">{{ card.subtle }}</span>
|
|
67
|
+
</StatusCard>
|
|
36
68
|
</div>
|
|
37
69
|
</div>
|
|
38
70
|
|
|
39
71
|
<div class="admin-section">
|
|
40
72
|
<h3>Recent Activity</h3>
|
|
41
|
-
<
|
|
42
|
-
<
|
|
43
|
-
<span class="
|
|
44
|
-
|
|
45
|
-
</
|
|
46
|
-
<
|
|
47
|
-
<span class="
|
|
48
|
-
|
|
49
|
-
</
|
|
50
|
-
<
|
|
51
|
-
<span class="
|
|
52
|
-
|
|
53
|
-
</
|
|
54
|
-
<
|
|
55
|
-
<span class="
|
|
56
|
-
|
|
57
|
-
</
|
|
58
|
-
<
|
|
59
|
-
<span class="
|
|
60
|
-
|
|
61
|
-
</
|
|
62
|
-
</
|
|
73
|
+
<ul>
|
|
74
|
+
<li>
|
|
75
|
+
<span class="subtle">2 minutes ago</span>
|
|
76
|
+
— User "john.doe" logged in
|
|
77
|
+
</li>
|
|
78
|
+
<li>
|
|
79
|
+
<span class="subtle">5 minutes ago</span>
|
|
80
|
+
— New user account created
|
|
81
|
+
</li>
|
|
82
|
+
<li>
|
|
83
|
+
<span class="subtle">12 minutes ago</span>
|
|
84
|
+
— System backup completed
|
|
85
|
+
</li>
|
|
86
|
+
<li>
|
|
87
|
+
<span class="subtle">1 hour ago</span>
|
|
88
|
+
— Database maintenance performed
|
|
89
|
+
</li>
|
|
90
|
+
<li>
|
|
91
|
+
<span class="subtle">2 hours ago</span>
|
|
92
|
+
— User "admin" updated settings
|
|
93
|
+
</li>
|
|
94
|
+
</ul>
|
|
63
95
|
</div>
|
|
64
96
|
|
|
65
97
|
<div class="admin-section">
|
|
66
98
|
<h3>Quick Actions</h3>
|
|
67
|
-
<div role
|
|
68
|
-
<button
|
|
69
|
-
<button
|
|
70
|
-
<button
|
|
71
|
-
<button
|
|
99
|
+
<div role="toolbar">
|
|
100
|
+
<button type="button">Create User</button>
|
|
101
|
+
<button type="button">System Backup</button>
|
|
102
|
+
<button type="button">View Logs</button>
|
|
103
|
+
<button type="button">Settings</button>
|
|
72
104
|
</div>
|
|
73
105
|
</div>
|
|
74
106
|
</div>
|
|
@@ -76,6 +108,69 @@
|
|
|
76
108
|
</template>
|
|
77
109
|
|
|
78
110
|
<script setup>
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
import { computed, ref } from 'vue'
|
|
112
|
+
|
|
113
|
+
const showBarLabels = ref(true)
|
|
114
|
+
const cardCompact = ref(false)
|
|
115
|
+
const cardAlign = ref('start')
|
|
116
|
+
const gridLayout = ref('3')
|
|
117
|
+
|
|
118
|
+
const showHideOptions = [
|
|
119
|
+
{ label: 'Shown', value: true },
|
|
120
|
+
{ label: 'Hidden', value: false },
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
const onOffOptions = [
|
|
124
|
+
{ label: 'On', value: true },
|
|
125
|
+
{ label: 'Off', value: false },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
const alignOptions = [
|
|
129
|
+
{ value: 'start', label: 'Start' },
|
|
130
|
+
{ value: 'center', label: 'Center' },
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
const gridLayoutOptions = [
|
|
134
|
+
{ value: '3', label: '3 columns' },
|
|
135
|
+
{ value: '2', label: '2 columns' },
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
const gridClass = computed(() => (
|
|
139
|
+
gridLayout.value === '2' ? 'grid-boxed-2' : 'grid-boxed'
|
|
140
|
+
))
|
|
141
|
+
|
|
142
|
+
const overviewCards = [
|
|
143
|
+
{
|
|
144
|
+
karma: 'good',
|
|
145
|
+
barLabel: 'Users',
|
|
146
|
+
title: 'Total Users',
|
|
147
|
+
stat: '1,247',
|
|
148
|
+
subtle: '+12% from last month',
|
|
149
|
+
to: { name: 'UserDetails' },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
karma: 'bad',
|
|
153
|
+
barLabel: 'Live',
|
|
154
|
+
title: 'Active Sessions',
|
|
155
|
+
stat: '89',
|
|
156
|
+
subtle: '-3% from last hour',
|
|
157
|
+
to: { name: 'TableExample' },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
karma: 'warning',
|
|
161
|
+
barLabel: 'Load',
|
|
162
|
+
title: 'System Load',
|
|
163
|
+
stat: '67%',
|
|
164
|
+
subtle: 'Moderate',
|
|
165
|
+
to: { name: 'StatusExample' },
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
karma: 'good',
|
|
169
|
+
barLabel: 'Disk',
|
|
170
|
+
title: 'Storage Used',
|
|
171
|
+
stat: '2.4 GB',
|
|
172
|
+
subtle: '45% of total',
|
|
173
|
+
to: { name: 'ViewItem', params: { id: '1' } },
|
|
174
|
+
},
|
|
175
|
+
]
|
|
81
176
|
</script>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Section
|
|
3
|
+
title="Loading area"
|
|
4
|
+
:icon="Loading03Icon"
|
|
5
|
+
subtitle="Placeholder for async regions — wrap content or use as a standalone status block."
|
|
6
|
+
>
|
|
7
|
+
<p>
|
|
8
|
+
Bind <code>:loading</code> around a default slot for the usual Vue pattern, or
|
|
9
|
+
render <code><LoadingArea /></code> on its own while data is pending.
|
|
10
|
+
The spinner uses Hugeicons <code>Loading03Icon</code> with a CSS rotation
|
|
11
|
+
(respects <code>prefers-reduced-motion</code>).
|
|
12
|
+
</p>
|
|
13
|
+
</Section>
|
|
14
|
+
|
|
15
|
+
<Section title="Wrap content" subtitle="Toggle loading to swap between placeholder and content">
|
|
16
|
+
<template #toolbar>
|
|
17
|
+
<button type="button" class="neutral" @click="contentLoading = !contentLoading">
|
|
18
|
+
{{ contentLoading ? 'Show content' : 'Show loading' }}
|
|
19
|
+
</button>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<LoadingArea :loading="contentLoading" message="Fetching table rows…" min-height="10rem">
|
|
23
|
+
<div class="demo-panel">
|
|
24
|
+
<p>Content is ready. This block is the default slot.</p>
|
|
25
|
+
<ul>
|
|
26
|
+
<li>Ada — London</li>
|
|
27
|
+
<li>Grace — New York</li>
|
|
28
|
+
<li>Katherine — Newport News</li>
|
|
29
|
+
</ul>
|
|
30
|
+
</div>
|
|
31
|
+
</LoadingArea>
|
|
32
|
+
</Section>
|
|
33
|
+
|
|
34
|
+
<Section title="Overlay mode" subtitle="Keep layout size while covering content">
|
|
35
|
+
<template #toolbar>
|
|
36
|
+
<button type="button" class="neutral" @click="runOverlayFetch">
|
|
37
|
+
{{ overlayLoading ? 'Loading…' : 'Simulate refresh' }}
|
|
38
|
+
</button>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<LoadingArea
|
|
42
|
+
:loading="overlayLoading"
|
|
43
|
+
overlay
|
|
44
|
+
message="Refreshing…"
|
|
45
|
+
min-height="8rem"
|
|
46
|
+
>
|
|
47
|
+
<div class="demo-panel">
|
|
48
|
+
<p>
|
|
49
|
+
With <code>overlay</code>, the default slot stays mounted so the area does
|
|
50
|
+
not collapse. Useful for refresh-in-place.
|
|
51
|
+
</p>
|
|
52
|
+
<p class="subtle">Last refreshed: {{ lastRefreshedLabel }}</p>
|
|
53
|
+
</div>
|
|
54
|
+
</LoadingArea>
|
|
55
|
+
</Section>
|
|
56
|
+
|
|
57
|
+
<Section title="Standalone placeholder" subtitle="No default slot — just the loading state">
|
|
58
|
+
<LoadingArea message="Connecting to server…" min-height="8rem" />
|
|
59
|
+
</Section>
|
|
60
|
+
|
|
61
|
+
<Section title="Custom placeholder slot" subtitle="Replace the spinner with your own markup">
|
|
62
|
+
<LoadingArea :loading="true" min-height="8rem">
|
|
63
|
+
<template #placeholder>
|
|
64
|
+
<p class="custom-placeholder">Preparing preview…</p>
|
|
65
|
+
</template>
|
|
66
|
+
</LoadingArea>
|
|
67
|
+
</Section>
|
|
68
|
+
|
|
69
|
+
<Section title="Idiomatic async example" subtitle="ref + async function with try/finally">
|
|
70
|
+
<template #toolbar>
|
|
71
|
+
<button type="button" class="good" :disabled="peopleLoading" @click="loadPeople">
|
|
72
|
+
{{ peopleLoading ? 'Loading…' : 'Load people' }}
|
|
73
|
+
</button>
|
|
74
|
+
<button type="button" class="neutral" :disabled="!people.length" @click="people = []">
|
|
75
|
+
Clear
|
|
76
|
+
</button>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<LoadingArea :loading="peopleLoading" message="Loading people…" min-height="10rem">
|
|
80
|
+
<div v-if="peopleError" class="demo-panel">
|
|
81
|
+
<p class="annotation bad">{{ peopleError }}</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div v-else-if="people.length" class="demo-panel">
|
|
84
|
+
<ul>
|
|
85
|
+
<li v-for="person in people" :key="person.name">
|
|
86
|
+
{{ person.name }} — {{ person.city }}
|
|
87
|
+
</li>
|
|
88
|
+
</ul>
|
|
89
|
+
</div>
|
|
90
|
+
<p v-else class="subtle">No people loaded yet. Click “Load people”.</p>
|
|
91
|
+
</LoadingArea>
|
|
92
|
+
</Section>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<script setup>
|
|
96
|
+
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
97
|
+
import { Loading03Icon } from '@hugeicons/core-free-icons'
|
|
98
|
+
import { getExampleTableRows } from '../data/examplePeople.js'
|
|
99
|
+
|
|
100
|
+
const contentLoading = ref(true)
|
|
101
|
+
const overlayLoading = ref(false)
|
|
102
|
+
const lastRefreshedAt = ref(Date.now())
|
|
103
|
+
const people = ref([])
|
|
104
|
+
const peopleLoading = ref(false)
|
|
105
|
+
const peopleError = ref('')
|
|
106
|
+
|
|
107
|
+
let overlayTimer = null
|
|
108
|
+
let peopleTimer = null
|
|
109
|
+
|
|
110
|
+
const lastRefreshedLabel = computed(() =>
|
|
111
|
+
new Date(lastRefreshedAt.value).toLocaleTimeString(),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
function runOverlayFetch() {
|
|
115
|
+
if (overlayLoading.value) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
overlayLoading.value = true
|
|
119
|
+
clearTimeout(overlayTimer)
|
|
120
|
+
overlayTimer = setTimeout(() => {
|
|
121
|
+
lastRefreshedAt.value = Date.now()
|
|
122
|
+
overlayLoading.value = false
|
|
123
|
+
overlayTimer = null
|
|
124
|
+
}, 1500)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function loadPeople() {
|
|
128
|
+
peopleLoading.value = true
|
|
129
|
+
peopleError.value = ''
|
|
130
|
+
try {
|
|
131
|
+
people.value = await fetchPeople()
|
|
132
|
+
} catch (error) {
|
|
133
|
+
peopleError.value = error?.message || 'Failed to load people.'
|
|
134
|
+
people.value = []
|
|
135
|
+
} finally {
|
|
136
|
+
peopleLoading.value = false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function fetchPeople() {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
clearTimeout(peopleTimer)
|
|
143
|
+
peopleTimer = setTimeout(() => {
|
|
144
|
+
peopleTimer = null
|
|
145
|
+
if (Math.random() < 0.15) {
|
|
146
|
+
reject(new Error('Simulated network failure. Try again.'))
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
resolve(getExampleTableRows().slice(0, 5))
|
|
150
|
+
}, 1200)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onBeforeUnmount(() => {
|
|
155
|
+
clearTimeout(overlayTimer)
|
|
156
|
+
clearTimeout(peopleTimer)
|
|
157
|
+
})
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<style scoped>
|
|
161
|
+
.demo-panel {
|
|
162
|
+
padding: 1rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.demo-panel ul {
|
|
166
|
+
margin: 0.5rem 0 0;
|
|
167
|
+
padding-left: 1.25rem;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.custom-placeholder {
|
|
171
|
+
margin: 0;
|
|
172
|
+
font-style: italic;
|
|
173
|
+
color: var(--muted-text-color);
|
|
174
|
+
}
|
|
175
|
+
</style>
|