@gitlab/ui 91.14.0 → 91.15.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [91.15.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v91.14.0...v91.15.0) (2024-09-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * add duo chat context menu keyboard interactions ([a031d8c](https://gitlab.com/gitlab-org/gitlab-ui/commit/a031d8c823757e7a22138c3bdc7de9ff24c25da9))
7
+
1
8
  # [91.14.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v91.13.0...v91.14.0) (2024-09-10)
2
9
 
3
10
 
@@ -1,8 +1,8 @@
1
1
  import debounce from 'lodash/debounce';
2
2
  import { translate } from '../../../../../../../utils/i18n';
3
3
  import GlCard from '../../../../../../base/card/card';
4
- import { contextItemsValidator, categoriesValidator } from '../utils';
5
4
  import GlDuoChatContextItemSelections from '../duo_chat_context_item_selections/duo_chat_context_item_selections';
5
+ import { contextItemsValidator, categoriesValidator, wrapIndex } from '../utils';
6
6
  import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items';
7
7
  import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items';
8
8
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
@@ -66,14 +66,17 @@ var script = {
66
66
  },
67
67
  data() {
68
68
  return {
69
- activeIndex: 0,
69
+ selectedCategory: null,
70
70
  searchQuery: '',
71
- selectedCategory: null
71
+ activeIndex: 0
72
72
  };
73
73
  },
74
74
  computed: {
75
75
  showCategorySelection() {
76
76
  return this.open && !this.selectedCategory;
77
+ },
78
+ allResultsAreDisabled() {
79
+ return this.results.every(result => !result.isEnabled);
77
80
  }
78
81
  },
79
82
  watch: {
@@ -84,14 +87,22 @@ var script = {
84
87
  },
85
88
  searchQuery(query) {
86
89
  this.debouncedSearch(query);
90
+ },
91
+ results(newResults) {
92
+ const firstEnabledIndex = newResults.findIndex(result => result.isEnabled);
93
+ this.activeIndex = firstEnabledIndex >= 0 ? firstEnabledIndex : 0;
87
94
  }
88
95
  },
89
96
  methods: {
90
97
  selectCategory(category) {
91
- this.activeIndex = 0;
92
98
  this.searchQuery = '';
93
99
  this.selectedCategory = category;
94
-
100
+ this.$emit('search', {
101
+ category: category.value,
102
+ query: ''
103
+ });
104
+ },
105
+ debouncedSearch: debounce(function search(query) {
95
106
  /**
96
107
  * Emitted when a search should be performed.
97
108
  * @property {Object} filter
@@ -99,10 +110,10 @@ var script = {
99
110
  * @property {string} filter.query - The search query
100
111
  */
101
112
  this.$emit('search', {
102
- category: category.value,
103
- query: ''
113
+ category: this.selectedCategory.value,
114
+ query
104
115
  });
105
- },
116
+ }, SEARCH_DEBOUNCE_MS),
106
117
  selectItem(item) {
107
118
  if (!item.isEnabled) {
108
119
  return;
@@ -127,15 +138,72 @@ var script = {
127
138
  */
128
139
  this.$emit('remove', item);
129
140
  },
130
- debouncedSearch: debounce(function search(query) {
131
- this.$emit('search', {
132
- category: this.selectedCategory.value,
133
- query
134
- });
135
- }, SEARCH_DEBOUNCE_MS),
136
141
  resetSelection() {
137
142
  this.selectedCategory = null;
143
+ this.searchQuery = '';
138
144
  this.activeIndex = 0;
145
+ },
146
+ async scrollActiveItemIntoView() {
147
+ await this.$nextTick();
148
+ const activeItem = document.getElementById(`dropdown-item-${this.activeIndex}`);
149
+ if (activeItem) {
150
+ activeItem.scrollIntoView({
151
+ block: 'nearest',
152
+ inline: 'start'
153
+ });
154
+ }
155
+ },
156
+ handleKeyUp(e) {
157
+ switch (e.key) {
158
+ case 'ArrowDown':
159
+ case 'ArrowUp':
160
+ e.preventDefault();
161
+ this.moveActiveIndex(e.key === 'ArrowDown' ? 1 : -1);
162
+ this.scrollActiveItemIntoView();
163
+ break;
164
+ case 'Enter':
165
+ e.preventDefault();
166
+ if (this.showCategorySelection) {
167
+ this.selectCategory(this.categories[this.activeIndex]);
168
+ return;
169
+ }
170
+ if (!this.results.length) {
171
+ return;
172
+ }
173
+ this.selectItem(this.results[this.activeIndex]);
174
+ break;
175
+ case 'Escape':
176
+ e.preventDefault();
177
+ if (this.showCategorySelection) {
178
+ this.$emit('close');
179
+ return;
180
+ }
181
+ this.selectedCategory = null;
182
+ break;
183
+ }
184
+ },
185
+ moveActiveIndex(step) {
186
+ if (this.showCategorySelection) {
187
+ // Categories cannot be disabled, so just loop to the next/prev one
188
+ this.activeIndex = wrapIndex(this.activeIndex, step, this.categories.length);
189
+ return;
190
+ }
191
+
192
+ // Return early if there are no results or all results are disabled
193
+ if (!this.results.length || this.allResultsAreDisabled) {
194
+ return;
195
+ }
196
+
197
+ // contextItems CAN be disabled, so loop to next/prev but ensure we don't land on a disabled one
198
+ let newIndex = this.activeIndex;
199
+ do {
200
+ newIndex = wrapIndex(newIndex, step, this.results.length);
201
+ if (newIndex === this.activeIndex) {
202
+ // If we've looped through all items and found no enabled ones, keep the current index
203
+ return;
204
+ }
205
+ } while (!this.results[newIndex].isEnabled);
206
+ this.activeIndex = newIndex;
139
207
  }
140
208
  },
141
209
  i18n: {
@@ -147,7 +215,7 @@ var script = {
147
215
  const __vue_script__ = script;
148
216
 
149
217
  /* template */
150
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',[(_vm.selections.length)?_c('gl-duo-chat-context-item-selections',{staticClass:"gl-mb-3",attrs:{"selections":_vm.selections,"removable":true,"title":_vm.$options.i18n.selectedContextItemsTitle,"default-collapsed":false},on:{"remove":_vm.removeItem}}):_vm._e(),_vm._v(" "),(_vm.open)?_c('gl-card',{staticClass:"slash-commands !gl-absolute gl-bottom-0 gl-w-full gl-pl-0 gl-shadow-md",attrs:{"body-class":"!gl-p-2","data-testid":"context-item-menu"}},[(_vm.showCategorySelection)?_c('gl-duo-chat-context-item-menu-category-items',{attrs:{"active-index":_vm.activeIndex,"categories":_vm.categories},on:{"select":_vm.selectCategory,"active-index-change":function($event){_vm.activeIndex = $event;}}}):_c('gl-duo-chat-context-item-menu-search-items',{attrs:{"active-index":_vm.activeIndex,"category":_vm.selectedCategory,"loading":_vm.loading,"error":_vm.error,"results":_vm.results},on:{"select":_vm.selectItem,"active-index-change":function($event){_vm.activeIndex = $event;}},model:{value:(_vm.searchQuery),callback:function ($$v) {_vm.searchQuery=$$v;},expression:"searchQuery"}})],1):_vm._e()],1)};
218
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',[(_vm.selections.length)?_c('gl-duo-chat-context-item-selections',{staticClass:"gl-mb-3",attrs:{"selections":_vm.selections,"removable":true,"title":_vm.$options.i18n.selectedContextItemsTitle,"default-collapsed":false},on:{"remove":_vm.removeItem}}):_vm._e(),_vm._v(" "),(_vm.open)?_c('gl-card',{staticClass:"slash-commands !gl-absolute gl-bottom-0 gl-w-full gl-pl-0 gl-shadow-md",attrs:{"body-class":"!gl-p-2","data-testid":"context-item-menu"}},[(_vm.showCategorySelection)?_c('gl-duo-chat-context-item-menu-category-items',{attrs:{"active-index":_vm.activeIndex,"categories":_vm.categories},on:{"select":_vm.selectCategory,"active-index-change":function($event){_vm.activeIndex = $event;}}}):_c('gl-duo-chat-context-item-menu-search-items',{attrs:{"active-index":_vm.activeIndex,"category":_vm.selectedCategory,"loading":_vm.loading,"error":_vm.error,"results":_vm.results},on:{"select":_vm.selectItem,"keyup":_vm.handleKeyUp,"active-index-change":function($event){_vm.activeIndex = $event;}},model:{value:(_vm.searchQuery),callback:function ($$v) {_vm.searchQuery=$$v;},expression:"searchQuery"}})],1):_vm._e()],1)};
151
219
  var __vue_staticRenderFns__ = [];
152
220
 
153
221
  /* style */
@@ -78,11 +78,17 @@ var script = {
78
78
  this.$emit('select', contextItem);
79
79
  this.userInitiatedSearch = false;
80
80
  },
81
+ handleKeyUp(e) {
82
+ this.$emit('keyup', e);
83
+ },
81
84
  setActiveIndex(index) {
82
85
  var _this$results$index;
83
86
  if ((_this$results$index = this.results[index]) !== null && _this$results$index !== void 0 && _this$results$index.isEnabled) {
84
87
  this.$emit('active-index-change', index);
85
88
  }
89
+ },
90
+ isActiveItem(contextItem, index) {
91
+ return index === this.activeIndex && contextItem.isEnabled;
86
92
  }
87
93
  },
88
94
  i18n: {
@@ -95,9 +101,9 @@ const __vue_script__ = script;
95
101
 
96
102
  /* template */
97
103
  var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',[_c('div',{staticClass:"gl-max-h-31 gl-overflow-y-scroll"},[(_vm.loading)?_c('gl-duo-chat-context-item-menu-search-items-loading',{attrs:{"rows":_vm.numLoadingItems}}):(_vm.error)?_c('gl-alert',{staticClass:"gl-m-3",attrs:{"variant":"danger","dismissible":false,"data-testid":"search-results-error"}},[_vm._v("\n "+_vm._s(_vm.error)+"\n ")]):(_vm.showEmptyState)?_c('div',{staticClass:"gl-rounded-base gl-p-3 gl-text-center gl-text-secondary",attrs:{"data-testid":"search-results-empty-state"}},[_vm._v("\n "+_vm._s(_vm.$options.i18n.emptyStateMessage)+"\n ")]):_c('ul',{staticClass:"gl-mb-1 gl-list-none gl-flex-row gl-pl-0"},_vm._l((_vm.results),function(contextItem,index){return _c('gl-dropdown-item',{key:contextItem.id,staticClass:"duo-chat-context-search-result-item",class:{
98
- 'active-command': index === _vm.activeIndex,
104
+ 'active-command': _vm.isActiveItem(contextItem, index),
99
105
  'gl-cursor-not-allowed [&>button]:focus-within:!gl-shadow-none': !contextItem.isEnabled,
100
- },attrs:{"id":("dropdown-item-" + index),"tabindex":!contextItem.isEnabled ? -1 : undefined,"data-testid":"search-result-item"},on:{"click":function($event){return _vm.selectItem(contextItem)}}},[_c('div',{on:{"mouseenter":function($event){return _vm.setActiveIndex(index)}}},[_c('gl-duo-chat-context-item-menu-search-item',{class:{ 'gl-text-secondary': !contextItem.isEnabled },attrs:{"context-item":contextItem,"category":_vm.category,"data-testid":"search-result-item-details"}})],1)])}),1)],1),_vm._v(" "),_c('gl-form-input',{ref:"contextMenuSearchInput",attrs:{"value":_vm.searchQuery,"placeholder":_vm.searchInputPlaceholder,"autofocus":"","data-testid":"context-menu-search-input"},on:{"input":function($event){return _vm.$emit('update:searchQuery', $event)}}})],1)};
106
+ },attrs:{"id":("dropdown-item-" + index),"tabindex":!contextItem.isEnabled ? -1 : undefined,"data-testid":"search-result-item"},on:{"click":function($event){return _vm.selectItem(contextItem)}}},[_c('div',{on:{"mouseenter":function($event){return _vm.setActiveIndex(index)}}},[_c('gl-duo-chat-context-item-menu-search-item',{class:{ 'gl-text-secondary': !contextItem.isEnabled },attrs:{"context-item":contextItem,"category":_vm.category,"data-testid":"search-result-item-details"}})],1)])}),1)],1),_vm._v(" "),_c('gl-form-input',{ref:"contextMenuSearchInput",attrs:{"value":_vm.searchQuery,"placeholder":_vm.searchInputPlaceholder,"autofocus":"","data-testid":"context-menu-search-input"},on:{"input":function($event){return _vm.$emit('update:searchQuery', $event)},"keyup":_vm.handleKeyUp}})],1)};
101
107
  var __vue_staticRenderFns__ = [];
