picocrank 1.22.2 → 1.23.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.
@@ -1,10 +1,24 @@
1
1
  <template>
2
- <table class = "row-hover">
2
+ <table class="row-hover" :class="{ loading: isLoading }">
3
3
  <thead>
4
- <th v-for="(header, index) in visibleHeaders" :key="index" @click="toggleSort(header)" :class="{ sortable: header.sortable }" :style="{ width: header.width || 'auto' }">
5
- {{ header.label || header.key }}
4
+ <th
5
+ v-for="(header, index) in visibleHeaders"
6
+ :key="header.key || index"
7
+ :class="headerClasses(header)"
8
+ :style="{ width: header.width || 'auto' }"
9
+ @click="onHeaderClick(header, $event)"
10
+ @contextmenu.prevent="openFilter(header, $event)"
11
+ @pointerdown="onHeaderPointerDown(header, $event)"
12
+ @pointermove="longPress.onPointerMove"
13
+ @pointerup="longPress.onPointerUp"
14
+ @pointercancel="longPress.onPointerCancel"
15
+ @pointerleave="longPress.onPointerLeave"
16
+ >
17
+ <span class="header-label">{{ header.label || header.key }}</span>
6
18
 
7
- <span v-if="header.sortable" style:= "width: 1.5em; display: inline-block; text-align: center;">
19
+ <span v-if="filterable && isHeaderFiltered(header)" class="filter-indicator" title="Filtered" aria-hidden="true">⧩</span>
20
+
21
+ <span v-if="header.sortable" class="sort-indicator">
8
22
  <span v-if="sortBy === header.key">
9
23
  <span v-if="sortDir === 'asc'">▲</span>
10
24
  <span v-else-if="sortDir === 'desc'">▼</span>
@@ -13,19 +27,21 @@
13
27
  </th>
14
28
  </thead>
15
29
  <tbody>
16
- <tr v-if="pagedItems.length === 0">
17
- <td :colspan="visibleHeaders.length">No items found</td>
30
+ <tr v-if="displayRows.length === 0">
31
+ <td :colspan="visibleHeaders.length">
32
+ <span v-if="isLoading">Loading…</span>
33
+ <span v-else>No items found</span>
34
+ </td>
18
35
  </tr>
19
- <tr v-else v-for="(row, index) in pagedItems" :key="index">
20
- <td v-for="(header, index) in visibleHeaders" :key="index" :class="{ hidden: header.hidden }">
21
- <component
22
- v-if="slotFor(header.key)"
23
- :is="slotFor(header.key)"
36
+ <tr v-else v-for="(row, rowIndex) in displayRows" :key="rowIndex">
37
+ <td v-for="(header, cellIndex) in visibleHeaders" :key="header.key || cellIndex" :class="{ hidden: header.hidden }">
38
+ <component
39
+ v-if="slotFor(header.key)"
40
+ :is="slotFor(header.key)"
24
41
  :class="{ hidden: header.hidden }"
25
- :row="row"
42
+ :row="row"
26
43
  :value="row[header.key]"
27
- :key="index"
28
- />
44
+ />
29
45
  <span v-else>
30
46
  {{ row[header.key] }}
31
47
  </span>
@@ -33,100 +49,399 @@
33
49
  </tr>
34
50
  </tbody>
35
51
  </table>
36
- <div v-if="showPagination" class = "padding">
37
- <Pagination :total = "total" v-model:page="page" v-model:page-size="pageSize" />
52
+ <div v-if="showPagination" class="padding">
53
+ <Pagination :total="totalCount" v-model:page="page" v-model:page-size="pageSize" />
38
54
  </div>
55
+
56
+ <TableColumnFilterPopover
57
+ v-if="filterable"
58
+ v-model:open="filterPopoverOpen"
59
+ :header="activeFilterHeader"
60
+ :anchor-el="activeFilterAnchor"
61
+ :value="activeFilterValue"
62
+ :rows="props.data"
63
+ :select-options="activeSelectOptions"
64
+ @apply="onFilterApply"
65
+ @clear="onFilterClear"
66
+ @cancel="onFilterCancel"
67
+ />
39
68
  </template>
