picocrank 1.22.1 → 1.23.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.
@@ -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
+ }