webcake-ui-kit 1.1.19 → 1.1.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-ui-kit",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "description": "UI Kit for Vue 2 && 3 - Pure Options API",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -51,6 +51,7 @@
51
51
  "./WkTable": "./src/components/table/Table.vue",
52
52
  "./WkTabs": "./src/components/tabs/Tabs.vue",
53
53
  "./WkTag": "./src/components/tag/Tag.vue",
54
+ "./WkTextarea": "./src/components/textarea/Textarea.vue",
54
55
  "./WkToggle": "./src/components/toggle/Toggle.vue",
55
56
  "./WkToggleGroup": "./src/components/toggle-group/ToggleGroup.vue",
56
57
  "./WkTooltip": "./src/components/tooltip/Tooltip.vue",
@@ -35,6 +35,7 @@
35
35
  @click="onHeaderClick(col)"
36
36
  @keydown.enter.prevent="onHeaderClick(col)"
37
37
  @keydown.space.prevent="onHeaderClick(col)"
38
+ v-bind="customHeader ? customHeader(col, i) : {}"
38
39
  >
39
40
  <span class="ui-table__th-inner">
40
41
  <slot name="headerCell" :column="col">{{ col.title }}</slot>
@@ -57,7 +58,7 @@
57
58
  />
58
59
  </tr>
59
60
  </thead>
60
- <tbody class="ui-table__body" :style="bodyStyle">
61
+ <tbody class="ui-table__body" :style="bodyStyle" @scroll="onBodyScroll">
61
62
  <tr v-if="!rows.length" class="ui-table__row ui-table__row--empty">
62
63
  <td class="ui-table__cell ui-table__empty-cell" :colspan="totalColumns">
63
64
  <slot name="emptyText">
@@ -65,33 +66,56 @@
65
66
  </slot>
66
67
  </td>
67
68
  </tr>
68
- <tr
69
- v-for="(record, index) in rows"
70
- v-else
71
- :key="getRowKey(record, index)"
72
- :class="['ui-table__row', 'ui-table__row--body', isSelected(record, index) && 'ui-table__row--selected']"
73
- :style="{ height: toCssSize(rowHeight) }"
74
- @click="onRowClick(record, index, $event)"
75
- >
76
- <td v-if="hasSelection" class="ui-table__cell ui-table__td ui-table__cell--selection">
77
- <WkCheckbox :checked="isSelected(record, index)" @change="checked => onToggleRow(record, index, checked)" />
78
- </td>
79
- <td
80
- v-for="col in columns"
81
- :key="colKey(col)"
69
+ <template v-else>
70
+ <!-- Top spacer reserves the height of the rows scrolled out above the window. -->
71
+ <tr v-if="isVirtual" class="ui-table__spacer" :style="{ height: topSpacerHeight + 'px' }" aria-hidden="true">
72
+ <td class="ui-table__spacer-cell" :colspan="totalColumns" />
73
+ </tr>
74
+ <tr
75
+ v-for="(record, i) in renderRows"
76
+ :key="getRowKey(record, rowIndex(i))"
82
77
  :class="[
83
- 'ui-table__cell',
84
- 'ui-table__td',
85
- `ui-table__cell--${alignOf(col)}`,
86
- col.ellipsis && 'ui-table__cell--ellipsis'
78
+ 'ui-table__row',
79
+ 'ui-table__row--body',
80
+ isSelected(record, rowIndex(i)) && 'ui-table__row--selected'
87
81
  ]"
88
- :style="colStyle(col)"
82
+ :style="{ height: toCssSize(rowHeight) }"
83
+ v-bind="customRow ? customRow(record, rowIndex(i)) : {}"
84
+ @click="hasSelection ? onRowClick(record, rowIndex(i), $event) : undefined"
89
85
  >
