PluginProbe
The Innovative Form Builder – IvyForms / 0.8.1
The Innovative Form Builder – IvyForms v0.8.1
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.1, at frontend/src/views/_components/table/IvyTable.vue

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