@stonecrop/atable 0.5.0 → 0.6.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.
@@ -0,0 +1,212 @@
1
+ <template>
2
+ <div class="column-filter">
3
+ <input
4
+ v-if="(column.filterType || 'text') === 'text'"
5
+ v-model="filterValue"
6
+ type="text"
7
+ class="filter-input"
8
+ @input="updateFilter(filterValue)" />
9
+
10
+ <input
11
+ v-else-if="column.filterType === 'number'"
12
+ v-model="filterValue"
13
+ type="number"
14
+ class="filter-input"
15
+ @input="updateFilter(filterValue)" />
16
+
17
+ <label v-else-if="column.filterType === 'checkbox'" class="checkbox-filter">
18
+ <input v-model="filterValue" type="checkbox" class="filter-checkbox" @change="updateFilter(filterValue)" />
19
+ <span>{{ column.label }}</span>
20
+ </label>
21
+
22
+ <select
23
+ v-else-if="column.filterType === 'select'"
24
+ v-model="filterValue"
25
+ class="filter-select"
26
+ @change="updateFilter(filterValue)">
27
+ <option value="">All</option>
28
+ <option v-for="option in getSelectOptions(column)" :key="option.value || option" :value="option.value || option">
29
+ {{ option.label || option }}
30
+ </option>
31
+ </select>
32
+
33
+ <input
34
+ v-else-if="column.filterType === 'date'"
35
+ v-model="filterValue"
36
+ type="date"
37
+ class="filter-input"
38
+ @change="updateFilter(filterValue)" />
39
+
40
+ <div v-else-if="column.filterType === 'dateRange'" class="date-range-filter">
41
+ <input
42
+ v-model="dateFilter.startValue"
43
+ type="date"
44
+ class="filter-input"
45
+ @change="updateDateRangeFilter('start', dateFilter.startValue)" />
46
+ <span class="date-separator">-</span>
47
+ <input
48
+ v-model="dateFilter.endValue"
49
+ type="date"
50
+ class="filter-input"
51
+ @change="updateDateRangeFilter('end', dateFilter.endValue)" />
52
+ </div>
53
+
54
+ <component
55
+ v-else-if="column.filterType === 'component' && column.filterComponent"
56
+ :is="column.filterComponent"
57
+ :value="filterValue"
58
+ :column="column"
59
+ :colIndex="colIndex"
60
+ :store="store"
61
+ @update:value="updateFilter($event)" />
62
+
63
+ <button v-if="hasActiveFilter" @click="clearFilter" class="clear-btn" title="Clear">×</button>
64
+ </div>
65
+ </template>
66
+
67
+ <script setup lang="ts">
68
+ import { ref, reactive, computed } from 'vue'
69
+ import { createTableStore } from '../stores/table'
70
+ import type { TableColumn } from '../types'
71
+
72
+ const { column, colIndex, store } = defineProps<{
73
+ column: TableColumn
74
+ colIndex: number
75
+ store: ReturnType<typeof createTableStore>
76
+ }>()
77
+
78
+ const filterValue = ref<any>('')
79
+ const dateFilter = reactive({
80
+ startValue: '' as string,
81
+ endValue: '' as string,
82
+ })
83
+
84
+ const getSelectOptions = (column: TableColumn): any[] => {
85
+ if (column.filterOptions) return column.filterOptions
86
+
87
+ // Auto-generate options from data
88
+ const uniqueValues = new Set<any>()
89
+ store.rows.forEach(row => {
90
+ const value = row[column.name]
91
+ if (value !== null && value !== undefined && value !== '') {
92
+ uniqueValues.add(value)
93
+ }
94
+ })
95
+
96
+ return Array.from(uniqueValues).map(value => ({
97
+ value: value,
98
+ label: String(value),
99
+ }))
100
+ }
101
+
102
+ const hasActiveFilter = computed(() => {
103
+ return !!(filterValue.value || dateFilter.startValue || dateFilter.endValue)
104
+ })
105
+
106
+ // Filter actions
107
+ const updateFilter = (value: any) => {
108
+ if (!value && column.filterType !== 'checkbox') {
109
+ store.clearFilter(colIndex)
110
+ filterValue.value = ''
111
+ } else {
112
+ filterValue.value = value
113
+ store.setFilter(colIndex, { value })
114
+ }
115
+ }
116
+
117
+ const updateDateRangeFilter = (rangeType: 'start' | 'end', value: any) => {
118
+ if (rangeType === 'start') {
119
+ dateFilter.startValue = value
120
+ } else {
121
+ dateFilter.endValue = value
122
+ }
123
+
124
+ if (!dateFilter.startValue && !dateFilter.endValue) {
125
+ store.clearFilter(colIndex)
126
+ } else {
127
+ store.setFilter(colIndex, {
128
+ value: null,
129
+ startValue: dateFilter.startValue,
130
+ endValue: dateFilter.endValue,
131
+ })
132
+ }
133
+ }
134
+
135
+ const clearFilter = () => {
136
+ filterValue.value = ''
137
+ dateFilter.startValue = ''
138
+ dateFilter.endValue = ''
139
+ store.clearFilter(colIndex)
140
+ }
141
+ </script>
142
+
143
+ <style scoped>
144
+ .column-filter {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 0.25rem;
148
+ width: 100%;
149
+ }
150
+
151
+ .filter-input,
152
+ .filter-select {
153
+ background-color: var(--sc-form-background) !important;
154
+ padding: 0.15rem 0.2rem;
155
+ border: 1px solid var(--sc-form-border);
156
+ border-radius: 3px;
157
+ font-size: 0.875rem;
158
+ color: var(--sc-text-color);
159
+ width: 100%;
160
+ box-sizing: border-box;
161
+ }
162
+
163
+ .filter-input:focus,
164
+ .filter-select:focus {
165
+ outline: none;
166
+ border-color: var(--sc-input-active-border-color);
167
+ }
168
+
169
+ .checkbox-filter {
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 0.25rem;
173
+ font-size: 0.875rem;
174
+ color: var(--sc-text-color);
175
+ cursor: pointer;
176
+ }
177
+
178
+ .filter-checkbox {
179
+ margin: 0;
180
+ }
181
+
182
+ .date-range-filter {
183
+ display: flex;
184
+ gap: 0.25rem;
185
+ align-items: center;
186
+ width: 100%;
187
+ }
188
+
189
+ .date-range-filter .filter-input {
190
+ flex: 1;
191
+ min-width: 0;
192
+ }
193
+
194
+ .date-separator {
195
+ color: var(--sc-gray-50);
196
+ font-weight: 500;
197
+ padding: 0 0.25rem;
198
+ flex-shrink: 0;
199
+ }
200
+
201
+ .clear-btn {
202
+ background: var(--sc-gray-10, #f0f0f0);
203
+ border: 1px solid var(--sc-form-border);
204
+ border-radius: 3px;
205
+ color: var(--sc-gray-70);
206
+ cursor: pointer;
207
+ font-size: 1rem;
208
+ padding: 0.15rem 0.4rem;
209
+ line-height: 1;
210
+ flex-shrink: 0;
211
+ }
212
+ </style>
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <thead v-if="columns.length">
3
+ <!-- Header row -->
3
4
  <tr class="atable-header-row" tabindex="-1">
4
5
  <th
5
6
  v-if="store.zeroColumn"
@@ -17,15 +18,36 @@
17
18
  :data-colindex="colKey"
18
19
  tabindex="-1"
19
20
  :style="store.getHeaderCellStyle(column)"
20
- :class="column.pinned ? 'sticky-column' : ''">
21
+ :class="`${column.pinned ? 'sticky-column' : ''} ${column.sortable === false ? '' : 'cursor-pointer'}`"
22
+ @click="column.sortable !== false ? handleSort(colKey) : undefined">
21
23
  <slot>{{ column.label || String.fromCharCode(colKey + 97).toUpperCase() }}</slot>
22
24
  </th>
23
25
  </tr>
26
+ <!-- Filters row -->
27
+ <tr v-if="filterableColumns.length > 0" class="atable-filters-row">
28
+ <th
29
+ v-if="store.zeroColumn"
30
+ :class="[
31
+ store.hasPinnedColumns ? 'sticky-index' : '',
32
+ store.isTreeView ? 'tree-index' : '',
33
+ store.config.view === 'list-expansion' ? 'list-expansion-index' : '',
34
+ ]"
35
+ class="list-index" />
36
+ <th
37
+ v-for="(column, colKey) in columns"
38
+ :key="`filter-${column.name}`"
39
+ :class="`${column.pinned ? 'sticky-column' : ''}`"
40
+ :style="store.getHeaderCellStyle(column)">
41
+ <ATableColumnFilter v-if="column.filterable" :column="column" :col-index="colKey" :store="store" />
42
+ </th>
43
+ </tr>
24
44
  </thead>
