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.
- package/AGENTS.md +11 -0
- package/docs/buttons-with-icons.md +97 -0
- package/index.html +1 -1
- package/package.json +2 -2
- package/router.js +26 -17
- package/vite.config.js +1 -1
- package/vue/components/Table.vue +448 -104
- package/vue/components/TableColumnFilterPopover.vue +421 -0
- package/vue/composables/tableFilters.js +251 -0
- package/vue/composables/useLongPress.js +76 -0
- package/vue/{views → examples}/App.vue +1 -0
- package/vue/{views → examples}/ButtonsExample.vue +54 -1
- package/vue/examples/TableExample.vue +46 -0
- package/vue/examples/TableRemoteExample.vue +79 -0
- package/vue/{views → examples}/Welcome.vue +5 -2
- package/vue/views/TableExample.vue +0 -46
- /package/vue/{views → examples}/Admin.vue +0 -0
- /package/vue/{views → examples}/CalendarExample.vue +0 -0
- /package/vue/{views → examples}/DialogExample.vue +0 -0
- /package/vue/{views → examples}/FormExample.vue +0 -0
- /package/vue/{views → examples}/Login.vue +0 -0
- /package/vue/{views → examples}/LoginExample.vue +0 -0
- /package/vue/{views → examples}/NavigationGridExample.vue +0 -0
- /package/vue/{views → examples}/PageStructure.vue +0 -0
- /package/vue/{views → examples}/ReadOnlyTextAreaExample.vue +0 -0
- /package/vue/{views → examples}/StatusExample.vue +0 -0
- /package/vue/{views → examples}/TabsExample.vue +0 -0
- /package/vue/{views → examples}/ThemePreview.vue +0 -0
- /package/vue/{views → examples}/UserDetails.vue +0 -0
- /package/vue/{views → examples}/ViewItem.vue +0 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div
|
|
4
|
+
v-if="open"
|
|
5
|
+
ref="popoverRef"
|
|
6
|
+
class="table-column-filter-popover"
|
|
7
|
+
role="dialog"
|
|
8
|
+
:aria-labelledby="titleId"
|
|
9
|
+
:style="popoverStyle"
|
|
10
|
+
@click.stop
|
|
11
|
+
>
|
|
12
|
+
<div class="popover-body">
|
|
13
|
+
<h4 :id="titleId" ref="titleRef" class="popover-title">
|
|
14
|
+
{{ popoverTitle }}
|
|
15
|
+
</h4>
|
|
16
|
+
|
|
17
|
+
<div v-if="filterType === 'text'" class="filter-fields">
|
|
18
|
+
<label>
|
|
19
|
+
<span class="field-label">Match</span>
|
|
20
|
+
<select v-model="draft.operator">
|
|
21
|
+
<option value="contains">Contains</option>
|
|
22
|
+
<option value="equals">Equals</option>
|
|
23
|
+
<option value="startsWith">Starts with</option>
|
|
24
|
+
<option value="empty">Is empty</option>
|
|
25
|
+
</select>
|
|
26
|
+
</label>
|
|
27
|
+
<label v-if="draft.operator !== 'empty'">
|
|
28
|
+
<span class="field-label">Value</span>
|
|
29
|
+
<input v-model="draft.value" type="text" @keydown.enter.prevent="apply" />
|
|
30
|
+
</label>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div v-else-if="filterType === 'number'" class="filter-fields">
|
|
34
|
+
<label>
|
|
35
|
+
<span class="field-label">Operator</span>
|
|
36
|
+
<select v-model="draft.operator">
|
|
37
|
+
<option value="eq">Equals</option>
|
|
38
|
+
<option value="gt">Greater than</option>
|
|
39
|
+
<option value="gte">Greater or equal</option>
|
|
40
|
+
<option value="lt">Less than</option>
|
|
41
|
+
<option value="lte">Less or equal</option>
|
|
42
|
+
</select>
|
|
43
|
+
</label>
|
|
44
|
+
<label>
|
|
45
|
+
<span class="field-label">Value</span>
|
|
46
|
+
<input v-model="draft.value" type="number" @keydown.enter.prevent="apply" />
|
|
47
|
+
</label>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div v-else-if="filterType === 'boolean'" class="filter-fields">
|
|
51
|
+
<RadioGroup
|
|
52
|
+
v-model="booleanValue"
|
|
53
|
+
:options="booleanOptions"
|
|
54
|
+
variant="list"
|
|
55
|
+
:aria-label="`Filter ${header?.label || header?.key} by boolean value`"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div v-else-if="filterType === 'select'" class="filter-fields filter-fields-select">
|
|
60
|
+
<div
|
|
61
|
+
ref="checkGroupScrollRef"
|
|
62
|
+
class="checkgroup-scroll"
|
|
63
|
+
:style="checkGroupScrollStyle"
|
|
64
|
+
>
|
|
65
|
+
<CheckGroup
|
|
66
|
+
v-model="draft.values"
|
|
67
|
+
:options="selectOptions"
|
|
68
|
+
:aria-label="`Filter ${header?.label || header?.key} by selected values`"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
<p v-if="selectOptions.length === 0" class="subtle">No options available.</p>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div ref="actionsRef" role="toolbar" class="popover-actions">
|
|
76
|
+
<button type="button" class="neutral" @click="cancel">Cancel</button>
|
|
77
|
+
<button type="button" class="neutral" @click="clear">Clear</button>
|
|
78
|
+
<button type="button" @click="apply">Apply</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</Teleport>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<script setup>
|
|
85
|
+
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
|
|
86
|
+
import CheckGroup from './CheckGroup.vue'
|
|
87
|
+
import RadioGroup from './RadioGroup.vue'
|
|
88
|
+
import {
|
|
89
|
+
createEmptyFilter,
|
|
90
|
+
inferFilterType,
|
|
91
|
+
normalizeFilter,
|
|
92
|
+
} from '../composables/tableFilters.js'
|
|
93
|
+
|
|
94
|
+
const props = defineProps({
|
|
95
|
+
open: {
|
|
96
|
+
type: Boolean,
|
|
97
|
+
default: false,
|
|
98
|
+
},
|
|
99
|
+
header: {
|
|
100
|
+
type: Object,
|
|
101
|
+
default: null,
|
|
102
|
+
},
|
|
103
|
+
anchorEl: {
|
|
104
|
+
type: Object,
|
|
105
|
+
default: null,
|
|
106
|
+
},
|
|
107
|
+
value: {
|
|
108
|
+
type: Object,
|
|
109
|
+
default: null,
|
|
110
|
+
},
|
|
111
|
+
rows: {
|
|
112
|
+
type: Array,
|
|
113
|
+
default: () => [],
|
|
114
|
+
},
|
|
115
|
+
selectOptions: {
|
|
116
|
+
type: Array,
|
|
117
|
+
default: () => [],
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const emit = defineEmits(['update:open', 'apply', 'clear', 'cancel'])
|
|
122
|
+
|
|
123
|
+
const MIN_VISIBLE_OPTIONS = 3
|
|
124
|
+
const MAX_VISIBLE_OPTIONS = 20
|
|
125
|
+
const VIEWPORT_MARGIN_PX = 8
|
|
126
|
+
|
|
127
|
+
const popoverRef = ref(null)
|
|
128
|
+
const titleRef = ref(null)
|
|
129
|
+
const actionsRef = ref(null)
|
|
130
|
+
const checkGroupScrollRef = ref(null)
|
|
131
|
+
const popoverStyle = ref({ top: '0px', left: '0px' })
|
|
132
|
+
const checkGroupScrollStyle = ref({})
|
|
133
|
+
const titleId = useId()
|
|
134
|
+
const draft = ref(createEmptyFilter({ key: '' }))
|
|
135
|
+
|
|
136
|
+
const filterType = computed(() => {
|
|
137
|
+
if (!props.header) {
|
|
138
|
+
return 'text'
|
|
139
|
+
}
|
|
140
|
+
return inferFilterType(props.header, props.rows)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const columnLabel = computed(() => props.header?.label || props.header?.key || '')
|
|
144
|
+
|
|
145
|
+
const selectedCount = computed(() => {
|
|
146
|
+
if (filterType.value !== 'select' || !Array.isArray(draft.value.values)) {
|
|
147
|
+
return 0
|
|
148
|
+
}
|
|
149
|
+
return draft.value.values.length
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const popoverTitle = computed(() => {
|
|
153
|
+
if (filterType.value === 'select') {
|
|
154
|
+
return `Filter ${columnLabel.value} (${selectedCount.value} selected)`
|
|
155
|
+
}
|
|
156
|
+
return `Filter ${columnLabel.value}`
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const booleanOptions = [
|
|
160
|
+
{ value: 'any', label: 'Any' },
|
|
161
|
+
{ value: true, label: 'Yes' },
|
|
162
|
+
{ value: false, label: 'No' },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
const booleanValue = computed({
|
|
166
|
+
get() {
|
|
167
|
+
if (draft.value.value === true) return true
|
|
168
|
+
if (draft.value.value === false) return false
|
|
169
|
+
return 'any'
|
|
170
|
+
},
|
|
171
|
+
set(value) {
|
|
172
|
+
if (value === 'any') {
|
|
173
|
+
draft.value = { type: 'boolean', value: null }
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
draft.value = { type: 'boolean', value: value === true }
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
function positionPopover() {
|
|
181
|
+
const anchor = props.anchorEl
|
|
182
|
+
if (!anchor || !popoverRef.value) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rect = anchor.getBoundingClientRect()
|
|
187
|
+
const popoverRect = popoverRef.value.getBoundingClientRect()
|
|
188
|
+
const margin = VIEWPORT_MARGIN_PX
|
|
189
|
+
let top = rect.bottom + margin
|
|
190
|
+
let left = rect.left
|
|
191
|
+
|
|
192
|
+
if (left + popoverRect.width > window.innerWidth - margin) {
|
|
193
|
+
left = window.innerWidth - popoverRect.width - margin
|
|
194
|
+
}
|
|
195
|
+
if (left < margin) {
|
|
196
|
+
left = margin
|
|
197
|
+
}
|
|
198
|
+
if (top + popoverRect.height > window.innerHeight - margin) {
|
|
199
|
+
top = rect.top - popoverRect.height - margin
|
|
200
|
+
}
|
|
201
|
+
if (top < margin) {
|
|
202
|
+
top = margin
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
popoverStyle.value = {
|
|
206
|
+
top: `${top}px`,
|
|
207
|
+
left: `${left}px`,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function updateCheckGroupScrollLimit() {
|
|
212
|
+
checkGroupScrollStyle.value = {}
|
|
213
|
+
|
|
214
|
+
if (filterType.value !== 'select' || !popoverRef.value || !checkGroupScrollRef.value) {
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const anchor = props.anchorEl
|
|
219
|
+
const anchorRect = anchor?.getBoundingClientRect()
|
|
220
|
+
const popoverEl = popoverRef.value
|
|
221
|
+
const titleHeight = titleRef.value?.offsetHeight ?? 0
|
|
222
|
+
const actionsHeight = actionsRef.value?.offsetHeight ?? 0
|
|
223
|
+
const popoverStyles = window.getComputedStyle(popoverEl)
|
|
224
|
+
const paddingTop = Number.parseFloat(popoverStyles.paddingTop) || 0
|
|
225
|
+
const paddingBottom = Number.parseFloat(popoverStyles.paddingBottom) || 0
|
|
226
|
+
const bodyGap = 12
|
|
227
|
+
|
|
228
|
+
let availablePopoverHeight = window.innerHeight - (VIEWPORT_MARGIN_PX * 2)
|
|
229
|
+
if (anchorRect) {
|
|
230
|
+
const spaceBelow = window.innerHeight - VIEWPORT_MARGIN_PX - anchorRect.bottom
|
|
231
|
+
const spaceAbove = anchorRect.top - VIEWPORT_MARGIN_PX
|
|
232
|
+
availablePopoverHeight = Math.max(spaceBelow, spaceAbove)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const reservedHeight = paddingTop + paddingBottom + titleHeight + actionsHeight + bodyGap
|
|
236
|
+
const optionsAreaBudget = Math.max(0, availablePopoverHeight - reservedHeight)
|
|
237
|
+
|
|
238
|
+
const firstLabel = checkGroupScrollRef.value.querySelector('label')
|
|
239
|
+
const rowHeight = firstLabel?.offsetHeight || 36
|
|
240
|
+
const optionCount = props.selectOptions.length
|
|
241
|
+
|
|
242
|
+
const maxVisibleByViewport = Math.floor(optionsAreaBudget / rowHeight)
|
|
243
|
+
const visibleSlots = Math.min(
|
|
244
|
+
MAX_VISIBLE_OPTIONS,
|
|
245
|
+
Math.max(MIN_VISIBLE_OPTIONS, maxVisibleByViewport),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const rowsToShow = optionCount === 0
|
|
249
|
+
? MIN_VISIBLE_OPTIONS
|
|
250
|
+
: Math.min(optionCount, visibleSlots)
|
|
251
|
+
|
|
252
|
+
checkGroupScrollStyle.value = {
|
|
253
|
+
maxHeight: `${rowsToShow * rowHeight}px`,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resetDraft() {
|
|
258
|
+
if (!props.header) {
|
|
259
|
+
draft.value = createEmptyFilter({ key: '' })
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const base = createEmptyFilter(props.header, props.rows)
|
|
264
|
+
if (props.value) {
|
|
265
|
+
draft.value = {
|
|
266
|
+
...base,
|
|
267
|
+
...JSON.parse(JSON.stringify(props.value)),
|
|
268
|
+
}
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
draft.value = base
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function onDocumentPointerDown(event) {
|
|
276
|
+
if (!props.open || !popoverRef.value) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (popoverRef.value.contains(event.target)) {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (props.anchorEl?.contains?.(event.target)) {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
cancel()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function onDocumentKeyDown(event) {
|
|
289
|
+
if (!props.open) {
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (event.key === 'Escape') {
|
|
293
|
+
event.preventDefault()
|
|
294
|
+
cancel()
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function apply() {
|
|
299
|
+
const normalized = normalizeFilter(props.header, draft.value, props.rows)
|
|
300
|
+
emit('apply', normalized)
|
|
301
|
+
emit('update:open', false)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function clear() {
|
|
305
|
+
emit('clear')
|
|
306
|
+
emit('update:open', false)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function cancel() {
|
|
310
|
+
emit('cancel')
|
|
311
|
+
emit('update:open', false)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
watch(
|
|
315
|
+
() => [props.open, props.header, props.value, props.selectOptions.length],
|
|
316
|
+
async ([isOpen]) => {
|
|
317
|
+
if (!isOpen) {
|
|
318
|
+
checkGroupScrollStyle.value = {}
|
|
319
|
+
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
|
320
|
+
document.removeEventListener('keydown', onDocumentKeyDown)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
resetDraft()
|
|
325
|
+
await nextTick()
|
|
326
|
+
updateCheckGroupScrollLimit()
|
|
327
|
+
await nextTick()
|
|
328
|
+
positionPopover()
|
|
329
|
+
popoverRef.value?.querySelector('input, select, button')?.focus()
|
|
330
|
+
|
|
331
|
+
document.addEventListener('pointerdown', onDocumentPointerDown)
|
|
332
|
+
document.addEventListener('keydown', onDocumentKeyDown)
|
|
333
|
+
},
|
|
334
|
+
{ immediate: true },
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
onBeforeUnmount(() => {
|
|
338
|
+
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
|
339
|
+
document.removeEventListener('keydown', onDocumentKeyDown)
|
|
340
|
+
})
|
|
341
|
+
</script>
|
|
342
|
+
|
|
343
|
+
<style scoped>
|
|
344
|
+
.table-column-filter-popover {
|
|
345
|
+
position: fixed;
|
|
346
|
+
z-index: 20;
|
|
347
|
+
display: flex;
|
|
348
|
+
flex-direction: column;
|
|
349
|
+
min-width: 16rem;
|
|
350
|
+
max-width: min(24rem, calc(100vw - 1rem));
|
|
351
|
+
max-height: calc(100vh - 1rem);
|
|
352
|
+
padding: 0.75rem;
|
|
353
|
+
border: 1px solid var(--border-color, #e1e5e9);
|
|
354
|
+
border-radius: 0.4rem;
|
|
355
|
+
background: var(--background-color, #fff);
|
|
356
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
357
|
+
overflow: hidden;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.popover-body {
|
|
361
|
+
flex: 1 1 auto;
|
|
362
|
+
min-height: 0;
|
|
363
|
+
display: flex;
|
|
364
|
+
flex-direction: column;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.popover-title {
|
|
368
|
+
margin: 0 0 0.75rem;
|
|
369
|
+
font-size: 0.95rem;
|
|
370
|
+
flex-shrink: 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.filter-fields {
|
|
374
|
+
display: flex;
|
|
375
|
+
flex-direction: column;
|
|
376
|
+
gap: 0.75rem;
|
|
377
|
+
min-height: 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.filter-fields-select {
|
|
381
|
+
flex: 1 1 auto;
|
|
382
|
+
min-height: 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.checkgroup-scroll {
|
|
386
|
+
overflow-y: auto;
|
|
387
|
+
min-height: 0;
|
|
388
|
+
overscroll-behavior: contain;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.filter-fields label {
|
|
392
|
+
display: flex;
|
|
393
|
+
flex-direction: column;
|
|
394
|
+
gap: 0.25rem;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.field-label {
|
|
398
|
+
font-size: 0.85rem;
|
|
399
|
+
color: var(--text-muted, #666);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.popover-actions {
|
|
403
|
+
display: flex;
|
|
404
|
+
flex-shrink: 0;
|
|
405
|
+
flex-wrap: wrap;
|
|
406
|
+
justify-content: flex-end;
|
|
407
|
+
gap: 0.5rem;
|
|
408
|
+
margin-top: 0.75rem;
|
|
409
|
+
padding-top: 0.75rem;
|
|
410
|
+
border-top: 1px solid var(--border-color, #e1e5e9);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
html[data-theme="dark"] .table-column-filter-popover {
|
|
414
|
+
background: var(--background-color, #1e1e1e);
|
|
415
|
+
border-color: var(--border-color, #444);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
html[data-theme="dark"] .popover-actions {
|
|
419
|
+
border-top-color: var(--border-color, #444);
|
|
420
|
+
}
|
|
421
|
+
</style>
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const TEXT_OPERATORS = ['contains', 'equals', 'startsWith', 'empty']
|
|
2
|
+
const NUMBER_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte']
|
|
3
|
+
|
|
4
|
+
export function inferFilterType(header, rows = []) {
|
|
5
|
+
if (header.filterType) {
|
|
6
|
+
return header.filterType
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const key = header.key
|
|
10
|
+
for (const row of rows) {
|
|
11
|
+
const value = row?.[key]
|
|
12
|
+
if (value === null || value === undefined) {
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === 'boolean') {
|
|
16
|
+
return 'boolean'
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === 'number') {
|
|
19
|
+
return 'number'
|
|
20
|
+
}
|
|
21
|
+
break
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return 'text'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createEmptyFilter(header, rows = []) {
|
|
28
|
+
const type = inferFilterType(header, rows)
|
|
29
|
+
|
|
30
|
+
switch (type) {
|
|
31
|
+
case 'number':
|
|
32
|
+
return { type: 'number', operator: 'eq', value: null }
|
|
33
|
+
case 'boolean':
|
|
34
|
+
return { type: 'boolean', value: null }
|
|
35
|
+
case 'select':
|
|
36
|
+
return { type: 'select', values: [] }
|
|
37
|
+
default:
|
|
38
|
+
return { type: 'text', operator: 'contains', value: '' }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeFilter(header, partial, rows = []) {
|
|
43
|
+
if (!partial) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const type = partial.type || inferFilterType(header, rows)
|
|
48
|
+
|
|
49
|
+
switch (type) {
|
|
50
|
+
case 'number': {
|
|
51
|
+
const operator = NUMBER_OPERATORS.includes(partial.operator) ? partial.operator : 'eq'
|
|
52
|
+
const raw = partial.value
|
|
53
|
+
const value = raw === '' || raw === null || raw === undefined ? null : Number(raw)
|
|
54
|
+
if (value === null || Number.isNaN(value)) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
return { type: 'number', operator, value }
|
|
58
|
+
}
|
|
59
|
+
case 'boolean': {
|
|
60
|
+
if (partial.value === null || partial.value === undefined || partial.value === 'any') {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
return { type: 'boolean', value: Boolean(partial.value) }
|
|
64
|
+
}
|
|
65
|
+
case 'select': {
|
|
66
|
+
const values = Array.isArray(partial.values)
|
|
67
|
+
? partial.values.filter((value) => value !== null && value !== undefined && value !== '')
|
|
68
|
+
: []
|
|
69
|
+
if (values.length === 0) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
return { type: 'select', values }
|
|
73
|
+
}
|
|
74
|
+
default: {
|
|
75
|
+
const operator = TEXT_OPERATORS.includes(partial.operator) ? partial.operator : 'contains'
|
|
76
|
+
const value = partial.value == null ? '' : String(partial.value)
|
|
77
|
+
if (operator === 'empty') {
|
|
78
|
+
return { type: 'text', operator: 'empty', value: '' }
|
|
79
|
+
}
|
|
80
|
+
if (value.trim() === '') {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
return { type: 'text', operator, value }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isFilterActive(filter) {
|
|
89
|
+
if (!filter) {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
switch (filter.type) {
|
|
94
|
+
case 'text':
|
|
95
|
+
return filter.operator === 'empty' || String(filter.value ?? '').trim() !== ''
|
|
96
|
+
case 'number':
|
|
97
|
+
return filter.value !== null && filter.value !== undefined && !Number.isNaN(filter.value)
|
|
98
|
+
case 'boolean':
|
|
99
|
+
return filter.value === true || filter.value === false
|
|
100
|
+
case 'select':
|
|
101
|
+
return Array.isArray(filter.values) && filter.values.length > 0
|
|
102
|
+
default:
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function compareText(value, filter) {
|
|
108
|
+
const text = value == null ? '' : String(value)
|
|
109
|
+
const needle = String(filter.value ?? '')
|
|
110
|
+
const haystack = text.toLowerCase()
|
|
111
|
+
const search = needle.toLowerCase()
|
|
112
|
+
|
|
113
|
+
switch (filter.operator) {
|
|
114
|
+
case 'equals':
|
|
115
|
+
return haystack === search
|
|
116
|
+
case 'startsWith':
|
|
117
|
+
return haystack.startsWith(search)
|
|
118
|
+
case 'empty':
|
|
119
|
+
return text.trim() === ''
|
|
120
|
+
case 'contains':
|
|
121
|
+
default:
|
|
122
|
+
return haystack.includes(search)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compareNumber(value, filter) {
|
|
127
|
+
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const numeric = Number(value)
|
|
132
|
+
const target = Number(filter.value)
|
|
133
|
+
|
|
134
|
+
switch (filter.operator) {
|
|
135
|
+
case 'gt':
|
|
136
|
+
return numeric > target
|
|
137
|
+
case 'gte':
|
|
138
|
+
return numeric >= target
|
|
139
|
+
case 'lt':
|
|
140
|
+
return numeric < target
|
|
141
|
+
case 'lte':
|
|
142
|
+
return numeric <= target
|
|
143
|
+
case 'eq':
|
|
144
|
+
default:
|
|
145
|
+
return numeric === target
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function applyColumnFilter(row, header, filter) {
|
|
150
|
+
if (!isFilterActive(filter)) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const value = row?.[header.key]
|
|
155
|
+
|
|
156
|
+
switch (filter.type) {
|
|
157
|
+
case 'number':
|
|
158
|
+
return compareNumber(value, filter)
|
|
159
|
+
case 'boolean':
|
|
160
|
+
return Boolean(value) === filter.value
|
|
161
|
+
case 'select':
|
|
162
|
+
return filter.values.some((selected) => selected === value || String(selected) === String(value))
|
|
163
|
+
case 'text':
|
|
164
|
+
default:
|
|
165
|
+
return compareText(value, filter)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function applyFilters(rows, headers, filters) {
|
|
170
|
+
if (!filters || Object.keys(filters).length === 0) {
|
|
171
|
+
return rows
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const headerByKey = new Map(headers.map((header) => [header.key, header]))
|
|
175
|
+
|
|
176
|
+
return rows.filter((row) => {
|
|
177
|
+
for (const [key, filter] of Object.entries(filters)) {
|
|
178
|
+
const header = headerByKey.get(key)
|
|
179
|
+
if (!header || !isFilterActive(filter)) {
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (!applyColumnFilter(row, header, filter)) {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function buildSelectOptions(rows, header) {
|
|
191
|
+
if (Array.isArray(header.filterOptions) && header.filterOptions.length > 0) {
|
|
192
|
+
return header.filterOptions.map((option) => {
|
|
193
|
+
if (typeof option === 'string' || typeof option === 'number') {
|
|
194
|
+
return { value: option, label: String(option) }
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
value: option.value,
|
|
198
|
+
label: option.label ?? String(option.value),
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const seen = new Set()
|
|
204
|
+
const options = []
|
|
205
|
+
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
const value = row?.[header.key]
|
|
208
|
+
if (value === null || value === undefined || value === '') {
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
const key = String(value)
|
|
212
|
+
if (seen.has(key)) {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
seen.add(key)
|
|
216
|
+
options.push({ value, label: String(value) })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return options.sort((a, b) => a.label.localeCompare(b.label))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function cloneFilters(filters = {}) {
|
|
223
|
+
return JSON.parse(JSON.stringify(filters ?? {}))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function sortRows(rows, sortBy, sortDir) {
|
|
227
|
+
if (!sortBy) {
|
|
228
|
+
return [...rows]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return [...rows].sort((a, b) => {
|
|
232
|
+
const av = a[sortBy]
|
|
233
|
+
const bv = b[sortBy]
|
|
234
|
+
|
|
235
|
+
if (av === bv) return 0
|
|
236
|
+
if (av === null || av === undefined) return 1
|
|
237
|
+
if (bv === null || bv === undefined) return -1
|
|
238
|
+
if (typeof av === 'string' && typeof bv === 'string') {
|
|
239
|
+
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
|
|
240
|
+
}
|
|
241
|
+
if (typeof av === 'number' && typeof bv === 'number') {
|
|
242
|
+
return sortDir === 'asc' ? av - bv : bv - av
|
|
243
|
+
}
|
|
244
|
+
if (typeof av === 'boolean' && typeof bv === 'boolean') {
|
|
245
|
+
return sortDir === 'asc' ? (av ? 1 : 0) - (bv ? 1 : 0) : (bv ? 1 : 0) - (av ? 1 : 0)
|
|
246
|
+
}
|
|
247
|
+
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
|
248
|
+
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
|
249
|
+
return 0
|
|
250
|
+
})
|
|
251
|
+
}
|