@gitlab/ui 107.7.1 → 108.0.1

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.
@@ -1,23 +1,60 @@
1
1
  <script>
2
2
  import isObject from 'lodash/isObject';
3
- import { BFormInput } from '../../../../vendor/bootstrap-vue/src/components/form-input/form-input';
3
+ import uniqueId from 'lodash/uniqueId';
4
+ import isBoolean from 'lodash/isBoolean';
5
+ import toInteger from 'lodash/toInteger';
6
+ import toString from 'lodash/toString';
4
7
 
8
+ import { toFloat } from '../../../../utils/number_utils';
9
+ import { isVisible, stopEvent } from '../../../../utils/utils';
5
10
  import { formInputWidths } from '../../../../utils/constants';
6
11
 
12
+ // Valid supported input types
13
+ const TYPES = [
14
+ 'text',
15
+ 'password',
16
+ 'email',
17
+ 'number',
18
+ 'url',
19
+ 'tel',
20
+ 'search',
21
+ 'range',
22
+ 'color',
23
+ 'date',
24
+ 'time',
25
+ 'datetime',
26
+ 'datetime-local',
27
+ 'month',
28
+ 'week',
29
+ ];
30
+
7
31
  const MODEL_PROP = 'value';
8
32
  const MODEL_EVENT = 'input';
9
33
 