102
108
 
103
109
  /* style */
@@ -22,4 +22,15 @@ function formatMergeRequestId(iid) {
22
22
  return `!${iid}`;
23
23
  }
24
24
 
25
- export { categoriesValidator, categoryValidator, contextItemValidator, contextItemsValidator, formatIssueId, formatMergeRequestId };
25
+ /**
26
+ * Calculates a new index within a range. If the new index would fall out of bounds, wraps to the start/end of the range.
27
+ * @param {number} currentIndex - The starting index.
28
+ * @param {number} step - The number of steps to move (positive or negative).
29
+ * @param {number} totalLength - The total number of items in the range.
30
+ * @returns {number} The new index.
31
+ */
32
+ function wrapIndex(currentIndex, step, totalLength) {
33
+ return (currentIndex + step + totalLength) % totalLength;
34
+ }
35
+
36
+ export { categoriesValidator, categoryValidator, contextItemValidator, contextItemsValidator, formatIssueId, formatMergeRequestId, wrapIndex };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "91.14.0",
3
+ "version": "91.15.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -2,8 +2,8 @@
2
2
  import debounce from 'lodash/debounce';
3
3
  import { translate } from '../../../../../../../utils/i18n';
4
4
  import GlCard from '../../../../../../base/card/card.vue';
