PluginProbe
The Innovative Form Builder – IvyForms / 0.8.2
The Innovative Form Builder – IvyForms v0.8.2
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / table / IvyTable.vue

IvyTable.vue in The Innovative Form Builder – IvyForms 0.8.2, at frontend/src/views/_components/table/IvyTable.vue

440 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 ref="tableContainerRef"
4 class="ivyforms-table__container ivyforms-flex ivyforms-flex-direction-column ivyforms-width-100"
5 >
6 <!-- Slot for custom header (when row is selected) -->
7 <div v-if="isCustomHeader" class="ivyforms-table__custom-header ivyforms-pt-2">
8 <slot name="header" />
9 </div>
10 <IvyScrollbar ref="scrollbarRef">
11 <ElAutoResizer :class="{ loading: props.loading }">
12 <template #default="{ width }">
13 <ElTableV2
14 v-bind="$attrs"
15 :columns="formattedColumns"
16 :data="props.data"
17 :width="Math.max(width, props.minTableWidth)"
18 :height="tableHeight"
19 class="ivyforms-table"
20 :fixed="props.fixed"
21 :row-class="getRowClass"
22 >
23 <!-- Slot for custom column headers with sorting -->
24 <template #header-cell="{ column }">
25 <div
26 class="ivyforms-flex ivyforms-align-items-center"
27 :style="{
28 cursor: column.sortable ? 'pointer' : 'default',
29 }"
30 @click="emitSortChange(column)"
31 >
32 <component :is="column.headerRenderer()" v-if="column.headerRenderer" />
33
34 <div
35 v-else-if="column.comingSoon"
36 class="ivyforms-flex ivyforms-align-items-center ivyforms-gap-8"
37 >
38 <div class="ivyforms-table__container__coming-soon-title">{{ column.title }}</div>
39 <ComingSoonBadge size="s" />
40 </div>
41 <span v-else>{{ column.title }}</span>
42
43 <!-- Sorting icons -->
44 <span v-if="column.sortable">
45 <span v-if="sortColumn === column.key">
46 <span v-if="sortOrder === 'asc'">
47 <IvyIcon
48 size="d"
49 outer-size="20px"
50 :name="isDarkMode ? 'sort-asc-dark' : 'sort-asc'"
51 category="table"
52 />
53 </span>
54 <span v-else-if="sortOrder === 'desc'">
55 <IvyIcon
56 size="d"
57 outer-size="20px"
58 :name="isDarkMode ? 'sort-desc-dark' : 'sort-desc'"
59 category="table"
60 />
61 </span>
62 </span>
63 <span v-else>
64 <IvyIcon
65 size="d"
66 outer-size="20px"
67 name="arrow-up-down-2"
68 category="arrows"
69 type="outline"
70 color="var(--map-base-dusk-symbol-2)"
71 />
72 </span>
73 </span>
74 </div>
75 </template>
76
77 <!-- Slot for empty table/no filters match -->
78 <template #empty>
79 <slot name="empty" />
80 </template>
81 </ElTableV2>
82 </template>
83 </ElAutoResizer>
84 </IvyScrollbar>
85 </div>
86 </template>
87
88 <script lang="ts" setup>
89 import { computed, ref, h, onMounted, onUnmounted, watch, nextTick } from 'vue'
90 import type { TableColumn } from '@/views/_components/table/IvyTableColumn.ts'
91 import type { CellRenderProps } from '@/views/_components/table/IvyCellRendererProps.ts'
92 import IvyScrollbar from '@/views/_components/scrollbar/IvyScrollbar.vue'
93
94 interface Props {
95 data: Array<{ [key: string]: string | number | boolean }>
96 columns?: TableColumn[]
97 loading?: boolean
98 pagination?: boolean
99 groupBy?: string
100 fixed?: boolean
101 isCustomHeader?: boolean
102 minTableWidth?: number
103 }
104
105 const props = withDefaults(defineProps<Props>(), {
106 loading: false,
107 pagination: false,
108 isCustomHeader: false,
109 columns: () => [],
110 groupBy: '',
111 minTableWidth: 240,
112 })
113
114 const emit = defineEmits<{
115 (e: 'sort-change', sortEvent: { column: string | null; order: 'asc' | 'desc' | null }): void
116 }>()
117
118 // Sort icons based on theme
119 const isDarkMode = ref(document.body.classList.contains('ivyforms-theme-dark'))
120
121 const observer = new MutationObserver(() => {
122 isDarkMode.value = document.body.classList.contains('ivyforms-theme-dark')
123 })
124
125 const tableContainerRef = ref<HTMLElement | null>(null)
126 const scrollbarRef = ref<InstanceType<typeof IvyScrollbar> | null>(null)
127 const flooredWidth = ref<number>(0)
128 const tableHeight = ref<number>(550)
129
130 onMounted(() => {
131 if (tableContainerRef.value) {
132 const updateHeight = () => {
133 const rowHeight = 50
134 const numRows = props.data.length + 1
135 const maxTableHeight = numRows * rowHeight
136
137 // Get the parent of the ElResizer element
138 const tableContainer = tableContainerRef.value
139 const parentOfParent = tableContainer?.parentElement?.parentElement as HTMLElement | null
140 const parentOfParentHeight = parentOfParent ? parentOfParent.offsetHeight : 0
141 // If pagination is enabled, subtract the height of the pagination component
142 const finalMaxHeight = props.pagination ? parentOfParentHeight - 160 : parentOfParentHeight
143
144 // Set a minimum height for the table when there is no data - if there is only one row, it should be at least 550px
145 const minHeight = numRows > 1 ? 0 : 550
146 const newHeight = Math.max(minHeight, Math.min(maxTableHeight, finalMaxHeight))
147
148 // Only update if the difference is significant to prevent micro-adjustments
149 if (Math.abs(newHeight - tableHeight.value) > 1) {
150 tableHeight.value = newHeight
151 }
152 }
153
154 // Keep a small stable-read check so we only emit when width is stable
155 let lastMeasured = 0
156 let stableCount = 0
157 const STABLE_REQUIRED = 2
158 const HYSTERESIS = 3 // px
159
160 const updateDimensions = (entry?: ResizeObserverEntry) => {
161 window.requestAnimationFrame(() => {
162 // Measure width
163 const measured =
164 entry?.contentRect?.width || tableContainerRef.value?.clientWidth || props.minTableWidth
165 const newWidth = Math.max(props.minTableWidth, Math.floor(measured))
166
167 if (Math.abs(newWidth - lastMeasured) <= HYSTERESIS) {
168 stableCount += 1
169 } else {
170 stableCount = 0
171 }
172
173 lastMeasured = newWidth
174
175 // Only update flooredWidth when measurement has been stable
176 if (
177 stableCount >= STABLE_REQUIRED &&
178 Math.abs(newWidth - flooredWidth.value) > HYSTERESIS
179 ) {
180 flooredWidth.value = newWidth
181 }
182
183 // Update height
184 updateHeight()
185 })
186 }
187
188 // Run once initially
189 updateDimensions()
190
191 const onWindowResize = () => updateDimensions()
192 window.addEventListener('resize', onWindowResize)
193 const resizeObserver = new window.ResizeObserver((entries) => {
194 // Use the first entry (the container) to measure contentRect.width
195 if (entries && entries.length > 0) {
196 updateDimensions(entries[0])
197 } else {
198 updateDimensions()
199 }
200 })
201 if (tableContainerRef.value) resizeObserver.observe(tableContainerRef.value)
202 onUnmounted(() => {
203 window.removeEventListener('resize', onWindowResize)
204 resizeObserver.disconnect()
205 })
206 }
207 })
208
209 observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
210
211 onUnmounted(() => {
212 observer.disconnect()
213 })
214
215 // Watch for data changes to update table height
216 watch(
217 () => props.data.length,
218 () => {
219 const rowHeight = 50
220 const numRows = props.data.length + 1
221 const maxTableHeight = numRows * rowHeight
222
223 const tableContainer = tableContainerRef.value
224 const parentOfParent = tableContainer?.parentElement?.parentElement as HTMLElement | null
225 const parentOfParentHeight = parentOfParent ? parentOfParent.offsetHeight : 0
226 const finalMaxHeight = props.pagination ? parentOfParentHeight - 160 : parentOfParentHeight
227
228 const minHeight = numRows > 1 ? 0 : 550
229 const newHeight = Math.max(minHeight, Math.min(maxTableHeight, finalMaxHeight))
230
231 if (Math.abs(newHeight - tableHeight.value) > 1) {
232 tableHeight.value = newHeight
233 }
234 },
235 )
236
237 // Watch for column changes to update scrollbar (fixes horizontal scrollbar not appearing after column visibility change)
238 watch(
239 () => props.columns?.filter((col) => col.visible).length,
240 () => {
241 nextTick(() => {
242 scrollbarRef.value?.update()
243 window.dispatchEvent(new Event('resize'))
244 })
245 },
246 )
247
248 // Format columns to use custom components inside headers and cells
249 const formattedColumns = computed(
250 () =>
251 props.columns
252 ?.filter((col) => col.visible)
253 .map((col) => ({
254 ...col,
255 sortable: col.sortable || false,
256 headerRenderer: col.headerRenderer || undefined,
257 cellRenderer:
258 col.cellRenderer || ((props: CellRenderProps) => h('span', {}, props.rowData[col.key])),
259 minWidth:
260 typeof col.minWidth === 'string' ? parseInt(col.minWidth, 10) || 100 : col.minWidth, // Ensure minWidth is a number
261 width: typeof col.width === 'string' ? parseInt(col.width, 10) || 100 : col.width, // Ensure width is a number
262 })) || [],
263 )
264
265 // Sorting state (only for UI purposes)
266 const sortColumn = ref<string | null>(null)
267 const sortOrder = ref<'asc' | 'desc' | null>(null)
268
269 // Emit sorting changes
270 const emitSortChange = (column) => {
271 if (!column.sortable) return
272
273 if (sortColumn.value === column.key) {
274 // Toggle order
275 sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
276 } else {
277 // New column selected, set ascending order
278 sortColumn.value = column.key
279 sortOrder.value = 'asc'
280 }
281
282 // Emit the sorting change event
283 const sortEvent = {
284 column: sortColumn.value,
285 order: sortOrder.value,
286 }
287 emit('sort-change', sortEvent)
288 }
289
290 // Custom row classes
291 const getRowClass = ({ rowData }: { rowData: Record<string, unknown> }) => {
292 const classes: string[] = []
293 if (rowData.status === 'unread') classes.push('ivyforms-unread-entry')
294 if (rowData.selected) classes.push('is-selected')
295 return classes.join(' ')
296 }
297 </script>
298
299 <style lang="scss">
300 .ivyforms-table__container {
301 position: relative;
302
303 &__coming-soon-title {
304 opacity: 0.5;
305 cursor: not-allowed;
306 }
307
308 // Horizontal scrollbar opacity to match vertical
309 .el-virtual-scrollbar {
310 bottom: unset !important;
311 }
312
313 .el-scrollbar__thumb {
314 opacity: var(--el-scrollbar-opacity, 0.3) !important;
315
316 &:hover {
317 background-color: var(
318 --el-scrollbar-hover-bg-color,
319 var(--el-text-color-secondary)
320 ) !important;
321 opacity: var(--el-scrollbar-hover-opacity, 0.5) !important;
322 }
323 }
324
325 .ivyforms-table__custom-header {
326 position: absolute;
327 top: 0;
328 left: 0;
329 right: 0;
330 z-index: 10;
331 background: var(--map-ground-level-1-foreground);
332 border-bottom: 1px solid var(--map-divider);
333 }
334
335 .el-auto-resizer {
336 &.loading {
337 width: 100%;
338 height: 100%;
339 min-width: 0;
340 min-height: 0;
341 overflow: hidden;
342 }
343 }
344
345 .ivyforms-table {
346 // Header
347 .el-table-v2__header {
348 border-bottom: 1px solid var(--map-divider);
349 background: var(--map-ground-level-1-foreground);
350 transition: none;
351
352 // Columns
353 .el-table-v2__header-cell {
354 display: flex;
355 background: var(--map-ground-level-1-foreground);
356 color: var(--map-base-text-0);
357 text-align: center;
358 font-size: 14px;
359 font-style: normal;
360 font-weight: 400;
361 line-height: 20px;
362
363 &:has(.el-checkbox) {
364 justify-content: center;
365 }
366
367 // Sortable
368 &.is-sortable {
369 span {
370 margin-left: 0 !important;
371 }
372 .el-table-v2__sort-icon {
373 svg {
374 display: none;
375 }
376 }
377 }
378 }
379 }
380
381 // Hide all checkbox labels
382 .el-checkbox__label {
383 display: none;
384 }
385
386 // Body
387 .el-table-v2__body {
388 background: var(--map-ground-level-1-foreground);
389 transition: none;
390
391 // Row cells with checkbox - modern browsers support
392 .el-table-v2__row-cell {
393 &:has(.el-checkbox) {
394 justify-content: center;
395 }
396 }
397
398 // Alternative approach for older browsers
399 .el-table-v2__row-cell.el-checkbox-cell {
400 justify-content: center;
401 }
402
403 // Rows
404 .el-table-v2__row {
405 display: flex;
406 height: 48px;
407 align-items: center;
408 flex-shrink: 0;
409 border-bottom: 1px solid var(--map-divider);
410 background: var(--map-ground-level-1-foreground);
411 color: var(--map-base-dusk-symbol-2);
412 transition: none;
413
414 &:hover,
415 &.hovered {
416 background: var(--map-hover);
417 }
418
419 // Selected
420 &.is-selected {
421 background:
422 linear-gradient(0deg, var(--map-accent-amber-o05) 0%, var(--map-accent-amber-o05) 100%),
423 var(--map-ground-level-1-foreground);
424 }
425 }
426 }
427 }
428 }
429
430 .el-skeleton {
431 &.ivyforms-skeleton {
432 margin: 1px;
433
434 .ivyforms-skeleton-item {
435 height: 48px;
436 }
437 }
438 }
439 </style>
440