10
34
  export default {
11
35
  name: 'GlFormInput',
12
- components: {
13
- BFormInput,
14
- },
15
- inheritAttrs: false,
16
36
  model: {
17
37
  prop: MODEL_PROP,
18
38
  event: MODEL_EVENT,
19
39
  },
20
40
  props: {
41
+ /**
42
+ * The current value of the input. Result will always be a string, except when the `number` prop is used
43
+ */
44
+ value: {
45
+ type: [Number, String],
46
+ required: false,
47
+ default: '',
48
+ },
49
+ /**
50
+ * The type of input to render.
51
+ */
52
+ type: {
53
+ type: String,
54
+ required: false,
55
+ default: 'text',
56
+ validator: (value) => TYPES.includes(value),
57
+ },
21
58
  /**
22
59
  * Maximum width of the input
23
60
  */
@@ -31,9 +68,237 @@ export default {
31
68
  return widths.every((width) => Object.values(formInputWidths).includes(width));
32
69
  },
33
70
  },
71
+ /**
72
+ * Used to set the `id` attribute on the rendered content, and used as the base to generate any additional element IDs as needed
73
+ */
74
+ id: {
75
+ type: String,
76
+ required: false,
77
+ default: undefined,
78
+ },
79
+ /**
80
+ * When set to `true`, attempts to auto-focus the control when it is mounted, or re-activated when in a keep-alive. Does not set the `autofocus` attribute on the control
81
+ */
82
+ autofocus: {
83
+ type: Boolean,
84
+ required: false,
85
+ default: false,
86
+ },
87
+ /**
88
+ * When set to `true`, disables the component's functionality and places it in a disabled state
89
+ */
90
+ disabled: {
91
+ type: Boolean,
92
+ required: false,
93
+ default: false,
94
+ },
95
+ /**
96
+ * ID of the form that the form control belongs to. Sets the `form` attribute on the control
97
+ */
98
+ form: {
99
+ type: String,
100
+ required: false,
101
+ default: undefined,
102
+ },
103
+ /**
104
+ * Sets the value of the `name` attribute on the form control
105
+ */
106
+ name: {
107
+ type: String,
108
+ required: false,
109
+ default: undefined,
110
+ },
111
+ /**
112
+ * Adds the `required` attribute to the form control
113
+ */
114
+ required: {
115
+ type: Boolean,
116
+ required: false,
117
+ default: false,
118
+ },
119
+ /**
120
+ * Controls the validation state appearance of the component. `true` for valid, `false` for invalid, or `null` for no validation state
121
+ */
122
+ state: {
123
+ type: Boolean,
124
+ required: false,
125
+ default: null,
126
+ },
127
+ /**
128
+ * Sets the `placeholder` attribute value on the form control
129
+ */
130
+ placeholder: {
131
+ type: String,
132
+ required: false,
133
+ default: undefined,
134
+ },
135
+ /**
136
+ * Optional value to set for the 'aria-invalid' attribute. Supported values are 'true' and 'false'. If not set, the 'state' prop will dictate the value
137
+ */
138
+ ariaInvalid: {
139
+ type: [Boolean, String],
140
+ required: false,
141
+ default: false,
142
+ },
143
+ /**
144
+ * Sets the 'autocomplete' attribute value on the form control
145
+ */
146
+ autocomplete: {
147
+ type: String,
148
+ required: false,
149
+ default: undefined,
150
+ },
151
+ /**
152
+ * When set to a number of milliseconds greater than zero, will debounce the user input. Has no effect if prop 'lazy' is set
153
+ */
154
+ debounce: {
155
+ type: [Number, String],
156
+ required: false,
157
+ default: undefined,
158
+ },
159
+ /**
160
+ * Reference to a function for formatting the input
161
+ */
162
+ formatter: {
163
+ type: Function,
164
+ required: false,
165
+ default: undefined,
166
+ },
167
+ /**
168
+ * When set, updates the v-model on 'change'/'blur' events instead of 'input'. Emulates the Vue '.lazy' v-model modifier
169
+ */
170
+ lazy: {
171
+ type: Boolean,
172
+ required: false,
173
+ default: false,
174
+ },
175
+ /**
176
+ * When set, the input is formatted on blur instead of each keystroke (if there is a formatter specified)
177
+ */
178
+ lazyFormatter: {
179
+ type: Boolean,
180
+ required: false,
181
+ default: false,
182
+ },
183
+ /**
184
+ * When set attempts to convert the input value to a native number. Emulates the Vue '.number' v-model modifier
185
+ */
186
+ number: {
187
+ type: Boolean,
188
+ required: false,
189
+ default: false,
190
+ },
191
+ /**
192
+ * Set the form control as readonly and renders the control to look like plain text (no borders)
193
+ */
194
+ plaintext: {
195
+ type: Boolean,
196
+ required: false,
197
+ default: false,
198
+ },
199
+ /**
200
+ * Sets the `readonly` attribute on the form control
201
+ */
202
+ readonly: {
203
+ type: Boolean,
204
+ required: false,
205
+ default: false,
206
+ },
207
+ /**
208
+ * When set, trims any leading and trailing white space from the input value. Emulates the Vue '.trim' v-model modifier
209
+ */
210
+ trim: {
211
+ type: Boolean,
212
+ required: false,
213
+ default: false,
214
+ },
215
+ /**
216
+ * The ID of the associated datalist element or component
217
+ */
218
+ list: {
219
+ type: String,
220
+ required: false,
221
+ default: undefined,
222
+ },
223
+ /**
224
+ * Value to set in the 'max' attribute on the input. Used by number-like inputs
225
+ */
226
+ max: {
227
+ type: [Number, String],
228
+ required: false,
229
+ default: undefined,
230
+ },
231
+ /**
232
+ * Value to set in the 'min' attribute on the input. Used by number-like inputs
233
+ */
234
+ min: {
235
+ type: [Number, String],
236
+ required: false,
237
+ default: undefined,
238
+ },
239
+ /**
240
+ * Value to set in the 'step' attribute on the input. Used by number-like inputs
241
+ */
242
+ step: {
243
+ type: [Number, String],
244
+ required: false,
245
+ default: undefined,
246
+ },
247
+ },
248
+ data() {
249
+ return {
250
+ localValue: toString(this.value),
251
+ vModelValue: this.modifyValue(this.value),
252
+ localId: null,
253
+ };
34
254
  },