5
- import { categoriesValidator, contextItemsValidator } from '../utils';
6
5
  import GlDuoChatContextItemSelections from '../duo_chat_context_item_selections/duo_chat_context_item_selections.vue';
6
+ import { categoriesValidator, contextItemsValidator, wrapIndex } from '../utils';
7
7
  import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items.vue';
8
8
  import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items.vue';
9
9
 
@@ -67,15 +67,18 @@ export default {
67
67
  },
68
68
  data() {
69
69
  return {
70
- activeIndex: 0,
71
- searchQuery: '',
72
70
  selectedCategory: null,
71
+ searchQuery: '',
72
+ activeIndex: 0,
73
73
  };
74
74
  },
75
75
  computed: {
76
76
  showCategorySelection() {
77
77
  return this.open && !this.selectedCategory;
78
78
  },
79
+ allResultsAreDisabled() {
80
+ return this.results.every((result) => !result.isEnabled);
81
+ },
79
82
  },
80
83
  watch: {
81
84
  open(isOpen) {
@@ -86,13 +89,22 @@ export default {
86
89
  searchQuery(query) {
87
90
  this.debouncedSearch(query);
88
91
  },
92
+ results(newResults) {
93
+ const firstEnabledIndex = newResults.findIndex((result) => result.isEnabled);
94
+ this.activeIndex = firstEnabledIndex >= 0 ? firstEnabledIndex : 0;
95
+ },
89
96
  },
90
97
  methods: {
91
98
  selectCategory(category) {
92
- this.activeIndex = 0;
93
99
  this.searchQuery = '';
94
100
  this.selectedCategory = category;
95
101
 
102
+ this.$emit('search', {
103
+ category: category.value,
104
+ query: '',
105
+ });
106
+ },
107
+ debouncedSearch: debounce(function search(query) {
96
108
  /**
97
109
  * Emitted when a search should be performed.
98
110
  * @property {Object} filter
@@ -100,10 +112,10 @@ export default {
100
112
  * @property {string} filter.query - The search query
101
113
  */
102
114
  this.$emit('search', {
103
- category: category.value,
104
- query: '',
115
+ category: this.selectedCategory.value,
116
+ query,
105
117
  });
106
- },
118
+ }, SEARCH_DEBOUNCE_MS),
107
119
  selectItem(item) {
108
120
  if (!item.isEnabled) {
109
121
  return;
@@ -131,16 +143,76 @@ export default {
131
143
  */
132
144
  this.$emit('remove', item);
133
145
  },
134
- debouncedSearch: debounce(function search(query) {
135
- this.$emit('search', {
136
- category: this.selectedCategory.value,
137
- query,
138
- });
139
- }, SEARCH_DEBOUNCE_MS),
140
146
  resetSelection() {
141
147
  this.selectedCategory = null;
148
+ this.searchQuery = '';
142
149
  this.activeIndex = 0;
143
150
  },
151
+ async scrollActiveItemIntoView() {
152
+ await this.$nextTick();
153
+
154
+ const activeItem = document.getElementById(`dropdown-item-${this.activeIndex}`);
155
+ if (activeItem) {
156
+ activeItem.scrollIntoView({ block: 'nearest', inline: 'start' });
157
+ }
158
+ },
159
+ handleKeyUp(e) {
160
+ switch (e.key) {
161
+ case 'ArrowDown':
162
+ case 'ArrowUp':
163
+ e.preventDefault();
164
+ this.moveActiveIndex(e.key === 'ArrowDown' ? 1 : -1);
165
+ this.scrollActiveItemIntoView();
166
+ break;
167
+ case 'Enter':
168
+ e.preventDefault();
169
+ if (this.showCategorySelection) {
170
+ this.selectCategory(this.categories[this.activeIndex]);
171
+ return;
172
+ }
173
+ if (!this.results.length) {
174
+ return;
175
+ }
176
+ this.selectItem(this.results[this.activeIndex]);
177
+ break;
178
+ case 'Escape':
179
+ e.preventDefault();
180
+ if (this.showCategorySelection) {
181
+ this.$emit('close');
182
+ return;
183
+ }
184
+
185
+ this.selectedCategory = null;
186
+ break;
187
+ default:
188
+ break;
189
+ }
190
+ },
191
+ moveActiveIndex(step) {
192
+ if (this.showCategorySelection) {
193
+ // Categories cannot be disabled, so just loop to the next/prev one
194
+ this.activeIndex = wrapIndex(this.activeIndex, step, this.categories.length);
195
+ return;
196
+ }
197
+
198
+ // Return early if there are no results or all results are disabled
199
+ if (!this.results.length || this.allResultsAreDisabled) {
200
+ return;
201
+ }
202
+
203
+ // contextItems CAN be disabled, so loop to next/prev but ensure we don't land on a disabled one
204
+ let newIndex = this.activeIndex;
205
+ do {
206
+ newIndex = wrapIndex(newIndex, step, this.results.length);
207
+
208
+ if (newIndex === this.activeIndex) {
209
+ // If we've looped through all items and found no enabled ones, keep the current index
210
+ return;
211
+ }
212
+ } while (!this.results[newIndex].isEnabled);
213
+
214
+ this.activeIndex = newIndex;
215
+ },
144
216
  },
145
217
  i18n: {
146
218
  selectedContextItemsTitle: translate(
@@ -184,6 +256,7 @@ export default {
184
256
  :error="error"
185
257
  :results="results"
186
258
  @select="selectItem"
259
+ @keyup="handleKeyUp"
187
260
  @active-index-change="activeIndex = $event"
188
261
  />
189
262
  </gl-card>
@@ -83,11 +83,17 @@ export default {
83
83
  this.$emit('select', contextItem);
84
84
  this.userInitiatedSearch = false;
85
85
  },
86
+ handleKeyUp(e) {
87
+ this.$emit('keyup', e);
88
+ },
86
89
  setActiveIndex(index) {
87
90
  if (this.results[index]?.isEnabled) {
88
91
  this.$emit('active-index-change', index);
89
92
  }
90
93
  },
94
+ isActiveItem(contextItem, index) {
95
+ return index === this.activeIndex && contextItem.isEnabled;
96
+ },
91
97
  },
92
98
  i18n: {
93
99
  emptyStateMessage: translate('GlDuoChatContextItemMenu.emptyStateMessage', 'No results found'),
@@ -120,7 +126,7 @@ export default {
120
126
  :id="`dropdown-item-${index}`"
121
127
  :key="contextItem.id"
122
128
  :class="{
123
- 'active-command': index === activeIndex,
129
+ 'active-command': isActiveItem(contextItem, index),
124
130
  'gl-cursor-not-allowed [&>button]:focus-within:!gl-shadow-none': !contextItem.isEnabled,
125
131
  }"
126
132
  :tabindex="!contextItem.isEnabled ? -1 : undefined"
@@ -146,6 +152,7 @@ export default {
146
152
  autofocus
147
153
  data-testid="context-menu-search-input"
148
154
  @input="$emit('update:searchQuery', $event)"
155
+ @keyup="handleKeyUp"
149
156
  />
150
157
  </div>
151
158
  </template>
@@ -39,3 +39,14 @@ export function formatMergeRequestId(iid) {
39
39
 
40
40
  return `!${iid}`;
41
41
  }
42
+
43
+ /**
44
+ * Calculates a new index within a range. If the new index would fall out of bounds, wraps to the start/end of the range.
45
+ * @param {number} currentIndex - The starting index.
46
+ * @param {number} step - The number of steps to move (positive or negative).
47
+ * @param {number} totalLength - The total number of items in the range.
48
+ * @returns {number} The new index.
49
+ */
50
+ export function wrapIndex(currentIndex, step, totalLength) {
51
+ return (currentIndex + step + totalLength) % totalLength;
52
+ }