@community-release/nx-ui 0.0.57 → 0.0.59
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/dist/module.d.mts +4 -0
- package/dist/module.d.ts +4 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -0
- package/dist/runtime/components/button/index.vue +7 -11
- package/dist/runtime/components/icons/chevron-down.svg +1 -0
- package/dist/runtime/components/icons/xmark.svg +1 -0
- package/dist/runtime/components/input/index.vue +36 -11
- package/dist/runtime/components/styles/components.less +2 -0
- package/dist/runtime/components/typeahead-input/index.vue +558 -0
- package/dist/runtime/components/typeahead-input/normalizeString.d.ts +2 -0
- package/dist/runtime/components/typeahead-input/normalizeString.mjs +32 -0
- package/dist/runtime/components/typeahead-input/similarText.d.ts +23 -0
- package/dist/runtime/components/typeahead-input/similarText.mjs +68 -0
- package/package.json +1 -1
package/dist/module.d.mts
CHANGED
package/dist/module.d.ts
CHANGED
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
<script setup>
|
|
46
46
|
// Imports
|
|
47
|
-
import { ref, computed
|
|
47
|
+
import { ref, computed } from 'vue';
|
|
48
48
|
import UiImpulseIndicator from '../impulse-indicator.vue';
|
|
49
49
|
import UiLoading from '../loading.vue';
|
|
50
50
|
import comProps from '#build/ui.button.mjs';
|
|
@@ -155,16 +155,12 @@
|
|
|
155
155
|
width : refCom.value.offsetWidth,
|
|
156
156
|
height : refCom.value.offsetHeight
|
|
157
157
|
};
|
|
158
|
-
|
|
159
|
-
// Handle navigate
|
|
160
|
-
// if (computedType == 'a') {
|
|
161
|
-
// if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
162
|
-
|
|
163
|
-
// e.preventDefault();
|
|
164
|
-
|
|
165
|
-
// router.push(props.href);
|
|
166
|
-
// }
|
|
167
158
|
}
|
|
159
|
+
|
|
160
|
+
// Expose
|
|
161
|
+
defineExpose({
|
|
162
|
+
refCom
|
|
163
|
+
});
|
|
168
164
|
</script>
|
|
169
165
|
|
|
170
166
|
<style lang="less">
|
|
@@ -378,7 +374,7 @@
|
|
|
378
374
|
// Disabled
|
|
379
375
|
&.tag-disabled {
|
|
380
376
|
opacity: 0.6;
|
|
381
|
-
cursor:
|
|
377
|
+
cursor: not-allowed;
|
|
382
378
|
pointer-events: none;
|
|
383
379
|
}
|
|
384
380
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024"><path d="M1006.444 334.973 553.473 768.982c-15.17 12.8-29.393 18.014-41.481 18.014-12.089 0-28.373-5.26-39.324-15.852L17.563 334.973c-22.746-21.57-23.486-59.733-1.707-80.355 21.63-22.814 57.789-23.556 80.378-1.703l415.758 398.498 415.757-398.217c22.518-21.852 58.737-21.11 80.378 1.704 21.784 20.34 21.073 58.503-1.683 80.073z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024"><path d="M1008.882 1008.993c-20.007 20.01-52.377 20.01-72.406 0L511.992 584.366 87.41 1008.993c-20.007 20.01-52.377 20.01-72.406 0-20.007-20.009-20.007-52.382 0-72.414l424.677-424.531L15.069 87.421c-20.007-20.01-20.007-52.383 0-72.414 20.007-20.01 52.377-20.01 72.406 0L511.992 439.73l424.58-424.627c20.007-20.01 52.377-20.01 72.406 0 20.007 20.009 20.007 52.382 0 72.414L584.302 512.048l424.58 424.627c20.157 19.84 20.157 52.479 0 72.318z"/></svg>
|
|
@@ -17,13 +17,17 @@
|
|
|
17
17
|
|
|
18
18
|
@change="updateValue($event.target.value)"
|
|
19
19
|
@input="updateValue($event.target.value)"
|
|
20
|
-
@
|
|
20
|
+
@focus="handleFocusBlur(true, $event.target.value)"
|
|
21
|
+
@blur="handleFocusBlur(false, $event.target.value)"
|
|
21
22
|
@keyup.enter="updateValue($event.target.value, true, true)"
|
|
22
|
-
|
|
23
|
-
@
|
|
23
|
+
@keydown.down="emit('keydown.down', $event)"
|
|
24
|
+
@keydown.tab="emit('keydown.tab', $event)"
|
|
25
|
+
@keydown.esc.prevent="emit('keydown.esc', $event)"
|
|
24
26
|
|
|
25
27
|
formnovalidate
|
|
26
28
|
spellcheck="false"
|
|
29
|
+
|
|
30
|
+
v-bind="inputAttrs"
|
|
27
31
|
/>
|
|
28
32
|
</div>
|
|
29
33
|
<div class="slot-append" v-if="hasSlot('append')"><slot name="append"></slot></div>
|
|
@@ -33,13 +37,17 @@
|
|
|
33
37
|
|
|
34
38
|
<script setup>
|
|
35
39
|
// Import
|
|
36
|
-
import { ref, computed, useSlots
|
|
40
|
+
import { ref, computed, useSlots, useAttrs } from 'vue';
|
|
37
41
|
import UiImpulseIndicator from '../impulse-indicator.vue';
|
|
38
42
|
import comProps from '#build/ui.input.mjs';
|
|
39
43
|
|
|
40
44
|
// Misc
|
|
41
|
-
const emit = defineEmits([
|
|
45
|
+
const emit = defineEmits([
|
|
46
|
+
'input', 'enter', 'focus', 'blur', 'update:modelValue',
|
|
47
|
+
'keydown.down', 'keydown.tab', 'keydown.esc',
|
|
48
|
+
]);
|
|
42
49
|
const slots = useSlots();
|
|
50
|
+
const attrs = useAttrs();
|
|
43
51
|
|
|
44
52
|
// Data
|
|
45
53
|
const props = defineProps({
|
|
@@ -97,6 +105,15 @@
|
|
|
97
105
|
return ar;
|
|
98
106
|
});
|
|
99
107
|
|
|
108
|
+
const inputAttrs = {};
|
|
109
|
+
for (const key in attrs) {
|
|
110
|
+
if (key.startsWith('input.')) {
|
|
111
|
+
const k = key.slice(6) // All after "input."
|
|
112
|
+
|
|
113
|
+
inputAttrs[k] = attrs[key];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
// Methods
|
|
101
118
|
function updateValue(value, doTrim = false, submit = false) {
|
|
102
119
|
const validValue = doTrim ? value.trim() : value;
|
|
@@ -104,21 +121,29 @@
|
|
|
104
121
|
if (value !== validValue)
|
|
105
122
|
refInput.value.value = validValue;
|
|
106
123
|
|
|
107
|
-
|
|
124
|
+
if (props.modelValue !== validValue) {
|
|
125
|
+
emit('update:modelValue', validValue);
|
|
126
|
+
emit('input', validValue);
|
|
127
|
+
}
|
|
108
128
|
|
|
109
129
|
if (submit) emit('enter', validValue);
|
|
110
130
|
}
|
|
111
131
|
|
|
112
|
-
function
|
|
113
|
-
haveFocus.value =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
updateValue(
|
|
132
|
+
function handleFocusBlur(focus, value) {
|
|
133
|
+
haveFocus.value = focus;
|
|
134
|
+
emit(focus ? 'focus' : 'blur', focus);
|
|
135
|
+
|
|
136
|
+
if (!focus) updateValue(value);
|
|
117
137
|
}
|
|
118
138
|
|
|
119
139
|
const hasSlot = (name) => {
|
|
120
140
|
return !!slots[name];
|
|
121
141
|
};
|
|
142
|
+
|
|
143
|
+
// Expose
|
|
144
|
+
defineExpose({
|
|
145
|
+
refInput
|
|
146
|
+
});
|
|
122
147
|
</script>
|
|
123
148
|
|
|
124
149
|
<style lang="less">
|
|
@@ -5,3 +5,5 @@
|
|
|
5
5
|
@ui-select-value-font-weight: var(--ui-font-weight-medium);
|
|
6
6
|
@ui-select-background-color: var(--ui-color-surface);
|
|
7
7
|
@ui-input-background-color: var(--ui-color-bg);
|
|
8
|
+
@ui-typeahead-input-list-bg: var(--ui-color-bg);
|
|
9
|
+
@ui-typeahead-input-list-border-radius: var(--ui-border-radius-small);
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="component-ui-typeahead-input" ref="refEl" :class="classes">
|
|
3
|
+
<div class="component-ui-typeahead-input-grid">
|
|
4
|
+
<ui-input
|
|
5
|
+
ref="refComInput"
|
|
6
|
+
|
|
7
|
+
:modelValue="modelValue"
|
|
8
|
+
@update:modelValue="v => emit('update:modelValue', v)"
|
|
9
|
+
|
|
10
|
+
:input-id="inputId"
|
|
11
|
+
:error="error"
|
|
12
|
+
:name="name"
|
|
13
|
+
:required="required"
|
|
14
|
+
:disabled="disabled"
|
|
15
|
+
:placeholder="placeholder"
|
|
16
|
+
@input="handleInputTyping"
|
|
17
|
+
@focus="handleInputFocus(true)"
|
|
18
|
+
@blur="handleInputFocus(false)"
|
|
19
|
+
@keydown.down="selectFirstListItem"
|
|
20
|
+
@keydown.esc="handleInputEsc"
|
|
21
|
+
@enter="handleInputFocus(false)"
|
|
22
|
+
|
|
23
|
+
v-bind="inputAttrs"
|
|
24
|
+
>
|
|
25
|
+
<template #prepend>
|
|
26
|
+
<ui-button
|
|
27
|
+
variant="flat"
|
|
28
|
+
shape="square"
|
|
29
|
+
:color="color"
|
|
30
|
+
|
|
31
|
+
@click.stop="toggleList(true)"
|
|
32
|
+
@keydown.down.prevent="selectFirstListItem"
|
|
33
|
+
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
tabindex="-1"
|
|
36
|
+
|
|
37
|
+
:disabled="disabled"
|
|
38
|
+
>
|
|
39
|
+
<svg class="list-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M1006.444 334.973 553.473 768.982c-15.17 12.8-29.393 18.014-41.481 18.014-12.089 0-28.373-5.26-39.324-15.852L17.563 334.973c-22.746-21.57-23.486-59.733-1.707-80.355 21.63-22.814 57.789-23.556 80.378-1.703l415.758 398.498 415.757-398.217c22.518-21.852 58.737-21.11 80.378 1.704 21.784 20.34 21.073 58.503-1.683 80.073z"/></svg>
|
|
40
|
+
</ui-button>
|
|
41
|
+
</template>
|
|
42
|
+
<template #append>
|
|
43
|
+
<ui-button
|
|
44
|
+
variant="flat"
|
|
45
|
+
shape="square"
|
|
46
|
+
:color="color"
|
|
47
|
+
|
|
48
|
+
@click.stop="emit('update:modelValue', '')"
|
|
49
|
+
|
|
50
|
+
:aria-label="dictionary['clear']"
|
|
51
|
+
:disabled="disabled"
|
|
52
|
+
>
|
|
53
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="14" height="14"><path fill="currentColor" d="M1008.882 1008.993c-20.007 20.01-52.377 20.01-72.406 0L511.992 584.366 87.41 1008.993c-20.007 20.01-52.377 20.01-72.406 0-20.007-20.009-20.007-52.382 0-72.414l424.677-424.531L15.069 87.421c-20.007-20.01-20.007-52.383 0-72.414 20.007-20.01 52.377-20.01 72.406 0L511.992 439.73l424.58-424.627c20.007-20.01 52.377-20.01 72.406 0 20.007 20.009 20.007 52.382 0 72.414L584.302 512.048l424.58 424.627c20.157 19.84 20.157 52.479 0 72.318z"/></svg>
|
|
54
|
+
</ui-button>
|
|
55
|
+
</template>
|
|
56
|
+
</ui-input>
|
|
57
|
+
<ui-button shape="default" :disabled="disabled" :color="color">{{ dictionary['submit'] }}</ui-button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<Teleport to="body">
|
|
61
|
+
<div v-if="listIsShown" class="component-ui-typeahead-input-list" :style="listStyle" @click="handleListClick">
|
|
62
|
+
<!-- ♿ Element for screen reader users, to annotate the number of results found -->
|
|
63
|
+
<div
|
|
64
|
+
class="component-ui-typeahead-input-list--header"
|
|
65
|
+
:role="a11yAnnotation.length ? 'alert' : ''"
|
|
66
|
+
:key="a11yAnnotation"
|
|
67
|
+
>
|
|
68
|
+
{{ a11yAnnotation }}
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<ul ref="refList" role="listbox">
|
|
72
|
+
<li
|
|
73
|
+
v-for="(item, index) of filteredList"
|
|
74
|
+
@click="handleListItemClick(item)"
|
|
75
|
+
role="option"
|
|
76
|
+
tabindex="0"
|
|
77
|
+
@keydown.down.prevent="focusNext(index)"
|
|
78
|
+
@keydown.up.prevent="focusPrev(index)"
|
|
79
|
+
@keydown.enter.prevent="handleListItemClick(item)"
|
|
80
|
+
@keydown.tab="handleListTab($event, index)"
|
|
81
|
+
@keydown.esc="handleListEsc"
|
|
82
|
+
>
|
|
83
|
+
{{ item.name }}
|
|
84
|
+
</li>
|
|
85
|
+
</ul>
|
|
86
|
+
|
|
87
|
+
<div class="component-ui-typeahead-input-list--footer">
|
|
88
|
+
<ui-button ref="refListBtnClose" @keydown.tab="handleListButtonTab" @click="listHide" variant="flat" block>{{ dictionary['close'] }}</ui-button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</Teleport>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<script setup>
|
|
96
|
+
// Imports
|
|
97
|
+
import { ref, computed, watch, onMounted, onBeforeUnmount, useAttrs } from 'vue';
|
|
98
|
+
import normalizeString from './normalizeString';
|
|
99
|
+
import similarText from './similarText';
|
|
100
|
+
|
|
101
|
+
// Misc
|
|
102
|
+
const attrs = useAttrs();
|
|
103
|
+
|
|
104
|
+
// Data
|
|
105
|
+
const emit = defineEmits(['update:modelValue']);
|
|
106
|
+
|
|
107
|
+
const props = defineProps({
|
|
108
|
+
modelValue: {
|
|
109
|
+
required: true
|
|
110
|
+
},
|
|
111
|
+
inputId: {
|
|
112
|
+
default: 'typeahead',
|
|
113
|
+
},
|
|
114
|
+
name: {
|
|
115
|
+
default: 'typeahead',
|
|
116
|
+
},
|
|
117
|
+
placeholder: {
|
|
118
|
+
default: '',
|
|
119
|
+
},
|
|
120
|
+
disabled: {
|
|
121
|
+
type: Boolean,
|
|
122
|
+
default: false,
|
|
123
|
+
},
|
|
124
|
+
required: {
|
|
125
|
+
type: Boolean,
|
|
126
|
+
default: false,
|
|
127
|
+
},
|
|
128
|
+
error: {
|
|
129
|
+
type: Boolean,
|
|
130
|
+
default: false,
|
|
131
|
+
},
|
|
132
|
+
impulse: {
|
|
133
|
+
default: false
|
|
134
|
+
},
|
|
135
|
+
size: {
|
|
136
|
+
type: String,
|
|
137
|
+
default: 'medium'
|
|
138
|
+
},
|
|
139
|
+
dictionary: {
|
|
140
|
+
type: Object,
|
|
141
|
+
default() {
|
|
142
|
+
return {
|
|
143
|
+
list: 'View list',
|
|
144
|
+
clear: 'Clear input',
|
|
145
|
+
close: 'Close',
|
|
146
|
+
submit: 'Ok',
|
|
147
|
+
itemsFound: `Found {n} options`,
|
|
148
|
+
noItemsFound: `No items found`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
color: {
|
|
154
|
+
type: String,
|
|
155
|
+
default: 'primary'
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
options: {
|
|
159
|
+
type: Array,
|
|
160
|
+
required: true
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const refComInput = ref(null);
|
|
165
|
+
const refEl = ref(null);
|
|
166
|
+
const refList = ref(null);
|
|
167
|
+
const refListBtnClose = ref(null);
|
|
168
|
+
|
|
169
|
+
let inputBlurTimeout;
|
|
170
|
+
|
|
171
|
+
const classes = computed(() => {
|
|
172
|
+
const result = [];
|
|
173
|
+
|
|
174
|
+
if (listIsShown.value) result.push('tag-list-active');
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// List
|
|
180
|
+
const listIsShown = ref(false);
|
|
181
|
+
const listStyle = ref({});
|
|
182
|
+
const optionsPrepared = false;
|
|
183
|
+
const preparedOptions = ref([]);
|
|
184
|
+
const showFullList = ref(false);
|
|
185
|
+
|
|
186
|
+
const filteredList = computed(() => {
|
|
187
|
+
let result = [];
|
|
188
|
+
let query = showFullList.value ? '' : props.modelValue;
|
|
189
|
+
let options = preparedOptions.value;
|
|
190
|
+
|
|
191
|
+
query = normalizeString(query);
|
|
192
|
+
|
|
193
|
+
for (let item of options) {
|
|
194
|
+
// Looking for a query entry
|
|
195
|
+
const position = item.n.indexOf( query );
|
|
196
|
+
|
|
197
|
+
if (position > -1) {
|
|
198
|
+
result.push({
|
|
199
|
+
likenesses: 100, // Match percentage
|
|
200
|
+
position, // Word position in string
|
|
201
|
+
item
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
// We compare the query for similarity with each individual word
|
|
205
|
+
for (let j=0, position=0, likenesses; j < item.w.length; j++) {
|
|
206
|
+
likenesses = similarText(item.w[j], query, true);
|
|
207
|
+
|
|
208
|
+
// likenesses > {minimum likeness percent}
|
|
209
|
+
if (likenesses > 67) {
|
|
210
|
+
result.push({
|
|
211
|
+
likenesses,
|
|
212
|
+
position,
|
|
213
|
+
item
|
|
214
|
+
});
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
position += item.w.length + 1; // 1 = space
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Sort by likenesses (more is better)
|
|
225
|
+
result.sort(function(a, b) { return b.likenesses - a.likenesses; });
|
|
226
|
+
|
|
227
|
+
// Sort by position (less is better)
|
|
228
|
+
result.sort(function(a, b) { return a.position - b.position; });
|
|
229
|
+
|
|
230
|
+
// Format result to match vue-select
|
|
231
|
+
return result.map(v => {
|
|
232
|
+
return v.item;
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
// 💬 Input data
|
|
238
|
+
const inputAttrs = {};
|
|
239
|
+
for (const key in attrs) {
|
|
240
|
+
if (key.startsWith('input.')) {
|
|
241
|
+
inputAttrs[key] = attrs[key];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
// 💬 Input methods
|
|
247
|
+
function selectInput() {
|
|
248
|
+
refComInput.value.refInput.focus();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Handle input typing */
|
|
252
|
+
function handleInputTyping(v) {
|
|
253
|
+
if (v?.length) listShow();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Handle input focus
|
|
258
|
+
* @param {Boolean} haveFocus
|
|
259
|
+
*/
|
|
260
|
+
function handleInputFocus(haveFocus) {
|
|
261
|
+
if (haveFocus) {
|
|
262
|
+
listShow();
|
|
263
|
+
} else {
|
|
264
|
+
clearTimeout(inputBlurTimeout);
|
|
265
|
+
inputBlurTimeout = setTimeout(listHide, 125);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Handle input escape press
|
|
271
|
+
* - prevent default action
|
|
272
|
+
* - hide the list
|
|
273
|
+
* @param {Event} e
|
|
274
|
+
*/
|
|
275
|
+
function handleInputEsc(e) {
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
listHide();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
// 📝 List methods
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Toggle list
|
|
285
|
+
* @param {Boolean} full - show full list
|
|
286
|
+
*/
|
|
287
|
+
function toggleList(full = false) {
|
|
288
|
+
if (listIsShown.value)
|
|
289
|
+
listHide();
|
|
290
|
+
else
|
|
291
|
+
listShow(full);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Select first list item
|
|
296
|
+
* @param {Event} e
|
|
297
|
+
*/
|
|
298
|
+
function selectFirstListItem(e) {
|
|
299
|
+
// If list is active
|
|
300
|
+
if (refList.value) {
|
|
301
|
+
const li = refList.value.querySelector('li');
|
|
302
|
+
|
|
303
|
+
// If items exists
|
|
304
|
+
if (li) {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
li.focus();
|
|
307
|
+
preventListClose();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Focus prev list element, or focus input */
|
|
313
|
+
function focusPrev(index) {
|
|
314
|
+
if (index > 0) {
|
|
315
|
+
const li = refList.value.querySelectorAll('li');
|
|
316
|
+
|
|
317
|
+
li[index - 1].focus();
|
|
318
|
+
} else {
|
|
319
|
+
// If on first then return to <input />
|
|
320
|
+
selectInput();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Focus next list element */
|
|
325
|
+
function focusNext(index) {
|
|
326
|
+
const li = refList.value.querySelectorAll('li');
|
|
327
|
+
|
|
328
|
+
if (index < li.length - 1) li[index + 1].focus();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Handle list item click
|
|
333
|
+
* - update modelValue
|
|
334
|
+
* - hide the list
|
|
335
|
+
* - focus <input />
|
|
336
|
+
*/
|
|
337
|
+
function handleListItemClick(item) {
|
|
338
|
+
emit('update:modelValue', `${item.name} (${item.value})`);
|
|
339
|
+
listHide();
|
|
340
|
+
|
|
341
|
+
selectInput();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function handleListClick(e) {
|
|
345
|
+
preventListClose();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Handle list tab press
|
|
350
|
+
* Shift + Tab on first -> focus <input />
|
|
351
|
+
* @param {Event} e
|
|
352
|
+
* @param {Number} index
|
|
353
|
+
*/
|
|
354
|
+
function handleListTab(e, index) {
|
|
355
|
+
if (e.shiftKey && index === 0) {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
selectInput();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Handle list escape press
|
|
363
|
+
* - select input
|
|
364
|
+
* - hide the list
|
|
365
|
+
*/
|
|
366
|
+
function handleListEsc() {
|
|
367
|
+
selectInput();
|
|
368
|
+
listHide();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Show the list
|
|
373
|
+
* @param {boolean} full - show full list
|
|
374
|
+
*/
|
|
375
|
+
function listShow(full = false) {
|
|
376
|
+
if (listIsShown.value && showFullList.value == full) return;
|
|
377
|
+
|
|
378
|
+
preventListClose();
|
|
379
|
+
|
|
380
|
+
if (!optionsPrepared) prepareOptions();
|
|
381
|
+
|
|
382
|
+
// Position list
|
|
383
|
+
const rect = refEl.value.getBoundingClientRect();
|
|
384
|
+
|
|
385
|
+
const top = rect.top + window.scrollY;
|
|
386
|
+
const left = rect.left + window.scrollX;
|
|
387
|
+
const width = rect.width;
|
|
388
|
+
const height = rect.height;
|
|
389
|
+
|
|
390
|
+
listStyle.value = {
|
|
391
|
+
top: top + height + 'px',
|
|
392
|
+
left: left + 'px',
|
|
393
|
+
width: width + 'px'
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
showFullList.value = full;
|
|
397
|
+
listIsShown.value = true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Hide the list */
|
|
401
|
+
function listHide() {
|
|
402
|
+
if (listIsShown.value) listIsShown.value = false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Prevent list close */
|
|
406
|
+
function preventListClose() {
|
|
407
|
+
clearTimeout(inputBlurTimeout);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Handle tab click while focus list close button
|
|
412
|
+
* @param {Event} e
|
|
413
|
+
*/
|
|
414
|
+
function handleListButtonTab(e) {
|
|
415
|
+
if (!e.shiftKey) e.preventDefault();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Prepare list options */
|
|
419
|
+
function prepareOptions() {
|
|
420
|
+
const result = [];
|
|
421
|
+
|
|
422
|
+
for (let item of props.options) {
|
|
423
|
+
const n = normalizeString(item.name);
|
|
424
|
+
const o = {
|
|
425
|
+
value: item.value,
|
|
426
|
+
name: item.name,
|
|
427
|
+
n,
|
|
428
|
+
w: n.replace(',', '').split(' ')
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
result.push(o);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
preparedOptions.value = result;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ♿ Annotation for screen reader users, to annotate the number of results found
|
|
438
|
+
const a11yAnnotation = ref('');
|
|
439
|
+
let tA11y;
|
|
440
|
+
|
|
441
|
+
watch(filteredList, (v) => {
|
|
442
|
+
clearTimeout(tA11y);
|
|
443
|
+
tA11y = setTimeout(() => {
|
|
444
|
+
a11yAnnotation.value = v.length ? props.dictionary['itemsFound'].replace('{n}', v.length) : props.dictionary['noItemsFound'];
|
|
445
|
+
}, 500);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// 🖱️ Handle click outside
|
|
449
|
+
function handleClickOutside(e) {
|
|
450
|
+
const el1 = e.target.closest('.component-ui-typeahead-input-list');
|
|
451
|
+
const el2 = e.target.closest('.component-ui-typeahead-input');
|
|
452
|
+
|
|
453
|
+
if (!el1 && !el2) listHide();
|
|
454
|
+
}
|
|
455
|
+
onMounted(() => {
|
|
456
|
+
document.addEventListener('click', handleClickOutside);
|
|
457
|
+
});
|
|
458
|
+
onBeforeUnmount(() => {
|
|
459
|
+
document.removeEventListener('click', handleClickOutside);
|
|
460
|
+
});
|
|
461
|
+
</script>
|
|
462
|
+
|
|
463
|
+
<style lang="less">
|
|
464
|
+
@import '../styles/components.less';
|
|
465
|
+
|
|
466
|
+
@com-text-small: var(--ui-text-small);
|
|
467
|
+
|
|
468
|
+
@com-input-height-default: var(--ui-input-height-default);
|
|
469
|
+
|
|
470
|
+
@com-color-primary: var(--ui-color-primary);
|
|
471
|
+
@com-color-text-on-primary: var(--ui-color-text-on-primary);
|
|
472
|
+
@com-color-surface: @ui-typeahead-input-list-bg;
|
|
473
|
+
@com-color-border: var(--ui-color-border);
|
|
474
|
+
|
|
475
|
+
@com-space-mini: var(--ui-space-mini);
|
|
476
|
+
@com-space-micro: var(--ui-space-micro);
|
|
477
|
+
|
|
478
|
+
@com-border-radius: @ui-typeahead-input-list-border-radius;
|
|
479
|
+
|
|
480
|
+
.component-ui-typeahead-input {
|
|
481
|
+
.list-icon {
|
|
482
|
+
transition: all var(--ui-ani-time) var(--ui-ani-ease);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
&.tag-list-active {
|
|
486
|
+
.list-icon {
|
|
487
|
+
transform: rotate(180deg);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.component-ui-typeahead-input-grid {
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
display: grid;
|
|
496
|
+
grid-template-columns: auto 50px;
|
|
497
|
+
gap: @com-space-micro;
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
input:focus-visible {
|
|
501
|
+
outline: unset;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.component-ui-input .component-ui-button {
|
|
505
|
+
padding: 0;
|
|
506
|
+
width: @com-input-height-default;
|
|
507
|
+
height: 100%;
|
|
508
|
+
font-size: @com-text-small;
|
|
509
|
+
|
|
510
|
+
.slot-default {
|
|
511
|
+
position: relative;
|
|
512
|
+
display: grid;
|
|
513
|
+
place-items: center;
|
|
514
|
+
height: 100%;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
&:focus-visible {
|
|
518
|
+
outline-offset: -8px;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.component-ui-typeahead-input-list {
|
|
524
|
+
z-index: 999;
|
|
525
|
+
|
|
526
|
+
position: absolute;
|
|
527
|
+
margin-top: 5px;
|
|
528
|
+
background: @com-color-surface;
|
|
529
|
+
border: 1px solid @com-color-border;
|
|
530
|
+
border-radius: @com-border-radius;
|
|
531
|
+
|
|
532
|
+
ul {
|
|
533
|
+
overflow-x: hidden;
|
|
534
|
+
overflow-y: auto;
|
|
535
|
+
max-height: 400px;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
li {
|
|
539
|
+
padding: @com-space-micro @com-space-mini;
|
|
540
|
+
cursor: pointer;
|
|
541
|
+
|
|
542
|
+
&:hover,
|
|
543
|
+
&:focus-visible {
|
|
544
|
+
color: @com-color-text-on-primary;
|
|
545
|
+
background: @com-color-primary;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.component-ui-typeahead-input-list--header {
|
|
550
|
+
text-align: center;
|
|
551
|
+
padding: @com-space-mini;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.component-ui-typeahead-input-list--footer {
|
|
555
|
+
padding: @com-space-mini;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Normalize string, replace letters with extra symbols, convert to lower case
|
|
2
|
+
* @param {string} s
|
|
3
|
+
* @returns {string}
|
|
4
|
+
*/
|
|
5
|
+
export default (s) => {
|
|
6
|
+
if (!s) return '';
|
|
7
|
+
|
|
8
|
+
const arChars = [
|
|
9
|
+
[['ā', 'à', 'á', 'â', 'ã', 'ä', 'å'], 'a'],
|
|
10
|
+
[['č'], 'c'],
|
|
11
|
+
[['ē'], 'e'],
|
|
12
|
+
[['ģ'], 'g'],
|
|
13
|
+
[['ī'], 'i'],
|
|
14
|
+
[['ķ'], 'k'],
|
|
15
|
+
[['ļ'], 'l'],
|
|
16
|
+
[['ņ'], 'n'],
|
|
17
|
+
[['š'], 's'],
|
|
18
|
+
[['ū'], 'u'],
|
|
19
|
+
[['ž'], 'z'],
|
|
20
|
+
[['!', '\\(', '\\)', '\\[', '\\]', '/'], ''],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
s = s.toLocaleLowerCase();
|
|
24
|
+
|
|
25
|
+
for (let item of arChars) {
|
|
26
|
+
for (let letterToReplace of item[0]) {
|
|
27
|
+
s = s.replace( new RegExp(letterToReplace, 'g'), item[1] );
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return s.replace(/\s{2,}/g, '').trim();
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares two strings for similarity (PHP's similar_text algorithm).
|
|
3
|
+
*
|
|
4
|
+
* The algorithm finds the longest common substring between the two strings
|
|
5
|
+
* and then recursively compares the parts to the left and right of the match.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ Complexity is O(n³), which may be slow for very long strings, but accurate.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} first - The first string to compare.
|
|
10
|
+
* @param {string} second - The second string to compare.
|
|
11
|
+
* @param {boolean} [percent=false] - If true, returns the similarity as a percentage (0–100).
|
|
12
|
+
* If false or omitted, returns the number of matching characters.
|
|
13
|
+
* @returns {number} The count of matching characters or the similarity percentage.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Returns the number of matching characters
|
|
17
|
+
* similarText("hello", "hallo"); // 4
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // Returns the similarity percentage
|
|
21
|
+
* similarText("hello", "hallo", true); // ~80
|
|
22
|
+
*/
|
|
23
|
+
export default function similarText(first: string, second: string, returnPercent?: boolean): number;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares two strings for similarity (PHP's similar_text algorithm).
|
|
3
|
+
*
|
|
4
|
+
* The algorithm finds the longest common substring between the two strings
|
|
5
|
+
* and then recursively compares the parts to the left and right of the match.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ Complexity is O(n³), which may be slow for very long strings, but accurate.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} first - The first string to compare.
|
|
10
|
+
* @param {string} second - The second string to compare.
|
|
11
|
+
* @param {boolean} [percent=false] - If true, returns the similarity as a percentage (0–100).
|
|
12
|
+
* If false or omitted, returns the number of matching characters.
|
|
13
|
+
* @returns {number} The count of matching characters or the similarity percentage.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Returns the number of matching characters
|
|
17
|
+
* similarText("hello", "hallo"); // 4
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // Returns the similarity percentage
|
|
21
|
+
* similarText("hello", "hallo", true); // ~80
|
|
22
|
+
*/
|
|
23
|
+
export default function similarText(first, second, returnPercent = false) {
|
|
24
|
+
if (first == null || second == null) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
first = String(first);
|
|
29
|
+
second = String(second);
|
|
30
|
+
|
|
31
|
+
let pos1 = 0, pos2 = 0, max = 0;
|
|
32
|
+
const firstLength = first.length;
|
|
33
|
+
const secondLength = second.length;
|
|
34
|
+
|
|
35
|
+
for (let p = 0; p < firstLength; p++) {
|
|
36
|
+
for (let q = 0; q < secondLength; q++) {
|
|
37
|
+
let l = 0;
|
|
38
|
+
while (
|
|
39
|
+
p + l < firstLength &&
|
|
40
|
+
q + l < secondLength &&
|
|
41
|
+
first.charAt(p + l) === second.charAt(q + l)
|
|
42
|
+
) {
|
|
43
|
+
l++;
|
|
44
|
+
}
|
|
45
|
+
if (l > max) {
|
|
46
|
+
max = l;
|
|
47
|
+
pos1 = p;
|
|
48
|
+
pos2 = q;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let sum = max;
|
|
54
|
+
|
|
55
|
+
if (sum > 0) {
|
|
56
|
+
if (pos1 > 0 && pos2 > 0) {
|
|
57
|
+
sum += similarText(first.slice(0, pos1), second.slice(0, pos2));
|
|
58
|
+
}
|
|
59
|
+
if (pos1 + max < firstLength && pos2 + max < secondLength) {
|
|
60
|
+
sum += similarText(
|
|
61
|
+
first.slice(pos1 + max),
|
|
62
|
+
second.slice(pos2 + max)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return returnPercent ? (sum * 200) / (firstLength + secondLength) : sum;
|
|
68
|
+
}
|