@gitlab/ui 138.0.0 → 138.1.0

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,17 +1,185 @@
1
1
  <script>
2
- import { isObject } from 'lodash-es';
3
- import { BFormSelect } from '../../../../vendor/bootstrap-vue/src/components/form-select/form-select';
2
+ import { isObject, uniqueId, isPlainObject, isBoolean, isUndefined, isNull, get } from 'lodash-es';
4
3
  import { formInputWidths } from '../../../../utils/constants';
4
+ import { isVisible } from '../../../../utils/utils';
5
+ import { SafeHtmlDirective as SafeHtml } from '../../../../directives/safe_html/safe_html';
5
6
 
6
7
  export default {
7
8
  name: 'GlFormSelect',
8
- components: {
9
- BFormSelect,
9
+ directives: {
10
+ SafeHtml,
10
11
  },
11
12
  inheritAttrs: false,
12
13
  props: {
13
14
  /**
14
- * Width of the select element.
15
+ * Current value of the select. Should be set to an array when the 'multiple' prop is set
16
+ */
17
+ value: {
18
+ // Value can be any type!
19
+ type: undefined,
20
+ required: false,
21
+ default: undefined,
22
+ },
23
+ /**
24
+ * Array of items to render in the component
25
+ */
26
+ options: {
27
+ type: [Array, Object],
28
+ required: false,
29
+ default: undefined,
30
+ },
31
+ /**
32
+ * When set to a number larger than 0, will set the number of display option rows. Note not all browser will respect this setting
33
+ * @deprecated Only useful with `multiple`, which is also deprecated.
34
+ */
35
+ selectSize: {
36
+ type: Number,
37
+ required: false,
38
+ default: 0,
39
+ },
40
+ /**
41
+ * Used to set the `id` attribute on the rendered content, and used as the base to generate any additional element IDs as needed
42
+ */
43
+ id: {
44
+ type: String,
45
+ required: false,
46
+ default: undefined,
47
+ },
48
+ /**
49
+ * Render the form control in plain mode, rather than custom styled mode
50
+ * @deprecated Use a native <select> element instead.
51
+ */
52
+ plain: {
53
+ type: Boolean,
54
+ required: false,
55
+ default: false,
56
+ },
57
+ /**
58
+ * Set the size of the component's appearance. 'sm', 'md' (default), or 'lg'
59
+ * @deprecated Has no effect. Use the `width` prop instead.
60
+ */
61
+ size: {
62
+ type: String,
63
+ required: false,
64
+ default: undefined,
65
+ },
66
+ /**
67
+ * 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
68
+ */
69
+ ariaInvalid: {
70
+ type: [Boolean, String],
71
+ required: false,
72
+ default: false,
73
+ },
74
+ /**
75
+ * Controls the validation state appearance of the component. `true` for valid, `false` for invalid, or `null` for no validation state
76
+ */
77
+ state: {
78
+ // Tri-state prop: true, false, null (or undefined)
79
+ type: Boolean,
80
+ required: false,
81
+ default: null,
82
+ },
83
+ /**
84
+ * Field name in the `options` array that should be used for the value
85
+ */
86
+ valueField: {
87
+ type: String,
88
+ required: false,
89
+ default: 'value',
90
+ },
91
+ /**
92
+ * The key to use from the option object to get the label
93
+ */
94
+ labelField: {
95
+ type: String,
96
+ required: false,
97
+ default: 'label',
98
+ },
99
+ /**
100
+ * The key to use from the option object to get the options
101
+ */
102
+ optionsField: {
103
+ type: String,
104
+ required: false,
105
+ default: 'options',
106
+ },
107
+ /**
108
+ * Field name in the `options` array that should be used for the text label
109
+ */
110
+ textField: {
111
+ type: String,
112
+ required: false,
113
+ default: 'text',
114
+ },
115
+ /**
116
+ * Field name in the `options` array that should be used for the disabled state
117
+ */
118
+ disabledField: {
119
+ type: String,
120
+ required: false,
121
+ default: 'disabled',
122
+ },
123
+ /**
124
+ * Field name in the `options` array that should be used for the html label instead of text field
125
+ * @deprecated Using the `.html` field (whatever its name) is potentially unsafe.
126
+ */
127
+ htmlField: {
128
+ type: String,
129
+ required: false,
130
+ default: 'html',
131
+ },
132
+ /**
133
+ * When set, allows multiple options to be selected (multi-select)
134
+ * @deprecated Use GlCollapsibleListbox instead.
135
+ */
136
+ multiple: {
137
+ type: Boolean,
138
+ required: false,
139
+ default: false,
140
+ },
141
+ /**
142
+ * 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
143
+ */
144
+ autofocus: {
145
+ type: Boolean,
146
+ required: false,
147
+ default: false,
148
+ },
149
+ /**
150
+ * When set to `true`, disables the component's functionality and places it in a disabled state
151
+ */
152
+ disabled: {
153
+ type: Boolean,
154
+ required: false,
155
+ default: false,
156
+ },
157
+ /**
158
+ * ID of the form that the form control belongs to. Sets the `form` attribute on the control
159
+ */
160
+ form: {
161
+ type: String,
162
+ required: false,
163
+ default: undefined,
164
+ },
165
+ /**
166
+ * Sets the value of the `name` attribute on the form control
167
+ */
168
+ name: {
169
+ type: String,
170
+ required: false,
171
+ default: undefined,
172
+ },
173
+ /**
174
+ * Adds the `required` attribute to the form control
175
+ */
176
+ required: {
177
+ type: Boolean,
178
+ required: false,
179
+ default: false,
180
+ },
181
+ /**
182
+ * Maximum width of the Select
15
183
  */
16
184
  width: {
17
185
  type: [String, Object],
@@ -32,7 +200,32 @@ export default {
32
200
  default: '',
33
201
  },
34
202
  },
203
+ data() {
204
+ return {
205
+ localValue: this.value,
206
+ localId: null,
207
+ };
208
+ },
35
209
  computed: {
210
+ computedId() {
211
+ return this.id || this.localId;
212
+ },
213
+ computedSelectSize() {
214
+ // Custom selects with a size of zero causes the arrows to be hidden,
215
+ // so dont render the size attribute in this case
216
+ return !this.plain && this.selectSize === 0 ? null : this.selectSize;
217
+ },
218
+ computedAriaInvalid() {
219
+ const { ariaInvalid } = this;
220
+ if (ariaInvalid === true || ariaInvalid === 'true' || ariaInvalid === '') {
221
+ return 'true';
222
+ }
223
+ return this.computedState === false ? 'true' : ariaInvalid;
224
+ },
225
+ computedState() {
226
+ // If not a boolean, ensure that value is null
227
+ return isBoolean(this.state) ? this.state : null;
228
+ },
36
229
  cssClasses() {
37
230
  if (this.width === null) {
38
231
  return [];
@@ -54,15 +247,202 @@ export default {
54
247
  // eslint-disable-next-line @gitlab/tailwind-no-interpolation -- Not a CSS utility
55
248
  return [`gl-form-select-${this.width}`];
56
249
  },
250
+ stateClass() {
251
+ if (this.computedState === true) return 'is-valid';
252
+ if (this.computedState === false) return 'is-invalid';
253
+ return null;
254
+ },
255
+ inputClass() {
256
+ return [
257
+ 'gl-form-select',
258
+ this.plain ? 'form-control' : 'custom-select',
259
+ this.size && this.plain ? `form-control-${this.size}` : null,
260
+ this.size && !this.plain ? `custom-select-${this.size}` : null,
261
+ this.stateClass,
262
+ this.selectClass,
263
+ ];
264
+ },
265
+ normalizedOptions() {
266
+ return this.normalizeOptions(this.options);
267
+ },
268
+ },
269
+ watch: {
270
+ value(newValue) {
271
+ this.localValue = newValue;
272
+ },
273
+ localValue(newValue) {
274
+ /**
275
+ * Emitted when the select value changes
276
+ *
277
+ * @event input
278
+ * @type {any}
279
+ */
280
+ this.$emit('input', newValue);
281
+ },
282
+ },
283
+ activated() {
284
+ this.handleAutofocus();
285
+ },
286
+ async mounted() {
287
+ this.handleAutofocus();
288
+ await this.$nextTick();
289
+ // Update DOM with auto-generated ID after mount
290
+ // to prevent SSR hydration errors
291
+ this.localId = uniqueId('gl-form-select-');
292
+ },
293
+ methods: {
294
+ focus() {
295
+ this.$refs.select?.focus();
296
+ },
297
+ blur() {
298
+ this.$refs.select?.blur();
299
+ },
300
+ normalizeOptions(options) {
301
+ // Normalize the given options array
302
+ if (Array.isArray(options)) return options.map((option) => this.normalizeOption(option));
303
+
304
+ if (isPlainObject(options)) {
305
+ if (process.env.NODE_ENV !== 'production') {
306
+ // Deprecate the object options format
307
+ // eslint-disable-next-line no-console
308
+ console.warn(
309
+ '[@gitlab/ui] GlFormSelect - Setting prop "options" to an object is deprecated. Use the array format instead.',
310
+ );
311
+ }
312
+
313
+ // Normalize a `options` object to an array of options
314
+ return Object.keys(options).map((key) => this.normalizeOption(options[key] || {}, key));
315
+ }
316
+
317
+ // If not an array or object, return an empty array
318
+ return [];
319
+ },
320
+ normalizeOption(option, key = null) {
321
+ // When the option is an object, normalize it
322
+ if (isPlainObject(option)) {
323
+ const value = get(option, this.valueField);
324
+ const text = get(option, this.textField);
325
+ const options = get(option, this.optionsField, null);
326
+ // When it has options, create an <optgroup> object
327
+ if (!isNull(options)) {
328
+ return {
329
+ label: String(get(option, this.labelField) || text),
330
+ options: this.normalizeOptions(options),
331
+ };
332
+ }
333
+ // Otherwise create an <option> object
334
+ return {
335
+ value: isUndefined(value) ? key || text : value,
336
+ text: String(isUndefined(text) ? key : text),
337
+ html: get(option, this.htmlField),
338
+ disabled: Boolean(get(option, this.disabledField)),
339
+ };
340
+ }
341
+ // Otherwise create an <option> object from the given value
342
+ return {
343
+ value: key || option,
344
+ text: String(option),
345
+ disabled: false,
346
+ };
347
+ },
348
+ isOptionGroup(option) {
349
+ return Array.isArray(option.options);
350
+ },
351
+ async onChange(event) {
352
+ const { target } = event;
353
+ const selectedValue = Array.from(target.options)
354
+ .filter((option) => option.selected)
355
+ // NOTE: the _value property is set up by both Vue 2 and 3 explicitly for the
356
+ // purpose of handling non-string values on form elements.
357
+ //
358
+ // See:
359
+ // - https://github.com/vuejs/vue/blob/v2.7.16/src/platforms/web/runtime/modules/dom-props.ts#L42-L44
360
+ // - https://github.com/vuejs/vue/blob/v2.7.16/test/unit/modules/vdom/modules/dom-props.spec.ts#L38
361
+ // - https://github.com/vuejs/core/blob/v3.4.27/packages/runtime-dom/src/modules/props.ts#L48-L50
362
+ // - https://github.com/vuejs/core/blob/v3.4.27/packages/runtime-dom/__tests__/patchProps.spec.ts#L25
363
+ // eslint-disable-next-line no-underscore-dangle
364
+ .map((option) => ('_value' in option ? option._value : option.value));
365
+
366
+ this.localValue = target.multiple ? selectedValue : selectedValue[0];
367
+
368
+ await this.$nextTick();
369
+ /**
370
+ * Emitted when the select value changes via user interaction
371
+ *
372
+ * @event change
373
+ * @type {any}
374
+ */
375
+ this.$emit('change', this.localValue);
376
+ },
377
+ async handleAutofocus() {
378
+ await this.$nextTick();
379
+ window.requestAnimationFrame(() => {
380
+ if (this.autofocus && isVisible(this.$refs.select)) this.focus();
381
+ });
382
+ },
57
383
  },
58
384
  };
59
385
  </script>
60
386
  <template>
61
387
  <span class="gl-form-select-wrapper" :class="cssClasses">
62
- <b-form-select class="gl-form-select" v-bind="$attrs" :class="selectClass" v-on="$listeners">
63
- <template v-for="slot in Object.keys($slots)" #[slot]>
64
- <slot :name="slot"></slot>
388
+ <select
389
+ :id="computedId"
390
+ ref="select"
391
+ v-model="localValue"
392
+ :aria-invalid="computedAriaInvalid"
393
+ :aria-required="required ? 'true' : null"
394
+ :class="inputClass"
395
+ :disabled="disabled"
396
+ :form="form"
397
+ :multiple="multiple"
398
+ :name="name"
399
+ :required="required"
400
+ :size="computedSelectSize"
401
+ v-bind="$attrs"
402
+ @change="onChange"
403
+ >
404
+ <!-- @slot Slot to place options or option groups above options provided via the 'options' prop -->
405
+ <slot name="first"></slot>
406
+
407
+ <template v-for="(option, index) of normalizedOptions">
408
+ <template v-if="isOptionGroup(option)">
409
+ <optgroup :key="index" :label="option.label">
410
+ <template v-for="(childOption, childIndex) of option.options">
411
+ <option
412
+ v-if="childOption.html"
413
+ :key="`${childIndex}-html`"
414
+ v-safe-html="childOption.html"
415
+ :value="childOption.value"
416
+ :disabled="childOption.disabled"
417
+ ></option>
418
+ <option
419
+ v-else
420
+ :key="childIndex"
421
+ :value="childOption.value"
422
+ :disabled="childOption.disabled"
423
+ >
424
+ {{ childOption.text }}
425
+ </option>
426
+ </template>
427
+ </optgroup>
428
+ </template>
429
+
430
+ <template v-else>
431
+ <option
432
+ v-if="option.html"
433
+ :key="`${index}-html`"
434
+ v-safe-html="option.html"
435
+ :value="option.value"
436
+ :disabled="option.disabled"
437
+ ></option>
438
+ <option v-else :key="index" :value="option.value" :disabled="option.disabled">
439
+ {{ option.text }}
440
+ </option>
441
+ </template>
65
442
  </template>
66
- </b-form-select>
443
+
444
+ <!-- @slot Content to place in the form select after 'options' prop options -->
445
+ <slot></slot>
446
+ </select>
67
447
  </span>
68
448
  </template>
@@ -14,9 +14,6 @@ export const NAME_FORM = 'BForm'
14
14
  export const NAME_FORM_GROUP = 'BFormGroup'
15
15
  export const NAME_FORM_INVALID_FEEDBACK = 'BFormInvalidFeedback'
16
16
  export const NAME_FORM_ROW = 'BFormRow'
17
- export const NAME_FORM_SELECT = 'BFormSelect'
18
- export const NAME_FORM_SELECT_OPTION = 'BFormSelectOption'
19
- export const NAME_FORM_SELECT_OPTION_GROUP = 'BFormSelectOptionGroup'
20
17
  export const NAME_FORM_TEXT = 'BFormText'
21
18
  export const NAME_FORM_VALID_FEEDBACK = 'BFormValidFeedback'
22
19
  export const NAME_LINK = 'BLink'
@@ -1,51 +0,0 @@
1
- import { extend } from '../../vue';
2
- import { NAME_FORM_SELECT_OPTION_GROUP } from '../../constants/components';
3
- import { SLOT_NAME_FIRST } from '../../constants/slots';
4
- import { htmlOrText } from '../../utils/html';
5
- import { sortKeys } from '../../utils/object';
6
- import { props as props$1, formOptionsMixin } from '../../mixins/form-options';
7
- import { normalizeSlotMixin } from '../../mixins/normalize-slot';
8
- import { BFormSelectOption } from './form-select-option';
9
-
10
- // --- Props ---
11
-
12
- const props = sortKeys({
13
- ...props$1,
14
- label: {
15
- type: String,
16
- required: true
17
- }
18
- });
19
-
20
- // --- Main component ---
21
-
22
- // @vue/component
23
- const BFormSelectOptionGroup = /*#__PURE__*/extend({
24
- name: NAME_FORM_SELECT_OPTION_GROUP,
25
- mixins: [normalizeSlotMixin, formOptionsMixin],
26
- props,
27
- render(h) {
28
- const label = this.label;
29
- const $options = this.formOptions.map((option, index) => {
30
- const value = option.value,
31
- text = option.text,
32
- html = option.html,
33
- disabled = option.disabled;
34
- return h(BFormSelectOption, {
35
- attrs: {
36
- value,
37
- disabled
38
- },
39
- domProps: htmlOrText(html, text),
40
- key: `option_${index}`
41
- });
42
- });
43
- return h('optgroup', {
44
- attrs: {
45
- label
46
- }
47
- }, [this.normalizeSlot(SLOT_NAME_FIRST), $options, this.normalizeSlot()]);
48
- }
49
- });
50
-
51
- export { BFormSelectOptionGroup, props };
@@ -1,41 +0,0 @@
1
- import { extend, mergeData } from '../../vue';
2
- import { NAME_FORM_SELECT_OPTION } from '../../constants/components';
3
-
4
- // --- Props ---
5
-
6
- const props = {
7
- disabled: {
8
- type: Boolean,
9
- required: false,
10
- default: false
11
- },
12
- value: {
13
- required: true
14
- }
15
- };
16
-
17
- // --- Main component ---
18
-
19
- // @vue/component
20
- const BFormSelectOption = /*#__PURE__*/extend({
21
- name: NAME_FORM_SELECT_OPTION,
22
- functional: true,
23
- props,
24
- render(h, _ref) {
25
- let props = _ref.props,
26
- data = _ref.data,
27
- children = _ref.children;
28
- const value = props.value,
29
- disabled = props.disabled;
30
- return h('option', mergeData(data, {
31
- attrs: {
32
- disabled
33
- },
34
- domProps: {
35
- value
36
- }
37
- }), children);
38
- }
39
- });
40
-
41
- export { BFormSelectOption, props };
@@ -1,153 +0,0 @@
1
- import { extend } from '../../vue';
2
- import { NAME_FORM_SELECT } from '../../constants/components';
3
- import { EVENT_NAME_CHANGE, EVENT_NAME_INPUT } from '../../constants/events';
4
- import { SLOT_NAME_FIRST } from '../../constants/slots';
5
- import { from } from '../../utils/array';
6
- import { attemptFocus, attemptBlur } from '../../utils/dom';
7
- import { htmlOrText } from '../../utils/html';
8
- import { isArray } from '../../utils/inspect';
9
- import { sortKeys } from '../../utils/object';
10
- import { props as props$2, formControlMixin } from '../../mixins/form-control';
11
- import { props as props$3, formCustomMixin } from '../../mixins/form-custom';
12
- import { props as props$4, formSizeMixin } from '../../mixins/form-size';
13
- import { props as props$5, formStateMixin } from '../../mixins/form-state';
14
- import { normalizeSlotMixin } from '../../mixins/normalize-slot';
15
- import { props as props$1, idMixin } from '../../mixins/id';
16
- import { optionsMixin } from './helpers/mixin-options';
17
- import { BFormSelectOption } from './form-select-option';
18
- import { BFormSelectOptionGroup } from './form-select-option-group';
19
-
20
- const MODEL_PROP_NAME = 'value';
21
- const MODEL_EVENT_NAME = EVENT_NAME_INPUT;
22
-
23
- // --- Props ---
24
-
25
- const props = sortKeys({
26
- ...props$1,
27
- [MODEL_PROP_NAME]: {},
28
- ...props$2,
29
- ...props$3,
30
- ...props$4,
31
- ...props$5,
32
- ariaInvalid: {
33
- type: [Boolean, String],
34
- required: false,
35
- default: false
36
- },
37
- multiple: {
38
- type: Boolean,
39
- required: false,
40
- default: false
41
- },
42
- // Browsers default size to `0`, which shows 4 rows in most browsers in multiple mode
43
- // Size of `1` can bork out Firefox
44
- selectSize: {
45
- type: Number,
46
- required: false,
47
- default: 0
48
- }
49
- });
50
-
51
- // --- Main component ---
52
-
53
- // @vue/component
54
- const BFormSelect = /*#__PURE__*/extend({
55
- name: NAME_FORM_SELECT,
56
- mixins: [idMixin, formControlMixin, formSizeMixin, formStateMixin, formCustomMixin, optionsMixin, normalizeSlotMixin],
57
- model: {
58
- prop: MODEL_PROP_NAME,
59
- event: MODEL_EVENT_NAME
60
- },
61
- props,
62
- data() {
63
- return {
64
- localValue: this[MODEL_PROP_NAME]
65
- };
66
- },
67
- computed: {
68
- computedSelectSize() {
69
- // Custom selects with a size of zero causes the arrows to be hidden,
70
- // so dont render the size attribute in this case
71
- return !this.plain && this.selectSize === 0 ? null : this.selectSize;
72
- },
73
- inputClass() {
74
- return [this.plain ? 'form-control' : 'custom-select', this.size && this.plain ? `form-control-${this.size}` : null, this.size && !this.plain ? `custom-select-${this.size}` : null, this.stateClass];
75
- }
76
- },
77
- watch: {
78
- value(newValue) {
79
- this.localValue = newValue;
80
- },
81
- localValue() {
82
- this.$emit(MODEL_EVENT_NAME, this.localValue);
83
- }
84
- },
85
- methods: {
86
- focus() {
87
- attemptFocus(this.$refs.input);
88
- },
89
- blur() {
90
- attemptBlur(this.$refs.input);
91
- },
92
- onChange(event) {
93
- const target = event.target;
94
- const selectedValue = from(target.options).filter(o => o.selected).map(o => '_value' in o ? o._value : o.value);
95
- this.localValue = target.multiple ? selectedValue : selectedValue[0];
96
- this.$nextTick(() => {
97
- this.$emit(EVENT_NAME_CHANGE, this.localValue);
98
- });
99
- }
100
- },
101
- render(h) {
102
- const name = this.name,
103
- disabled = this.disabled,
104
- required = this.required,
105
- size = this.computedSelectSize,
106
- value = this.localValue;
107
- const $options = this.formOptions.map((option, index) => {
108
- const value = option.value,
109
- label = option.label,
110
- options = option.options,
111
- disabled = option.disabled;
112
- const key = `option_${index}`;
113
- return isArray(options) ? h(BFormSelectOptionGroup, {
114
- props: {
115
- label,
116
- options
117
- },
118
- key
119
- }) : h(BFormSelectOption, {
120
- props: {
121
- value,
122
- disabled
123
- },
124
- domProps: htmlOrText(option.html, option.text),
125
- key
126
- });
127
- });
128
- return h('select', {
129
- class: this.inputClass,
130
- attrs: {
131
- id: this.safeId(),
132
- name,
133
- form: this.form || null,
134
- multiple: this.multiple || null,
135
- size,
136
- disabled,
137
- required,
138
- 'aria-required': required ? 'true' : null,
139
- 'aria-invalid': this.computedAriaInvalid
140
- },
141
- on: {
142
- change: this.onChange
143
- },
144
- directives: [{
145
- name: 'model',
146
- value
147
- }],
148
- ref: 'input'
149
- }, [this.normalizeSlot(SLOT_NAME_FIRST), $options, this.normalizeSlot()]);
150
- }
151
- });
152
-
153
- export { BFormSelect, props };