25
45
  </template>
26
46
 
27
47
  <script setup lang="ts">
48
+ import { computed } from 'vue'
28
49
  import { vResizeObserver } from '@vueuse/components'
50
+ import ATableColumnFilter from './ATableColumnFilter.vue'
29
51
  import { createTableStore } from '../stores/table'
30
52
  import type { TableColumn } from '../types'
31
53
 
@@ -34,6 +56,10 @@ const { columns, store } = defineProps<{
34
56
  store: ReturnType<typeof createTableStore>
35
57
  }>()
36
58
 
59
+ const filterableColumns = computed(() => columns.filter(column => column.filterable))
60
+
61
+ const handleSort = (colIndex: number) => store.sortByColumn(colIndex)
62
+
37
63
  const onResize = (entries: ReadonlyArray<ResizeObserverEntry>) => {
38
64
  for (const entry of entries) {
39
65
  if (entry.borderBoxSize.length === 0) continue
@@ -75,4 +101,13 @@ th {
75
101
  width: 2ch;
76
102
  margin-left: 5px;
77
103
  }
104
+
105
+ .cursor-pointer {
106
+ cursor: pointer;
107
+ }
108
+
109
+ .atable-filters-row th {
110
+ padding: 0.25rem 0.5ch;
111
+ vertical-align: top;
112
+ }
78
113
  </style>
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import ATableLoading from './components/ATableLoading.vue'
10
10
  import ATableLoadingBar from './components/ATableLoadingBar.vue'
11
11
  import ATableModal from './components/ATableModal.vue'
12
12
  export { createTableStore } from './stores/table'
13
+ export type { FilterState, FilterStateRecord } from './stores/table'
13
14
  export type * from './types'
14
15
 
15
16
  /**
@@ -15,6 +15,25 @@ import type {
15
15
  } from '../types'
16
16
  import { generateHash } from '../utils'
17
17
 
18
+ /**
19
+ * Represents the state of a single filter
20
+ * @public
21
+ */
22
+ export interface FilterState {
23
+ /** The main filter value */
24
+ value: any
25
+ /** Start value for date range filters */
26
+ startValue?: any
27
+ /** End value for date range filters */
28
+ endValue?: any
29
+ }
30
+
31
+ /**
32
+ * Record mapping column indices to their filter states
33
+ * @public
34
+ */
35
+ export type FilterStateRecord = Record<number, FilterState>
36
+
18
37
  /**
19
38
  * Create a table store
20
39
  * @param initData - Initial data for the table store
@@ -186,6 +205,11 @@ export const createTableStore = (initData: {
186
205
  const ganttBars = ref<GanttBarInfo[]>([])
187
206
  const connectionHandles = ref<ConnectionHandle[]>([])
188
207
  const connectionPaths = ref<ConnectionPath[]>([])
208
+ const sortState = ref<{ column: number | null; direction: 'asc' | 'desc' | null }>({
209
+ column: null,
210
+ direction: null,
211
+ })
212
+ const filterState = ref<FilterStateRecord>({})
189
213
 
190
214
  // getters
191
215
  const hasPinnedColumns = computed(() => columns.value.some(col => col.pinned))
@@ -207,6 +231,63 @@ export const createTableStore = (initData: {
207
231
  config.value.view ? ['list', 'tree', 'tree-gantt', 'list-expansion'].includes(config.value.view) : false
208
232
  )
209
233
 
234
+ const filteredRows = computed(() => {
235
+ let filtered = rows.value.map((row, originalIndex) => ({
236
+ ...row,
237
+ originalIndex,
238
+ }))
239
+
240
+ // Apply filters
241
+ Object.entries(filterState.value).forEach(([colIndexStr, filter]) => {
242
+ const colIndex = parseInt(colIndexStr)
243
+ const column = columns.value[colIndex]
244
+
245
+ if (!column) return
246
+
247
+ // Skip if filter has no value (except for dateRange and checkbox which can have different value structures)
248
+ const hasFilterValue =
249
+ filter.value ||
250
+ filter.startValue ||
251
+ filter.endValue ||
252
+ (column.filterType === 'checkbox' && filter.value !== undefined)
253
+
254
+ if (!hasFilterValue) return
255
+
256
+ filtered = filtered.filter(row => {
257
+ const cellValue = row[column.name]
258
+ return applyFilter(cellValue, filter, column)
259
+ })
260
+ })
261
+
262
+ // Apply sorting if active
263
+ if (sortState.value.column !== null && sortState.value.direction) {
264
+ const column = columns.value[sortState.value.column]
265
+ const direction = sortState.value.direction
266
+
267
+ filtered.sort((a, b) => {
268
+ let aVal = a[column.name]
269
+ let bVal = b[column.name]
270
+
271
+ if (aVal === null || aVal === undefined) aVal = ''
272
+ if (bVal === null || bVal === undefined) bVal = ''
273
+
274
+ const aNum = Number(aVal)
275
+ const bNum = Number(bVal)
276
+ const isNumeric = !isNaN(aNum) && !isNaN(bNum) && aVal !== '' && bVal !== ''
277
+
278
+ if (isNumeric) {
279
+ return direction === 'asc' ? aNum - bNum : bNum - aNum
280
+ } else {
281
+ const aStr = String(aVal).toLowerCase()
282
+ const bStr = String(bVal).toLowerCase()
283
+ return direction === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr)
284
+ }
285
+ })
286
+ }
287
+
288
+ return filtered
289
+ })
290
+
210
291
  // actions
211
292
  const getCellData = <T = any>(colIndex: number, rowIndex: number): T => table.value[`${colIndex}:${rowIndex}`]
212
293
  const setCellData = (colIndex: number, rowIndex: number, value: any) => {
@@ -484,6 +565,118 @@ export const createTableStore = (initData: {
484
565
  return connectionHandles.value.filter(handle => handle.barId === barId)
485
566
  }
486
567
 
568
+ const sortByColumn = (colIndex: number) => {
569
+ const column = columns.value[colIndex]
570
+ if (column.sortable === false) return
571
+
572
+ let newDirection: 'asc' | 'desc'
573
+ if (sortState.value.column === colIndex) {
574
+ if (sortState.value.direction === 'asc') {
575
+ newDirection = 'desc'
576
+ } else {
577
+ newDirection = 'asc'
578
+ }
579
+ } else {
580
+ newDirection = 'asc'
581
+ }
582
+
583
+ sortState.value.column = colIndex
584
+ sortState.value.direction = newDirection
585
+
586
+ // Note: The actual sorting is now handled in the filteredRows computed property
587
+ // This ensures that sorting works on filtered data without modifying the original rows
588
+ }
589
+
590
+ const applyFilter = (cellValue: any, filter: FilterState, column: TableColumn): boolean => {
591
+ const filterType = column.filterType || 'text'
592
+ const value = filter.value
593
+
594
+ if (!value && filterType !== 'dateRange' && filterType !== 'checkbox') return true
595
+
596
+ switch (filterType) {
597
+ case 'text': {
598
+ // Handle objects with nested properties
599
+ let searchableText = ''
600
+ if (typeof cellValue === 'object' && cellValue !== null) {
601
+ // If it's an object, search in all string values
602
+ searchableText = Object.values(cellValue as Record<string, unknown>).join(' ')
603
+ } else {
604
+ searchableText = String(cellValue || '')
605
+ }
606
+ return searchableText.toLowerCase().includes(String(value).toLowerCase())
607
+ }
608
+
609
+ case 'number': {
610
+ const numValue = Number(cellValue)
611
+ const filterNum = Number(value)
612
+ return !isNaN(numValue) && !isNaN(filterNum) && numValue === filterNum
613
+ }
614
+
615
+ case 'select':
616
+ return cellValue === value
617
+
618
+ case 'checkbox':
619
+ // For checkbox filter, if checked (true), show only truthy values
620
+ // If unchecked (false/undefined), show all values
621
+ if (value === true) {
622
+ return !!cellValue
623
+ }
624
+ return true
625
+
626
+ case 'date': {
627
+ // Handle both timestamp numbers and date strings
628
+ let cellDate: Date
629
+ if (typeof cellValue === 'number') {
630
+ // Apply the same year transformation as in the format function
631
+ const originalDate = new Date(cellValue)
632
+ const currentYear = new Date().getFullYear()
633
+ cellDate = new Date(currentYear, originalDate.getMonth(), originalDate.getDate())
634
+ } else {
635
+ cellDate = new Date(String(cellValue))
636
+ }
637
+ const filterDate = new Date(String(value))
638
+ return cellDate.toDateString() === filterDate.toDateString()
639
+ }
640
+
641
+ case 'dateRange': {
642
+ const startValue = filter.startValue
643
+ const endValue = filter.endValue
644
+ if (!startValue && !endValue) return true
645
+
646
+ // Handle both timestamp numbers and date strings
647
+ let cellDateRange: Date
648
+ if (typeof cellValue === 'number') {
649
+ // Apply the same year transformation as in the format function
650
+ const originalDate = new Date(cellValue)
651
+ const currentYear = new Date().getFullYear()
652
+ cellDateRange = new Date(currentYear, originalDate.getMonth(), originalDate.getDate())
653
+ } else {
654
+ cellDateRange = new Date(String(cellValue))
655
+ }
656
+ if (startValue && cellDateRange < new Date(String(startValue))) return false
657
+ if (endValue && cellDateRange > new Date(String(endValue))) return false
658
+
659
+ return true
660
+ }
661
+
662
+ default:
663
+ return true
664
+ }
665
+ }
666
+
667
+ const setFilter = (colIndex: number, filter: FilterState) => {
668
+ if (!filter.value && !filter.startValue && !filter.endValue) {
669
+ // Remove filter if empty
670
+ delete filterState.value[colIndex]
671
+ } else {
672
+ filterState.value[colIndex] = filter
673
+ }
674
+ }
675
+
676
+ const clearFilter = (colIndex: number) => {
677
+ delete filterState.value[colIndex]
678
+ }
679
+
487
680
  return {
488
681
  // state
489
682
  columns,
@@ -491,13 +684,16 @@ export const createTableStore = (initData: {
491
684
  connectionHandles,
492
685
  connectionPaths,
493
686
  display,
687
+ filterState,
494
688
  ganttBars,
495
689
  modal,
496
690
  rows,
691
+ sortState,
497
692
  table,
498
693
  updates,
499
694
 
500
695
  // getters
696
+ filteredRows,
501
697
  hasPinnedColumns,
502
698
  isGanttView,
503
699
  isTreeView,
@@ -506,6 +702,7 @@ export const createTableStore = (initData: {
506
702
  zeroColumn,
507
703
 
508
704
  // actions
705
+ clearFilter,
509
706
  closeModal,
510
707
  createConnection,
511
708
  deleteConnection,
@@ -524,6 +721,8 @@ export const createTableStore = (initData: {
524
721
  resizeColumn,
525
722
  setCellData,
526
723
  setCellText,
724
+ setFilter,
725
+ sortByColumn,
527
726
  toggleRowExpand,
528
727
  unregisterConnectionHandle,
529
728
  unregisterGanttBar,
@@ -72,6 +72,37 @@ export interface TableColumn {
72
72
  */
73
73
  resizable?: boolean
74
74
 
75
+ /**
76
+ * Control whether the column should be sortable.
77
+ *
78
+ * @defaultValue true
79
+ */
80
+ sortable?: boolean
81
+
82
+ /**
83
+ * Control whether the column should be filterable and define filter configuration.
84
+ *
85
+ * @defaultValue true
86
+ */
87
+ filterable?: boolean
88
+
89
+ /**
90
+ * The type of filter for the column.
91
+ *
92
+ * @defaultValue 'text'
93
+ */
94
+ filterType?: 'text' | 'select' | 'number' | 'date' | 'dateRange' | 'checkbox' | 'component'
95
+
96
+ /**
97
+ * Options for select-type filters.
98
+ */
99
+ filterOptions?: any[]
100
+
101
+ /**
102
+ * Custom component for filtering.
103
+ */
104
+ filterComponent?: string
105
+
75
106
  /**
76
107
  * The component to use to render the cell for the column. If not provided, the table will
77
108
  * render the default `<td>` element.