35
255
  computed: {
36
- cssClasses() {
256
+ computedId() {
257
+ return this.id || this.localId;
258
+ },
259
+ localType() {
260
+ // We only allow certain types
261
+ const { type } = this;
262
+ return TYPES.includes(type) ? type : 'text';
263
+ },
264
+ computedAriaInvalid() {
265
+ const { ariaInvalid } = this;
266
+ if (ariaInvalid === true || ariaInvalid === 'true' || ariaInvalid === '') {
267
+ return 'true';
268
+ }
269
+ return this.computedState === false ? 'true' : ariaInvalid;
270
+ },
271
+ computedAttrs() {
272
+ const { localType: type, name, form, disabled, placeholder, required, min, max, step } = this;
273
+
274
+ return {
275
+ id: this.computedId,
276
+ name,
277
+ form,
278
+ type,
279
+ disabled,
280
+ placeholder,
281
+ required,
282
+ autocomplete: this.autocomplete || null,
283
+ readonly: this.readonly || this.plaintext,
284
+ min,
285
+ max,
286
+ step,
287
+ list: type !== 'password' ? this.list : null,
288
+ 'aria-required': required ? 'true' : null,
289
+ 'aria-invalid': this.computedAriaInvalid,
290
+ };
291
+ },
292
+ computedState() {
293
+ // If not a boolean, ensure that value is null
294
+ return isBoolean(this.state) ? this.state : null;
295
+ },
296
+ stateClass() {
297
+ if (this.computedState === true) return 'is-valid';
298
+ if (this.computedState === false) return 'is-invalid';
299
+ return null;
300
+ },
301
+ widthClasses() {
37
302
  if (this.width === null) {
38
303
  return [];
39
304
  }
@@ -54,45 +319,325 @@ export default {
54
319
  // eslint-disable-next-line @gitlab/tailwind-no-interpolation -- Not a CSS utility
55
320
  return [`gl-form-input-${this.width}`];
56
321
  },
57
- listeners() {
322
+ computedClass() {
323
+ const { plaintext, type } = this;
324
+ const isRange = type === 'range';
325
+ const isColor = type === 'color';
326
+
327
+ return [
328
+ ...this.widthClasses,
329
+ {
330
+ // Range input needs class `custom-range`
331
+ 'custom-range': isRange,
332
+ // `plaintext` not supported by `type="range"` or `type="color"`
333
+ 'form-control-plaintext': plaintext && !isRange && !isColor,
334
+ // `form-control` not used by `type="range"` or `plaintext`
335
+ // Always used by `type="color"`
336
+ 'form-control': isColor || (!plaintext && !isRange),
337
+ },
338
+ this.stateClass,
339
+ ];
340
+ },
341
+ computedListeners() {
58
342
  return {
59
343
  ...this.$listeners,
60
- // Swap purpose of input and update events from underlying BFormInput.
61
- // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/631.
62
- input: (...args) => {
63
- /**
64
- * Emitted to update the v-model
65
- *
66
- * @event update
67
- * @property {string} value new value
68
- */
69
- this.$emit('update', ...args);
70
- },
71
- update: (...args) => {
72
- /**
73
- * Triggered by user interaction. Emitted after any formatting (not including 'trim' or 'number' props).
74
- * Useful for getting the currently entered value when the 'debounce' or 'lazy' props are set.
75
- *
76
- * @event input
77
- * @property {string} value new value
78
- */
79
- this.$emit(MODEL_EVENT, ...args);
80
- },
344
+ input: this.onInput,
345
+ change: this.onChange,
346
+ blur: this.onBlur,
81
347
  };
82
348
  },
349
+ computedDebounce() {
350
+ // Ensure we have a positive number equal to or greater than 0
351
+ return Math.max(toInteger(this.debounce), 0);
352
+ },
353
+ hasFormatter() {
354
+ return typeof this.formatter === 'function';
355
+ },
83
356
  noWheel() {
84
- return this.$attrs.type === 'number';
357
+ return this.type === 'number';
358
+ },
359
+ selectionStart: {
360
+ // Expose selectionStart for formatters, etc
361
+ cache: false,
362
+ get() {
363
+ return this.$refs.input.selectionStart;
364
+ },
365
+ set(val) {
366
+ this.$refs.input.selectionStart = val;
367
+ },
368
+ },
369
+ selectionEnd: {
370
+ // Expose selectionEnd for formatters, etc
371
+ cache: false,
372
+ get() {
373
+ return this.$refs.input.selectionEnd;
374
+ },
375
+ set(val) {
376
+ this.$refs.input.selectionEnd = val;
377
+ },
378
+ },
379
+ selectionDirection: {
380
+ // Expose selectionDirection for formatters, etc
381
+ cache: false,
382
+ get() {
383
+ return this.$refs.input.selectionDirection;
384
+ },
385
+ set(val) {
386
+ this.$refs.input.selectionDirection = val;
387
+ },
388
+ },
389
+ validity: {
390
+ // Expose validity property
391
+ cache: false,
392
+ get() {
393
+ return this.$refs.input.validity;
394
+ },
395
+ },
396
+ validationMessage: {
397
+ // Expose validationMessage property
398
+ cache: false,
399
+ get() {
400
+ return this.$refs.input.validationMessage;
401
+ },
402
+ },
403
+ willValidate: {
404
+ // Expose willValidate property
405
+ cache: false,
406
+ get() {
407
+ return this.$refs.input.willValidate;
408
+ },
409
+ },
410
+ },
411
+ watch: {
412
+ value(newValue) {
413
+ const stringifyValue = toString(newValue);
414
+ const modifiedValue = this.modifyValue(newValue);
415
+ if (stringifyValue !== this.localValue || modifiedValue !== this.vModelValue) {
416
+ // Clear any pending debounce timeout, as we are overwriting the user input
417
+ this.clearDebounce();
418
+ // Update the local values
419
+ this.localValue = stringifyValue;
420
+ this.vModelValue = modifiedValue;
421
+ }
422
+ },
423
+ noWheel(newValue) {
424
+ this.setWheelStopper(newValue);
425
+ },
426
+ },
427
+ created() {
428
+ // Create private non-reactive props
429
+ this.$_inputDebounceTimer = null;
430
+ },
431
+ mounted() {
432
+ this.setWheelStopper(this.noWheel);
433
+ this.handleAutofocus();
434
+ this.$nextTick(() => {
435
+ // Update DOM with auto-generated ID after mount
436
+ // to prevent SSR hydration errors
437
+ this.localId = uniqueId('gl-form-input-');
438
+ });
439
+ },
440
+ deactivated() {
441
+ // Turn off listeners when keep-alive component deactivated
442
+ this.setWheelStopper(false);
443
+ },
444
+ activated() {
445
+ // Turn on listeners (if no-wheel) when keep-alive component activated
446
+ this.setWheelStopper(this.noWheel);
447
+ this.handleAutofocus();
448
+ },
449
+ beforeDestroy() {
450
+ this.setWheelStopper(false);
451
+ this.clearDebounce();
452
+ },
453
+ methods: {
454
+ focus() {
455
+ if (!this.disabled) {
456
+ this.$refs.input?.focus();
457
+ }
458
+ },
459
+ blur() {
460
+ if (!this.disabled) {
461
+ this.$refs.input?.blur();
462
+ }
463
+ },
464
+ clearDebounce() {
465
+ clearTimeout(this.$_inputDebounceTimer);
466
+ this.$_inputDebounceTimer = null;
467
+ },
468
+ formatValue(value, event, force = false) {
469
+ let newValue = toString(value);
470
+ if (this.hasFormatter && (!this.lazyFormatter || force)) {
471
+ newValue = this.formatter(value, event);
472
+ }
473
+ return newValue;
474
+ },
475
+ modifyValue(value) {
476
+ let newValue = toString(value);
477
+ // Emulate `.trim` modifier behaviour
478
+ if (this.trim) {
479
+ newValue = newValue.trim();
480
+ }
481
+ // Emulate `.number` modifier behaviour
482
+ if (this.number) {
483
+ newValue = toFloat(newValue, newValue);
484
+ }
485
+ return newValue;
486
+ },
487
+ updateValue(value, force = false) {
488
+ const { lazy } = this;
489
+ if (lazy && !force) {
490
+ return;
491
+ }
492
+ // Make sure to always clear the debounce when `updateValue()`
493
+ // is called, even when the v-model hasn't changed
494
+ this.clearDebounce();
495
+ // Define the shared update logic in a method to be able to use
496
+ // it for immediate and debounced value changes
497
+ const doUpdate = () => {
498
+ const newValue = this.modifyValue(value);
499
+ if (newValue !== this.vModelValue) {
500
+ this.vModelValue = newValue;
501
+ this.$emit(MODEL_EVENT, newValue);
502
+ } else if (this.hasFormatter) {
503
+ // When the `vModelValue` hasn't changed but the actual input value
504
+ // is out of sync, make sure to change it to the given one
505
+ // Usually caused by browser autocomplete and how it triggers the
506
+ // change or input event, or depending on the formatter function
507
+ // https://github.com/bootstrap-vue/bootstrap-vue/issues/2657
508
+ // https://github.com/bootstrap-vue/bootstrap-vue/issues/3498
509
+ const $input = this.$refs.input;
510
+ if ($input && newValue !== $input.value) {
511
+ $input.value = newValue;
512
+ }
513
+ }
514
+ };
515
+ // Only debounce the value update when a value greater than `0`
516
+ // is set and we are not in lazy mode or this is a forced update
517
+ const debounce = this.computedDebounce;
518
+ if (debounce > 0 && !lazy && !force) {
519
+ this.$_inputDebounceTimer = setTimeout(doUpdate, debounce);
520
+ } else {
521
+ // Immediately update the v-model
522
+ doUpdate();
523
+ }
524
+ },
525
+ onInput(event) {
526
+ // `event.target.composing` is set by Vue
527
+ // https://github.com/vuejs/vue/blob/dev/src/platforms/web/runtime/directives/model.js
528
+ // TODO: Is this needed now with the latest Vue?
529
+ if (event.target.composing) {
530
+ return;
531
+ }
532
+ const { value } = event.target;
533
+ const formattedValue = this.formatValue(value, event);
534
+ // Exit when the `formatter` function strictly returned `false`
535
+ // or prevented the input event
536
+ if (formattedValue === false || event.defaultPrevented) {
537
+ stopEvent(event, { propagation: false });
538
+ return;
539
+ }
540
+ this.localValue = formattedValue;
541
+ this.updateValue(formattedValue);
542
+ /**
543
+ * The `input` and `update` events are swapped
544
+ * see https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1628
545
+ */
546
+ this.$emit('update', formattedValue);
547
+ },
548
+ onChange(event) {
549
+ const { value } = event.target;
550
+ const formattedValue = this.formatValue(value, event);
551
+ // Exit when the `formatter` function strictly returned `false`
552
+ // or prevented the input event
553
+ if (formattedValue === false || event.defaultPrevented) {
554
+ stopEvent(event, { propagation: false });
555
+ return;
556
+ }
557
+ this.localValue = formattedValue;
558
+ this.updateValue(formattedValue, true);
559
+ this.$emit('change', formattedValue);
560
+ },
561
+ onBlur(event) {
562
+ // Apply the `localValue` on blur to prevent cursor jumps
563
+ // on mobile browsers (e.g. caused by autocomplete)
564
+ const { value } = event.target;
565
+ const formattedValue = this.formatValue(value, event, true);
566
+ if (formattedValue !== false) {
567
+ // We need to use the modified value here to apply the
568
+ // `.trim` and `.number` modifiers properly
569
+ this.localValue = toString(this.modifyValue(formattedValue));
570
+ // We pass the formatted value here since the `updateValue` method
571
+ // handles the modifiers itself
572
+ this.updateValue(formattedValue, true);
573
+ }
574
+ // Emit native blur event
575
+ this.$emit('blur', event);
576
+ },
577
+ setWheelStopper(on) {
578
+ const { input } = this.$refs;
579
+ // We use native events, so that we don't interfere with propagation
580
+ if (on) {
581
+ input.addEventListener('focus', this.onWheelFocus);
582
+ input.addEventListener('blur', this.onWheelBlur);
583
+ } else {
584
+ input.removeEventListener('focus', this.onWheelFocus);
585
+ input.removeEventListener('blur', this.onWheelBlur);
586
+ document.removeEventListener('wheel', this.stopWheel);
587
+ }
588
+ },
589
+ onWheelFocus() {
590
+ document.addEventListener('wheel', this.stopWheel);
591
+ },
592
+ onWheelBlur() {
593
+ document.removeEventListener('wheel', this.stopWheel);
594
+ },
595
+ stopWheel(event) {
596
+ stopEvent(event, { propagation: false });
597
+ this.blur();
598
+ },
599
+ handleAutofocus() {
600
+ this.$nextTick(() => {
601
+ window.requestAnimationFrame(() => {
602
+ if (this.autofocus && isVisible(this.$refs.input)) this.focus();
603
+ });
604
+ });
605
+ },
606
+ select(...args) {
607
+ // For external handler that may want a select() method
608
+ this.$refs.input.select(args);
609
+ },
610
+ setSelectionRange(...args) {
611
+ // For external handler that may want a setSelectionRange(a,b,c) method
612
+ this.$refs.input.setSelectionRange(args);
613
+ },
614
+ setRangeText(...args) {
615
+ // For external handler that may want a setRangeText(a,b,c) method
616
+ this.$refs.input.setRangeText(args);
617
+ },
618
+ setCustomValidity(...args) {
619
+ // For external handler that may want a setCustomValidity(...) method
620
+ return this.$refs.input.setCustomValidity(args);
621
+ },
622
+ checkValidity(...args) {
623
+ // For external handler that may want a checkValidity(...) method
624
+ return this.$refs.input.checkValidity(args);
625
+ },
626
+ reportValidity(...args) {
627
+ // For external handler that may want a reportValidity(...) method
628
+ return this.$refs.input.reportValidity(args);
85
629
  },
86
630
  },
87
631
  };
88
632
  </script>
89
633
 
90
634
  <template>
91
- <b-form-input
635
+ <input
636
+ ref="input"
637
+ :value="localValue"
92
638
  class="gl-form-input"
93
- :class="cssClasses"
94
- :no-wheel="noWheel"
95
- v-bind="$attrs"
96
- v-on="listeners"
639
+ :class="computedClass"
640
+ v-bind="computedAttrs"
641
+ v-on="computedListeners"
97
642
  />
98
643
  </template>
@@ -8,7 +8,7 @@ import GlFormInput from '../form/form_input/form_input.vue';
8
8
  import GlFormInputGroup from '../form/form_input_group/form_input_group.vue';
9
9
 
10
10
  export default {
11
- name: 'GlSearchboxByClick',
11
+ name: 'GlSearchBoxByClick',
12
12
  components: {
13
13
  GlClearIconButton,
14
14
  GlButton,
@@ -6,7 +6,7 @@ import GlLoadingIcon from '../loading_icon/loading_icon.vue';
6
6
  import { translate } from '../../../utils/i18n';
7
7
 
8
8
  export default {
9
- name: 'GlSearchboxByType',
9
+ name: 'GlSearchBoxByType',
10
10
  components: {
11
11
  GlClearIconButton,
12
12
  GlIcon,
@@ -1,6 +1,8 @@
1
1
  import { isVisible } from '../vendor/bootstrap-vue/src/utils/dom';
2
2
  import { COMMA, CONTRAST_LEVELS, labelColorOptions, focusableTags } from './constants';
3
3
 
4
+ export { isVisible };
5
+
4
6
  export function debounceByAnimationFrame(fn) {
5
7
  let requestId;
6
8