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
|
@@ -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: {
|
|
@@ -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>
|
|
@@ -2,9 +2,53 @@
|
|
|
2
2
|
<Section
|
|
3
3
|
:title="tableSectionTitle"
|
|
4
4
|
:icon="TableIcon"
|
|
5
|
-
subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter."
|
|
5
|
+
subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter. Enable “Row context menu” to right-click rows."
|
|
6
6
|
:padding="false"
|
|
7
7
|
>
|
|
8
|
+
<template v-if="multiSelectEnabled" #toolbar>
|
|
9
|
+
<span v-if="selectedKeys.length" class="subtle selection-count">
|
|
10
|
+
{{ selectedKeys.length }} selected
|
|
11
|
+
</span>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="inline-icon neutral"
|
|
15
|
+
:disabled="!selectedKeys.length"
|
|
16
|
+
@click="bulkExportSelected"
|
|
17
|
+
>
|
|
18
|
+
<HugeiconsIcon
|
|
19
|
+
:icon="Download01Icon"
|
|
20
|
+
width="1em"
|
|
21
|
+
height="1em"
|
|
22
|
+
:strokeWidth="iconStrokeWidth"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
/>
|
|
25
|
+
<span>Export</span>
|
|
26
|
+
</button>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="inline-icon bad"
|
|
30
|
+
:disabled="!selectedKeys.length"
|
|
31
|
+
@click="bulkDeleteSelected"
|
|
32
|
+
>
|
|
33
|
+
<HugeiconsIcon
|
|
34
|
+
:icon="Delete02Icon"
|
|
35
|
+
width="1em"
|
|
36
|
+
height="1em"
|
|
37
|
+
:strokeWidth="iconStrokeWidth"
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
/>
|
|
40
|
+
<span>Delete</span>
|
|
41
|
+
</button>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
class="neutral"
|
|
45
|
+
:disabled="!selectedKeys.length"
|
|
46
|
+
@click="clearSelection"
|
|
47
|
+
>
|
|
48
|
+
Clear selection
|
|
49
|
+
</button>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
8
52
|
<Table
|
|
9
53
|
table-id="table-example"
|
|
10
54
|
row-key="name"
|
|
@@ -14,6 +58,9 @@
|
|
|
14
58
|
:filterable="enabledFeatures.includes('filters')"
|
|
15
59
|
:column-options="enabledFeatures.includes('columnOptions')"
|
|
16
60
|
:loading="enabledFeatures.includes('loading')"
|
|
61
|
+
:selectable="multiSelectEnabled"
|
|
62
|
+
v-model:selected-keys="selectedKeys"
|
|
63
|
+
:row-context-menu="rowContextMenuEnabled ? rowContextMenuItems : null"
|
|
17
64
|
:horizontal-scroll="layout === 'scroll'"
|
|
18
65
|
:responsive-columns="layout === 'priorities'"
|
|
19
66
|
:sticky-cols="stickyCols"
|
|
@@ -127,6 +174,25 @@
|
|
|
127
174
|
<p v-else-if="enabledFeatures.includes('columnOptions')" class="event-line subtle">
|
|
128
175
|
Layout: using developer defaults (change columns, order, or filters to see a preset or “Custom” label).
|
|
129
176
|
</p>
|
|
177
|
+
|
|
178
|
+
<p v-if="multiSelectEnabled && selectedKeys.length" class="event-line">
|
|
179
|
+
<strong>Selection:</strong>
|
|
180
|
+
{{ selectedKeys.join(', ') }}
|
|
181
|
+
</p>
|
|
182
|
+
<p v-else-if="multiSelectEnabled" class="event-line subtle">
|
|
183
|
+
Selection: enable “Multi-select”, then click rows or checkboxes. Use the toolbar Export / Delete actions on the selected set.
|
|
184
|
+
</p>
|
|
185
|
+
|
|
186
|
+
<p v-if="lastContextMenuAction" class="event-line">
|
|
187
|
+
<strong>Context menu:</strong>
|
|
188
|
+
{{ lastContextMenuAction }}
|
|
189
|
+
</p>
|
|
190
|
+
<p v-else-if="rowContextMenuEnabled" class="event-line subtle">
|
|
191
|
+
Context menu: right-click a row. With multi-select, right-click inside the selection to act on all selected rows.
|
|
192
|
+
</p>
|
|
193
|
+
<p v-else-if="lastBulkAction" class="event-line subtle">
|
|
194
|
+
{{ lastBulkAction }}
|
|
195
|
+
</p>
|
|
130
196
|
</div>
|
|
131
197
|
</Section>
|
|
132
198
|
|
|
@@ -165,11 +231,13 @@
|
|
|
165
231
|
|
|
166
232
|
<script setup>
|
|
167
233
|
import { ref, computed, onMounted } from 'vue'
|
|
168
|
-
import {
|
|
234
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
235
|
+
import { TableIcon, Settings01Icon, Download01Icon, Delete02Icon, ViewIcon } from '@hugeicons/core-free-icons'
|
|
169
236
|
import { getExampleTableRows } from '../data/examplePeople.js'
|
|
170
237
|
import { countActiveFilters } from '../composables/tableFilters.js'
|
|
171
238
|
|
|
172
|
-
const
|
|
239
|
+
const iconStrokeWidth = 2.5
|
|
240
|
+
const defaultFeatures = ['pagination', 'filters', 'columnOptions', 'rowClick', 'rowContextMenu']
|
|
173
241
|
|
|
174
242
|
const defaultColumnVisibility = {
|
|
175
243
|
birthYear: false,
|
|
@@ -184,9 +252,15 @@ const dataConnected = ref(true)
|
|
|
184
252
|
const data = ref([])
|
|
185
253
|
const lastRowClick = ref(null)
|
|
186
254
|
const lastQuery = ref(null)
|
|
255
|
+
const lastBulkAction = ref(null)
|
|
256
|
+
const lastContextMenuAction = ref(null)
|
|
257
|
+
const selectedKeys = ref([])
|
|
187
258
|
const tableLayoutLabel = ref('')
|
|
188
259
|
const rowDialogRef = ref(null)
|
|
189
260
|
|
|
261
|
+
const multiSelectEnabled = computed(() => enabledFeatures.value.includes('multiSelect'))
|
|
262
|
+
const rowContextMenuEnabled = computed(() => enabledFeatures.value.includes('rowContextMenu'))
|
|
263
|
+
|
|
190
264
|
const tableSectionTitle = computed(() => {
|
|
191
265
|
if (tableLayoutLabel.value) {
|
|
192
266
|
return `Table Example (${tableLayoutLabel.value})`
|
|
@@ -204,6 +278,8 @@ const featureOptions = [
|
|
|
204
278
|
{ value: 'filters', label: 'Column filters' },
|
|
205
279
|
{ value: 'columnOptions', label: 'Column options' },
|
|
206
280
|
{ value: 'rowClick', label: 'Row click' },
|
|
281
|
+
{ value: 'multiSelect', label: 'Multi-select' },
|
|
282
|
+
{ value: 'rowContextMenu', label: 'Row context menu' },
|
|
207
283
|
{ value: 'loading', label: 'Loading state' },
|
|
208
284
|
]
|
|
209
285
|
|
|
@@ -285,12 +361,56 @@ const tableListeners = computed(() => {
|
|
|
285
361
|
const listeners = {
|
|
286
362
|
onQueryChange: handleQueryChange,
|
|
287
363
|
}
|
|
288
|
-
|
|
364
|
+
// Row click opens the detail dialog; multi-select uses row clicks for selection instead.
|
|
365
|
+
if (enabledFeatures.value.includes('rowClick') && !multiSelectEnabled.value) {
|
|
289
366
|
listeners.onRowClick = handleRowClick
|
|
290
367
|
}
|
|
291
368
|
return listeners
|
|
292
369
|
})
|
|
293
370
|
|
|
371
|
+
const rowContextMenuItems = computed(() => [
|
|
372
|
+
{
|
|
373
|
+
id: 'view',
|
|
374
|
+
label: 'View details',
|
|
375
|
+
icon: ViewIcon,
|
|
376
|
+
action: ({ row, rows }) => {
|
|
377
|
+
const target = rows[0] ?? row
|
|
378
|
+
if (!target) {
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
lastContextMenuAction.value = `View “${target.name}”`
|
|
382
|
+
lastRowClick.value = {
|
|
383
|
+
row: target,
|
|
384
|
+
index: tableData.value.findIndex((item) => item.name === target.name),
|
|
385
|
+
}
|
|
386
|
+
rowDialogRef.value?.showModal()
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: 'export',
|
|
391
|
+
label: 'Export',
|
|
392
|
+
icon: Download01Icon,
|
|
393
|
+
action: ({ keys }) => {
|
|
394
|
+
lastContextMenuAction.value = `Exported ${keys.length} row(s): ${keys.join(', ')}`
|
|
395
|
+
lastBulkAction.value = lastContextMenuAction.value
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{ divider: true },
|
|
399
|
+
{
|
|
400
|
+
id: 'delete',
|
|
401
|
+
label: 'Delete',
|
|
402
|
+
icon: Delete02Icon,
|
|
403
|
+
danger: true,
|
|
404
|
+
action: ({ keys }) => {
|
|
405
|
+
const removeSet = new Set(keys)
|
|
406
|
+
data.value = data.value.filter((row) => !removeSet.has(row.name))
|
|
407
|
+
selectedKeys.value = selectedKeys.value.filter((key) => !removeSet.has(key))
|
|
408
|
+
lastContextMenuAction.value = `Deleted ${keys.length} row(s): ${keys.join(', ')}`
|
|
409
|
+
lastBulkAction.value = lastContextMenuAction.value
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
])
|
|
413
|
+
|
|
294
414
|
function handleRowClick(payload) {
|
|
295
415
|
lastRowClick.value = payload
|
|
296
416
|
rowDialogRef.value?.showModal()
|
|
@@ -306,6 +426,29 @@ function handleQueryChange(query) {
|
|
|
306
426
|
lastQuery.value = query
|
|
307
427
|
}
|
|
308
428
|
|
|
429
|
+
function clearSelection() {
|
|
430
|
+
selectedKeys.value = []
|
|
431
|
+
lastBulkAction.value = 'Selection cleared.'
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function bulkExportSelected() {
|
|
435
|
+
if (!selectedKeys.value.length) {
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
lastBulkAction.value = `Exported ${selectedKeys.value.length} row(s): ${selectedKeys.value.join(', ')}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function bulkDeleteSelected() {
|
|
442
|
+
if (!selectedKeys.value.length) {
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const removed = [...selectedKeys.value]
|
|
446
|
+
const removeSet = new Set(removed)
|
|
447
|
+
data.value = data.value.filter((row) => !removeSet.has(row.name))
|
|
448
|
+
selectedKeys.value = []
|
|
449
|
+
lastBulkAction.value = `Deleted ${removed.length} row(s): ${removed.join(', ')}`
|
|
450
|
+
}
|
|
451
|
+
|
|
309
452
|
function statusTagClass(value) {
|
|
310
453
|
if (value === 'Active') {
|
|
311
454
|
return 'good'
|
|
@@ -323,7 +466,11 @@ function resetControls() {
|
|
|
323
466
|
stickyColsOption.value = '2'
|
|
324
467
|
wideTable.value = true
|
|
325
468
|
dataConnected.value = true
|
|
469
|
+
data.value = getExampleTableRows()
|
|
326
470
|
lastRowClick.value = null
|
|
471
|
+
lastBulkAction.value = null
|
|
472
|
+
lastContextMenuAction.value = null
|
|
473
|
+
selectedKeys.value = []
|
|
327
474
|
if (rowDialogRef.value?.open) {
|
|
328
475
|
rowDialogRef.value.close()
|
|
329
476
|
}
|
|
@@ -347,6 +494,11 @@ onMounted(() => {
|
|
|
347
494
|
margin: 0.35rem 0;
|
|
348
495
|
}
|
|
349
496
|
|
|
497
|
+
.selection-count {
|
|
498
|
+
align-self: center;
|
|
499
|
+
margin-right: 0.5rem;
|
|
500
|
+
}
|
|
501
|
+
|
|
350
502
|
.row-detail-dialog .dialog-panel h3 {
|
|
351
503
|
margin: 0 0 0.5rem;
|
|
352
504
|
}
|