@nethserver/ns8-ui-lib 0.0.96 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nethserver/ns8-ui-lib",
3
- "version": "0.0.96",
3
+ "version": "0.0.99",
4
4
  "description": "Vue.js library for NethServer 8 UI",
5
5
  "keywords": [
6
6
  "nethserver",
@@ -0,0 +1,512 @@
1
+ <template>
2
+ <div
3
+ class="ns-combo-box cv-combo-box"
4
+ :class="`${carbonPrefix}--list-box__wrapper`"
5
+ @focusout="onFocusOut"
6
+ >
7
+ <label
8
+ v-if="title"
9
+ :for="uid"
10
+ :class="[
11
+ `${carbonPrefix}--label`,
12
+ { [`${carbonPrefix}--label--disabled`]: disabled },
13
+ ]"
14
+ >{{ title }}</label
15
+ >
16
+
17
+ <div
18
+ role="listbox"
19
+ tabindex="-1"
20
+ class=""
21
+ :class="[
22
+ `${carbonPrefix}--combo-box ${carbonPrefix}--list-box`,
23
+ {
24
+ [`${carbonPrefix}--list-box--light`]: isLight,
25
+ [`${carbonPrefix}--combo-box--expanded`]: open,
26
+ [`${carbonPrefix}--list-box--expanded`]: open,
27
+ [`${carbonPrefix}--combo-box--disabled ${carbonPrefix}--list-box--disabled`]:
28
+ disabled,
29
+ },
30
+ ]"
31
+ :data-invalid="isInvalid"
32
+ v-bind="$attrs"
33
+ @keydown.down.prevent="onDown"
34
+ @keydown.up.prevent="onUp"
35
+ @keydown.enter.prevent="onEnter"
36
+ @keydown.esc.prevent="onEsc"
37
+ @click="onClick"
38
+ >
39
+ <WarningFilled16
40
+ v-if="isInvalid"
41
+ :class="[`${carbonPrefix}--list-box__invalid-icon`]"
42
+ />
43
+ <div
44
+ role="button"
45
+ aria-haspopup="true"
46
+ :aria-expanded="open ? 'true' : 'false'"
47
+ :aria-owns="uid"
48
+ :aria-controls="uid"
49
+ :class="[`${carbonPrefix}--list-box__field`]"
50
+ tabindex="-1"
51
+ type="button"
52
+ :aria-label="open ? 'close menu' : 'open menu'"
53
+ data-toggle="true"
54
+ ref="button"
55
+ >
56
+ <input
57
+ ref="input"
58
+ :class="[
59
+ `${carbonPrefix}--text-input`,
60
+ {
61
+ [`${carbonPrefix}--text-input--empty`]:
62
+ !filter || filter.length === 0,
63
+ },
64
+ ]"
65
+ :aria-controls="uid"
66
+ aria-autocomplete="list"
67
+ role="combobox"
68
+ :aria-disabled="disabled"
69
+ :aria-expanded="open ? 'true' : 'false'"
70
+ autocomplete="off"
71
+ :disabled="disabled"
72
+ :placeholder="label"
73
+ v-model="filter"
74
+ @input="onInput"
75
+ @focus="inputFocus"
76
+ @click.stop.prevent="inputClick"
77
+ />
78
+ <div
79
+ v-if="filter"
80
+ role="button"
81
+ :class="[`${carbonPrefix}--list-box__selection`]"
82
+ tabindex="0"
83
+ :title="clearFilterLabel"
84
+ @click.stop="clearFilter"
85
+ @keydown.enter.stop.prevent="clearFilter"
86
+ @keydown.space.stop.prevent
87
+ @keyup.space.stop.prevent="clearFilter"
88
+ >
89
+ <Close16 />
90
+ </div>
91
+
92
+ <div
93
+ :class="[
94
+ `${carbonPrefix}--list-box__menu-icon`,
95
+ { [`${carbonPrefix}--list-box__menu-icon--open`]: open },
96
+ ]"
97
+ role="button"
98
+ >
99
+ <chevron-down-16 :aria-label="open ? 'Close menu' : 'Open menu'" />
100
+ </div>
101
+ </div>
102
+
103
+ <div
104
+ v-show="open"
105
+ :id="uid"
106
+ :class="[`${carbonPrefix}--list-box__menu`]"
107
+ role="listbox"
108
+ ref="list"
109
+ >
110
+ <div
111
+ v-for="(item, index) in limitedDataOptions"
112
+ :key="`combo-box-${index}`"
113
+ :class="[
114
+ `${carbonPrefix}--list-box__menu-item`,
115
+ {
116
+ [`${carbonPrefix}--list-box__menu-item--highlighted`]:
117
+ highlighted === item.value,
118
+ },
119
+ ]"
120
+ ref="option"
121
+ @click.stop.prevent="onItemClick(item.value)"
122
+ @mousemove="onMousemove(item.value)"
123
+ @mousedown.prevent
124
+ >
125
+ <div :class="[`${carbonPrefix}--list-box__menu-item__option`]">
126
+ {{
127
+ showItemType && item.type
128
+ ? item.label + " - " + item.type
129
+ : item.label
130
+ }}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ <div v-if="isInvalid" :class="[`${carbonPrefix}--form-requirement`]">
136
+ <slot name="invalid-message">{{ invalidMessage }}</slot>
137
+ </div>
138
+ <div
139
+ v-if="!isInvalid && isHelper"
140
+ :class="[
141
+ `${carbonPrefix}--form__helper-text`,
142
+ { [`${carbonPrefix}--form__helper-text--disabled`]: disabled },
143
+ ]"
144
+ >
145
+ <slot name="helper-text">{{ helperText }}</slot>
146
+ </div>
147
+ </div>
148
+ </template>
149
+
150
+ <script>
151
+ import {
152
+ themeMixin,
153
+ uidMixin,
154
+ carbonPrefixMixin,
155
+ methodsMixin,
156
+ } from "@carbon/vue/src/mixins";
157
+ import WarningFilled16 from "@carbon/icons-vue/es/warning--filled/16";
158
+ import ChevronDown16 from "@carbon/icons-vue/es/chevron--down/16";
159
+ import Close16 from "@carbon/icons-vue/es/close/16";
160
+ import _cloneDeep from "lodash/cloneDeep";
161
+
162
+ export default {
163
+ name: "NsComboBox",
164
+ inheritAttrs: false,
165
+ mixins: [
166
+ themeMixin,
167
+ uidMixin,
168
+ carbonPrefixMixin,
169
+ methodsMixin({ input: ["focus", "blur"] }),
170
+ ],
171
+ components: { WarningFilled16, ChevronDown16, Close16 },
172
+ props: {
173
+ autoFilter: Boolean,
174
+ autoHighlight: Boolean,
175
+ disabled: Boolean,
176
+ invalidMessage: { type: String, default: undefined },
177
+ helperText: { type: String, default: undefined },
178
+ title: String,
179
+ label: {
180
+ type: String,
181
+ default: "Choose",
182
+ },
183
+ highlight: String,
184
+ value: String,
185
+ options: {
186
+ type: Array,
187
+ required: true,
188
+ validator(list) {
189
+ const result = list.every(
190
+ (item) =>
191
+ typeof item.name === "string" &&
192
+ typeof item.label === "string" &&
193
+ typeof item.value === "string"
194
+ );
195
+ if (!result) {
196
+ console.warn(
197
+ "NsComboBox - all options must have name, label and value"
198
+ );
199
+ }
200
+ return result;
201
+ },
202
+ },
203
+ clearFilterLabel: { type: String, default: "Clear filter" },
204
+ userInputLabel: { type: String, default: "user input" },
205
+ // limit the number of options to be displayed
206
+ maxDisplayOptions: { type: Number, default: 100 },
207
+ acceptUserInput: { type: Boolean, default: false },
208
+ showItemType: { type: Boolean, default: false },
209
+ },
210
+ data() {
211
+ return {
212
+ open: false,
213
+ dataOptions: null,
214
+ dataValue: this.value,
215
+ dataHighlighted: null,
216
+ dataFilter: null,
217
+ isHelper: false,
218
+ isInvalid: false,
219
+ // includes user input items
220
+ internalOptions: [],
221
+ };
222
+ },
223
+ model: {
224
+ prop: "value",
225
+ event: "change",
226
+ },
227
+ watch: {
228
+ highlight() {
229
+ this.highlighted = this.highlight;
230
+ },
231
+ value() {
232
+ this.dataValue = this.value;
233
+ this.highlighted = this.value;
234
+ this.internalUpdateValue(this.value);
235
+ },
236
+ options() {
237
+ this.internalOptions = _cloneDeep(this.options);
238
+ this.updateOptions();
239
+ },
240
+ },
241
+ created() {
242
+ this.internalOptions = _cloneDeep(this.options);
243
+ this.updateOptions();
244
+ },
245
+ mounted() {
246
+ this.filter = this.value;
247
+ this.highlighted = this.value ? this.value : this.highlight; // override highlight with value if provided
248
+ this.checkSlots();
249
+ },
250
+ updated() {
251
+ this.checkSlots();
252
+ },
253
+ computed: {
254
+ highlighted: {
255
+ get() {
256
+ return this.dataHighlighted;
257
+ },
258
+ set(val) {
259
+ let firstMatchIndex = this.dataOptions.findIndex(
260
+ (item) => item.value === val
261
+ );
262
+ if (firstMatchIndex < 0) {
263
+ firstMatchIndex = this.dataOptions.length ? 0 : -1;
264
+ this.dataHighlighted =
265
+ firstMatchIndex >= 0 ? this.dataOptions[0].value : "";
266
+ } else {
267
+ this.dataHighlighted = val;
268
+ }
269
+ if (firstMatchIndex >= 0) {
270
+ this.$nextTick(() => {
271
+ // $nextTick to prevent highlight check ahead of list update on filter
272
+ this.checkHighlightPosition(firstMatchIndex);
273
+ });
274
+ }
275
+ },
276
+ },
277
+ filter: {
278
+ get() {
279
+ return this.dataFilter;
280
+ },
281
+ set(val) {
282
+ this.dataFilter = val ? val : "";
283
+ this.$emit("filter", val);
284
+ },
285
+ },
286
+ limitedDataOptions() {
287
+ return this.dataOptions.slice(0, this.maxDisplayOptions);
288
+ },
289
+ },
290
+ methods: {
291
+ checkSlots() {
292
+ // NOTE: this.$slots is not reactive so needs to be managed on updated
293
+ this.isInvalid = !!(
294
+ this.$slots["invalid-message"] ||
295
+ (this.invalidMessage && this.invalidMessage.length)
296
+ );
297
+ this.isHelper = !!(
298
+ this.$slots["helper-text"] ||
299
+ (this.helperText && this.helperText.length)
300
+ );
301
+ },
302
+ clearFilter() {
303
+ if (this.disabled) return;
304
+ this.internalUpdateValue("");
305
+ this.filter = "";
306
+ this.$refs.input.focus();
307
+ this.doOpen(true);
308
+ this.updateOptions();
309
+ this.$emit("change", this.dataValue);
310
+ },
311
+ checkHighlightPosition(newHiglight) {
312
+ if (
313
+ this.$refs.list &&
314
+ this.$refs.option &&
315
+ this.$refs.option[newHiglight]
316
+ ) {
317
+ if (
318
+ this.$refs.list.scrollTop > this.$refs.option[newHiglight].offsetTop
319
+ ) {
320
+ this.$refs.list.scrollTop = this.$refs.option[newHiglight].offsetTop;
321
+ } else if (
322
+ this.$refs.list.scrollTop + this.$refs.list.clientHeight <
323
+ this.$refs.option[newHiglight].offsetTop +
324
+ this.$refs.option[newHiglight].offsetHeight
325
+ ) {
326
+ this.$refs.list.scrollTop =
327
+ this.$refs.option[newHiglight].offsetTop +
328
+ this.$refs.option[newHiglight].offsetHeight -
329
+ this.$refs.list.clientHeight;
330
+ }
331
+ }
332
+ },
333
+ doMove(up) {
334
+ if (this.dataOptions.length > 0) {
335
+ // requery could have changed
336
+ const currentHighlight = this.dataOptions.findIndex(
337
+ (item) => item.value === this.highlighted
338
+ );
339
+ let newHiglight;
340
+
341
+ if (up) {
342
+ if (currentHighlight <= 0) {
343
+ newHiglight = this.dataOptions.length - 1;
344
+ } else {
345
+ newHiglight = currentHighlight - 1;
346
+ }
347
+ } else {
348
+ if (currentHighlight >= this.dataOptions.length - 1) {
349
+ newHiglight = 0;
350
+ } else {
351
+ newHiglight = currentHighlight + 1;
352
+ }
353
+ }
354
+ this.highlighted = this.dataOptions[newHiglight].value;
355
+ // this.checkHighlightPosition(newHiglight);
356
+ }
357
+ },
358
+ updateOptions() {
359
+ if (this.autoFilter && this.filter) {
360
+ const escFilter = this.filter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
361
+ const pat = new RegExp(escFilter, "iu");
362
+ this.dataOptions = this.internalOptions
363
+ .filter((opt) => pat.test(opt.label))
364
+ .slice(0);
365
+ } else {
366
+ this.dataOptions = this.internalOptions.slice(0);
367
+ }
368
+ if (this.highlight !== this.highlighted) {
369
+ this.highlighted = this.highlight;
370
+ }
371
+
372
+ // added for ns-combo-box
373
+ if (this.acceptUserInput && this.filter && this.filter.trim()) {
374
+ // suggest user input
375
+ const trimmedFilter = this.filter.trim();
376
+ const itemFound = this.internalOptions.find(
377
+ (o) => o.value.trim() === trimmedFilter
378
+ );
379
+
380
+ if (!itemFound) {
381
+ this.dataOptions.push({
382
+ name: trimmedFilter,
383
+ label: trimmedFilter,
384
+ value: trimmedFilter,
385
+ type: this.userInputLabel,
386
+ });
387
+ }
388
+ }
389
+ },
390
+ updateHighlight() {
391
+ let firstMatchIndex;
392
+ if (this.autoHighlight && this.dataOptions.length > 0) {
393
+ // then highlight first match
394
+ const filterRegex = new RegExp(this.filter, "iu");
395
+ firstMatchIndex = this.dataOptions.findIndex((item) =>
396
+ filterRegex.test(item.label)
397
+ );
398
+ if (firstMatchIndex < 0) {
399
+ firstMatchIndex = 0;
400
+ }
401
+ this.highlighted = this.dataOptions[firstMatchIndex].value;
402
+ // this.checkHighlightPosition(firstMatchIndex);
403
+ }
404
+ },
405
+ onInput() {
406
+ if (this.disabled) return;
407
+ this.doOpen(true);
408
+
409
+ this.updateOptions();
410
+ this.updateHighlight();
411
+
412
+ if (this.acceptUserInput) {
413
+ this.internalUpdateValue(this.filter);
414
+ this.$emit("change", this.dataValue);
415
+ }
416
+ },
417
+ doOpen(newVal) {
418
+ this.open = newVal;
419
+ },
420
+ onDown() {
421
+ if (this.disabled) return;
422
+ if (!this.open) {
423
+ this.doOpen(true);
424
+ } else {
425
+ this.doMove(false);
426
+ }
427
+ },
428
+ onUp() {
429
+ if (this.disabled) return;
430
+ if (this.open) {
431
+ this.doMove(true);
432
+ }
433
+ },
434
+ onEsc() {
435
+ if (this.disabled) return;
436
+ this.doOpen(false);
437
+ this.$el.focus();
438
+ },
439
+ onEnter() {
440
+ if (this.disabled) return;
441
+ this.doOpen(!this.open);
442
+ if (!this.open) {
443
+ this.onItemClick(this.highlighted);
444
+ this.$refs.input.focus();
445
+ }
446
+ },
447
+ onClick() {
448
+ if (this.disabled) return;
449
+ this.doOpen(!this.open);
450
+ if (this.open) {
451
+ this.$refs.input.focus();
452
+ } else {
453
+ this.$refs.button.focus();
454
+ }
455
+ },
456
+ clearValues() {
457
+ this.dataValue = "";
458
+ this.$refs.input.focus();
459
+ this.$emit("change", this.dataValue);
460
+ },
461
+ onFocusOut(ev) {
462
+ if (!this.$el.contains(ev.relatedTarget)) {
463
+ this.doOpen(false);
464
+ }
465
+ },
466
+ onMousemove(val) {
467
+ this.highlighted = val;
468
+ },
469
+ internalUpdateValue(val) {
470
+ this.dataValue = val;
471
+ const filterOption = this.dataOptions.find((item) => item.value === val);
472
+ if (filterOption) {
473
+ this.filter = filterOption.label;
474
+ }
475
+ },
476
+ onItemClick(val) {
477
+ if (!val) {
478
+ return;
479
+ }
480
+
481
+ if (this.disabled) return;
482
+
483
+ if (
484
+ this.acceptUserInput &&
485
+ !this.internalOptions.find((item) => item.value === val)
486
+ ) {
487
+ this.internalOptions.push({
488
+ name: val,
489
+ label: val,
490
+ value: val,
491
+ type: this.userInputLabel,
492
+ });
493
+ }
494
+
495
+ this.internalUpdateValue(val);
496
+ this.$refs.input.focus();
497
+ this.open = false; // close after user makes a selection
498
+ this.$emit("change", this.dataValue);
499
+ },
500
+ inputClick() {
501
+ if (this.disabled) return;
502
+ if (!this.open) {
503
+ this.doOpen(true);
504
+ }
505
+ },
506
+ inputFocus() {
507
+ if (this.disabled) return;
508
+ this.doOpen(true);
509
+ },
510
+ },
511
+ };
512
+ </script>