90
- <slot name="bodyCell" :column="col" :record="record" :text="cellText(col, record)" :index="index">{{
91
- cellText(col, record)
92
- }}</slot>
93
- </td>
94
- </tr>
86
+ <td
87
+ v-if="hasSelection"
88
+ class="ui-table__cell ui-table__td ui-table__cell--selection"
89
+ @click.stop="onSelectionCellClick(record, rowIndex(i), $event)"
90
+ >
91
+ <WkCheckbox :checked="isSelected(record, rowIndex(i))" />
92
+ </td>
93
+ <td
94
+ v-for="col in columns"
95
+ :key="colKey(col)"
96
+ :class="[
97
+ 'ui-table__cell',
98
+ 'ui-table__td',
99
+ `ui-table__cell--${alignOf(col)}`,
100
+ col.ellipsis && 'ui-table__cell--ellipsis'
101
+ ]"
102
+ :style="colStyle(col)"
103
+ >
104
+ <slot name="bodyCell" :column="col" :record="record" :text="cellText(col, record)" :index="rowIndex(i)">{{
105
+ cellText(col, record)
106
+ }}</slot>
107
+ </td>
108
+ </tr>
109
+ <!-- Bottom spacer reserves the height of the rows below the window. -->
110
+ <tr
111
+ v-if="isVirtual"
112
+ class="ui-table__spacer"
113
+ :style="{ height: bottomSpacerHeight + 'px' }"
114
+ aria-hidden="true"
115
+ >
116
+ <td class="ui-table__spacer-cell" :colspan="totalColumns" />
117
+ </tr>
118
+ </template>
95
119
  </tbody>
96
120
  </table>
97
121
  </div>
@@ -104,6 +128,7 @@ import WkiArrowUp from '../../icons/ArrowUp.vue'
104
128
  import WkiArrowDown from '../../icons/ArrowDown.vue'
105
129
  import WkiArrowDownUp from '../../icons/ArrowDownUp.vue'
106
130
  import { toCssSize } from '../../utils/common'
131
+ import { computeVirtualWindow } from '../../utils/virtualScroll'
107
132
 
