jobdone-shared-files 1.0.13 → 1.0.14
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/ProjectManagement/projectNavbar.vue +363 -363
- package/autocompleteSelect.vue +461 -461
- package/common/directives/collapse.js +12 -12
- package/common/directives/popovers.js +10 -10
- package/common/directives/selectPlaceholder.js +52 -52
- package/common/directives/textareaAutoHeight.js +10 -10
- package/common/directives/tooltip.js +10 -10
- package/common/format.js +26 -26
- package/index.js +14 -14
- package/lightboxWithOverview.vue +156 -156
- package/package.json +19 -19
- package/paginate.vue +141 -141
- package/style/css/vue-loading-overlay/index.css +40 -40
- package/style/scss/Common/Animation.scss +9 -9
- package/style/scss/Common/SelectableTable.scss +34 -34
- package/style/scss/Common/filepond.scss +31 -31
- package/style/scss/Common/thumbnail-group.scss +14 -14
- package/style/scss/Layout/LayoutBase.scss +1031 -1031
- package/style/scss/Layout/LayoutMobile.scss +206 -206
- package/style/scss/Layout/LayoutProject.scss +126 -126
- package/style/scss/Layout/LayoutSinglePage.scss +17 -17
- package/style/scss/Layout/LayoutTwoColumn.scss +60 -60
- package/style/scss/Settings/_Mixins.scss +232 -232
- package/style/scss/Settings/_MobileVariables.scss +11 -11
- package/style/scss/Settings/_bs-variables-dark.scss +70 -70
- package/style/scss/Settings/_bs-variables.scss +1743 -1743
- package/style/scss/Settings/_color-mode.scss +122 -122
- package/style/scss/Settings/_custom-variables.scss +10 -10
- package/tagEditor.vue +249 -249
- package/tree.vue +69 -69
- package/treeItem.vue +355 -371
- package/vueLoadingOverlay.vue +74 -74
package/autocompleteSelect.vue
CHANGED
|
@@ -1,462 +1,462 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div class="autocomplete-select-component-trigger-content" ref="componentContentInput">
|
|
3
|
-
<!-- preview input -->
|
|
4
|
-
<button type="button" class='form-control autocomplete-select-component-display-selecting' :disabled="disabled"
|
|
5
|
-
:class="[triggerClass, previewInput]">{{
|
|
6
|
-
selectedPreviewText
|
|
7
|
-
}}</button>
|
|
8
|
-
<!-- search input -->
|
|
9
|
-
<input class='form-control autocomplete-select-component-keyword-filter-input' :class="[triggerClass, searchInput]"
|
|
10
|
-
type="text" ref="keywordFilterInput" v-model="keyword"
|
|
11
|
-
:placeholder="searchPlaceholder == '' ? selectedPreviewText : searchPlaceholder" maxlength="50"
|
|
12
|
-
@keydown.enter="keyboardSelectConfirm($event)" @keydown.up="keyboardSwitch($event, -1)"
|
|
13
|
-
@keydown.down="keyboardSwitch($event, 1)" @change="keyboardSwitchIndexReset()">
|
|
14
|
-
<Teleport :to="listPut">
|
|
15
|
-
<div class="autocomplete-select-component-selector-content" :class="{ 'active': active }"
|
|
16
|
-
ref="componentContentList" :style="positionStyle">
|
|
17
|
-
<ul class="autocomplete-select-component-opt-list" ref="opt_list">
|
|
18
|
-
<li v-if="!listIsOnTop && clearBtn" class="border-bottom" :title="clearPlaceholder">
|
|
19
|
-
<button class="autocomplete-select-component-opt-item is-clear" type="button"
|
|
20
|
-
@click.stop="clearSelected()">
|
|
21
|
-
{{ clearPlaceholder }}
|
|
22
|
-
</button>
|
|
23
|
-
</li>
|
|
24
|
-
<li v-if="filterList?.length == 0">
|
|
25
|
-
<div class="autocomplete-select-component-opt-item is-nothing">
|
|
26
|
-
沒有符合條件的選項
|
|
27
|
-
</div>
|
|
28
|
-
</li>
|
|
29
|
-
<template v-if="active">
|
|
30
|
-
<li v-for="(opt, idx) in filterList" :key="idx" @click.stop="selectConfirm(opt)" :title="opt.name"
|
|
31
|
-
:id="`autocomplete-select-component-opt-item_${idx}`">
|
|
32
|
-
<button class="autocomplete-select-component-opt-item" type="button"
|
|
33
|
-
:class="idx == keyboardSwitchIndex ? 'active' : ''">
|
|
34
|
-
<template v-if="htmlOption">
|
|
35
|
-
<slot name="option" :optData="opt"></slot>
|
|
36
|
-
</template>
|
|
37
|
-
<template v-else>
|
|
38
|
-
{{ previewAdjust(opt) }}
|
|
39
|
-
</template>
|
|
40
|
-
</button>
|
|
41
|
-
</li>
|
|
42
|
-
</template>
|
|
43
|
-
<li v-if="listIsOnTop && clearBtn" class="border-top" :title="clearPlaceholder">
|
|
44
|
-
<button class="autocomplete-select-component-opt-item is-clear" type="button"
|
|
45
|
-
@click.stop="clearSelected()">
|
|
46
|
-
{{ clearPlaceholder }}
|
|
47
|
-
</button>
|
|
48
|
-
</li>
|
|
49
|
-
</ul>
|
|
50
|
-
</div>
|
|
51
|
-
</Teleport>
|
|
52
|
-
</div>
|
|
53
|
-
</template>
|
|
54
|
-
<script setup>
|
|
55
|
-
// enum Data & Functions
|
|
56
|
-
|
|
57
|
-
// vue & bootstrap
|
|
58
|
-
import { ref, onMounted, onUnmounted, computed, toRaw } from 'vue'
|
|
59
|
-
|
|
60
|
-
// plugins
|
|
61
|
-
|
|
62
|
-
// vue components
|
|
63
|
-
|
|
64
|
-
const props = defineProps({
|
|
65
|
-
selectedData: {
|
|
66
|
-
required: true
|
|
67
|
-
},
|
|
68
|
-
opts: {
|
|
69
|
-
type: Array,
|
|
70
|
-
default: []
|
|
71
|
-
},
|
|
72
|
-
placeholder: {
|
|
73
|
-
type: String,
|
|
74
|
-
default: '請選擇'
|
|
75
|
-
},
|
|
76
|
-
searchPlaceholder: {
|
|
77
|
-
type: String,
|
|
78
|
-
default: ''
|
|
79
|
-
},
|
|
80
|
-
triggerClass: {
|
|
81
|
-
type: String,
|
|
82
|
-
default: ''
|
|
83
|
-
},
|
|
84
|
-
listPut: {
|
|
85
|
-
type: String,
|
|
86
|
-
default: 'body'
|
|
87
|
-
},
|
|
88
|
-
|
|
89
|
-
clearBtn: {
|
|
90
|
-
type: Boolean,
|
|
91
|
-
default: true
|
|
92
|
-
},
|
|
93
|
-
clearPlaceholder: {
|
|
94
|
-
type: String,
|
|
95
|
-
default: '清除選擇'
|
|
96
|
-
},
|
|
97
|
-
resetValue: {
|
|
98
|
-
default: ''
|
|
99
|
-
},
|
|
100
|
-
disabled: {
|
|
101
|
-
type: Boolean,
|
|
102
|
-
default: false
|
|
103
|
-
},
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
previewKey: {
|
|
107
|
-
type: String,
|
|
108
|
-
default: '',
|
|
109
|
-
},
|
|
110
|
-
bindingKey: {
|
|
111
|
-
type: String,
|
|
112
|
-
default: '',
|
|
113
|
-
},
|
|
114
|
-
filterKeys: {
|
|
115
|
-
type: Array,
|
|
116
|
-
default: ['name'],
|
|
117
|
-
},
|
|
118
|
-
isUndefinedHint: {
|
|
119
|
-
type: String,
|
|
120
|
-
default: '⚠️ 未找到符合原選擇的選項,可能相關資料曾發生異動',
|
|
121
|
-
},
|
|
122
|
-
htmlOption: {
|
|
123
|
-
type: Boolean,
|
|
124
|
-
default: false
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const emit = defineEmits(['select'])
|
|
131
|
-
|
|
132
|
-
// Just DOM
|
|
133
|
-
const componentContentInput = ref(null)
|
|
134
|
-
const componentContentList = ref(null)
|
|
135
|
-
const keywordFilterInput = ref(null)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// 是否開啟下拉選單
|
|
139
|
-
const active = ref(false)
|
|
140
|
-
const domPosition = ref({ bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0 })
|
|
141
|
-
function activeSelector() {
|
|
142
|
-
if (props.disabled) {
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
active.value = true
|
|
146
|
-
keywordFilterInput.value.focus()
|
|
147
|
-
domPosition.value = componentContentInput.value.getBoundingClientRect()
|
|
148
|
-
}
|
|
149
|
-
const listIsOnTop = ref(false)
|
|
150
|
-
const positionStyle = computed(() => {
|
|
151
|
-
let screenHeight = window.innerHeight;
|
|
152
|
-
let info = toRaw(domPosition.value)
|
|
153
|
-
listIsOnTop.value = info.top >= (screenHeight - info.bottom)
|
|
154
|
-
let str = ''
|
|
155
|
-
if (listIsOnTop.value) {
|
|
156
|
-
str = `bottom:${screenHeight - info.top}px;max-height:calc(${info.top}px - 10%);`
|
|
157
|
-
} else {
|
|
158
|
-
str = `top:${info.bottom}px;max-height:calc(${screenHeight - info.bottom}px - 10%);`
|
|
159
|
-
}
|
|
160
|
-
return `${str}left:${info.x}px;max-width:calc(90% - ${info.x}px);min-width:${Math.abs(info.x - info.right)}px;`
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
function leave() {
|
|
164
|
-
listIsOnTop.value = false
|
|
165
|
-
active.value = false
|
|
166
|
-
keyword.value = ''
|
|
167
|
-
// TODO SCROLL CLOSE
|
|
168
|
-
keyboardSwitchIndexReset()
|
|
169
|
-
keywordFilterInput.value.blur()
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 確認傳入選單列表內的選項Type,只檢查第一個,請內容統一,不要傳奇怪的東西進來
|
|
173
|
-
const allowOptItemType = ['string', 'number', 'object']
|
|
174
|
-
const optItemType = computed(() => {
|
|
175
|
-
if (!Array.isArray(props.opts) || props.opts?.length < 1) {
|
|
176
|
-
return ''
|
|
177
|
-
}
|
|
178
|
-
let sampling = props.opts[0]
|
|
179
|
-
let type = typeof sampling
|
|
180
|
-
if (!allowOptItemType.includes(type)) {
|
|
181
|
-
return ''
|
|
182
|
-
}
|
|
183
|
-
if ((sampling instanceof Date) && (sampling instanceof RegExp) && Array.isArray(sampling)) {
|
|
184
|
-
return ''
|
|
185
|
-
}
|
|
186
|
-
return type?.toLowerCase()
|
|
187
|
-
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
// 篩選
|
|
192
|
-
const keyword = ref("")
|
|
193
|
-
const filterList = computed(() => {
|
|
194
|
-
if (optItemType.value === '') {
|
|
195
|
-
return []
|
|
196
|
-
}
|
|
197
|
-
if (keyword.value == '') {
|
|
198
|
-
return props.opts
|
|
199
|
-
}
|
|
200
|
-
let kwReplace = keyword.value.replace(/[.*+?^${}()|[\]\\]/g, "");
|
|
201
|
-
let regex = new RegExp(kwReplace, "i");
|
|
202
|
-
if (optItemType.value === 'object') {
|
|
203
|
-
let final = props.opts.filter(i => {
|
|
204
|
-
for (var idx = 0; idx < props.filterKeys.length; idx++) {
|
|
205
|
-
if (regex.test(i[props.filterKeys[idx].toString()])) {
|
|
206
|
-
return i
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
})
|
|
210
|
-
return final
|
|
211
|
-
}
|
|
212
|
-
return props.opts.filter(i => {
|
|
213
|
-
if (regex.test(i)) {
|
|
214
|
-
return i
|
|
215
|
-
}
|
|
216
|
-
})
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
// 從選項列表中挖出整個選中的item,因選中的值(可能是某個key)
|
|
220
|
-
const selectedItem = computed(() => {
|
|
221
|
-
if (!props.selectedData) {
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// 如果選中的值是某個Key,需自行指定bindingKey
|
|
226
|
-
if (props.bindingKey !== '') {
|
|
227
|
-
return props.opts.find(i => i[props.bindingKey.toString()] == props.selectedData) ?? props.selectedData
|
|
228
|
-
}
|
|
229
|
-
return props.selectedData
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// 整理 選項與選中值 的顯示文字
|
|
234
|
-
function previewAdjust(itemObj) {
|
|
235
|
-
if (props.previewKey !== '') {
|
|
236
|
-
let strAry = props.previewKey.split('+')
|
|
237
|
-
let isUndefined = false
|
|
238
|
-
|
|
239
|
-
let finalStr = strAry.reduce((acc, cur) => {
|
|
240
|
-
let v = cur.trim()
|
|
241
|
-
if (v.match(/'[^']+'/g)) {
|
|
242
|
-
return acc + v.substring(1, v.length - 1)
|
|
243
|
-
} else {
|
|
244
|
-
if (itemObj[v] === undefined) {
|
|
245
|
-
isUndefined = true
|
|
246
|
-
}
|
|
247
|
-
return acc + itemObj[v]
|
|
248
|
-
}
|
|
249
|
-
}, '')
|
|
250
|
-
if (isUndefined) {
|
|
251
|
-
return props.isUndefinedHint
|
|
252
|
-
}
|
|
253
|
-
return finalStr ? finalStr : props.placeholder
|
|
254
|
-
}
|
|
255
|
-
return itemObj
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// 選中的值的顯示
|
|
260
|
-
const selectedPreviewText = computed(() => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (!selectedItem.value || Object.keys(selectedItem.value).length === 0) {
|
|
264
|
-
return props.placeholder
|
|
265
|
-
}
|
|
266
|
-
return previewAdjust(selectedItem.value)
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
// 清除選擇內容
|
|
270
|
-
function clearSelected() {
|
|
271
|
-
emit('select', props.resetValue)
|
|
272
|
-
leave()
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 選擇送出
|
|
276
|
-
function selectConfirm(v) {
|
|
277
|
-
if (!v) {
|
|
278
|
-
return
|
|
279
|
-
}
|
|
280
|
-
emit('select', JSON.parse(JSON.stringify(v)))
|
|
281
|
-
leave()
|
|
282
|
-
keyboardSwitchIndexReset()
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// 鍵盤送出
|
|
286
|
-
function keyboardSelectConfirm(event) {
|
|
287
|
-
if (event.isComposing) {
|
|
288
|
-
return
|
|
289
|
-
}
|
|
290
|
-
if (filterList.value?.length) {
|
|
291
|
-
selectConfirm(filterList.value[keyboardSwitchIndex.value])
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// 鍵盤挑選
|
|
296
|
-
const keyboardSwitchIndex = ref(0)
|
|
297
|
-
function keyboardSwitch(event, v) {
|
|
298
|
-
if (event.isComposing) {
|
|
299
|
-
return
|
|
300
|
-
}
|
|
301
|
-
if (filterList.value?.length < 1) {
|
|
302
|
-
keyboardSwitchIndexReset()
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
let idx = Number(keyboardSwitchIndex.value) + v
|
|
306
|
-
|
|
307
|
-
if (idx < 0) {
|
|
308
|
-
keyboardSwitchIndex.value = filterList.value.length - 1
|
|
309
|
-
document.getElementById(`autocomplete-select-component-opt-item_${keyboardSwitchIndex.value}`)?.scrollIntoView()
|
|
310
|
-
return
|
|
311
|
-
}
|
|
312
|
-
if (idx > filterList.value.length - 1) {
|
|
313
|
-
keyboardSwitchIndexReset()
|
|
314
|
-
document.getElementById(`autocomplete-select-component-opt-item_${keyboardSwitchIndex.value}`)?.scrollIntoView()
|
|
315
|
-
return
|
|
316
|
-
}
|
|
317
|
-
keyboardSwitchIndex.value = idx
|
|
318
|
-
document.getElementById(`autocomplete-select-component-opt-item_${keyboardSwitchIndex.value}`)?.scrollIntoView()
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function keyboardSwitchIndexReset() {
|
|
322
|
-
keyboardSwitchIndex.value = 0
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// 樣式
|
|
326
|
-
const previewInput = computed(() => {
|
|
327
|
-
if (selectedPreviewText.value == props.placeholder) {
|
|
328
|
-
return 'autocomplete-select-component-value-null'
|
|
329
|
-
}
|
|
330
|
-
return ''
|
|
331
|
-
})
|
|
332
|
-
const searchInput = computed(() => {
|
|
333
|
-
if (active.value) {
|
|
334
|
-
return 'active'
|
|
335
|
-
}
|
|
336
|
-
return ''
|
|
337
|
-
})
|
|
338
|
-
function initTrigger(event) {
|
|
339
|
-
if (componentContentInput.value.contains(event.target) || componentContentList.value.contains(event.target)) {
|
|
340
|
-
activeSelector()
|
|
341
|
-
} else {
|
|
342
|
-
leave()
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
onMounted(() => {
|
|
346
|
-
window.addEventListener("click", initTrigger);
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
onUnmounted(() => {
|
|
350
|
-
window.removeEventListener("click", initTrigger);
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
</script>
|
|
354
|
-
<style lang="scss" scoped>
|
|
355
|
-
* {
|
|
356
|
-
word-break: break-word;
|
|
357
|
-
overflow-wrap: break-word;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
.autocomplete-select-component-trigger-content {
|
|
361
|
-
position: relative;
|
|
362
|
-
width: 100%;
|
|
363
|
-
|
|
364
|
-
.form-control {
|
|
365
|
-
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%232D4155%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e");
|
|
366
|
-
background-repeat: no-repeat;
|
|
367
|
-
background-position: right 0.75rem center;
|
|
368
|
-
background-size: 16px 12px;
|
|
369
|
-
text-align: start;
|
|
370
|
-
width: 100%;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
.autocomplete-select-component-value-null {
|
|
374
|
-
color: var(--bs-secondary-color);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
.autocomplete-select-component-display-selecting {
|
|
378
|
-
position: absolute;
|
|
379
|
-
top: 0;
|
|
380
|
-
left: 0;
|
|
381
|
-
z-index: 1;
|
|
382
|
-
white-space: nowrap;
|
|
383
|
-
overflow: hidden;
|
|
384
|
-
text-overflow: ellipsis;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
.autocomplete-select-component-keyword-filter-input {
|
|
388
|
-
opacity: 0;
|
|
389
|
-
position: relative;
|
|
390
|
-
|
|
391
|
-
&.active {
|
|
392
|
-
opacity: 1;
|
|
393
|
-
z-index: 2;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
.autocomplete-select-component-selector-content {
|
|
399
|
-
visibility: hidden;
|
|
400
|
-
display: none;
|
|
401
|
-
position: fixed;
|
|
402
|
-
z-index: 2000;
|
|
403
|
-
margin-top: 0.25rem;
|
|
404
|
-
margin-bottom: 0.25rem;
|
|
405
|
-
max-height: 76vh;
|
|
406
|
-
overflow: auto;
|
|
407
|
-
|
|
408
|
-
ul.autocomplete-select-component-opt-list {
|
|
409
|
-
background: #fff;
|
|
410
|
-
list-style: none;
|
|
411
|
-
border: 1px solid var(--gray-400);
|
|
412
|
-
border-radius: var(--bs-border-radius);
|
|
413
|
-
margin: 0;
|
|
414
|
-
padding: 0;
|
|
415
|
-
overflow: auto;
|
|
416
|
-
padding: .25rem 0;
|
|
417
|
-
|
|
418
|
-
>li {
|
|
419
|
-
width: 100%;
|
|
420
|
-
overflow: auto;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
.autocomplete-select-component-opt-item {
|
|
424
|
-
background-color: transparent;
|
|
425
|
-
border: none;
|
|
426
|
-
padding: .25rem .5rem;
|
|
427
|
-
word-break: break-word;
|
|
428
|
-
width: 100%;
|
|
429
|
-
overflow: hidden;
|
|
430
|
-
text-align: start;
|
|
431
|
-
text-overflow: ellipsis;
|
|
432
|
-
color: var(--gray-600);
|
|
433
|
-
|
|
434
|
-
&.is-nothing {
|
|
435
|
-
color: var(--bs-danger);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
&.is-clear {
|
|
439
|
-
color: var(--gray-500);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
&:hover,
|
|
443
|
-
&.is-clear:hover {
|
|
444
|
-
background: rgba(var(--bs-primary-rgb), 1) !important;
|
|
445
|
-
color: #fff !important;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
&.active {
|
|
449
|
-
background: rgba(var(--bs-primary-rgb), .1);
|
|
450
|
-
color: var(--bs-primary);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
&.active {
|
|
456
|
-
display: block;
|
|
457
|
-
box-shadow: var(--bs-box-shadow);
|
|
458
|
-
visibility: visible;
|
|
459
|
-
z-index: 2000;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
1
|
+
<template>
|
|
2
|
+
<div class="autocomplete-select-component-trigger-content" ref="componentContentInput">
|
|
3
|
+
<!-- preview input -->
|
|
4
|
+
<button type="button" class='form-control autocomplete-select-component-display-selecting' :disabled="disabled"
|
|
5
|
+
:class="[triggerClass, previewInput]">{{
|
|
6
|
+
selectedPreviewText
|
|
7
|
+
}}</button>
|
|
8
|
+
<!-- search input -->
|
|
9
|
+
<input class='form-control autocomplete-select-component-keyword-filter-input' :class="[triggerClass, searchInput]"
|
|
10
|
+
type="text" ref="keywordFilterInput" v-model="keyword"
|
|
11
|
+
:placeholder="searchPlaceholder == '' ? selectedPreviewText : searchPlaceholder" maxlength="50"
|
|
12
|
+
@keydown.enter="keyboardSelectConfirm($event)" @keydown.up="keyboardSwitch($event, -1)"
|
|
13
|
+
@keydown.down="keyboardSwitch($event, 1)" @change="keyboardSwitchIndexReset()">
|
|
14
|
+
<Teleport :to="listPut">
|
|
15
|
+
<div class="autocomplete-select-component-selector-content" :class="{ 'active': active }"
|
|
16
|
+
ref="componentContentList" :style="positionStyle">
|
|
17
|
+
<ul class="autocomplete-select-component-opt-list" ref="opt_list">
|
|
18
|
+
<li v-if="!listIsOnTop && clearBtn" class="border-bottom" :title="clearPlaceholder">
|
|
19
|
+
<button class="autocomplete-select-component-opt-item is-clear" type="button"
|
|
20
|
+
@click.stop="clearSelected()">
|
|
21
|
+
{{ clearPlaceholder }}
|
|
22
|
+
</button>
|
|
23
|
+
</li>
|
|
24
|
+
<li v-if="filterList?.length == 0">
|
|
25
|
+
<div class="autocomplete-select-component-opt-item is-nothing">
|
|
26
|
+
沒有符合條件的選項
|
|
27
|
+
</div>
|
|
28
|
+
</li>
|
|
29
|
+
<template v-if="active">
|
|
30
|
+
<li v-for="(opt, idx) in filterList" :key="idx" @click.stop="selectConfirm(opt)" :title="opt.name"
|
|
31
|
+
:id="`autocomplete-select-component-opt-item_${idx}`">
|
|
32
|
+
<button class="autocomplete-select-component-opt-item" type="button"
|
|
33
|
+
:class="idx == keyboardSwitchIndex ? 'active' : ''">
|
|
34
|
+
<template v-if="htmlOption">
|
|
35
|
+
<slot name="option" :optData="opt"></slot>
|
|
36
|
+
</template>
|
|
37
|
+
<template v-else>
|
|
38
|
+
{{ previewAdjust(opt) }}
|
|
39
|
+
</template>
|
|
40
|
+
</button>
|
|
41
|
+
</li>
|
|
42
|
+
</template>
|
|
43
|
+
<li v-if="listIsOnTop && clearBtn" class="border-top" :title="clearPlaceholder">
|
|
44
|
+
<button class="autocomplete-select-component-opt-item is-clear" type="button"
|
|
45
|
+
@click.stop="clearSelected()">
|
|
46
|
+
{{ clearPlaceholder }}
|
|
47
|
+
</button>
|
|
48
|
+
</li>
|
|
49
|
+
</ul>
|
|
50
|
+
</div>
|
|
51
|
+
</Teleport>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
<script setup>
|
|
55
|
+
// enum Data & Functions
|
|
56
|
+
|
|
57
|
+
// vue & bootstrap
|
|
58
|
+
import { ref, onMounted, onUnmounted, computed, toRaw } from 'vue'
|
|
59
|
+
|
|
60
|
+
// plugins
|
|
61
|
+
|
|
62
|
+
// vue components
|
|
63
|
+
|
|
64
|
+
const props = defineProps({
|
|
65
|
+
selectedData: {
|
|
66
|
+
required: true
|
|
67
|
+
},
|
|
68
|
+
opts: {
|
|
69
|
+
type: Array,
|
|
70
|
+
default: []
|
|
71
|
+
},
|
|
72
|
+
placeholder: {
|
|
73
|
+
type: String,
|
|
74
|
+
default: '請選擇'
|
|
75
|
+
},
|
|
76
|
+
searchPlaceholder: {
|
|
77
|
+
type: String,
|
|
78
|
+
default: ''
|
|
79
|
+
},
|
|
80
|
+
triggerClass: {
|
|
81
|
+
type: String,
|
|
82
|
+
default: ''
|
|
83
|
+
},
|
|
84
|
+
listPut: {
|
|
85
|
+
type: String,
|
|
86
|
+
default: 'body'
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
clearBtn: {
|
|
90
|
+
type: Boolean,
|
|
91
|
+
default: true
|
|
92
|
+
},
|
|
93
|
+
clearPlaceholder: {
|
|
94
|
+
type: String,
|
|
95
|
+
default: '清除選擇'
|
|
96
|
+
},
|
|
97
|
+
resetValue: {
|
|
98
|
+
default: ''
|
|
99
|
+
},
|
|
100
|
+
disabled: {
|
|
101
|
+
type: Boolean,
|
|
102
|
+
default: false
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
previewKey: {
|
|
107
|
+
type: String,
|
|
108
|
+
default: '',
|
|
109
|
+
},
|
|
110
|
+
bindingKey: {
|
|
111
|
+
type: String,
|
|
112
|
+
default: '',
|
|
113
|
+
},
|
|
114
|
+
filterKeys: {
|
|
115
|
+
type: Array,
|
|
116
|
+
default: ['name'],
|
|
117
|
+
},
|
|
118
|
+
isUndefinedHint: {
|
|
119
|
+
type: String,
|
|
120
|
+
default: '⚠️ 未找到符合原選擇的選項,可能相關資料曾發生異動',
|
|
121
|
+
},
|
|
122
|
+
htmlOption: {
|
|
123
|
+
type: Boolean,
|
|
124
|
+
default: false
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const emit = defineEmits(['select'])
|
|
131
|
+
|
|
132
|
+
// Just DOM
|
|
133
|
+
const componentContentInput = ref(null)
|
|
134
|
+
const componentContentList = ref(null)
|
|
135
|
+
const keywordFilterInput = ref(null)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
// 是否開啟下拉選單
|
|
139
|
+
const active = ref(false)
|
|
140
|
+
const domPosition = ref({ bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0 })
|
|
141
|
+
function activeSelector() {
|
|
142
|
+
if (props.disabled) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
active.value = true
|
|
146
|
+
keywordFilterInput.value.focus()
|
|
147
|
+
domPosition.value = componentContentInput.value.getBoundingClientRect()
|
|
148
|
+
}
|
|
149
|
+
const listIsOnTop = ref(false)
|
|
150
|
+
const positionStyle = computed(() => {
|
|
151
|
+
let screenHeight = window.innerHeight;
|
|
152
|
+
let info = toRaw(domPosition.value)
|
|
153
|
+
listIsOnTop.value = info.top >= (screenHeight - info.bottom)
|
|
154
|
+
let str = ''
|
|
155
|
+
if (listIsOnTop.value) {
|
|
156
|
+
str = `bottom:${screenHeight - info.top}px;max-height:calc(${info.top}px - 10%);`
|
|
157
|
+
} else {
|
|
158
|
+
str = `top:${info.bottom}px;max-height:calc(${screenHeight - info.bottom}px - 10%);`
|
|
159
|
+
}
|
|
160
|
+
return `${str}left:${info.x}px;max-width:calc(90% - ${info.x}px);min-width:${Math.abs(info.x - info.right)}px;`
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
function leave() {
|
|
164
|
+
listIsOnTop.value = false
|
|
165
|
+
active.value = false
|
|
166
|
+
keyword.value = ''
|
|
167
|
+
// TODO SCROLL CLOSE
|
|
168
|
+
keyboardSwitchIndexReset()
|
|
169
|
+
keywordFilterInput.value.blur()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 確認傳入選單列表內的選項Type,只檢查第一個,請內容統一,不要傳奇怪的東西進來
|
|
173
|
+
const allowOptItemType = ['string', 'number', 'object']
|
|
174
|
+
const optItemType = computed(() => {
|
|
175
|
+
if (!Array.isArray(props.opts) || props.opts?.length < 1) {
|
|
176
|
+
return ''
|
|
177
|
+
}
|
|
178
|
+
let sampling = props.opts[0]
|
|
179
|
+
let type = typeof sampling
|
|
180
|
+
if (!allowOptItemType.includes(type)) {
|
|
181
|
+
return ''
|
|
182
|
+
}
|
|
183
|
+
if ((sampling instanceof Date) && (sampling instanceof RegExp) && Array.isArray(sampling)) {
|
|
184
|
+
return ''
|
|
185
|
+
}
|
|
186
|
+
return type?.toLowerCase()
|
|
187
|
+
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
//
|
|
191
|
+
// 篩選
|
|
192
|
+
const keyword = ref("")
|
|
193
|
+
const filterList = computed(() => {
|
|
194
|
+
if (optItemType.value === '') {
|
|
195
|
+
return []
|
|
196
|
+
}
|
|
197
|
+
if (keyword.value == '') {
|
|
198
|
+
return props.opts
|
|
199
|
+
}
|
|
200
|
+
let kwReplace = keyword.value.replace(/[.*+?^${}()|[\]\\]/g, "");
|
|
201
|
+
let regex = new RegExp(kwReplace, "i");
|
|
202
|
+
if (optItemType.value === 'object') {
|
|
203
|
+
let final = props.opts.filter(i => {
|
|
204
|
+
for (var idx = 0; idx < props.filterKeys.length; idx++) {
|
|
205
|
+
if (regex.test(i[props.filterKeys[idx].toString()])) {
|
|
206
|
+
return i
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
return final
|
|
211
|
+
}
|
|
212
|
+
return props.opts.filter(i => {
|
|
213
|
+
if (regex.test(i)) {
|
|
214
|
+
return i
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// 從選項列表中挖出整個選中的item,因選中的值(可能是某個key)
|
|
220
|
+
const selectedItem = computed(() => {
|
|
221
|
+
if (!props.selectedData) {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 如果選中的值是某個Key,需自行指定bindingKey
|
|
226
|
+
if (props.bindingKey !== '') {
|
|
227
|
+
return props.opts.find(i => i[props.bindingKey.toString()] == props.selectedData) ?? props.selectedData
|
|
228
|
+
}
|
|
229
|
+
return props.selectedData
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
// 整理 選項與選中值 的顯示文字
|
|
234
|
+
function previewAdjust(itemObj) {
|
|
235
|
+
if (props.previewKey !== '') {
|
|
236
|
+
let strAry = props.previewKey.split('+')
|
|
237
|
+
let isUndefined = false
|
|
238
|
+
|
|
239
|
+
let finalStr = strAry.reduce((acc, cur) => {
|
|
240
|
+
let v = cur.trim()
|
|
241
|
+
if (v.match(/'[^']+'/g)) {
|
|
242
|
+
return acc + v.substring(1, v.length - 1)
|
|
243
|
+
} else {
|
|
244
|
+
if (itemObj[v] === undefined) {
|
|
245
|
+
isUndefined = true
|
|
246
|
+
}
|
|
247
|
+
return acc + itemObj[v]
|
|
248
|
+
}
|
|
249
|
+
}, '')
|
|
250
|
+
if (isUndefined) {
|
|
251
|
+
return props.isUndefinedHint
|
|
252
|
+
}
|
|
253
|
+
return finalStr ? finalStr : props.placeholder
|
|
254
|
+
}
|
|
255
|
+
return itemObj
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
// 選中的值的顯示
|
|
260
|
+
const selectedPreviewText = computed(() => {
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if (!selectedItem.value || Object.keys(selectedItem.value).length === 0) {
|
|
264
|
+
return props.placeholder
|
|
265
|
+
}
|
|
266
|
+
return previewAdjust(selectedItem.value)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// 清除選擇內容
|
|
270
|
+
function clearSelected() {
|
|
271
|
+
emit('select', props.resetValue)
|
|
272
|
+
leave()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 選擇送出
|
|
276
|
+
function selectConfirm(v) {
|
|
277
|
+
if (!v) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
emit('select', JSON.parse(JSON.stringify(v)))
|
|
281
|
+
leave()
|
|
282
|
+
keyboardSwitchIndexReset()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 鍵盤送出
|
|
286
|
+
function keyboardSelectConfirm(event) {
|
|
287
|
+
if (event.isComposing) {
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
if (filterList.value?.length) {
|
|
291
|
+
selectConfirm(filterList.value[keyboardSwitchIndex.value])
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 鍵盤挑選
|
|
296
|
+
const keyboardSwitchIndex = ref(0)
|
|
297
|
+
function keyboardSwitch(event, v) {
|
|
298
|
+
if (event.isComposing) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (filterList.value?.length < 1) {
|
|
302
|
+
keyboardSwitchIndexReset()
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
let idx = Number(keyboardSwitchIndex.value) + v
|
|
306
|
+
|
|
307
|
+
if (idx < 0) {
|
|
308
|
+
keyboardSwitchIndex.value = filterList.value.length - 1
|
|
309
|
+
document.getElementById(`autocomplete-select-component-opt-item_${keyboardSwitchIndex.value}`)?.scrollIntoView()
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (idx > filterList.value.length - 1) {
|
|
313
|
+
keyboardSwitchIndexReset()
|
|
314
|
+
document.getElementById(`autocomplete-select-component-opt-item_${keyboardSwitchIndex.value}`)?.scrollIntoView()
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
keyboardSwitchIndex.value = idx
|
|
318
|
+
document.getElementById(`autocomplete-select-component-opt-item_${keyboardSwitchIndex.value}`)?.scrollIntoView()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function keyboardSwitchIndexReset() {
|
|
322
|
+
keyboardSwitchIndex.value = 0
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 樣式
|
|
326
|
+
const previewInput = computed(() => {
|
|
327
|
+
if (selectedPreviewText.value == props.placeholder) {
|
|
328
|
+
return 'autocomplete-select-component-value-null'
|
|
329
|
+
}
|
|
330
|
+
return ''
|
|
331
|
+
})
|
|
332
|
+
const searchInput = computed(() => {
|
|
333
|
+
if (active.value) {
|
|
334
|
+
return 'active'
|
|
335
|
+
}
|
|
336
|
+
return ''
|
|
337
|
+
})
|
|
338
|
+
function initTrigger(event) {
|
|
339
|
+
if (componentContentInput.value.contains(event.target) || componentContentList.value.contains(event.target)) {
|
|
340
|
+
activeSelector()
|
|
341
|
+
} else {
|
|
342
|
+
leave()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
onMounted(() => {
|
|
346
|
+
window.addEventListener("click", initTrigger);
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
onUnmounted(() => {
|
|
350
|
+
window.removeEventListener("click", initTrigger);
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
</script>
|
|
354
|
+
<style lang="scss" scoped>
|
|
355
|
+
* {
|
|
356
|
+
word-break: break-word;
|
|
357
|
+
overflow-wrap: break-word;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.autocomplete-select-component-trigger-content {
|
|
361
|
+
position: relative;
|
|
362
|
+
width: 100%;
|
|
363
|
+
|
|
364
|
+
.form-control {
|
|
365
|
+
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%232D4155%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e");
|
|
366
|
+
background-repeat: no-repeat;
|
|
367
|
+
background-position: right 0.75rem center;
|
|
368
|
+
background-size: 16px 12px;
|
|
369
|
+
text-align: start;
|
|
370
|
+
width: 100%;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.autocomplete-select-component-value-null {
|
|
374
|
+
color: var(--bs-secondary-color);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.autocomplete-select-component-display-selecting {
|
|
378
|
+
position: absolute;
|
|
379
|
+
top: 0;
|
|
380
|
+
left: 0;
|
|
381
|
+
z-index: 1;
|
|
382
|
+
white-space: nowrap;
|
|
383
|
+
overflow: hidden;
|
|
384
|
+
text-overflow: ellipsis;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.autocomplete-select-component-keyword-filter-input {
|
|
388
|
+
opacity: 0;
|
|
389
|
+
position: relative;
|
|
390
|
+
|
|
391
|
+
&.active {
|
|
392
|
+
opacity: 1;
|
|
393
|
+
z-index: 2;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.autocomplete-select-component-selector-content {
|
|
399
|
+
visibility: hidden;
|
|
400
|
+
display: none;
|
|
401
|
+
position: fixed;
|
|
402
|
+
z-index: 2000;
|
|
403
|
+
margin-top: 0.25rem;
|
|
404
|
+
margin-bottom: 0.25rem;
|
|
405
|
+
max-height: 76vh;
|
|
406
|
+
overflow: auto;
|
|
407
|
+
|
|
408
|
+
ul.autocomplete-select-component-opt-list {
|
|
409
|
+
background: #fff;
|
|
410
|
+
list-style: none;
|
|
411
|
+
border: 1px solid var(--gray-400);
|
|
412
|
+
border-radius: var(--bs-border-radius);
|
|
413
|
+
margin: 0;
|
|
414
|
+
padding: 0;
|
|
415
|
+
overflow: auto;
|
|
416
|
+
padding: .25rem 0;
|
|
417
|
+
|
|
418
|
+
>li {
|
|
419
|
+
width: 100%;
|
|
420
|
+
overflow: auto;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.autocomplete-select-component-opt-item {
|
|
424
|
+
background-color: transparent;
|
|
425
|
+
border: none;
|
|
426
|
+
padding: .25rem .5rem;
|
|
427
|
+
word-break: break-word;
|
|
428
|
+
width: 100%;
|
|
429
|
+
overflow: hidden;
|
|
430
|
+
text-align: start;
|
|
431
|
+
text-overflow: ellipsis;
|
|
432
|
+
color: var(--gray-600);
|
|
433
|
+
|
|
434
|
+
&.is-nothing {
|
|
435
|
+
color: var(--bs-danger);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
&.is-clear {
|
|
439
|
+
color: var(--gray-500);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
&:hover,
|
|
443
|
+
&.is-clear:hover {
|
|
444
|
+
background: rgba(var(--bs-primary-rgb), 1) !important;
|
|
445
|
+
color: #fff !important;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
&.active {
|
|
449
|
+
background: rgba(var(--bs-primary-rgb), .1);
|
|
450
|
+
color: var(--bs-primary);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
&.active {
|
|
456
|
+
display: block;
|
|
457
|
+
box-shadow: var(--bs-box-shadow);
|
|
458
|
+
visibility: visible;
|
|
459
|
+
z-index: 2000;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
462
|
</style>
|