@witchcraft/ui 0.3.14 → 0.3.16

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.
@@ -1,146 +1,362 @@
1
1
  <template>
2
- <!-- Assumes no scrollbars on children -->
3
- <table
4
- :class="twMerge(`table
5
- table-fixed
6
- border-separate
7
- border-spacing-0
8
- overflow-x-scroll
9
- scrollbar-hidden
10
- [&_.grip]:w-[5px]
11
- relative
12
- w-full
13
- box-content
14
- [&_thead]:font-bold
15
- [&_td]:p-1
16
- [&_td]:overflow-x-hidden
17
- [&.resizable-cols-error]:cursor-not-allowed
18
- [&.resizable-cols-error]:user-select-none
19
- `,
20
- cellBorder && `
21
- [&_td]:border-neutral-500
22
- [&_td:not(.last-row)]:border-b
23
- [&_td:not(.first-col)]:border-l
24
- `,
2
+ <!--
3
+ - moving the border to the wrapper is to hide the little bits of border sticking out
4
+ added back the right straight border otherwise the scrollbar looks ass
5
+ this is ever so slightly visible if there is no scrollbar
6
+
7
+ - relative is for the sticky header in dynamic mode
8
+
9
+ - dynamic mode REQUIRES grid since otherwise the transforms don't work because of how tanstack calculates them
10
+ - tried pre-calculating the transforms to take into account the previous elements (e.g. virtual.start - (height of previous rows)) but this was way to slow and buggy
11
+ -->
12
+ <div
13
+ :class="twMerge(`
14
+ table--container
15
+ overflow-auto
16
+ `,
17
+ hasScrollbar.vertical && `has-scrollbar-vertical`,
18
+ hasScrollbar.horizontal && `has-scrollbar-horizontal`,
19
+ stickyHeader && `
20
+ [&_thead]:sticky
21
+ [&_thead]:top-0
22
+ [&_thead]:z-1
23
+ [&_.grip]:z-2
24
+ `,
25
+ isPostSetup && `resizable-cols-setup`,
25
26
  border && `