108
133
  function defaultCompare(a, b) {
109
134
  if (a == null && b == null) return 0
@@ -137,9 +162,16 @@ export default {
137
162
  // Fixed overall height of the table viewport (px number or CSS size). Enables vertical scroll + sticky header.
138
163
  height: { type: [Number, String], default: null },
139
164
  rowHeight: { type: [Number, String], default: 39 },
140
- headerHeight: { type: [Number, String], default: 39 }
165
+ headerHeight: { type: [Number, String], default: 39 },
166
+ customRow: { type: Function, default: null },
167
+ customHeader: { type: Function, default: null },
168
+ // Virtualize the body: render only the rows visible in the viewport (+ overscan).
169
+ // Requires a vertical-scroll viewport (height or scroll.y) and a numeric rowHeight.
170
+ virtual: { type: Boolean, default: false },
171
+ // Extra rows rendered above and below the viewport to cushion fast scrolling.
172
+ overscan: { type: Number, default: 8 }
141
173
  },
142
- emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change', 'row-click'],
174
+ emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change'],
143
175
  data() {
144
176
  return {
145
177
  sortField: null,
@@ -147,6 +179,10 @@ export default {
147
179
  internalSelected: this.selectedRowKeys.slice(),
148
180
  scrollbarWidth: 0,
149
181
  colWidths: [],
182
+ lastClickedIndex: null,
183
+ scrollTop: 0,
184
+ containerHeight: 0,
185
+ rafId: null,
150
186
  toCssSize
151
187
  }
152
188
  },
@@ -188,6 +224,39 @@ export default {
188
224
  tableStyle() {
189
225
  const scroll = this.scroll || {}
190
226
  return scroll.x != null ? { minWidth: toCssSize(scroll.x) } : {}
227
+ },
228
+ // Numeric row height — virtualization needs a fixed px value. String sizes are
229
+ // parsed (e.g. '40px' -> 40); anything unparseable falls back to the prop default.
230
+ rowHeightPx() {
231
+ if (typeof this.rowHeight === 'number') return this.rowHeight
232
+ const parsed = parseFloat(this.rowHeight)
233
+ return Number.isFinite(parsed) ? parsed : 39
234
+ },
235
+ // Virtualization is only meaningful inside a bounded, scrollable viewport.
236
+ isVirtual() {
237
+ return this.virtual && this.hasVerticalScroll && this.rows.length > 0
238
+ },
239
+ virtualWindow() {
240
+ return computeVirtualWindow({
241
+ scrollTop: this.scrollTop,
242
+ rowHeight: this.rowHeightPx,
243
+ containerHeight: this.containerHeight,
244
+ total: this.rows.length,
245
+ overscan: this.overscan
246
+ })
247
+ },
248
+ // Rows actually committed to the DOM: a slice when virtual, otherwise everything.
249
+ renderRows() {
250
+ if (!this.isVirtual) return this.rows
251
+ return this.rows.slice(this.virtualWindow.startIndex, this.virtualWindow.endIndex)
252
+ },
253
+ topSpacerHeight() {
254
+ return this.isVirtual ? this.virtualWindow.offsetY : 0
255
+ },
256
+ bottomSpacerHeight() {
257
+ if (!this.isVirtual) return 0
258
+ const rendered = this.renderRows.length * this.rowHeightPx
259
+ return Math.max(0, this.virtualWindow.totalHeight - this.virtualWindow.offsetY - rendered)
191
260
  }
192
261
  },
193
262
  watch: {
@@ -197,15 +266,48 @@ export default {
197
266
  },
198
267
  mounted() {
199
268
  this.$nextTick(this.syncScrollbarGutter)
269
+ window.addEventListener('resize', this.onResize)
200
270
  },
201
271
  updated() {
202
272
  this.$nextTick(this.syncScrollbarGutter)
203
273
  },
274
+ // Declare both teardown hooks: Vue 2.7 fires beforeDestroy, Vue 3.4 fires beforeUnmount.
275
+ // The runtime that doesn't recognize a hook simply ignores it.
276
+ beforeUnmount() {
277
+ this.teardown()
278
+ },
204
279
  methods: {
280
+ teardown() {
281
+ window.removeEventListener('resize', this.onResize)
282
+ if (this.rafId != null) {
283
+ cancelAnimationFrame(this.rafId)
284
+ this.rafId = null
285
+ }
286
+ },
287
+ onResize() {
288
+ this.syncScrollbarGutter()
289
+ },
290
+ onBodyScroll(e) {
291
+ const top = e.target.scrollTop
292
+ // Coalesce bursts of scroll events into a single state update per frame so we
293
+ // re-slice at most once per repaint — keeps 10k+ rows smooth.
294
+ if (this.rafId != null) return
295
+ this.rafId = requestAnimationFrame(() => {
296
+ this.rafId = null
297
+ this.scrollTop = top
298
+ })
299
+ },
300
+ // Absolute index of a rendered row within the full (sorted) dataset.
301
+ rowIndex(i) {
302
+ return this.isVirtual ? this.virtualWindow.startIndex + i : i
303
+ },
205
304
  syncScrollbarGutter() {
206
305
  if (!this.$el) return
207
306
  const body = this.$el.querySelector('.ui-table__body')
208
307
  this.scrollbarWidth = body && this.hasVerticalScroll ? body.offsetWidth - body.clientWidth : 0
308
+ if (body && this.hasVerticalScroll) {
309
+ this.containerHeight = body.clientHeight
310
+ }
209
311
 
210
312
  if (this.hasVerticalScroll) {
211
313
  const firstRow = this.$el.querySelector('.ui-table__row--body')
@@ -289,19 +391,48 @@ export default {
289
391
  this.$emit('update:selectedRowKeys', keys)
290
392
  this.$emit('selection-change', keys, selectedRows)
291
393
  },
292
- onToggleRow(record, index, checked) {
293
- const key = this.getRowKey(record, index)
294
- const set = this.internalSelected.filter(k => k !== key)
295
- if (checked) set.push(key)
296
- this.internalSelected = set
394
+ handleSelection(record, index, checked, e) {
395
+ const isShiftKey = e && e.shiftKey
396
+
397
+ if (isShiftKey && this.lastClickedIndex != null) {
398
+ // Shift+click: select range from lastClickedIndex to current index
399
+ const start = Math.min(this.lastClickedIndex, index)
400
+ const end = Math.max(this.lastClickedIndex, index)
401
+ const rangeKeys = []
402
+ for (let i = start; i <= end; i++) {
403
+ rangeKeys.push(this.getRowKey(this.rows[i], i))
404
+ }
405
+ // Merge with existing selection (union)
406
+ const set = new Set(this.internalSelected)
407
+ rangeKeys.forEach(k => set.add(k))
408
+ this.internalSelected = Array.from(set)
409
+ } else {
410
+ // Normal click / Cmd+click: toggle individual row
411
+ const key = this.getRowKey(record, index)
412
+ const set = this.internalSelected.filter(k => k !== key)
413
+ if (checked) set.push(key)
414
+ this.internalSelected = set
415
+ }
416
+ this.lastClickedIndex = index
297
417
  this.emitSelection()
298
418
  },
419
+ onSelectionCellClick(record, index, e) {
420
+ // Click on the checkbox <td> — MouseEvent has modifier keys
421
+ const currentlySelected = this.isSelected(record, index)
422
+ this.handleSelection(record, index, !currentlySelected, e)
423
+ },
424
+ onRowClick(record, index, e) {
425
+ // Click on the row body — handle Shift/Cmd selection
426
+ const isMetaKey = e.metaKey || e.ctrlKey
427
+ const isShiftKey = e.shiftKey
428
+ if (!isMetaKey && !isShiftKey) return
429
+ const key = this.getRowKey(record, index)
430
+ const currentlySelected = this.internalSelected.includes(key)
431
+ this.handleSelection(record, index, !currentlySelected, e)
432
+ },
299
433
  onToggleAll(checked) {
300
434
  this.internalSelected = checked ? this.rows.map((record, index) => this.getRowKey(record, index)) : []
301
435
  this.emitSelection()
302
- },
303
- onRowClick(record, index, event) {
304
- this.$emit('row-click', record, index, event)
305
436
  }
306
437
  }
307
438
  }
@@ -195,3 +195,22 @@
195
195
  .ui-table--scroll-y .ui-table__cell--selection {
196
196
  width: 44px;
197
197
  }
198
+
199
+ /* ---------- Virtual scroll spacers ---------- */
200
+ /*
201
+ * Spacer rows reserve the scroll height of the off-window rows. They must paint
202
+ * nothing (no border/padding/background) so the row-height math stays exact, and
203
+ * are display:block so their inline height is honored verbatim inside the
204
+ * block-level scrolling tbody (the real rows are display:table; table-layout:fixed).
205
+ */
206
+ .ui-table__spacer {
207
+ display: block;
208
+ padding: 0;
209
+ border: none;
210
+ background: transparent;
211
+ }
212
+
213
+ .ui-table__spacer-cell {
214
+ padding: 0;
215
+ border: none;
216
+ }
@@ -1,7 +1,7 @@
1
1
  .ui-tag {
2
2
  display: inline-flex;
3
3
  align-items: center;
4
- gap: 6px;
4
+ gap: var(--spacing-6);
5
5
  padding: var(--spacing-3xs) var(--spacing-xs);
6
6
  border-radius: var(--rounded-lg);
7
7
  font-family: var(--font-family-body);
@@ -35,6 +35,10 @@
35
35
  /* — sizes — */
36
36
  /* sm: mini font, 6px radius, 2px 8px padding — same as base */
37
37
 
38
+ .ui-tag--sm {
39
+ gap: var(--spacing-2xs);
40
+ }
41
+
38
42
  .ui-tag--md {
39
43
  border-radius: var(--radius);
40
44
  font-size: var(--paragraph-small-font-size);
@@ -0,0 +1,111 @@
1
+ <template>
2
+ <span
3
+ :class="[
4
+ 'ui-textarea',
5
+ `ui-textarea--size-${size}`,
6
+ `ui-textarea--round-${roundness}`,
7
+ error && 'ui-textarea--error',
8
+ disabled && 'ui-textarea--disabled',
9
+ $attrs.class
10
+ ]"
11
+ >
12
+ <textarea
13
+ ref="textarea"
14
+ class="ui-textarea__field"
15
+ :class="{ 'ui-textarea__field--no-resize': !resizable || autosize }"
16
+ :value="currentValue"
17
+ :placeholder="placeholder"
18
+ :disabled="disabled"
19
+ :readonly="readonly"
20
+ :name="name"
21
+ :rows="rows"
22
+ :maxlength="maxLength"
23
+ v-bind="{ ...$attrs, class: undefined }"
24
+ @input="onInput"
25
+ @focus="onFocus"
26
+ @blur="onBlur"
27
+ ></textarea>
28
+ <span v-if="showCounter" class="ui-textarea__counter"
29
+ >{{ charCount }}<template v-if="maxLength !== undefined"> / {{ maxLength }}</template></span
30
+ >
31
+ </span>
32
+ </template>
33
+
34
+ <script>
35
+ export default {
36
+ name: 'Textarea',
37
+ inheritAttrs: false,
38
+ props: {
39
+ size: {
40
+ type: String,
41
+ default: 'default',
42
+ validator: v => ['default', 'mini'].includes(v)
43
+ },
44
+ roundness: {
45
+ type: String,
46
+ default: 'default',
47
+ validator: v => ['default', 'round'].includes(v)
48
+ },
49
+ value: { type: [String, Number], default: '' },
50
+ modelValue: { type: [String, Number], default: undefined },
51
+ placeholder: { type: String, default: '' },
52
+ name: { type: String, default: '' },
53
+ rows: { type: [String, Number], default: 3 },
54
+ error: { type: Boolean, default: false },
55
+ disabled: { type: Boolean, default: false },
56
+ readonly: { type: Boolean, default: false },
57
+ resizable: { type: Boolean, default: true },
58
+ autosize: { type: Boolean, default: false },
59
+ showCount: { type: Boolean, default: false },
60
+ maxLength: { type: Number, default: undefined }
61
+ },
62
+ emits: ['input', 'change', 'update:modelValue', 'focus', 'blur'],
63
+ computed: {
64
+ currentValue() {
65
+ return this.modelValue !== undefined ? this.modelValue : this.value
66
+ },
67
+ charCount() {
68
+ return String(this.currentValue).length
69
+ },
70
+ showCounter() {
71
+ return this.showCount || this.maxLength !== undefined
72
+ }
73
+ },
74
+ watch: {
75
+ currentValue() {
76
+ this.$nextTick(this.resize)
77
+ }
78
+ },
79
+ mounted() {
80
+ this.$nextTick(this.resize)
81
+ },
82
+ methods: {
83
+ focus() {
84
+ if (this.$refs.textarea) this.$refs.textarea.focus()
85
+ },
86
+ blur() {
87
+ if (this.$refs.textarea) this.$refs.textarea.blur()
88
+ },
89
+ resize() {
90
+ if (!this.autosize || !this.$refs.textarea) return
91
+ const el = this.$refs.textarea
92
+ el.style.height = 'auto'
93
+ el.style.height = el.scrollHeight + 'px'
94
+ },
95
+ onInput(e) {
96
+ const next = e.target.value
97
+ this.$emit('input', next)
98
+ this.$emit('update:modelValue', next)
99
+ this.$emit('change', next, e)
100
+ },
101
+ onFocus(e) {
102
+ this.$emit('focus', e.target.value, e)
103
+ },
104
+ onBlur(e) {
105
+ this.$emit('blur', e.target.value, e)
106
+ }
107
+ }
108
+ }
109
+ </script>
110
+
111
+ <style src="./textarea.css" scoped></style>
@@ -0,0 +1,121 @@
1
+ .ui-textarea {
2
+ display: block;
3
+ width: 100%;
4
+ background: var(--input);
5
+ border: 1px solid var(--border-primary);
6
+ box-shadow: var(--shadow-xs);
7
+ font-family: var(--font-family-body);
8
+ color: var(--primary-fg);
9
+ box-sizing: border-box;
10
+ transition:
11
+ border-color 0.12s ease,
12
+ box-shadow 0.12s ease;
13
+ }
14
+
15
+ /* Roundness — Round uses an 18px corner for both sizes; Default tracks size. */
16
+ .ui-textarea--round-default.ui-textarea--size-default {
17
+ border-radius: var(--rounded-xl);
18
+ }
19
+
20
+ .ui-textarea--round-default.ui-textarea--size-mini {
21
+ border-radius: var(--radius);
22
+ }
23
+
24
+ .ui-textarea--round-round {
25
+ border-radius: var(--radius-18);
26
+ }
27
+
28
+ /* Focus (interactive) — wrapper highlights when the field is focused. */
29
+ .ui-textarea:focus-within {
30
+ border-color: var(--border-focus);
31
+ box-shadow: var(--shadow-focus-ring);
32
+ }
33
+
34
+ /* Error (logical) — red border, no base shadow. */
35
+ .ui-textarea--error {
36
+ border-color: var(--destructive-border);
37
+ box-shadow: none;
38
+ }
39
+
40
+ .ui-textarea--error:focus-within {
41
+ border-color: var(--destructive-border);
42
+ box-shadow: var(--shadow-focus-ring-error);
43
+ }
44
+
45
+ /* Disabled — visual only; cursor handled by the inner field. */
46
+ .ui-textarea--disabled {
47
+ opacity: 0.5;
48
+ }
49
+
50
+ .ui-textarea--disabled:focus-within {
51
+ border-color: var(--border-primary);
52
+ box-shadow: var(--shadow-xs);
53
+ }
54
+
55
+ /* The native textarea — strips browser chrome and inherits typography. */
56
+ .ui-textarea__counter {
57
+ display: block;
58
+ text-align: right;
59
+ padding: 0 var(--spacing-xs) var(--spacing-xs);
60
+ font-size: var(--paragraph-mini-font-size);
61
+ line-height: var(--paragraph-mini-line-height);
62
+ color: var(--muted-fg);
63
+ pointer-events: none;
64
+ user-select: none;
65
+ }
66
+
67
+ .ui-textarea--size-mini .ui-textarea__counter {
68
+ padding: 0 var(--spacing-6) var(--spacing-6);
69
+ }
70
+
71
+ .ui-textarea__field {
72
+ display: block;
73
+ width: 100%;
74
+ margin: 0;
75
+ background: transparent;
76
+ border: 0;
77
+ outline: 0;
78
+ color: inherit;
79
+ font: inherit;
80
+ letter-spacing: inherit;
81
+ resize: vertical;
82
+ box-sizing: border-box;
83
+ }
84
+
85
+ .ui-textarea__field--no-resize {
86
+ resize: none;
87
+ }
88
+
89
+ .ui-textarea--size-default .ui-textarea__field {
90
+ min-height: 60px;
91
+ padding: var(--spacing-xs);
92
+ font-size: var(--paragraph-small-font-size);
93
+ line-height: var(--paragraph-small-line-height);
94
+ letter-spacing: var(--paragraph-small-letter-spacing);
95
+ }
96
+
97
+ .ui-textarea--size-mini .ui-textarea__field {
98
+ min-height: 48px;
99
+ padding: var(--spacing-6);
100
+ font-size: var(--paragraph-mini-font-size);
101
+ line-height: var(--paragraph-mini-line-height);
102
+ letter-spacing: var(--paragraph-mini-letter-spacing);
103
+ }
104
+
105
+ /* Round nudges the text off the larger corner (matches Figma insets). */
106
+ .ui-textarea--round-round.ui-textarea--size-default .ui-textarea__field {
107
+ padding: var(--spacing-sm) var(--spacing-xs) var(--spacing-xs) var(--spacing-sm);
108
+ }
109
+
110
+ .ui-textarea--round-round.ui-textarea--size-mini .ui-textarea__field {
111
+ padding: var(--spacing-10) var(--spacing-xs) var(--spacing-xs) var(--spacing-10);
112
+ }
113
+
114
+ .ui-textarea__field::placeholder {
115
+ color: var(--muted-fg);
116
+ opacity: 1;
117
+ }
118
+
119
+ .ui-textarea__field:disabled {
120
+ cursor: not-allowed;
121
+ }
package/src/index.js CHANGED
@@ -34,6 +34,7 @@ export { default as WkSwitchGroup } from './components/switch-group/SwitchGroup.
34
34
  export { default as WkTable } from './components/table/Table.vue'
35
35
  export { default as WkTabs } from './components/tabs/Tabs.vue'
36
36
  export { default as WkTag } from './components/tag/Tag.vue'
37
+ export { default as WkTextarea } from './components/textarea/Textarea.vue'
37
38
  export { default as WkToggle } from './components/toggle/Toggle.vue'
38
39
  export { default as WkToggleGroup } from './components/toggle-group/ToggleGroup.vue'
39
40
  export { default as WkTooltip } from './components/tooltip/Tooltip.vue'
@@ -7,6 +7,7 @@
7
7
  --radius-10: 10px;
8
8
  --radius-12: 12px;
9
9
  --radius-16: 16px;
10
+ --radius-18: 18px;
10
11
  --radius-24: 24px;
11
12
  --radius-infinite: 9999px;
12
13
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Framework-agnostic virtual-scroll window calculator.
3
+ *
4
+ * Given the current scroll position and a fixed row height, returns the slice of
5
+ * rows that should be rendered (plus an overscan buffer) and the vertical offset
6
+ * needed to position them. Kept as a pure function — no Vue, no DOM — so it works
7
+ * under both Vue 2.7 and Vue 3.4 Options API (the library bans the Composition API)
8
+ * and can be unit-tested in isolation.
9
+ *
10
+ * @param {Object} o
11
+ * @param {number} o.scrollTop Current scrollTop of the scroll container (px).
12
+ * @param {number} o.rowHeight Fixed height of a single row (px).
13
+ * @param {number} o.containerHeight Visible height of the scroll viewport (px).
14
+ * @param {number} o.total Total number of rows in the dataset.
15
+ * @param {number} o.overscan Extra rows rendered above/below the viewport.
16
+ * @returns {{ startIndex: number, endIndex: number, offsetY: number,
17
+ * totalHeight: number, visibleCount: number }}
18
+ */
19
+ export function computeVirtualWindow({ scrollTop, rowHeight, containerHeight, total, overscan }) {
20
+ const rh = rowHeight > 0 ? rowHeight : 0
21
+ const over = overscan > 0 ? overscan : 0
22
+ const count = total > 0 ? total : 0
23
+
24
+ // Rows that fit in the viewport. Guard rowHeight=0 to avoid Infinity/NaN.
25
+ const visibleCount = rh > 0 ? Math.max(1, Math.ceil(containerHeight / rh)) : count
26
+
27
+ const startIndex = rh > 0 ? Math.max(0, Math.floor(scrollTop / rh) - over) : 0
28
+ const endIndex = Math.min(count, startIndex + visibleCount + over * 2)
29
+
30
+ return {
31
+ startIndex,
32
+ endIndex,
33
+ offsetY: startIndex * rh,
34
+ totalHeight: count * rh,
35
+ visibleCount
36
+ }
37
+ }