webcake-ui-kit 1.1.19 → 1.1.21
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 +2 -1
- package/src/components/table/Table.vue +200 -35
- package/src/components/table/table.css +19 -0
- package/src/components/tag/tag.css +5 -1
- package/src/components/textarea/Textarea.vue +111 -0
- package/src/components/textarea/textarea.css +121 -0
- package/src/index.js +1 -0
- package/src/styles/border_radius.css +1 -0
- package/src/utils/virtualScroll.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-ui-kit",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.21",
|
|
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,8 @@
|
|
|
35
35
|
@click="onHeaderClick(col)"
|
|
36
36
|
@keydown.enter.prevent="onHeaderClick(col)"
|
|
37
37
|
@keydown.space.prevent="onHeaderClick(col)"
|
|
38
|
+
v-bind="customHeaderAttrs(col, i)"
|
|
39
|
+
v-on="customHeaderListeners(col, i)"
|
|
38
40
|
>
|
|
39
41
|
<span class="ui-table__th-inner">
|
|
40
42
|
<slot name="headerCell" :column="col">{{ col.title }}</slot>
|
|
@@ -57,7 +59,7 @@
|
|
|
57
59
|
/>
|
|
58
60
|
</tr>
|
|
59
61
|
</thead>
|
|
60
|
-
<tbody class="ui-table__body" :style="bodyStyle">
|
|
62
|
+
<tbody class="ui-table__body" :style="bodyStyle" @scroll="onBodyScroll">
|
|
61
63
|
<tr v-if="!rows.length" class="ui-table__row ui-table__row--empty">
|
|
62
64
|
<td class="ui-table__cell ui-table__empty-cell" :colspan="totalColumns">
|
|
63
65
|
<slot name="emptyText">
|
|
@@ -65,33 +67,57 @@
|
|
|
65
67
|
</slot>
|
|
66
68
|
</td>
|
|
67
69
|
</tr>
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
v-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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)"
|
|
70
|
+
<template v-else>
|
|
71
|
+
<!-- Top spacer reserves the height of the rows scrolled out above the window. -->
|
|
72
|
+
<tr v-if="isVirtual" class="ui-table__spacer" :style="{ height: topSpacerHeight + 'px' }" aria-hidden="true">
|
|
73
|
+
<td class="ui-table__spacer-cell" :colspan="totalColumns" />
|
|
74
|
+
</tr>
|
|
75
|
+
<tr
|
|
76
|
+
v-for="(record, i) in renderRows"
|
|
77
|
+
:key="getRowKey(record, rowIndex(i))"
|
|
82
78
|
:class="[
|
|
83
|
-
'ui-
|
|
84
|
-
'ui-
|
|
85
|
-
|
|
86
|
-
col.ellipsis && 'ui-table__cell--ellipsis'
|
|
79
|
+
'ui-table__row',
|
|
80
|
+
'ui-table__row--body',
|
|
81
|
+
isSelected(record, rowIndex(i)) && 'ui-table__row--selected'
|
|
87
82
|
]"
|
|
88
|
-
:style="
|
|
83
|
+
:style="{ height: toCssSize(rowHeight) }"
|
|
84
|
+
v-bind="customRowAttrs(record, rowIndex(i))"
|
|
85
|
+
v-on="customRowListeners(record, rowIndex(i))"
|
|
86
|
+
@click="hasSelection ? onRowClick(record, rowIndex(i), $event) : undefined"
|
|
89
87
|
>
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
<td
|
|
89
|
+
v-if="hasSelection"
|
|
90
|
+
class="ui-table__cell ui-table__td ui-table__cell--selection"
|
|
91
|
+
@click.stop="onSelectionCellClick(record, rowIndex(i), $event)"
|
|
92
|
+
>
|
|
93
|
+
<WkCheckbox :checked="isSelected(record, rowIndex(i))" />
|
|
94
|
+
</td>
|
|
95
|
+
<td
|
|
96
|
+
v-for="col in columns"
|
|
97
|
+
:key="colKey(col)"
|
|
98
|
+
:class="[
|
|
99
|
+
'ui-table__cell',
|
|
100
|
+
'ui-table__td',
|
|
101
|
+
`ui-table__cell--${alignOf(col)}`,
|
|
102
|
+
col.ellipsis && 'ui-table__cell--ellipsis'
|
|
103
|
+
]"
|
|
104
|
+
:style="colStyle(col)"
|
|
105
|
+
>
|
|
106
|
+
<slot name="bodyCell" :column="col" :record="record" :text="cellText(col, record)" :index="rowIndex(i)">{{
|
|
107
|
+
cellText(col, record)
|
|
108
|
+
}}</slot>
|
|
109
|
+
</td>
|
|
110
|
+
</tr>
|
|
111
|
+
<!-- Bottom spacer reserves the height of the rows below the window. -->
|
|
112
|
+
<tr
|
|
113
|
+
v-if="isVirtual"
|
|
114
|
+
class="ui-table__spacer"
|
|
115
|
+
:style="{ height: bottomSpacerHeight + 'px' }"
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
>
|
|
118
|
+
<td class="ui-table__spacer-cell" :colspan="totalColumns" />
|
|
119
|
+
</tr>
|
|
120
|
+
</template>
|
|
95
121
|
</tbody>
|
|
96
122
|
</table>
|
|
97
123
|
</div>
|
|
@@ -104,6 +130,7 @@ import WkiArrowUp from '../../icons/ArrowUp.vue'
|
|
|
104
130
|
import WkiArrowDown from '../../icons/ArrowDown.vue'
|
|
105
131
|
import WkiArrowDownUp from '../../icons/ArrowDownUp.vue'
|
|
106
132
|
import { toCssSize } from '../../utils/common'
|
|
133
|
+
import { computeVirtualWindow } from '../../utils/virtualScroll'
|
|
107
134
|
|
|
108
135
|
function defaultCompare(a, b) {
|
|
109
136
|
if (a == null && b == null) return 0
|
|
@@ -137,9 +164,16 @@ export default {
|
|
|
137
164
|
// Fixed overall height of the table viewport (px number or CSS size). Enables vertical scroll + sticky header.
|
|
138
165
|
height: { type: [Number, String], default: null },
|
|
139
166
|
rowHeight: { type: [Number, String], default: 39 },
|
|
140
|
-
headerHeight: { type: [Number, String], default: 39 }
|
|
167
|
+
headerHeight: { type: [Number, String], default: 39 },
|
|
168
|
+
customRow: { type: Function, default: null },
|
|
169
|
+
customHeader: { type: Function, default: null },
|
|
170
|
+
// Virtualize the body: render only the rows visible in the viewport (+ overscan).
|
|
171
|
+
// Requires a vertical-scroll viewport (height or scroll.y) and a numeric rowHeight.
|
|
172
|
+
virtual: { type: Boolean, default: false },
|
|
173
|
+
// Extra rows rendered above and below the viewport to cushion fast scrolling.
|
|
174
|
+
overscan: { type: Number, default: 8 }
|
|
141
175
|
},
|
|
142
|
-
emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change'
|
|
176
|
+
emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change'],
|
|
143
177
|
data() {
|
|
144
178
|
return {
|
|
145
179
|
sortField: null,
|
|
@@ -147,6 +181,10 @@ export default {
|
|
|
147
181
|
internalSelected: this.selectedRowKeys.slice(),
|
|
148
182
|
scrollbarWidth: 0,
|
|
149
183
|
colWidths: [],
|
|
184
|
+
lastClickedIndex: null,
|
|
185
|
+
scrollTop: 0,
|
|
186
|
+
containerHeight: 0,
|
|
187
|
+
rafId: null,
|
|
150
188
|
toCssSize
|
|
151
189
|
}
|
|
152
190
|
},
|
|
@@ -188,6 +226,39 @@ export default {
|
|
|
188
226
|
tableStyle() {
|
|
189
227
|
const scroll = this.scroll || {}
|
|
190
228
|
return scroll.x != null ? { minWidth: toCssSize(scroll.x) } : {}
|
|
229
|
+
},
|
|
230
|
+
// Numeric row height — virtualization needs a fixed px value. String sizes are
|
|
231
|
+
// parsed (e.g. '40px' -> 40); anything unparseable falls back to the prop default.
|
|
232
|
+
rowHeightPx() {
|
|
233
|
+
if (typeof this.rowHeight === 'number') return this.rowHeight
|
|
234
|
+
const parsed = parseFloat(this.rowHeight)
|
|
235
|
+
return Number.isFinite(parsed) ? parsed : 39
|
|
236
|
+
},
|
|
237
|
+
// Virtualization is only meaningful inside a bounded, scrollable viewport.
|
|
238
|
+
isVirtual() {
|
|
239
|
+
return this.virtual && this.hasVerticalScroll && this.rows.length > 0
|
|
240
|
+
},
|
|
241
|
+
virtualWindow() {
|
|
242
|
+
return computeVirtualWindow({
|
|
243
|
+
scrollTop: this.scrollTop,
|
|
244
|
+
rowHeight: this.rowHeightPx,
|
|
245
|
+
containerHeight: this.containerHeight,
|
|
246
|
+
total: this.rows.length,
|
|
247
|
+
overscan: this.overscan
|
|
248
|
+
})
|
|
249
|
+
},
|
|
250
|
+
// Rows actually committed to the DOM: a slice when virtual, otherwise everything.
|
|
251
|
+
renderRows() {
|
|
252
|
+
if (!this.isVirtual) return this.rows
|
|
253
|
+
return this.rows.slice(this.virtualWindow.startIndex, this.virtualWindow.endIndex)
|
|
254
|
+
},
|
|
255
|
+
topSpacerHeight() {
|
|
256
|
+
return this.isVirtual ? this.virtualWindow.offsetY : 0
|
|
257
|
+
},
|
|
258
|
+
bottomSpacerHeight() {
|
|
259
|
+
if (!this.isVirtual) return 0
|
|
260
|
+
const rendered = this.renderRows.length * this.rowHeightPx
|
|
261
|
+
return Math.max(0, this.virtualWindow.totalHeight - this.virtualWindow.offsetY - rendered)
|
|
191
262
|
}
|
|
192
263
|
},
|
|
193
264
|
watch: {
|
|
@@ -197,15 +268,80 @@ export default {
|
|
|
197
268
|
},
|
|
198
269
|
mounted() {
|
|
199
270
|
this.$nextTick(this.syncScrollbarGutter)
|
|
271
|
+
window.addEventListener('resize', this.onResize)
|
|
200
272
|
},
|
|
201
273
|
updated() {
|
|
202
274
|
this.$nextTick(this.syncScrollbarGutter)
|
|
203
275
|
},
|
|
276
|
+
// Declare both teardown hooks: Vue 2.7 fires beforeDestroy, Vue 3.4 fires beforeUnmount.
|
|
277
|
+
// The runtime that doesn't recognize a hook simply ignores it.
|
|
278
|
+
beforeUnmount() {
|
|
279
|
+
this.teardown()
|
|
280
|
+
},
|
|
204
281
|
methods: {
|
|
282
|
+
// Split a customRow/customHeader result into plain attributes and event listeners.
|
|
283
|
+
// The public API mirrors ant-design-vue's `onClick`/`onDblclick`/... convention, which
|
|
284
|
+
// Vue 3 auto-resolves to listeners when spread via v-bind — but Vue 2 would bind them as
|
|
285
|
+
// raw DOM attributes. So we strip the `onXxx` keys into a v-on map (event name lower-cased)
|
|
286
|
+
// and leave the rest for v-bind, which works identically on both runtimes.
|
|
287
|
+
splitBindings(raw) {
|
|
288
|
+
const attrs = {}
|
|
289
|
+
const on = {}
|
|
290
|
+
if (raw) {
|
|
291
|
+
Object.keys(raw).forEach(key => {
|
|
292
|
+
const match = /^on([A-Z].*)$/.exec(key)
|
|
293
|
+
if (match) {
|
|
294
|
+
on[match[1].charAt(0).toLowerCase() + match[1].slice(1)] = raw[key]
|
|
295
|
+
} else {
|
|
296
|
+
attrs[key] = raw[key]
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
return { attrs, on }
|
|
301
|
+
},
|
|
302
|
+
customRowAttrs(record, index) {
|
|
303
|
+
return this.customRow ? this.splitBindings(this.customRow(record, index)).attrs : {}
|
|
304
|
+
},
|
|
305
|
+
customRowListeners(record, index) {
|
|
306
|
+
return this.customRow ? this.splitBindings(this.customRow(record, index)).on : {}
|
|
307
|
+
},
|
|
308
|
+
customHeaderAttrs(col, index) {
|
|
309
|
+
return this.customHeader ? this.splitBindings(this.customHeader(col, index)).attrs : {}
|
|
310
|
+
},
|
|
311
|
+
customHeaderListeners(col, index) {
|
|
312
|
+
return this.customHeader ? this.splitBindings(this.customHeader(col, index)).on : {}
|
|
313
|
+
},
|
|
314
|
+
teardown() {
|
|
315
|
+
window.removeEventListener('resize', this.onResize)
|
|
316
|
+
if (this.rafId != null) {
|
|
317
|
+
cancelAnimationFrame(this.rafId)
|
|
318
|
+
this.rafId = null
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
onResize() {
|
|
322
|
+
this.syncScrollbarGutter()
|
|
323
|
+
},
|
|
324
|
+
onBodyScroll(e) {
|
|
325
|
+
const top = e.target.scrollTop
|
|
326
|
+
// Coalesce bursts of scroll events into a single state update per frame so we
|
|
327
|
+
// re-slice at most once per repaint — keeps 10k+ rows smooth.
|
|
328
|
+
if (this.rafId != null) return
|
|
329
|
+
this.rafId = requestAnimationFrame(() => {
|
|
330
|
+
this.rafId = null
|
|
331
|
+
this.scrollTop = top
|
|
332
|
+
})
|
|
333
|
+
},
|
|
334
|
+
// Absolute index of a rendered row within the full (sorted) dataset.
|
|
335
|
+
rowIndex(i) {
|
|
336
|
+
return this.isVirtual ? this.virtualWindow.startIndex + i : i
|
|
337
|
+
},
|
|
205
338
|
syncScrollbarGutter() {
|
|
206
339
|
if (!this.$el) return
|
|
207
340
|
const body = this.$el.querySelector('.ui-table__body')
|
|
208
341
|
this.scrollbarWidth = body && this.hasVerticalScroll ? body.offsetWidth - body.clientWidth : 0
|
|
342
|
+
if (body && this.hasVerticalScroll) {
|
|
343
|
+
this.containerHeight = body.clientHeight
|
|
344
|
+
}
|
|
209
345
|
|
|
210
346
|
if (this.hasVerticalScroll) {
|
|
211
347
|
const firstRow = this.$el.querySelector('.ui-table__row--body')
|
|
@@ -289,19 +425,48 @@ export default {
|
|
|
289
425
|
this.$emit('update:selectedRowKeys', keys)
|
|
290
426
|
this.$emit('selection-change', keys, selectedRows)
|
|
291
427
|
},
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
428
|
+
handleSelection(record, index, checked, e) {
|
|
429
|
+
const isShiftKey = e && e.shiftKey
|
|
430
|
+
|
|
431
|
+
if (isShiftKey && this.lastClickedIndex != null) {
|
|
432
|
+
// Shift+click: select range from lastClickedIndex to current index
|
|
433
|
+
const start = Math.min(this.lastClickedIndex, index)
|
|
434
|
+
const end = Math.max(this.lastClickedIndex, index)
|
|
435
|
+
const rangeKeys = []
|
|
436
|
+
for (let i = start; i <= end; i++) {
|
|
437
|
+
rangeKeys.push(this.getRowKey(this.rows[i], i))
|
|
438
|
+
}
|
|
439
|
+
// Merge with existing selection (union)
|
|
440
|
+
const set = new Set(this.internalSelected)
|
|
441
|
+
rangeKeys.forEach(k => set.add(k))
|
|
442
|
+
this.internalSelected = Array.from(set)
|
|
443
|
+
} else {
|
|
444
|
+
// Normal click / Cmd+click: toggle individual row
|
|
445
|
+
const key = this.getRowKey(record, index)
|
|
446
|
+
const set = this.internalSelected.filter(k => k !== key)
|
|
447
|
+
if (checked) set.push(key)
|
|
448
|
+
this.internalSelected = set
|
|
449
|
+
}
|
|
450
|
+
this.lastClickedIndex = index
|
|
297
451
|
this.emitSelection()
|
|
298
452
|
},
|
|
453
|
+
onSelectionCellClick(record, index, e) {
|
|
454
|
+
// Click on the checkbox <td> — MouseEvent has modifier keys
|
|
455
|
+
const currentlySelected = this.isSelected(record, index)
|
|
456
|
+
this.handleSelection(record, index, !currentlySelected, e)
|
|
457
|
+
},
|
|
458
|
+
onRowClick(record, index, e) {
|
|
459
|
+
// Click on the row body — handle Shift/Cmd selection
|
|
460
|
+
const isMetaKey = e.metaKey || e.ctrlKey
|
|
461
|
+
const isShiftKey = e.shiftKey
|
|
462
|
+
if (!isMetaKey && !isShiftKey) return
|
|
463
|
+
const key = this.getRowKey(record, index)
|
|
464
|
+
const currentlySelected = this.internalSelected.includes(key)
|
|
465
|
+
this.handleSelection(record, index, !currentlySelected, e)
|
|
466
|
+
},
|
|
299
467
|
onToggleAll(checked) {
|
|
300
468
|
this.internalSelected = checked ? this.rows.map((record, index) => this.getRowKey(record, index)) : []
|
|
301
469
|
this.emitSelection()
|
|
302
|
-
},
|
|
303
|
-
onRowClick(record, index, event) {
|
|
304
|
-
this.$emit('row-click', record, index, event)
|
|
305
470
|
}
|
|
306
471
|
}
|
|
307
472
|
}
|
|
@@ -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:
|
|
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'
|
|
@@ -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
|
+
}
|