26
- [&_thead_td]:bg-neutral-200
27
- [&_td]:border-neutral-500
28
- dark:[&_thead_td]:bg-neutral-800
29
- dark:[&_td]:border-neutral-500
30
- [&_td.first-row]:border-t
31
- [&_td.last-row]:border-b
32
- [&_td.last-col]:border-r
33
- [&_td.first-col]:border-l
34
- `,
27
+ border
28
+ border-neutral-500
29
+ `,
30
+ border && cellBorder && `
31
+ [&.has-scrollbar-horizontal_.last-row]:border-b
32
+ [&.has-scrollbar-horizontal_.last-row]:border-neutral-500
33
+ [&.has-scrollbar-vertical_.last-col]:border-r
34
+ [&.has-scrollbar-vertical_.last-col]:border-neutral-500
35
+ `,
36
+ (!resizableOptions.fitWidth || stickyHeader) && `
37
+ [&_td.tr]:rounded-tr-none!
38
+ [&_td.br]:rounded-br-none!
39
+ `,
40
+ // this combo prevents the x-scrollbar from showing up when it shouldn't
41
+ // and max-w-fit allows the border to shrink with the table columns
42
+ resizableOptions.fitWidth === false && `
43
+ [&_.grip]:last:translate-x-[-5px]
44
+ mr-1
45
+ max-w-fit
46
+ `,
35
47
  rounded &&`
36
- [&_td.br]:rounded-br-sm
37
- [&_td.bl]:rounded-bl-sm
38
- [&_td.tr]:rounded-tr-sm
39
- [&_td.tl]:rounded-tl-sm
48
+ rounded-md
49
+ `,
50
+ mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic' && `
51
+ relative
40
52
  `,
41
- ($attrs as any).class)"
42
- v-resizable-cols="resizableOptions"
53
+ ($attrs as any).wrapperClass)"
54
+ ref="parentRef"
43
55
  >
44
- <thead
45
- v-if="header"
46
- class="table--header"
56
+ <div
57
+ class="table--inner-container"
58
+ :style="{
59
+ ...(mergedVirtualizerOpts.enabled
60
+ ? { height: `${totalSize}px` }
61
+ : {})
62
+ }"
47
63
  >
48
- <tr class="table--row">
49
- <template
50
- v-for="col, i of cols"
51
- :key="col"
64
+ <!-- https://github.com/TanStack/virtual/issues/640#issuecomment-2795731690 -->
65
+ <table
66
+ :style="{
67
+ ...(stickyHeader && mergedVirtualizerOpts.enabled
68
+ ? { '--table-sticky-fix': `${totalSize-tableHeight}px` }
69
+ : {}),
70
+ ...($attrs as any).style ?? {}
71
+ }"
72
+ :class="twMerge(`
73
+ table
74
+ table-fixed
75
+ border-separate
76
+ border-spacing-0
77
+ scrollbar-hidden
78
+ [&_.grip]:w-[5px]
79
+ relative
80
+ w-full
81
+ box-content
82
+ [&_thead]:font-bold
83
+ [&_td]:p-1
84
+ [&_th]:p-1
85
+ [&.resizable-cols-error]:cursor-not-allowed
86
+ [&.resizable-cols-error]:user-select-none
87
+ [&_thead_th]:bg-neutral-200
88
+ dark:[&_thead_th]:bg-neutral-800
89
+ `,
90
+ isPostSetup && mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic' && `
91
+ grid
92
+ `,
93
+ stickyHeader && mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'fixed' && `
94
+ after:inline-block
95
+ after:h-(--table-sticky-fix)
96
+ `,
97
+ cellBorder && `
98
+ [&_td]:border-neutral-500
99
+ [&_td:not(.last-row)]:border-b
100
+ [&_td:not(.first-col)]:border-l
101
+ [&_th]:border-neutral-500
102
+ [&_th:not(.last-row)]:border-b
103
+ [&_th:not(.first-col)]:border-l
104
+ `,
105
+ !cellBorder && `
106
+ [&_.grip]:hover:bg-neutral-300
107
+ dark:[&_.grip]:hover:bg-neutral-700
108
+ `,
109
+ ($attrs as any).class)"
110
+ v-resizable-cols="resizableOptions"
111
+ >
112
+ <thead
113
+ v-if="header"
114
+ :class="twMerge(
115
+ `table--header`,
116
+ isPostSetup && mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic' && `
117
+ grid
118
+ top-0
119
+ `
120
+ )"
52
121
  >
53
- <slot
54
- :name="`header-${col.toString()}`"
55
- :class="[
56
- extraClasses[`-1-${i}`],
57
- 'cell table--header-cell',
58
- (colConfig as any)[col]?.resizable === false
59
- ? 'no-resize'
60
- : ''
61
- ].join(' ')"
62
- :style="`width:${widths.length > 0 ? widths[i] : ``}; `"
63
- :col-key="col"
64
- :config="(colConfig as any)[col]"
122
+ <tr
123
+ :class="twMerge(
124
+ `table--row`,
125
+ isPostSetup && mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic'
126
+ && `flex w-full`
127
+ )"
65
128
  >
66
- <td
67
- :class="[
68
- extraClasses[`-1-${i}`],
69
- 'cell table--header-cell',
70
- (colConfig as any)[col]?.resizable === false
71
- ? 'no-resize'
72
- : ''
73
- ].join(' ')"
74
- :style="`width:${widths.length > 0 ? widths[i] : ``}; `"
129
+ <template
130
+ v-for="col, i of cols"
131
+ :key="col"
75
132
  >
76
- {{ (colConfig as any)[col]?.name ?? col }}
77
- </td>
78
- </slot>
79
- </template>
80
- </tr>
81
- </thead>
82
- <tbody class="table--body">
83
- <template
84
- v-for="item, i of values"
85
- :key="typeof itemKey === 'function' ? itemKey(item) : item[itemKey]"
86
- >
87
- <tr class="table--row">
133
+ <slot
134
+ :name="`header-${col.toString()}`"
135
+ :class="classes[`-1-${i}`]"
136
+ :style="{ width: widths[i] }"
137
+ :col-key="col"
138
+ :config="(colConfig as any)[col]"
139
+ >
140
+ <th
141
+ :class="classes[`-1-${i}`]"
142
+ :style="{ width: widths[i] }"
143
+ >
144
+ {{ (colConfig as any)[col]?.name ?? col }}
145
+ </th>
146
+ </slot>
147
+ </template>
148
+ </tr>
149
+ </thead>
150
+ <tbody
151
+ :class="twMerge(
152
+ `table--body`,
153
+ isPostSetup && mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic' && `
154
+ grid
155
+ relative
156
+ `
157
+ )"
158
+ :style="{
159
+ ...(mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic'
160
+ ? { height: `${totalSize}px` }
161
+ : {})
162
+ }"
163
+ >
88
164
  <template
89
- v-for="col, j of cols"
90
- :key="(typeof itemKey === 'function' ? itemKey(item) : item[itemKey]) + col.toString()"
165
+ v-for="(virtual, index) in virtualList"
166
+ :key="virtual.key"
91
167
  >
92
- <slot
93
- :name="col"
94
- :item="item"
95
- :value="item[col]"
96
- :class="extraClasses[`${i}-${j}`] + 'table--cell cell'"
168
+ <tr
169
+ :class="twMerge(`
170
+ table--row
171
+ `,
172
+ isPostSetup && mergedVirtualizerOpts.enabled && mergedVirtualizerOpts.method === 'dynamic' && `
173
+ flex
174
+ absolute
175
+ w-full
176
+ `,
177
+ isPostSetup && mergedVirtualizerOpts.enabled && ` will-change-transform `
178
+ )"
179
+ :style="{
180
+ ...(mergedVirtualizerOpts.enabled
181
+ ? {
182
+ transform: mergedVirtualizerOpts.method === 'fixed'
183
+ ? `translateY(${virtual.start - index * virtual.size!}px)`
184
+ : `translateY(${virtual.start}px)`,
185
+ height: virtual.size
186
+ }
187
+ : {})
188
+ }"
189
+ :data-index="virtual.index"
190
+ :ref="measureElement"
97
191
  >
98
- <td :class="extraClasses[`${i}-${j}`] + 'table--cell cell'">
99
- {{ item[col] }}
100
- </td>
101
- </slot>
192
+ <template
193
+ v-for="col, j of cols"
194
+ :key="virtual.key + '-' + col.toString()"
195
+ >
196
+ <slot
197
+ :name="col"
198
+ :item="values[virtual.index]"
199
+ :value="values[virtual.index][col]"
200
+ :style="{ width: widths[j] }"
201
+ :class="classes[`${virtual.index}-${j}`]"
202
+ >
203
+ <td
204
+ :style="{ width: widths[j] }"
205
+ :class="classes[`${virtual.index}-${j}`]"
206
+ >
207
+ {{ values[virtual.index][col] }}
208
+ </td>
209
+ </slot>
210
+ </template>
211
+ </tr>
102
212
  </template>
103
- </tr>
104
- </template>
105
- </tbody>
106
- </table>
213
+ </tbody>
214
+ </table>
215
+ </div>
216
+ </div>
107
217
  </template>
108
218
 
109
219
  <!-- generic="T extends Record<string, any> -->"
110
220
  <script setup lang="ts" generic="T">
111
221
  import type { MakeRequired } from "@alanscodelog/utils"
112
222
  import { keys } from "@alanscodelog/utils/keys"
113
- import { computed, ref, type TableHTMLAttributes } from "vue"
223
+ import { throttle } from "@alanscodelog/utils/throttle"
224
+ import { useVirtualizer, type VirtualizerOptions } from "@tanstack/vue-virtual"
225
+ import { computed, onMounted, ref, type TableHTMLAttributes } from "vue"
114
226
 
227
+ import { useGlobalResizeObserver } from "../../composables/useGlobalResizeObserver.js"
115
228
  import { vResizableCols } from "../../directives/vResizableCols.js"
116
229
  import type { ResizableOptions, TableColConfig } from "../../types/index.js"
117
230
  import { twMerge } from "../../utils/twMerge.js"
118
231
  import type { TailwindClassProp } from "../shared/props.js"
119
232
 
120
233
  defineOptions({
121
- name: "LibTable"
234
+ name: "LibTable",
235
+ inheritAttrs: false
122
236
  })
123
237
 
124
238
  const props = withDefaults(defineProps<Props>(), {
125
239
  resizable: () => ({}),
126
240
  values: () => [] as T[],
127
- itemKey: "",
128
241
  cols: () => [] as (keyof T)[],
129
242
  rounded: true,
130
243
  border: true,
131
244
  cellBorder: true,
132
245
  header: true,
133
- colConfig: () => ({})
246
+ colConfig: () => ({}),
247
+ virtualizerOptions: () => ({ }),
248
+ enableStickyHeader: false,
249
+ itemKey: ""
134
250
  })
135
251
 
136
252
  const widths = ref([])
253
+
254
+
255
+ const isPostSetup = ref(false)
137
256
  const resizableOptions = computed<MakeRequired<Partial<ResizableOptions>, "colCount" | "widths">>(() => ({
138
257
  colCount: props.cols.length,
139
258
  widths,
140
259
  selector: ".cell",
141
- ...props.resizable
260
+ ...props.resizable,
261
+ onSetup: el => {
262
+ isPostSetup.value = true
263
+ if (props.resizable.onSetup) {
264
+ props.resizable.onSetup(el)
265
+ }
266
+ },
267
+ onTeardown: el => {
268
+ isPostSetup.value = false
269
+ if (props.resizable.onTeardown) {
270
+ props.resizable.onTeardown(el)
271
+ }
272
+ }
142
273
  }))
143
274
 
275
+
276
+ const parentRef = ref<HTMLElement | null>(null)
277
+ const mergedVirtualizerOpts = computed(() => {
278
+ return {
279
+ // we have to put the defaults here as they can't reference local variables
280
+ count: props.values.length,
281
+ getScrollElement: () => parentRef.value,
282
+ estimateSize: () => { return 33 },
283
+ overscan: props.virtualizerOptions?.overscan ?? (props.virtualizerOptions?.method === "dynamic" ? 10 : 50),
284
+ method: "fixed",
285
+ enabled: false,
286
+ ...props.virtualizerOptions
287
+ } satisfies Partial<VirtualizerOptions<any, any>> & { method: "fixed" | "dynamic" }
288
+ })
289
+
290
+ const rowVirtualizer = useVirtualizer(mergedVirtualizerOpts)
291
+
292
+ const virtualList = computed(() => {
293
+ return mergedVirtualizerOpts.value.enabled
294
+ ? rowVirtualizer.value.getVirtualItems()
295
+ : props.values.map((_, i) => ({
296
+ index: i,
297
+ size: undefined,
298
+ start: 0,
299
+ end: 0,
300
+ key: typeof props.itemKey === "function"
301
+ ? props.itemKey(_)
302
+ : props.itemKey
303
+ ? props.values[props.itemKey as keyof typeof props.values]
304
+ : i
305
+ }))
306
+ })
307
+
308
+ const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
309
+
310
+ function measureElement(el: any): void {
311
+ if (!el || !mergedVirtualizerOpts.value.enabled) return
312
+ if (mergedVirtualizerOpts.value?.method === "dynamic") {
313
+ rowVirtualizer.value.measureElement(el)
314
+ }
315
+ }
316
+
317
+ function forceRecalculateFixedVirtualizer() {
318
+ if (mergedVirtualizerOpts.value?.method === "dynamic" || !mergedVirtualizerOpts.value.enabled) return
319
+ if (!parentRef.value) {
320
+ throw new Error("forceRecalculateFixedVirtualizer cannot be called before the table is mounted.")
321
+ }
322
+ const height = parentRef.value.querySelector("td")?.getBoundingClientRect().height
323
+ if (!height) return
324
+ for (let i = 0; i < props.values.length; i++) {
325
+ rowVirtualizer.value.resizeItem(i, height)
326
+ }
327
+ }
328
+
329
+ const tableHeight = ref(0)
330
+ function updateTableHeight(): void {
331
+ if (!parentRef.value) return
332
+ const el = parentRef.value.querySelector("tbody")
333
+ if (!el) return
334
+ if (tableHeight.value === el.getBoundingClientRect().height) return
335
+ tableHeight.value = el.getBoundingClientRect().height
336
+ }
337
+ const throttledUpdateTableHeight = throttle(updateTableHeight, 100, { leading: true })
338
+
339
+
340
+ onMounted(() => {
341
+ throttledUpdateTableHeight()
342
+ forceRecalculateFixedVirtualizer()
343
+ useGlobalResizeObserver(parentRef, onResize)
344
+ })
345
+
346
+
347
+ const hasScrollbar = ref({ vertical: false, horizontal: false })
348
+ function onResize(): void {
349
+ const el = parentRef.value
350
+ if (!el) return
351
+ hasScrollbar.value = {
352
+ vertical: el.scrollHeight > el.clientHeight,
353
+ horizontal: el.scrollWidth > el.clientWidth
354
+ }
355
+ if (hasScrollbar.value.vertical) {
356
+ throttledUpdateTableHeight()
357
+ }
358
+ }
359
+
144
360
  /* props.values.length instead of `props.values.length - 1` because we're creating an artificial first row for the header */
145
361
  const getExtraClasses = (row: number, col: number, isHeader: boolean): string[] => {
146
362
  const res = {
@@ -156,15 +372,29 @@ const getExtraClasses = (row: number, col: number, isHeader: boolean): string[]
156
372
  return keys(res).filter(key => res[key])
157
373
  }
158
374
 
159
- const extraClasses = computed(() => Object.fromEntries([...Array(props.values.length + 1).keys()]
160
- .map(row => [...Array(props.cols.length).keys()]
161
- .map(col =>
162
- [
163
- `${row - 1}-${col}`,
164
- " " + getExtraClasses(row <= 0 ? 0 : row - 1, col, row === 0).join(" ") + " "
165
- ]))
166
- .flat()
167
- ))
375
+ const classes = computed(() => {
376
+ const res: Record<string, string> = {}
377
+ const headerTdClass = `table--header-cell cell truncate`
378
+ const bodyTdClass = `table--cell cell truncate`
379
+ for (let i = -1; i < props.values.length + 1; i++) {
380
+ for (let j = 0; j < props.cols.length; j++) {
381
+ const col = props.cols[j]!
382
+ const colConfig = props.colConfig[col]
383
+ const key = `${i}-${j}`
384
+ res[key] = twMerge(
385
+ getExtraClasses(i, j, i === -1).join(" "),
386
+ i === -1 ? headerTdClass : bodyTdClass,
387
+ i === -1 ? colConfig?.resizable === false && `no-resize` : undefined,
388
+ i !== -1 && mergedVirtualizerOpts.value.enabled && mergedVirtualizerOpts.value.method === "dynamic" && `flex`
389
+ )
390
+ }
391
+ }
392
+ return res
393
+ })
394
+
395
+ defineExpose({
396
+ forceRecalculateFixedVirtualizer
397
+ })
168
398
  </script>
169
399
 
170
400
  <script lang="ts">
@@ -174,7 +404,6 @@ type T = any
174
404
  type RealProps = {
175
405
  resizable?: Partial<ResizableOptions>
176
406
  values?: T[]
177
- itemKey?: keyof T | ((item: T) => string)
178
407
  /** Let's the table know the shape of the data since values might be empty. */
179
408
  cols?: (keyof T)[]
180
409
  rounded?: boolean
@@ -182,6 +411,54 @@ type RealProps = {
182
411
  cellBorder?: boolean
183
412
  header?: boolean
184
413
  colConfig?: TableColConfig<T>
414
+ /**
415
+ * See tanstack/vue-virtual {@link https://tanstack.com/virtual/latest/docs/api/virtualizer}
416
+ *
417
+ * The defaults are:
418
+ *
419
+ * - enabled: false
420
+ * - method: "fixed"
421
+ * - overscan: (50 if fixed, 10 if dynamic)
422
+ * - estimateSize: () => { return 33 }
423
+ *
424
+ * This also has an additional option, `method`, which can be set to `fixed` or `dynamic` (experimental).
425
+ *
426
+ * Notes:
427
+ *
428
+ * - Because of how virtualization works, initial layout (before .resizable-cols-setup class is applied) will only have access to the headers and not the rows. This can cause cols to look very small, especially if using resizable.fitWidth false.
429
+ *
430
+ * ### Fixed
431
+ *
432
+ * `fixed` is the default and will set the height of ALL items to the height of the first item onMounted (tanstack does not do this and if your estimateSize if off, the scrolling is weird).
433
+ *
434
+ * Since the table now truncates rows by default, they will always be the same height unless you change the inner styling. In fixed mode, `forceRecalculateFixedVirtualizer` is exposed if you need to force re-calculation.
435
+ *
436
+ * If using slots, be sure to at least pass the `class` slot prop to the td element. `style` with width is also supplied but is not required if you're displaying the table as a table.
437
+ *
438
+ * ### Dynamic (experimental)
439
+ *
440
+ * In `dynamic` mode we use tanstack's measureElement method. This is more expensive, but it will work with any heights.
441
+ *
442
+ * Dynamic mode also requires the table displays itself using grid and flex post setup as otherwise dynamic mode doesn't work.
443
+ *
444
+ * You don't need to do anything unless using slots. If using slots, pass the given `ref` slot prop to ref (internally this is tanstack's measureElement) and the class and style slot props at the very least:
445
+ * ```vue
446
+ * <template #[`${colName}`]="slotProps">
447
+ * <td
448
+ * :ref="slotProps.ref"
449
+ * :class="slotProps.class"
450
+ * :style="slotProps.style"
451
+ * >
452
+ * {{ slotProps.value }}
453
+ * </td>
454
+ * </template>
455
+ * ```
456
+ */
457
+ virtualizerOptions?: Partial<VirtualizerOptions<any, any>> & { method?: "fixed" | "dynamic" }
458
+ /** Whether to enable sticky header styles. This requires `border:false`. This moves the border to the wrapper and styles a straight border between the scroll bar and the rounded border. */
459
+ stickyHeader?: boolean
460
+ /** Which key to use for the rows (only if not using virtualization). */
461
+ itemKey?: keyof T | ((item: T) => string)
185
462
  }
186
463
  interface Props
187
464
  extends