webcake-ui-kit 1.1.18 → 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 +3 -1
- package/src/components/button/button.css +1 -0
- package/src/components/checkbox/checkbox.css +1 -0
- package/src/components/dropdown/Dropdown.vue +5 -1
- package/src/components/dropdown/DropdownMenuItems.vue +27 -33
- package/src/components/dropdown/dropdown-menu-items.css +5 -0
- package/src/components/empty/Empty.vue +2 -2
- package/src/components/table/Table.vue +441 -0
- package/src/components/table/table.css +216 -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/components/typography/Typography.vue +5 -3
- package/src/floating/focus-mixin.js +5 -0
- package/src/icons/ClippingMask.vue +56 -0
- package/src/icons/index.js +1 -0
- package/src/index.js +2 -0
- package/src/styles/border_radius.css +1 -0
- package/src/utils/common.js +4 -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.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",
|
|
@@ -48,8 +48,10 @@
|
|
|
48
48
|
"./WkSpinner": "./src/components/spinner/Spinner.vue",
|
|
49
49
|
"./WkSwitch": "./src/components/switch/Switch.vue",
|
|
50
50
|
"./WkSwitchGroup": "./src/components/switch-group/SwitchGroup.vue",
|
|
51
|
+
"./WkTable": "./src/components/table/Table.vue",
|
|
51
52
|
"./WkTabs": "./src/components/tabs/Tabs.vue",
|
|
52
53
|
"./WkTag": "./src/components/tag/Tag.vue",
|
|
54
|
+
"./WkTextarea": "./src/components/textarea/Textarea.vue",
|
|
53
55
|
"./WkToggle": "./src/components/toggle/Toggle.vue",
|
|
54
56
|
"./WkToggleGroup": "./src/components/toggle-group/ToggleGroup.vue",
|
|
55
57
|
"./WkTooltip": "./src/components/tooltip/Tooltip.vue",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<slot></slot>
|
|
22
22
|
</span>
|
|
23
23
|
</template>
|
|
24
|
-
<DropdownMenuItems v-if="hasItems" :items="items" @select="onItemSelect" />
|
|
24
|
+
<DropdownMenuItems v-if="hasItems" :items="items" :value="value" @select="onItemSelect" />
|
|
25
25
|
<slot v-else name="overlay"></slot>
|
|
26
26
|
</Menu>
|
|
27
27
|
</template>
|
|
@@ -71,6 +71,10 @@ export default {
|
|
|
71
71
|
default: function () {
|
|
72
72
|
return []
|
|
73
73
|
}
|
|
74
|
+
},
|
|
75
|
+
value: {
|
|
76
|
+
type: [String, Array],
|
|
77
|
+
default: null
|
|
74
78
|
}
|
|
75
79
|
},
|
|
76
80
|
emits: ['change', 'update:modelValue', 'open', 'close', 'select'],
|
|
@@ -7,12 +7,7 @@
|
|
|
7
7
|
<!-- Group: non-clickable label + flat children -->
|
|
8
8
|
<div v-else-if="item._type === 'group'" class="ui-dropdown-items__group">
|
|
9
9
|
<div class="ui-dropdown-items__group-label" :title="item.title || undefined">
|
|
10
|
-
<component
|
|
11
|
-
:is="item.icon"
|
|
12
|
-
v-if="item.icon && isComponent(item.icon)"
|
|
13
|
-
class="ui-dropdown-items__icon"
|
|
14
|
-
:color="item.colorIcon"
|
|
15
|
-
/>
|
|
10
|
+
<component :is="item.icon" v-if="item.icon && isComponent(item.icon)" :size="20" :color="item.colorIcon" />
|
|
16
11
|
<span :style="item.color ? { color: item.color } : undefined">{{ item.label }}</span>
|
|
17
12
|
</div>
|
|
18
13
|
<div
|
|
@@ -21,21 +16,18 @@
|
|
|
21
16
|
class="ui-dropdown-items__item"
|
|
22
17
|
:class="{
|
|
23
18
|
'ui-dropdown-items__item--disabled': child.disabled,
|
|
24
|
-
'ui-dropdown-items__item--destructive': child.destructive
|
|
19
|
+
'ui-dropdown-items__item--destructive': child.destructive,
|
|
20
|
+
'ui-dropdown-items__item--active': isActive(child.key)
|
|
25
21
|
}"
|
|
26
22
|
:title="child.title || undefined"
|
|
27
23
|
@click="onItemClick(child)"
|
|
28
24
|
>
|
|
29
|
-
<component
|
|
30
|
-
:is="child.icon"
|
|
31
|
-
v-if="child.icon && isComponent(child.icon)"
|
|
32
|
-
class="ui-dropdown-items__icon"
|
|
33
|
-
:color="item.colorIcon"
|
|
34
|
-
/>
|
|
25
|
+
<component :is="child.icon" v-if="child.icon && isComponent(child.icon)" :size="20" :color="item.colorIcon" />
|
|
35
26
|
<span class="ui-dropdown-items__label" :style="child.color ? { color: child.color } : undefined">{{
|
|
36
27
|
child.label
|
|
37
28
|
}}</span>
|
|
38
29
|
<span v-if="child.extra" class="ui-dropdown-items__extra">{{ child.extra }}</span>
|
|
30
|
+
<WkiCheck v-if="isActive(child.key)" :size="16" color="var(--muted-fg)" />
|
|
39
31
|
</div>
|
|
40
32
|
</div>
|
|
41
33
|
|
|
@@ -49,12 +41,7 @@
|
|
|
49
41
|
}"
|
|
50
42
|
:title="item.title || undefined"
|
|
51
43
|
>
|
|
52
|
-
<component
|
|
53
|
-
:is="item.icon"
|
|
54
|
-
v-if="item.icon && isComponent(item.icon)"
|
|
55
|
-
class="ui-dropdown-items__icon"
|
|
56
|
-
:color="item.colorIcon"
|
|
57
|
-
/>
|
|
44
|
+
<component :is="item.icon" v-if="item.icon && isComponent(item.icon)" :size="20" :color="item.colorIcon" />
|
|
58
45
|
<span class="ui-dropdown-items__label" :style="item.color ? { color: item.color } : undefined">{{
|
|
59
46
|
item.label
|
|
60
47
|
}}</span>
|
|
@@ -65,21 +52,18 @@
|
|
|
65
52
|
class="ui-dropdown-items__item ui-dropdown-items__item--sub"
|
|
66
53
|
:class="{
|
|
67
54
|
'ui-dropdown-items__item--disabled': child.disabled || item.disabled,
|
|
68
|
-
'ui-dropdown-items__item--destructive': child.destructive
|
|
55
|
+
'ui-dropdown-items__item--destructive': child.destructive,
|
|
56
|
+
'ui-dropdown-items__item--active': isActive(child.key)
|
|
69
57
|
}"
|
|
70
58
|
:title="child.title || undefined"
|
|
71
59
|
@click="onItemClick(child, item)"
|
|
72
60
|
>
|
|
73
|
-
<component
|
|
74
|
-
:is="child.icon"
|
|
75
|
-
v-if="child.icon && isComponent(child.icon)"
|
|
76
|
-
class="ui-dropdown-items__icon"
|
|
77
|
-
:color="item.colorIcon"
|
|
78
|
-
/>
|
|
61
|
+
<component :is="child.icon" v-if="child.icon && isComponent(child.icon)" :size="20" :color="item.colorIcon" />
|
|
79
62
|
<span class="ui-dropdown-items__label" :style="child.color ? { color: child.color } : undefined">{{
|
|
80
63
|
child.label
|
|
81
64
|
}}</span>
|
|
82
65
|
<span v-if="child.extra" class="ui-dropdown-items__extra">{{ child.extra }}</span>
|
|
66
|
+
<WkiCheck v-if="isActive(child.key)" :size="16" color="var(--muted-fg)" />
|
|
83
67
|
</div>
|
|
84
68
|
</div>
|
|
85
69
|
|
|
@@ -89,27 +73,26 @@
|
|
|
89
73
|
class="ui-dropdown-items__item"
|
|
90
74
|
:class="{
|
|
91
75
|
'ui-dropdown-items__item--disabled': item.disabled,
|
|
92
|
-
'ui-dropdown-items__item--destructive': item.destructive
|
|
76
|
+
'ui-dropdown-items__item--destructive': item.destructive,
|
|
77
|
+
'ui-dropdown-items__item--active': isActive(item.key)
|
|
93
78
|
}"
|
|
94
79
|
:title="item.title || undefined"
|
|
95
80
|
@click="onItemClick(item)"
|
|
96
81
|
>
|
|
97
|
-
<component
|
|
98
|
-
:is="item.icon"
|
|
99
|
-
v-if="item.icon && isComponent(item.icon)"
|
|
100
|
-
class="ui-dropdown-items__icon"
|
|
101
|
-
:color="item.colorIcon"
|
|
102
|
-
/>
|
|
82
|
+
<component :is="item.icon" v-if="item.icon && isComponent(item.icon)" :size="20" :color="item.colorIcon" />
|
|
103
83
|
<span class="ui-dropdown-items__label" :style="item.color ? { color: item.color } : undefined">{{
|
|
104
84
|
item.label
|
|
105
85
|
}}</span>
|
|
106
86
|
<span v-if="item.extra" class="ui-dropdown-items__extra">{{ item.extra }}</span>
|
|
87
|
+
<WkiCheck v-if="isActive(item.key)" :size="16" color="var(--muted-fg)" />
|
|
107
88
|
</div>
|
|
108
89
|
</div>
|
|
109
90
|
</div>
|
|
110
91
|
</template>
|
|
111
92
|
|
|
112
93
|
<script>
|
|
94
|
+
import WkiCheck from '../../icons/Check.vue'
|
|
95
|
+
|
|
113
96
|
function skipReactive(val) {
|
|
114
97
|
if (val && typeof val === 'object' && !val.__v_skip) {
|
|
115
98
|
val.__v_skip = true
|
|
@@ -119,12 +102,18 @@ function skipReactive(val) {
|
|
|
119
102
|
export default {
|
|
120
103
|
name: 'DropdownMenuItems',
|
|
121
104
|
|
|
105
|
+
components: { WkiCheck },
|
|
106
|
+
|
|
122
107
|
props: {
|
|
123
108
|
items: {
|
|
124
109
|
type: Array,
|
|
125
110
|
default: function () {
|
|
126
111
|
return []
|
|
127
112
|
}
|
|
113
|
+
},
|
|
114
|
+
value: {
|
|
115
|
+
type: [String, Array],
|
|
116
|
+
default: null
|
|
128
117
|
}
|
|
129
118
|
},
|
|
130
119
|
|
|
@@ -162,6 +151,11 @@ export default {
|
|
|
162
151
|
isComponent(val) {
|
|
163
152
|
return val !== null && typeof val === 'object'
|
|
164
153
|
},
|
|
154
|
+
isActive(key) {
|
|
155
|
+
if (this.value === null || this.value === undefined) return false
|
|
156
|
+
if (Array.isArray(this.value)) return this.value.indexOf(key) !== -1
|
|
157
|
+
return this.value === key
|
|
158
|
+
},
|
|
165
159
|
onItemClick(item, parentItem) {
|
|
166
160
|
if (item.disabled || (parentItem && parentItem.disabled)) return
|
|
167
161
|
this.$emit('select', item.key)
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
<slot name="media"></slot>
|
|
6
6
|
</div>
|
|
7
7
|
<div v-if="hasTextBlock" class="ui-empty__text">
|
|
8
|
-
<Typography v-if="hasTitle" variant="paragraph" weight="medium">
|
|
8
|
+
<Typography v-if="hasTitle" class="ui-empty__title" variant="paragraph-regular" weight="medium">
|
|
9
9
|
<slot name="title">{{ title }}</slot>
|
|
10
10
|
</Typography>
|
|
11
|
-
<Typography v-if="hasDescription" variant="paragraph-small" weight="regular">
|
|
11
|
+
<Typography v-if="hasDescription" class="ui-empty__description" variant="paragraph-small" weight="regular">
|
|
12
12
|
<slot name="description">{{ description }}</slot>
|
|
13
13
|
</Typography>
|
|
14
14
|
</div>
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="['ui-table', bordered && 'ui-table--bordered', hasVerticalScroll && 'ui-table--scroll-y']"
|
|
4
|
+
:style="containerStyle"
|
|
5
|
+
>
|
|
6
|
+
<table class="ui-table__table" :style="tableStyle">
|
|
7
|
+
<colgroup>
|
|
8
|
+
<col v-if="hasSelection" class="ui-table__col ui-table__col--selection" />
|
|
9
|
+
<col v-for="col in columns" :key="colKey(col)" :style="colStyle(col)" />
|
|
10
|
+
</colgroup>
|
|
11
|
+
<thead class="ui-table__head">
|
|
12
|
+
<tr
|
|
13
|
+
:class="['ui-table__row', 'ui-table__row--head', scrollbarWidth > 0 && 'ui-table__row--has-scrollbar']"
|
|
14
|
+
:style="{ height: toCssSize(headerHeight) }"
|
|
15
|
+
>
|
|
16
|
+
<th
|
|
17
|
+
v-if="hasSelection"
|
|
18
|
+
class="ui-table__cell ui-table__th ui-table__cell--selection"
|
|
19
|
+
:style="headCellStyle(0)"
|
|
20
|
+
>
|
|
21
|
+
<WkCheckbox :checked="allSelected" :disabled="!rows.length" @change="onToggleAll" />
|
|
22
|
+
</th>
|
|
23
|
+
<th
|
|
24
|
+
v-for="(col, i) in columns"
|
|
25
|
+
:key="colKey(col)"
|
|
26
|
+
:class="[
|
|
27
|
+
'ui-table__cell',
|
|
28
|
+
'ui-table__th',
|
|
29
|
+
`ui-table__cell--${alignOf(col)}`,
|
|
30
|
+
isSortable(col) && 'ui-table__th--sortable'
|
|
31
|
+
]"
|
|
32
|
+
:style="headCellStyle(hasSelection ? i + 1 : i, col)"
|
|
33
|
+
:tabindex="isSortable(col) ? 0 : undefined"
|
|
34
|
+
:aria-sort="ariaSort(col)"
|
|
35
|
+
@click="onHeaderClick(col)"
|
|
36
|
+
@keydown.enter.prevent="onHeaderClick(col)"
|
|
37
|
+
@keydown.space.prevent="onHeaderClick(col)"
|
|
38
|
+
v-bind="customHeader ? customHeader(col, i) : {}"
|
|
39
|
+
>
|
|
40
|
+
<span class="ui-table__th-inner">
|
|
41
|
+
<slot name="headerCell" :column="col">{{ col.title }}</slot>
|
|
42
|
+
<span
|
|
43
|
+
v-if="isSortable(col)"
|
|
44
|
+
:class="['ui-table__sort', sortStateOf(col) ? 'ui-table__sort--active' : 'ui-table__sort--inactive']"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
>
|
|
47
|
+
<WkiArrowUp v-if="sortStateOf(col) === 'ascend'" :size="16" />
|
|
48
|
+
<WkiArrowDown v-else-if="sortStateOf(col) === 'descend'" :size="16" />
|
|
49
|
+
<WkiArrowDownUp v-else :size="16" />
|
|
50
|
+
</span>
|
|
51
|
+
</span>
|
|
52
|
+
</th>
|
|
53
|
+
<th
|
|
54
|
+
v-if="scrollbarWidth > 0"
|
|
55
|
+
class="ui-table__th--scrollbar"
|
|
56
|
+
:style="{ width: scrollbarWidth + 'px' }"
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
/>
|
|
59
|
+
</tr>
|
|
60
|
+
</thead>
|
|
61
|
+
<tbody class="ui-table__body" :style="bodyStyle" @scroll="onBodyScroll">
|
|
62
|
+
<tr v-if="!rows.length" class="ui-table__row ui-table__row--empty">
|
|
63
|
+
<td class="ui-table__cell ui-table__empty-cell" :colspan="totalColumns">
|
|
64
|
+
<slot name="emptyText">
|
|
65
|
+
<WkEmpty :title="emptyText" />
|
|
66
|
+
</slot>
|
|
67
|
+
</td>
|
|
68
|
+
</tr>
|
|
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))"
|
|
77
|
+
:class="[
|
|
78
|
+
'ui-table__row',
|
|
79
|
+
'ui-table__row--body',
|
|
80
|
+
isSelected(record, rowIndex(i)) && 'ui-table__row--selected'
|
|
81
|
+
]"
|
|
82
|
+
:style="{ height: toCssSize(rowHeight) }"
|
|
83
|
+
v-bind="customRow ? customRow(record, rowIndex(i)) : {}"
|
|
84
|
+
@click="hasSelection ? onRowClick(record, rowIndex(i), $event) : undefined"
|
|
85
|
+
>
|
|
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>
|
|
119
|
+
</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
123
|
+
|
|
124
|
+
<script>
|
|
125
|
+
import WkCheckbox from '../checkbox/Checkbox.vue'
|
|
126
|
+
import WkEmpty from '../empty/Empty.vue'
|
|
127
|
+
import WkiArrowUp from '../../icons/ArrowUp.vue'
|
|
128
|
+
import WkiArrowDown from '../../icons/ArrowDown.vue'
|
|
129
|
+
import WkiArrowDownUp from '../../icons/ArrowDownUp.vue'
|
|
130
|
+
import { toCssSize } from '../../utils/common'
|
|
131
|
+
import { computeVirtualWindow } from '../../utils/virtualScroll'
|
|
132
|
+
|
|
133
|
+
function defaultCompare(a, b) {
|
|
134
|
+
if (a == null && b == null) return 0
|
|
135
|
+
if (a == null) return -1
|
|
136
|
+
if (b == null) return 1
|
|
137
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b
|
|
138
|
+
return String(a).localeCompare(String(b))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default {
|
|
142
|
+
name: 'Table',
|
|
143
|
+
components: { WkCheckbox, WkEmpty, WkiArrowUp, WkiArrowDown, WkiArrowDownUp },
|
|
144
|
+
props: {
|
|
145
|
+
// Column descriptors: { title, dataIndex, key?, width?, align?, sorter?, ellipsis? }
|
|
146
|
+
columns: { type: Array, default: () => [] },
|
|
147
|
+
// Row records. Each record should expose a stable key (see rowKey).
|
|
148
|
+
dataSource: { type: Array, default: () => [] },
|
|
149
|
+
// How to derive a row's key: a record field name, or a function (record) => key.
|
|
150
|
+
rowKey: { type: [String, Function], default: 'key' },
|
|
151
|
+
// Outer border + vertical column dividers.
|
|
152
|
+
bordered: { type: Boolean, default: false },
|
|
153
|
+
// Render a leading checkbox column for row selection.
|
|
154
|
+
rowSelection: { type: Boolean, default: false },
|
|
155
|
+
// Controlled selection — array of selected row keys.
|
|
156
|
+
selectedRowKeys: { type: Array, default: () => [] },
|
|
157
|
+
// Placeholder shown when dataSource is empty.
|
|
158
|
+
emptyText: { type: String, default: 'No data' },
|
|
159
|
+
// Fixed scroll viewport: { x, y } as px (number) or any CSS size (string).
|
|
160
|
+
// x sets the table min-width (horizontal scroll); y caps the body height (vertical scroll + sticky header).
|
|
161
|
+
scroll: { type: Object, default: null },
|
|
162
|
+
// Fixed overall height of the table viewport (px number or CSS size). Enables vertical scroll + sticky header.
|
|
163
|
+
height: { type: [Number, String], default: null },
|
|
164
|
+
rowHeight: { 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 }
|
|
173
|
+
},
|
|
174
|
+
emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change'],
|
|
175
|
+
data() {
|
|
176
|
+
return {
|
|
177
|
+
sortField: null,
|
|
178
|
+
sortOrder: null,
|
|
179
|
+
internalSelected: this.selectedRowKeys.slice(),
|
|
180
|
+
scrollbarWidth: 0,
|
|
181
|
+
colWidths: [],
|
|
182
|
+
lastClickedIndex: null,
|
|
183
|
+
scrollTop: 0,
|
|
184
|
+
containerHeight: 0,
|
|
185
|
+
rafId: null,
|
|
186
|
+
toCssSize
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
computed: {
|
|
190
|
+
hasSelection() {
|
|
191
|
+
return !!this.rowSelection
|
|
192
|
+
},
|
|
193
|
+
totalColumns() {
|
|
194
|
+
return this.columns.length + (this.hasSelection ? 1 : 0)
|
|
195
|
+
},
|
|
196
|
+
rows() {
|
|
197
|
+
const data = this.dataSource.slice()
|
|
198
|
+
if (!this.sortField || !this.sortOrder) return data
|
|
199
|
+
const col = this.columns.find(c => this.fieldOf(c) === this.sortField)
|
|
200
|
+
if (!col || !col.sorter) return data
|
|
201
|
+
const cmp =
|
|
202
|
+
typeof col.sorter === 'function' ? col.sorter : (a, b) => defaultCompare(a[this.sortField], b[this.sortField])
|
|
203
|
+
data.sort(cmp)
|
|
204
|
+
if (this.sortOrder === 'descend') data.reverse()
|
|
205
|
+
return data
|
|
206
|
+
},
|
|
207
|
+
allSelected() {
|
|
208
|
+
if (!this.rows.length) return false
|
|
209
|
+
return this.rows.every((record, index) => this.internalSelected.includes(this.getRowKey(record, index)))
|
|
210
|
+
},
|
|
211
|
+
hasVerticalScroll() {
|
|
212
|
+
return this.height != null || !!(this.scroll && this.scroll.y != null)
|
|
213
|
+
},
|
|
214
|
+
containerStyle() {
|
|
215
|
+
const scroll = this.scroll || {}
|
|
216
|
+
return scroll.x != null ? { overflowX: 'auto' } : {}
|
|
217
|
+
},
|
|
218
|
+
bodyStyle() {
|
|
219
|
+
const scroll = this.scroll || {}
|
|
220
|
+
if (this.height != null) return { height: toCssSize(this.height) }
|
|
221
|
+
if (scroll.y != null) return { maxHeight: toCssSize(scroll.y) }
|
|
222
|
+
return {}
|
|
223
|
+
},
|
|
224
|
+
tableStyle() {
|
|
225
|
+
const scroll = this.scroll || {}
|
|
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)
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
watch: {
|
|
263
|
+
selectedRowKeys(next) {
|
|
264
|
+
this.internalSelected = (next || []).slice()
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
mounted() {
|
|
268
|
+
this.$nextTick(this.syncScrollbarGutter)
|
|
269
|
+
window.addEventListener('resize', this.onResize)
|
|
270
|
+
},
|
|
271
|
+
updated() {
|
|
272
|
+
this.$nextTick(this.syncScrollbarGutter)
|
|
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
|
+
},
|
|
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
|
+
},
|
|
304
|
+
syncScrollbarGutter() {
|
|
305
|
+
if (!this.$el) return
|
|
306
|
+
const body = this.$el.querySelector('.ui-table__body')
|
|
307
|
+
this.scrollbarWidth = body && this.hasVerticalScroll ? body.offsetWidth - body.clientWidth : 0
|
|
308
|
+
if (body && this.hasVerticalScroll) {
|
|
309
|
+
this.containerHeight = body.clientHeight
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (this.hasVerticalScroll) {
|
|
313
|
+
const firstRow = this.$el.querySelector('.ui-table__row--body')
|
|
314
|
+
if (firstRow) {
|
|
315
|
+
const cells = Array.from(firstRow.children)
|
|
316
|
+
const widths = cells.map(c => c.getBoundingClientRect().width)
|
|
317
|
+
const changed =
|
|
318
|
+
widths.length !== this.colWidths.length ||
|
|
319
|
+
widths.some((w, i) => Math.abs(w - (this.colWidths[i] || 0)) > 0.1)
|
|
320
|
+
if (changed) {
|
|
321
|
+
this.colWidths = widths
|
|
322
|
+
}
|
|
323
|
+
} else if (this.colWidths.length) {
|
|
324
|
+
this.colWidths = []
|
|
325
|
+
}
|
|
326
|
+
} else if (this.colWidths.length) {
|
|
327
|
+
this.colWidths = []
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
headCellStyle(index, col = null) {
|
|
331
|
+
if (this.hasVerticalScroll && this.colWidths[index] !== undefined) {
|
|
332
|
+
return { width: `${this.colWidths[index]}px` }
|
|
333
|
+
}
|
|
334
|
+
return col ? this.colStyle(col) : {}
|
|
335
|
+
},
|
|
336
|
+
fieldOf(col) {
|
|
337
|
+
return col.dataIndex != null ? col.dataIndex : col.key
|
|
338
|
+
},
|
|
339
|
+
colKey(col) {
|
|
340
|
+
return col.key != null ? col.key : col.dataIndex
|
|
341
|
+
},
|
|
342
|
+
colStyle(col) {
|
|
343
|
+
if (col.width == null) return {}
|
|
344
|
+
return { width: typeof col.width === 'number' ? `${col.width}px` : col.width }
|
|
345
|
+
},
|
|
346
|
+
alignOf(col) {
|
|
347
|
+
return ['left', 'right', 'center'].includes(col.align) ? col.align : 'left'
|
|
348
|
+
},
|
|
349
|
+
cellText(col, record) {
|
|
350
|
+
const field = this.fieldOf(col)
|
|
351
|
+
return field != null ? record[field] : ''
|
|
352
|
+
},
|
|
353
|
+
isSortable(col) {
|
|
354
|
+
return !!col.sorter
|
|
355
|
+
},
|
|
356
|
+
sortStateOf(col) {
|
|
357
|
+
return this.sortField === this.fieldOf(col) ? this.sortOrder : null
|
|
358
|
+
},
|
|
359
|
+
ariaSort(col) {
|
|
360
|
+
const state = this.sortStateOf(col)
|
|
361
|
+
if (state === 'ascend') return 'ascending'
|
|
362
|
+
if (state === 'descend') return 'descending'
|
|
363
|
+
return undefined
|
|
364
|
+
},
|
|
365
|
+
onHeaderClick(col) {
|
|
366
|
+
if (!this.isSortable(col)) return
|
|
367
|
+
const field = this.fieldOf(col)
|
|
368
|
+
let order
|
|
369
|
+
if (this.sortField !== field) {
|
|
370
|
+
order = 'ascend'
|
|
371
|
+
} else {
|
|
372
|
+
order = this.sortOrder === 'ascend' ? 'descend' : this.sortOrder === 'descend' ? null : 'ascend'
|
|
373
|
+
}
|
|
374
|
+
this.sortField = order ? field : null
|
|
375
|
+
this.sortOrder = order
|
|
376
|
+
const payload = { field: this.sortField, order }
|
|
377
|
+
this.$emit('sort-change', payload)
|
|
378
|
+
this.$emit('change', { sorter: payload })
|
|
379
|
+
},
|
|
380
|
+
getRowKey(record, index) {
|
|
381
|
+
if (typeof this.rowKey === 'function') return this.rowKey(record)
|
|
382
|
+
const key = record[this.rowKey]
|
|
383
|
+
return key != null ? key : index
|
|
384
|
+
},
|
|
385
|
+
isSelected(record, index) {
|
|
386
|
+
return this.internalSelected.includes(this.getRowKey(record, index))
|
|
387
|
+
},
|
|
388
|
+
emitSelection() {
|
|
389
|
+
const keys = this.internalSelected.slice()
|
|
390
|
+
const selectedRows = this.dataSource.filter((record, index) => keys.includes(this.getRowKey(record, index)))
|
|
391
|
+
this.$emit('update:selectedRowKeys', keys)
|
|
392
|
+
this.$emit('selection-change', keys, selectedRows)
|
|
393
|
+
},
|
|
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
|
|
417
|
+
this.emitSelection()
|
|
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
|
+
},
|
|
433
|
+
onToggleAll(checked) {
|
|
434
|
+
this.internalSelected = checked ? this.rows.map((record, index) => this.getRowKey(record, index)) : []
|
|
435
|
+
this.emitSelection()
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
</script>
|
|
440
|
+
|
|
441
|
+
<style src="./table.css" scoped></style>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
.ui-table {
|
|
2
|
+
width: 100%;
|
|
3
|
+
font-family: var(--font-family-body);
|
|
4
|
+
color: var(--primary-fg);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.ui-table__table {
|
|
8
|
+
width: 100%;
|
|
9
|
+
border-collapse: collapse;
|
|
10
|
+
border-spacing: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.ui-table__col--selection {
|
|
14
|
+
width: 1%;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* ---------- Cells ---------- */
|
|
18
|
+
.ui-table__cell {
|
|
19
|
+
padding: var(--spacing-xs);
|
|
20
|
+
font-size: var(--paragraph-small-font-size);
|
|
21
|
+
line-height: var(--paragraph-small-line-height);
|
|
22
|
+
letter-spacing: var(--paragraph-small-letter-spacing);
|
|
23
|
+
text-align: left;
|
|
24
|
+
vertical-align: middle;
|
|
25
|
+
border-bottom: 1px solid var(--border-primary);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.ui-table__cell--right {
|
|
29
|
+
text-align: right;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ui-table__cell--center {
|
|
33
|
+
text-align: center;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.ui-table__cell--ellipsis {
|
|
37
|
+
max-width: 0;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
text-overflow: ellipsis;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.ui-table__cell--selection {
|
|
44
|
+
width: 1%;
|
|
45
|
+
white-space: nowrap;
|
|
46
|
+
text-align: center;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ---------- Header ---------- */
|
|
50
|
+
.ui-table__th {
|
|
51
|
+
background: var(--secondary-bg);
|
|
52
|
+
font-weight: var(--paragraph-medium-font-weight);
|
|
53
|
+
white-space: nowrap;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ui-table__th-inner {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: var(--spacing-xs);
|
|
60
|
+
max-width: 100%;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.ui-table__th--sortable {
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
user-select: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ui-table__th--sortable:hover {
|
|
69
|
+
background: var(--accent-2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.ui-table__th--sortable:focus-visible {
|
|
73
|
+
outline: none;
|
|
74
|
+
box-shadow: inset 0 0 0 2px var(--focus-ring);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ui-table__sort {
|
|
78
|
+
display: inline-flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
justify-content: center;
|
|
81
|
+
flex-shrink: 0;
|
|
82
|
+
width: 16px;
|
|
83
|
+
height: 16px;
|
|
84
|
+
color: var(--muted-fg);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.ui-table__sort--inactive {
|
|
88
|
+
opacity: 0.5;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ui-table__sort--active {
|
|
92
|
+
opacity: 1;
|
|
93
|
+
color: var(--primary-fg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ---------- Body ---------- */
|
|
97
|
+
.ui-table__td {
|
|
98
|
+
background: var(--primary-bg);
|
|
99
|
+
font-weight: var(--paragraph-font-weight);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ui-table__row--body {
|
|
103
|
+
transition: background-color 0.12s ease;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.ui-table__row--body:hover .ui-table__td {
|
|
107
|
+
background: var(--accent-bg);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ui-table__row--selected .ui-table__td {
|
|
111
|
+
background: var(--secondary-bg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ---------- Empty ---------- */
|
|
115
|
+
.ui-table__empty-cell {
|
|
116
|
+
padding: var(--spacing-3xl) var(--spacing-xs);
|
|
117
|
+
text-align: center;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------- Bordered ---------- */
|
|
121
|
+
.ui-table--bordered {
|
|
122
|
+
border: 1px solid var(--border-primary);
|
|
123
|
+
border-radius: var(--rounded-xl);
|
|
124
|
+
overflow: hidden;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.ui-table--bordered .ui-table__cell {
|
|
128
|
+
border-right: 1px solid var(--border-primary);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.ui-table--bordered .ui-table__cell:last-child {
|
|
132
|
+
border-right: none;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* With a scrollbar spacer present, the last real header column is second-to-last. */
|
|
136
|
+
.ui-table--bordered .ui-table__row--has-scrollbar .ui-table__cell:nth-last-child(2) {
|
|
137
|
+
border-right: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.ui-table--bordered .ui-table__row--body:last-child .ui-table__td {
|
|
141
|
+
border-bottom: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ---------- Scroll viewport (fixed header, scrollable body) ---------- */
|
|
145
|
+
.ui-table--scroll-y .ui-table__table,
|
|
146
|
+
.ui-table--scroll-y .ui-table__head,
|
|
147
|
+
.ui-table--scroll-y .ui-table__body {
|
|
148
|
+
display: block;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/*
|
|
152
|
+
* Switch to separate borders in scroll mode so each cell owns its border width precisely.
|
|
153
|
+
* border-collapse: collapse splits shared borders by 0.5px, which causes the scrollbar
|
|
154
|
+
* spacer <th> to be off by 1px. separate + border-spacing: 0 is visually identical
|
|
155
|
+
* (cells only have border-bottom and border-right, never border-top/left) but width-exact.
|
|
156
|
+
*/
|
|
157
|
+
.ui-table--scroll-y .ui-table__table {
|
|
158
|
+
border-collapse: separate;
|
|
159
|
+
border-spacing: 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Each row becomes its own fixed-layout table so header and body columns line up. */
|
|
163
|
+
.ui-table--scroll-y .ui-table__row {
|
|
164
|
+
display: table;
|
|
165
|
+
width: 100%;
|
|
166
|
+
table-layout: fixed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* The body is the actual scroll area; the header above it stays put. */
|
|
170
|
+
.ui-table--scroll-y .ui-table__body {
|
|
171
|
+
overflow-y: auto;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.ui-table__body::-webkit-scrollbar {
|
|
175
|
+
width: 6px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.ui-table__body::-webkit-scrollbar-thumb {
|
|
179
|
+
background: var(--border-primary);
|
|
180
|
+
border-radius: 3px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.ui-table--scroll-y .ui-table__head {
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.ui-table__th--scrollbar {
|
|
188
|
+
padding: 0;
|
|
189
|
+
border: none;
|
|
190
|
+
background: var(--secondary-bg);
|
|
191
|
+
border-bottom: 1px solid var(--border-primary);
|
|
192
|
+
box-sizing: border-box;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.ui-table--scroll-y .ui-table__cell--selection {
|
|
196
|
+
width: 44px;
|
|
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
|
+
}
|
|
@@ -75,8 +75,7 @@ export default {
|
|
|
75
75
|
},
|
|
76
76
|
color: {
|
|
77
77
|
type: String,
|
|
78
|
-
default: ''
|
|
79
|
-
validator: v => COLORS.indexOf(v) !== -1
|
|
78
|
+
default: ''
|
|
80
79
|
},
|
|
81
80
|
align: {
|
|
82
81
|
type: String,
|
|
@@ -95,7 +94,10 @@ export default {
|
|
|
95
94
|
},
|
|
96
95
|
rootStyle() {
|
|
97
96
|
if (!this.color) return null
|
|
98
|
-
|
|
97
|
+
if (COLORS.includes(this.color)) {
|
|
98
|
+
return { '--ui-typography-color': 'var(--' + this.color + ')' }
|
|
99
|
+
}
|
|
100
|
+
return { '--ui-typography-color': this.color }
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
}
|
|
@@ -28,6 +28,11 @@ export default {
|
|
|
28
28
|
document.contains &&
|
|
29
29
|
document.contains(t)
|
|
30
30
|
) {
|
|
31
|
+
// Skip restore if the user has already focused another element (e.g. clicked a different input
|
|
32
|
+
// while the close animation was running). Only restore when focus fell back to body/html,
|
|
33
|
+
// which happens when the floating panel itself held focus and then disappeared.
|
|
34
|
+
const active = document.activeElement
|
|
35
|
+
if (active && active !== document.body && active !== document.documentElement) return
|
|
31
36
|
t.focus()
|
|
32
37
|
}
|
|
33
38
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<BaseIcon v-bind="$attrs" v-on="listeners">
|
|
3
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
4
|
+
<path
|
|
5
|
+
fill-rule="evenodd"
|
|
6
|
+
clip-rule="evenodd"
|
|
7
|
+
d="M13.4203 4.82946C18.1574 4.82946 22.0111 8.68328 22.0112 13.4203C22.0112 18.1574 18.1574 22.0112 13.4203 22.0112C8.68326 22.0111 4.82946 18.1574 4.82946 13.4203C4.82951 8.68331 8.6833 4.82951 13.4203 4.82946ZM15.6194 14.8406C15.6194 15.2707 15.2707 15.6194 14.8406 15.6194H6.74129C7.66689 18.4238 10.3101 20.4535 13.4203 20.4536C17.2986 20.4536 20.4536 17.2986 20.4536 13.4203C20.4535 10.3102 18.4238 7.6669 15.6194 6.74129V14.8406ZM6.5558 11.9183C6.44868 12.4033 6.38705 12.9044 6.38704 13.4203C6.38704 13.6365 6.39852 13.8504 6.41785 14.0618H8.69932L6.5558 11.9183ZM7.93057 9.03147C7.62082 9.41901 7.35486 9.84227 7.13102 10.2911L10.9018 14.0618H12.9609L7.93057 9.03147ZM10.2911 7.13103C9.84223 7.35488 9.41836 7.62013 9.0308 7.92991L14.0618 12.9609V10.9018L10.2911 7.13103ZM13.4203 6.38705C12.9044 6.38706 12.4033 6.44869 11.9183 6.5558L14.0618 8.69933V6.41719C13.8504 6.39786 13.6365 6.38705 13.4203 6.38705Z"
|
|
8
|
+
fill="currentColor"
|
|
9
|
+
/>
|
|
10
|
+
<path
|
|
11
|
+
d="M2.76763 12.6415C3.19754 12.6417 3.54637 12.9904 3.54642 13.4203V14.0618H4.28705L4.2924 14.1254C4.32931 14.607 4.40392 15.0769 4.51271 15.535L4.5328 15.6194H2.76763C2.33755 15.6194 1.98883 15.2707 1.98883 14.8406V13.4203C1.98888 12.9903 2.33758 12.6415 2.76763 12.6415Z"
|
|
12
|
+
fill="currentColor"
|
|
13
|
+
/>
|
|
14
|
+
<path
|
|
15
|
+
d="M2.76763 9.0904C3.19751 9.09056 3.54632 9.4393 3.54642 9.8692V11.2895C3.54642 11.7195 3.19757 12.0681 2.76763 12.0683C2.33755 12.0683 1.98883 11.7196 1.98883 11.2895V9.8692C1.98893 9.4392 2.33761 9.0904 2.76763 9.0904Z"
|
|
16
|
+
fill="currentColor"
|
|
17
|
+
/>
|
|
18
|
+
<path
|
|
19
|
+
d="M2.76763 5.53928C3.19748 5.53944 3.54627 5.88823 3.54642 6.31808V7.73906C3.54626 8.16891 3.19748 8.5177 2.76763 8.51786C2.33764 8.51786 1.98899 8.16901 1.98883 7.73906V6.31808C1.98899 5.88813 2.33764 5.53928 2.76763 5.53928Z"
|
|
20
|
+
fill="currentColor"
|
|
21
|
+
/>
|
|
22
|
+
<path
|
|
23
|
+
d="M4.18794 1.98884C4.61793 1.98894 4.96673 2.33762 4.96673 2.76763C4.96658 3.19752 4.61783 3.54632 4.18794 3.54643H3.54642V4.18795C3.54632 4.61784 3.19751 4.96659 2.76763 4.96674C2.33761 4.96674 1.98893 4.61794 1.98883 4.18795V2.76763C1.98883 2.33755 2.33755 1.98884 2.76763 1.98884H4.18794Z"
|
|
24
|
+
fill="currentColor"
|
|
25
|
+
/>
|
|
26
|
+
<path
|
|
27
|
+
d="M14.8406 1.98884C15.2707 1.98884 15.6194 2.33755 15.6194 2.76763V4.53281L15.535 4.51272C15.0769 4.40392 14.607 4.32932 14.1254 4.29241L14.0618 4.28705V3.54643H13.4203C12.9904 3.54638 12.6417 3.19755 12.6415 2.76763C12.6415 2.33759 12.9903 1.98889 13.4203 1.98884H14.8406Z"
|
|
28
|
+
fill="currentColor"
|
|
29
|
+
/>
|
|
30
|
+
<path
|
|
31
|
+
d="M7.73905 1.98884C8.169 1.989 8.51785 2.33765 8.51785 2.76763C8.51769 3.19748 8.1689 3.54627 7.73905 3.54643H6.31807C5.88822 3.54628 5.53943 3.19749 5.53928 2.76763C5.53928 2.33765 5.88812 1.98899 6.31807 1.98884H7.73905Z"
|
|
32
|
+
fill="currentColor"
|
|
33
|
+
/>
|
|
34
|
+
<path
|
|
35
|
+
d="M11.2895 1.98884C11.7196 1.98884 12.0683 2.33755 12.0683 2.76763C12.0681 3.19758 11.7195 3.54643 11.2895 3.54643H9.86919C9.4393 3.54632 9.09055 3.19751 9.09039 2.76763C9.09039 2.33762 9.4392 1.98895 9.86919 1.98884H11.2895Z"
|
|
36
|
+
fill="currentColor"
|
|
37
|
+
/>
|
|
38
|
+
</svg>
|
|
39
|
+
</BaseIcon>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script>
|
|
43
|
+
import BaseIcon from './BaseIcon.vue'
|
|
44
|
+
|
|
45
|
+
export default {
|
|
46
|
+
name: 'GenerateImage',
|
|
47
|
+
components: { BaseIcon },
|
|
48
|
+
inheritAttrs: false,
|
|
49
|
+
emits: [],
|
|
50
|
+
computed: {
|
|
51
|
+
listeners() {
|
|
52
|
+
return this.$listeners || {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
package/src/icons/index.js
CHANGED
|
@@ -1736,4 +1736,5 @@ export { default as WkiLayersDown } from './LayersDown.vue'
|
|
|
1736
1736
|
export { default as WkiLayersUpToTop } from './LayersUpToTop.vue'
|
|
1737
1737
|
export { default as WkiLayersDownToBottom } from './LayersDownToBottom.vue'
|
|
1738
1738
|
export { default as WkiGeneratePrompt } from './GeneratePrompt.vue'
|
|
1739
|
+
export { default as WkiClippingMask } from './ClippingMask.vue'
|
|
1739
1740
|
// --- AUTO-GENERATED LUCIDE EXPORTS END ---
|
package/src/index.js
CHANGED
|
@@ -31,8 +31,10 @@ export { default as WkSlider } from './components/slider/Slider.vue'
|
|
|
31
31
|
export { default as WkSpinner } from './components/spinner/Spinner.vue'
|
|
32
32
|
export { default as WkSwitch } from './components/switch/Switch.vue'
|
|
33
33
|
export { default as WkSwitchGroup } from './components/switch-group/SwitchGroup.vue'
|
|
34
|
+
export { default as WkTable } from './components/table/Table.vue'
|
|
34
35
|
export { default as WkTabs } from './components/tabs/Tabs.vue'
|
|
35
36
|
export { default as WkTag } from './components/tag/Tag.vue'
|
|
37
|
+
export { default as WkTextarea } from './components/textarea/Textarea.vue'
|
|
36
38
|
export { default as WkToggle } from './components/toggle/Toggle.vue'
|
|
37
39
|
export { default as WkToggleGroup } from './components/toggle-group/ToggleGroup.vue'
|
|
38
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
|
+
}
|