40
69
 
41
70
  <script setup>
42
- import { ref, computed, onMounted, watch, useSlots } from 'vue';
43
- import Pagination from './Pagination.vue';
44
-
45
- const sortBy = ref(null);
46
- const sortDir = ref('asc');
47
- const page = ref(1);
48
- const pageSize = ref(10);
49
-
50
- const props = defineProps({
51
- headers: {
52
- type: Array,
53
- default: () => ['id'],
54
- },
55
- data: {
56
- type: Array,
57
- default: () => [],
58
- },
59
- showPagination: {
60
- type: Boolean,
61
- default: true,
62
- },
63
- });
64
-
65
- const slots = useSlots()
66
-
67
- function slotFor(key) {
68
- let s = slots[`cell-${key}`];
69
-
70
- return s || slots.cell || null;
71
- }
72
-
73
- const items = computed(() => {
74
- const arr = [...props.data];
75
-
76
- return arr;
77
- })
78
-
79
- const sortedItems = computed(() => {
80
- if (!sortBy.value) return [...items.value];
81
-
82
- const col = sortBy.value;
83
-
84
- return [...items.value].sort((a, b) => {
85
- const av = a[col];
86
- const bv = b[col];
87
-
88
- if (av === bv) return 0;
89
- if (av === null || av === undefined) return 1;
90
- if (bv === null || bv === undefined) return -1;
91
- if (typeof av === 'string' && typeof bv === 'string') {
92
- return sortDir.value === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
93
- }
94
- if (typeof av === 'number' && typeof bv === 'number') {
95
- return sortDir.value === 'asc' ? av - bv : bv - av;
96
- }
97
- if (typeof av === 'boolean' && typeof bv === 'boolean') {
98
- return sortDir.value === 'asc' ? (av ? 1 : 0) - (bv ? 1 : 0) : (bv ? 1 : 0) - (av ? 1 : 0);
99
- }
100
- if (av < bv) return sortDir.value === 'asc' ? -1 : 1;
101
- });
102
- });
103
-
104
- watch([sortedItems, pageSize], () => { page.value = 1 });
105
-
106
- const pagedItems = computed(() => {
107
- const start = (page.value - 1) * pageSize.value;
108
- const ret = sortedItems.value.slice(start, start + pageSize.value);
109
-
110
- return ret
111
- });
112
-
113
- const total = computed(() => sortedItems.value.length);
114
- const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
115
-
116
- const visibleHeaders = computed(() => {
117
- return props.headers.filter(h => !h.hidden);
118
- });
119
-
120
- function toggleSort(header) {
121
- if (!header.sortable) return;
122
-
123
- if (sortBy.value === header.key) {
124
- sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
125
- } else {
126
- sortBy.value = header.key;
127
- sortDir.value = 'asc';
71
+ import { ref, computed, watch, onMounted, onBeforeUnmount, useSlots } from 'vue'
72
+ import Pagination from './Pagination.vue'
73
+ import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
74
+ import { useLongPress } from '../composables/useLongPress.js'
75
+ import {
76
+ applyFilters,
77
+ buildSelectOptions,
78
+ cloneFilters,
79
+ isFilterActive,
80
+ sortRows,
81
+ } from '../composables/tableFilters.js'
82
+
83
+ const sortBy = ref(null)
84
+ const sortDir = ref('asc')
85
+ const page = ref(1)
86
+ const pageSize = ref(10)
87
+
88
+ const props = defineProps({
89
+ headers: {
90
+ type: Array,
91
+ default: () => ['id'],
92
+ },
93
+ data: {
94
+ type: Array,
95
+ default: () => [],
96
+ },
97
+ showPagination: {
98
+ type: Boolean,
99
+ default: true,
100
+ },
101
+ filterable: {
102
+ type: Boolean,
103
+ default: true,
104
+ },
105
+ filters: {
106
+ type: Object,
107
+ default: undefined,
108
+ },
109
+ fetchRows: {
110
+ type: Function,
111
+ default: null,
112
+ },
113
+ loading: {
114
+ type: Boolean,
115
+ default: false,
116
+ },
117
+ remoteDebounceMs: {
118
+ type: Number,
119
+ default: 300,
120
+ },
121
+ })
122
+
123
+ const emit = defineEmits([
124
+ 'update:filters',
125
+ 'filter-change',
126
+ 'query-change',
127
+ 'fetch-error',
128
+ ])
129
+
130
+ const slots = useSlots()
131
+ const internalFilters = ref({})
132
+ const remoteRows = ref([])
133
+ const remoteTotal = ref(0)
134
+ const internalLoading = ref(false)
135
+ const fetchRequestId = ref(0)
136
+ const fetchAbortController = ref(null)
137
+ const fetchDebounceTimer = ref(null)
138
+
139
+ const filterPopoverOpen = ref(false)
140
+ const activeFilterHeader = ref(null)
141
+ const activeFilterAnchor = ref(null)
142
+ const activeFilterValue = ref(null)
143
+
144
+ const isRemote = computed(() => typeof props.fetchRows === 'function')
145
+
146
+ const activeFilters = computed(() => {
147
+ if (!props.filterable) {
148
+ return {}
149
+ }
150
+ return props.filters !== undefined ? props.filters : internalFilters.value
151
+ })
152
+
153
+ const isLoading = computed(() => props.loading || internalLoading.value)
154
+
155
+ const visibleHeaders = computed(() => props.headers.filter((header) => !header.hidden))
156
+
157
+ const filteredItems = computed(() => {
158
+ if (isRemote.value) {
159
+ return remoteRows.value
160
+ }
161
+ return applyFilters(props.data, props.headers, activeFilters.value)
162
+ })
163
+
164
+ const sortedItems = computed(() => {
165
+ if (isRemote.value) {
166
+ return filteredItems.value
167
+ }
168
+ return sortRows(filteredItems.value, sortBy.value, sortDir.value)
169
+ })
170
+
171
+ const displayRows = computed(() => {
172
+ if (isRemote.value || !props.showPagination) {
173
+ return sortedItems.value
174
+ }
175
+
176
+ const start = (page.value - 1) * pageSize.value
177
+ return sortedItems.value.slice(start, start + pageSize.value)
178
+ })
179
+
180
+ const totalCount = computed(() => {
181
+ if (isRemote.value) {
182
+ return remoteTotal.value
183
+ }
184
+ return sortedItems.value.length
185
+ })
186
+
187
+ const activeSelectOptions = computed(() => {
188
+ if (!activeFilterHeader.value) {
189
+ return []
190
+ }
191
+ return buildSelectOptions(props.data, activeFilterHeader.value)
192
+ })
193
+
194
+ const queryParams = computed(() => ({
195
+ page: page.value,
196
+ pageSize: pageSize.value,
197
+ sortBy: sortBy.value,
198
+ sortDir: sortDir.value,
199
+ filters: cloneFilters(activeFilters.value),
200
+ }))
201
+
202
+ function slotFor(key) {
203
+ const named = slots[`cell-${key}`]
204
+ return named || slots.cell || null
205
+ }
206
+
207
+ function isHeaderFilterable(header) {
208
+ return props.filterable && header.filterable !== false
209
+ }
210
+
211
+ function isHeaderFiltered(header) {
212
+ return isFilterActive(activeFilters.value?.[header.key])
213
+ }
214
+
215
+ function headerClasses(header) {
216
+ return {
217
+ sortable: header.sortable,
218
+ filterable: isHeaderFilterable(header),
219
+ filtered: isHeaderFiltered(header),
220
+ }
221
+ }
222
+
223
+ function setFilters(nextFilters) {
224
+ const cloned = cloneFilters(nextFilters)
225
+ if (props.filters === undefined) {
226
+ internalFilters.value = cloned
227
+ }
228
+ emit('update:filters', cloned)
229
+ emit('filter-change', cloned)
230
+ }
231
+
232
+ function emitQueryChange() {
233
+ emit('query-change', queryParams.value)
234
+ }
235
+
236
+ function toggleSort(header) {
237
+ if (!header.sortable) {
238
+ return
239
+ }
240
+
241
+ if (sortBy.value === header.key) {
242
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
243
+ } else {
244
+ sortBy.value = header.key
245
+ sortDir.value = 'asc'
246
+ }
247
+ }
248
+
249
+ function onHeaderClick(header, event) {
250
+ if (longPress.suppressClick.value) {
251
+ longPress.onClick(event)
252
+ return
253
+ }
254
+ toggleSort(header)
255
+ }
256
+
257
+ function openFilter(header, event) {
258
+ if (!isHeaderFilterable(header)) {
259
+ return
260
+ }
261
+
262
+ activeFilterHeader.value = header
263
+ activeFilterAnchor.value = event.currentTarget
264
+ activeFilterValue.value = activeFilters.value?.[header.key] ?? null
265
+ filterPopoverOpen.value = true
266
+ }
267
+
268
+ const longPress = useLongPress((event) => {
269
+ const headerCell = event.currentTarget
270
+ const headerKey = headerCell?.dataset?.headerKey
271
+ const header = visibleHeaders.value.find((item) => item.key === headerKey)
272
+ if (header) {
273
+ openFilter(header, event)
274
+ }
275
+ })
276
+
277
+ function onHeaderPointerDown(header, event) {
278
+ if (!props.filterable) {
279
+ return
280
+ }
281
+ event.currentTarget.dataset.headerKey = header.key
282
+ longPress.onPointerDown(event)
283
+ }
284
+
285
+ function onFilterApply(filter) {
286
+ if (!activeFilterHeader.value) {
287
+ return
288
+ }
289
+
290
+ const nextFilters = cloneFilters(activeFilters.value)
291
+ if (filter) {
292
+ nextFilters[activeFilterHeader.value.key] = filter
293
+ } else {
294
+ delete nextFilters[activeFilterHeader.value.key]
295
+ }
296
+
297
+ setFilters(nextFilters)
298
+ page.value = 1
299
+ emitQueryChange()
300
+ }
301
+
302
+ function onFilterClear() {
303
+ if (!activeFilterHeader.value) {
304
+ return
305
+ }
306
+
307
+ const nextFilters = cloneFilters(activeFilters.value)
308
+ delete nextFilters[activeFilterHeader.value.key]
309
+ setFilters(nextFilters)
310
+ page.value = 1
311
+ emitQueryChange()
312
+ }
313
+
314
+ function onFilterCancel() {
315
+ activeFilterHeader.value = null
316
+ activeFilterAnchor.value = null
317
+ activeFilterValue.value = null
318
+ }
319
+
320
+ async function runFetchRows() {
321
+ if (!isRemote.value) {
322
+ return
323
+ }
324
+
325
+ if (fetchAbortController.value) {
326
+ fetchAbortController.value.abort()
327
+ }
328
+
329
+ const controller = new AbortController()
330
+ fetchAbortController.value = controller
331
+ const requestId = fetchRequestId.value + 1
332
+ fetchRequestId.value = requestId
333
+ internalLoading.value = true
334
+
335
+ try {
336
+ const result = await props.fetchRows(queryParams.value, { signal: controller.signal })
337
+ if (requestId !== fetchRequestId.value) {
338
+ return
339
+ }
340
+
341
+ remoteRows.value = Array.isArray(result?.rows) ? result.rows : []
342
+ remoteTotal.value = Number.isFinite(result?.total) ? result.total : remoteRows.value.length
343
+ } catch (error) {
344
+ if (error?.name === 'AbortError') {
345
+ return
346
+ }
347
+ emit('fetch-error', error)
348
+ if (requestId === fetchRequestId.value) {
349
+ remoteRows.value = []
350
+ remoteTotal.value = 0
351
+ }
352
+ } finally {
353
+ if (requestId === fetchRequestId.value) {
354
+ internalLoading.value = false
355
+ }
356
+ }
357
+ }
358
+
359
+ function scheduleFetchRows() {
360
+ if (!isRemote.value) {
361
+ return
362
+ }
363
+
364
+ if (fetchDebounceTimer.value !== null) {
365
+ clearTimeout(fetchDebounceTimer.value)
366
+ }
367
+
368
+ fetchDebounceTimer.value = window.setTimeout(() => {
369
+ fetchDebounceTimer.value = null
370
+ runFetchRows()
371
+ }, props.remoteDebounceMs)
372
+ }
373
+
374
+ watch(
375
+ () => [pageSize.value, sortBy.value, sortDir.value],
376
+ () => {
377
+ page.value = 1
378
+ if (isRemote.value) {
379
+ scheduleFetchRows()
380
+ }
381
+ emitQueryChange()
382
+ },
383
+ )
384
+
385
+ watch(page, () => {
386
+ if (isRemote.value) {
387
+ scheduleFetchRows()
388
+ }
389
+ emitQueryChange()
390
+ })
391
+
392
+ watch(
393
+ activeFilters,
394
+ () => {
395
+ page.value = 1
396
+ if (isRemote.value) {
397
+ scheduleFetchRows()
398
+ }
399
+ emitQueryChange()
400
+ },
401
+ { deep: true },
402
+ )
403
+
404
+ watch(
405
+ () => props.filters,
406
+ (newFilters) => {
407
+ if (newFilters !== undefined) {
408
+ internalFilters.value = cloneFilters(newFilters)
128
409
  }
410
+ },
411
+ { deep: true },
412
+ )
413
+
414
+ watch(
415
+ () => props.filterable,
416
+ (enabled) => {
417
+ if (!enabled) {
418
+ filterPopoverOpen.value = false
419
+ onFilterCancel()
420
+ }
421
+ },
422
+ )
423
+
424
+ watch(sortedItems, () => {
425
+ if (!isRemote.value) {
426
+ page.value = 1
427
+ }
428
+ })
429
+
430
+ onMounted(() => {
431
+ emitQueryChange()
432
+ if (isRemote.value) {
433
+ runFetchRows()
434
+ }
435
+ })
436
+
437
+ onBeforeUnmount(() => {
438
+ if (fetchAbortController.value) {
439
+ fetchAbortController.value.abort()
440
+ }
441
+ if (fetchDebounceTimer.value !== null) {
442
+ clearTimeout(fetchDebounceTimer.value)
129
443
  }
444
+ })
130
445
  </script>
131
446
 
132
447
  <style scoped>
@@ -135,8 +450,37 @@ table thead th.sortable:hover {
135
450
  color: #0366d6;
136
451
  }
137
452
 
138
- td:first-child, th:first-child {
139
- padding-left: 1rem;
453
+ table thead th.filterable {
454
+ cursor: context-menu;
455
+ }
456
+
457
+ table thead th.filtered {
458
+ color: #0366d6;
459
+ }
460
+
461
+ table.loading tbody {
462
+ opacity: 0.65;
463
+ }
464
+
465
+ .header-label {
466
+ margin-right: 0.25rem;
467
+ }
468
+
469
+ .filter-indicator {
470
+ display: inline-block;
471
+ margin-right: 0.25rem;
472
+ font-size: 0.85em;
473
+ opacity: 0.85;
140
474
  }
141
475
 
476
+ .sort-indicator {
477
+ width: 1.5em;
478
+ display: inline-block;
479
+ text-align: center;
480
+ }
481
+
482
+ td:first-child,
483
+ th:first-child {
484
+ padding-left: 1rem;
485
+ }
142
486
  </style>