@gitlab/ui 113.4.0 → 113.5.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
+ # [113.5.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v113.4.0...v113.5.0) (2025-05-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * **GlCollapsibleListbox:** improve a11y support ([13453e7](https://gitlab.com/gitlab-org/gitlab-ui/commit/13453e73c28ddc3acf22951738acfeb9e2ade4c0))
7
+
1
8
  # [113.4.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v113.3.2...v113.4.0) (2025-05-12)
2
9
 
3
10
 
@@ -496,7 +496,7 @@ var script = {
496
496
  const __vue_script__ = script;
497
497
 
498
498
  /* template */
499
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{directives:[{name:"outside",rawName:"v-outside.click.focusin",value:(_vm.close),expression:"close",modifiers:{"click":true,"focusin":true}}],class:[_vm.$options.BASE_DROPDOWN_CLASS, { '!gl-block': _vm.block }]},[_c(_vm.toggleComponent,_vm._g(_vm._b({ref:"toggle",tag:"component",attrs:{"id":_vm.toggleId,"data-testid":"base-dropdown-toggle"},on:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"esc",27,$event.key,["Esc","Escape"])){ return null; }$event.stopPropagation();$event.preventDefault();return _vm.close.apply(null, arguments)}}},'component',_vm.toggleAttributes,false),_vm.toggleListeners),[_vm._t("toggle",function(){return [_c('span',{staticClass:"gl-new-dropdown-button-text",class:{ 'gl-sr-only': _vm.textSrOnly }},[_vm._v("\n "+_vm._s(_vm.toggleText)+"\n ")]),_vm._v(" "),(!_vm.noCaret)?_c('gl-icon',{staticClass:"gl-button-icon gl-new-dropdown-chevron",attrs:{"name":"chevron-down"}}):_vm._e()]})],2),_vm._v(" "),_c('div',{ref:"content",staticClass:"gl-new-dropdown-panel",class:_vm.panelClasses,attrs:{"id":_vm.baseDropdownId,"data-testid":"base-dropdown-menu"},on:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"esc",27,$event.key,["Esc","Escape"])){ return null; }$event.stopPropagation();$event.preventDefault();return _vm.closeAndFocus.apply(null, arguments)}}},[_c('div',{ref:"dropdownArrow",staticClass:"gl-new-dropdown-arrow"}),_vm._v(" "),_c('div',{staticClass:"gl-new-dropdown-inner"},[_vm._t("default")],2)])],1)};
499
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{directives:[{name:"outside",rawName:"v-outside.click.focusin",value:(_vm.close),expression:"close",modifiers:{"click":true,"focusin":true}}],class:[_vm.$options.BASE_DROPDOWN_CLASS, { '!gl-block': _vm.block }]},[_c(_vm.toggleComponent,_vm._g(_vm._b({ref:"toggle",tag:"component",attrs:{"id":_vm.toggleId,"data-testid":"base-dropdown-toggle"},on:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"esc",27,$event.key,["Esc","Escape"])){ return null; }$event.stopPropagation();$event.preventDefault();return _vm.close.apply(null, arguments)}}},'component',_vm.toggleAttributes,false),_vm.toggleListeners),[_vm._t("toggle",function(){return [_c('span',{staticClass:"gl-new-dropdown-button-text",class:{ 'gl-sr-only': _vm.textSrOnly }},[_vm._v("\n "+_vm._s(_vm.toggleText)+"\n ")]),_vm._v(" "),(!_vm.noCaret)?_c('gl-icon',{staticClass:"gl-button-icon gl-new-dropdown-chevron",attrs:{"name":"chevron-down"}}):_vm._e()]})],2),_vm._v(" "),_c('div',{ref:"content",staticClass:"gl-new-dropdown-panel",class:_vm.panelClasses,attrs:{"id":_vm.baseDropdownId,"data-testid":"base-dropdown-menu"},on:{"keydown":function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"esc",27,$event.key,["Esc","Escape"])){ return null; }$event.stopPropagation();$event.preventDefault();return _vm.closeAndFocus.apply(null, arguments)}}},[_c('div',{ref:"dropdownArrow",staticClass:"gl-new-dropdown-arrow"}),_vm._v(" "),_c('div',{staticClass:"gl-new-dropdown-inner"},[_vm._t("default",null,{"visible":_vm.visible})],2)])],1)};
500
500
  var __vue_staticRenderFns__ = [];
501
501
 
502
502
  /* style */
@@ -1,14 +1,14 @@
1
1
  import clamp from 'lodash/clamp';
2
2
  import uniqueId from 'lodash/uniqueId';
3
3
  import { stopEvent } from '../../../../utils/utils';
4
- import { GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, POSITION_ABSOLUTE, POSITION_FIXED, GL_DROPDOWN_CONTENTS_CLASS, HOME, END, ARROW_UP, ARROW_DOWN, ENTER } from '../constants';
4
+ import { GL_DROPDOWN_SHOWN, GL_DROPDOWN_HIDDEN, POSITION_ABSOLUTE, POSITION_FIXED, GL_DROPDOWN_CONTENTS_CLASS, ARROW_UP, ENTER, ARROW_DOWN, END, HOME } from '../constants';
5
5
  import { buttonCategoryOptions, dropdownVariantOptions, buttonSizeOptions, dropdownPlacements } from '../../../../utils/constants';
6
6
  import GlButton from '../../button/button';
7
7
  import GlLoadingIcon from '../../loading_icon/loading_icon';
8
8
  import GlIntersectionObserver from '../../../utilities/intersection_observer/intersection_observer';
9
9
  import GlSearchBoxByType from '../../search_box_by_type/search_box_by_type';
10
10
  import GlBaseDropdown from '../base_dropdown/base_dropdown';
11
- import { translatePlural } from '../../../../utils/i18n';
11
+ import { translatePlural, translate } from '../../../../utils/i18n';
12
12
  import GlListboxItem from './listbox_item';
13
13
  import GlListboxSearchInput from './listbox_search_input';
14
14
  import GlListboxGroup from './listbox_group';
@@ -342,13 +342,22 @@ var script = {
342
342
  return {
343
343
  selectedValues: [],
344
344
  listboxId: uniqueId('listbox-'),
345
+ searchInputId: uniqueId('listbox-search-input-'),
345
346
  nextFocusedItemIndex: null,
346
347
  searchStr: '',
347
348
  topBoundaryVisible: true,
348
- bottomBoundaryVisible: true
349
+ bottomBoundaryVisible: true,
350
+ activeItemId: null,
351
+ itemIds: new Map()
349
352
  };
350
353
  },
351
354
  computed: {
355
+ ariaLabelledByID() {
356
+ if (this.searchable) {
357
+ return this.searchInputId;
358
+ }
359
+ return this.listAriaLabelledBy || this.headerId || this.toggleIdComputed;
360
+ },
352
361
  toggleIdComputed() {
353
362
  return this.toggleId || uniqueId('dropdown-toggle-btn-');
354
363
  },
@@ -451,6 +460,9 @@ var script = {
451
460
  showIntersectionObserver() {
452
461
  return this.infiniteScroll && !this.infiniteScrollLoading && !this.loading && !this.searching;
453
462
  },
463
+ isBusy() {
464
+ return this.infiniteScrollLoading || this.loading || this.searching;
465
+ },
454
466
  hasCustomToggle() {
455
467
  return Boolean(this.$scopedSlots.toggle);
456
468
  },
@@ -469,6 +481,18 @@ var script = {
469
481
  },
470
482
  hasFooter() {
471
483
  return Boolean(this.$scopedSlots.footer);
484
+ },
485
+ loadingAnnouncementText() {
486
+ if (this.infiniteScrollLoading) {
487
+ return translate('GlCollapsibleListbox.loadingAnnouncementText.loadingMoreItems', 'Loading more items');
488
+ }
489
+ if (this.searching) {
490
+ return translate('GlCollapsibleListbox.loadingAnnouncementText.searching', 'Searching');
491
+ }
492
+ if (this.loading) {
493
+ return translate('GlCollapsibleListbox.loadingAnnouncementText.loadingItems', 'Loading items');
494
+ }
495
+ return '';
472
496
  }
473
497
  },
474
498
  watch: {
@@ -561,6 +585,9 @@ var script = {
561
585
  */
562
586
  if (this.searchHasOptions) {
563
587
  this.nextFocusedItemIndex = 0;
588
+ // Set activeItemId for the first item
589
+ const firstItem = this.flattenedOptions[0];
590
+ this.activeItemId = this.generateItemId(firstItem);
564
591
  }
565
592
  } else {
566
593
  var _this$selectedIndices;
@@ -582,6 +609,21 @@ var script = {
582
609
  this.$emit(GL_DROPDOWN_HIDDEN);
583
610
  this.nextFocusedItemIndex = null;
584
611
  },
612
+ getNextIndex(currentIndex, keyCode, totalLength) {
613
+ // For UP: move up or wrap to end
614
+ if (keyCode === ARROW_UP) {
615
+ return currentIndex > 0 ? currentIndex - 1 : totalLength - 1;
616
+ }
617
+
618
+ // For DOWN: move down or wrap to start
619
+ return currentIndex < totalLength - 1 ? currentIndex + 1 : 0;
620
+ },
621
+ handleListNavigation(keyCode, elements) {
622
+ var _this$nextFocusedItem;
623
+ const currentIndex = (_this$nextFocusedItem = this.nextFocusedItemIndex) !== null && _this$nextFocusedItem !== void 0 ? _this$nextFocusedItem : -1;
624
+ const nextIndex = this.getNextIndex(currentIndex, keyCode, elements.length);
625
+ this.focusItem(nextIndex, elements, this.searchable);
626
+ },
585
627
  onKeydown(event) {
586
628
  const {
587
629
  code,
@@ -589,46 +631,54 @@ var script = {
589
631
  } = event;
590
632
  const elements = this.getFocusableListItemElements();
591
633
  if (elements.length < 1) return;
592
- let stop = true;
593
634
  const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
594
- if (code === HOME) {
595
- if (isSearchInput) {
596
- return;
597
- }
598
- this.focusItem(0, elements);
599
- } else if (code === END) {
600
- if (isSearchInput) {
601
- return;
602
- }
603
- this.focusItem(elements.length - 1, elements);
604
- } else if (code === ARROW_UP) {
605
- if (isSearchInput) {
606
- return;
607
- }
608
- if (this.searchable && elements.indexOf(target) === 0) {
609
- this.focusSearchInput();
610
- if (!this.searchHasOptions) {
611
- this.nextFocusedItemIndex = null;
635
+ let stop = true;
636
+ switch (code) {
637
+ case HOME:
638
+ // Jump to first item if searchable or not in search input
639
+ if (this.searchable || !isSearchInput) {
640
+ this.focusItem(0, elements, this.searchable);
612
641
  }
613
- } else {
614
- this.focusNextItem(event, elements, -1);
615
- }
616
- } else if (code === ARROW_DOWN) {
617
- if (isSearchInput) {
618
- this.focusItem(0, elements);
619
- } else {
620
- this.focusNextItem(event, elements, 1);
621
- }
622
- } else if (code === ENTER && isSearchInput) {
623
- if (this.searchHasOptions && elements.length > 0) {
624
- // Toggle selection state of the first item
625
- const firstItem = this.flattenedOptions[0];
626
- this.onSelect(firstItem, !this.isSelected(firstItem));
627
- }
628
- stop = true;
629
- } else {
630
- stop = false;
642
+ break;
643
+ case END:
644
+ // Jump to last item if searchable or not in search input
645
+ if (this.searchable || !isSearchInput) {
646
+ this.focusItem(elements.length - 1, elements, this.searchable);
647
+ }
648
+ break;
649
+ case ARROW_UP:
650
+ // Let default behavior work for non-searchable input
651
+ if (isSearchInput && !this.searchable) {
652
+ return;
653
+ }
654
+ this.handleListNavigation(ARROW_UP, elements);
655
+ break;
656
+ case ARROW_DOWN:
657
+ // Focus first item from search input, otherwise navigate down
658
+ if (isSearchInput && !this.searchable) {
659
+ this.focusItem(0, elements);
660
+ } else {
661
+ this.handleListNavigation(ARROW_DOWN, elements);
662
+ }
663
+ break;
664
+ case ENTER:
665
+ if (isSearchInput) {
666
+ // Toggle selection of highlighted item if one exists
667
+ if (elements.length > 0 && this.nextFocusedItemIndex !== null) {
668
+ const highlightedItem = this.flattenedOptions[this.nextFocusedItemIndex];
669
+ this.onSelect(highlightedItem, !this.isSelected(highlightedItem));
670
+ }
671
+ } else {
672
+ stop = false;
673
+ }
674
+ break;
675
+ default:
676
+ // Allow default behavior for unhandled keys
677
+ stop = false;
678
+ break;
631
679
  }
680
+
681
+ // Prevent default behavior for handled keys
632
682
  if (stop) {
633
683
  stopEvent(event);
634
684
  }
@@ -647,9 +697,25 @@ var script = {
647
697
  this.focusItem(nextIndex, elements);
648
698
  },
649
699
  focusItem(index, elements) {
650
- var _elements$index;
700
+ let keepSearchFocused = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
651
701
  this.nextFocusedItemIndex = index;
652
- (_elements$index = elements[index]) === null || _elements$index === void 0 ? void 0 : _elements$index.focus();
702
+
703
+ // Always update the activeItemId when focus changes
704
+ const item = this.flattenedOptions[index];
705
+ if (item) {
706
+ this.activeItemId = this.generateItemId(item);
707
+ } else {
708
+ this.activeItemId = null;
709
+ }
710
+
711
+ // If we're not keeping the search focused, focus the item
712
+ if (!keepSearchFocused) {
713
+ var _elements$index;
714
+ (_elements$index = elements[index]) === null || _elements$index === void 0 ? void 0 : _elements$index.focus();
715
+ }
716
+ this.$nextTick(() => {
717
+ this.scrollActiveItemIntoView();
718
+ });
653
719
  },
654
720
  focusSearchInput() {
655
721
  this.$refs.searchBox.focusInput();
@@ -767,7 +833,40 @@ var script = {
767
833
  }
768
834
  this.scrollObserver = observer;
769
835
  },
770
- isOption
836
+ isOption,
837
+ generateItemId(item) {
838
+ const key = item.value === null ? ITEM_NULL_KEY : item.value;
839
+ if (!this.itemIds.has(key)) {
840
+ this.itemIds.set(key, uniqueId('listbox-item-'));
841
+ }
842
+ return this.itemIds.get(key);
843
+ },
844
+ scrollActiveItemIntoView() {
845
+ const listContainer = this.$refs.list;
846
+ if (!this.activeItemId || !this.searchable || !listContainer) return;
847
+ const activeElement = document.getElementById(this.activeItemId);
848
+ if (!activeElement) return;
849
+ const containerRect = listContainer.getBoundingClientRect();
850
+ const itemRect = activeElement.getBoundingClientRect();
851
+ const itemTop = activeElement.offsetTop;
852
+ const padding = 30;
853
+
854
+ // If item is above the visible area
855
+ if (itemRect.top < containerRect.top) {
856
+ listContainer.scrollTo({
857
+ top: itemTop - padding,
858
+ behavior: 'smooth'
859
+ });
860
+ }
861
+
862
+ // If item is below the visible area
863
+ else if (itemRect.bottom > containerRect.bottom) {
864
+ listContainer.scrollTo({
865
+ top: itemTop - containerRect.height + activeElement.offsetHeight + padding,
866
+ behavior: 'smooth'
867
+ });
868
+ }
869
+ }
771
870
  }
772
871
  };
773
872
 
@@ -775,7 +874,9 @@ var script = {
775
874
  const __vue_script__ = script;
776
875
 
777
876
  /* template */
778
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-base-dropdown',{ref:"baseDropdown",attrs:{"aria-haspopup":"listbox","aria-labelledby":_vm.toggleAriaLabelledBy,"block":_vm.block,"toggle-id":_vm.toggleIdComputed,"toggle-text":_vm.listboxToggleText,"toggle-class":_vm.toggleButtonClasses,"text-sr-only":_vm.textSrOnly,"category":_vm.category,"variant":_vm.variant,"size":_vm.size,"icon":_vm.icon,"disabled":_vm.disabled,"loading":_vm.loading,"no-caret":_vm.noCaret,"placement":_vm.placement,"offset":_vm.dropdownOffset,"fluid-width":_vm.fluidWidth,"positioning-strategy":_vm.positioningStrategy},on:_vm._d({},[_vm.$options.events.GL_DROPDOWN_SHOWN,_vm.onShow,_vm.$options.events.GL_DROPDOWN_HIDDEN,_vm.onHide]),scopedSlots:_vm._u([(_vm.hasCustomToggle)?{key:"toggle",fn:function(){return [_vm._t("toggle")]},proxy:true}:null],null,true)},[_vm._v(" "),(_vm.headerText)?_c('div',{staticClass:"gl-flex gl-min-h-8 gl-items-center !gl-p-4",class:_vm.$options.HEADER_ITEMS_BORDER_CLASSES},[_c('div',{staticClass:"gl-grow gl-pr-2 gl-text-sm gl-font-bold gl-text-strong",attrs:{"id":_vm.headerId,"data-testid":"listbox-header-text"}},[_vm._v("\n "+_vm._s(_vm.headerText)+"\n ")]),_vm._v(" "),(_vm.showResetButton)?_c('gl-button',{staticClass:"!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset",attrs:{"category":"tertiary","size":"small","data-testid":"listbox-reset-button"},on:{"click":_vm.onResetButtonClicked}},[_vm._v("\n "+_vm._s(_vm.resetButtonLabel)+"\n ")]):_vm._e(),_vm._v(" "),(_vm.showSelectAllButton)?_c('gl-button',{staticClass:"!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset",attrs:{"category":"tertiary","size":"small","data-testid":"listbox-select-all-button"},on:{"click":_vm.onSelectAllButtonClicked}},[_vm._v("\n "+_vm._s(_vm.showSelectAllButtonLabel)+"\n ")]):_vm._e()],1):_vm._e(),_vm._v(" "),(_vm.searchable)?_c('div',{class:_vm.$options.HEADER_ITEMS_BORDER_CLASSES},[_c('gl-listbox-search-input',{ref:"searchBox",class:{ 'gl-listbox-topmost': !_vm.headerText },attrs:{"data-testid":"listbox-search-input","placeholder":_vm.searchPlaceholder},on:{"input":_vm.search,"keydown":[function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"enter",13,$event.key,"Enter")){ return null; }$event.preventDefault();},_vm.onKeydown]},model:{value:(_vm.searchStr),callback:function ($$v) {_vm.searchStr=$$v;},expression:"searchStr"}}),_vm._v(" "),(_vm.searching)?_c('gl-loading-icon',{staticClass:"gl-my-3",attrs:{"data-testid":"listbox-search-loader","size":"md"}}):_vm._e()],1):_vm._e(),_vm._v(" "),(_vm.showList)?_c(_vm.listboxTag,{ref:"list",tag:"component",staticClass:"gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay",class:_vm.listboxClasses,attrs:{"id":_vm.listboxId,"aria-labelledby":_vm.listAriaLabelledBy || _vm.headerId || _vm.toggleIdComputed,"role":"listbox","tabindex":"0"},on:{"keydown":_vm.onKeydown}},[_c(_vm.itemTag,{tag:"component",staticClass:"top-scrim-wrapper",attrs:{"aria-hidden":"true","data-testid":"top-scrim"}},[_c('div',{staticClass:"top-scrim",class:{ 'top-scrim-light': !_vm.hasHeader, 'top-scrim-dark': _vm.hasHeader }})]),_vm._v(" "),_c(_vm.itemTag,{ref:"top-boundary",tag:"component",attrs:{"aria-hidden":"true"}}),_vm._v(" "),_vm._l((_vm.items),function(item,index){return [(_vm.isOption(item))?[_c('gl-listbox-item',_vm._b({key:_vm.listboxItemKey(item),attrs:{"data-testid":("listbox-item-" + (item.value)),"is-highlighted":_vm.isHighlighted(item),"is-selected":_vm.isSelected(item),"is-focused":_vm.isFocused(item),"is-check-centered":_vm.isCheckCentered},on:{"select":function($event){return _vm.onSelect(item, $event)}}},'gl-listbox-item',_vm.listboxItemMoreItemsAriaAttributes(index),false),[_vm._t("list-item",function(){return [_vm._v("\n "+_vm._s(item.text)+"\n ")]},{"item":item})],2)]:[_c('gl-listbox-group',{key:item.text,class:_vm.groupClasses(index),attrs:{"name":item.text,"text-sr-only":item.textSrOnly},scopedSlots:_vm._u([(_vm.$scopedSlots['group-label'])?{key:"group-label",fn:function(){return [_vm._t("group-label",null,{"group":item})]},proxy:true}:null],null,true)},[_vm._v(" "),_vm._l((item.options),function(option){return _c('gl-listbox-item',{key:_vm.listboxItemKey(option),attrs:{"data-testid":("listbox-item-" + (option.value)),"is-highlighted":_vm.isHighlighted(option),"is-selected":_vm.isSelected(option),"is-focused":_vm.isFocused(option),"is-check-centered":_vm.isCheckCentered},on:{"select":function($event){return _vm.onSelect(option, $event)}}},[_vm._t("list-item",function(){return [_vm._v("\n "+_vm._s(option.text)+"\n ")]},{"item":option})],2)})],2)]]}),_vm._v(" "),(_vm.infiniteScrollLoading)?_c(_vm.itemTag,{tag:"component"},[_c('gl-loading-icon',{staticClass:"gl-my-3",attrs:{"data-testid":"listbox-infinite-scroll-loader","size":"md"}})],1):_vm._e(),_vm._v(" "),(_vm.showIntersectionObserver)?_c('gl-intersection-observer',{on:{"appear":_vm.onIntersectionObserverAppear}}):_vm._e(),_vm._v(" "),_c(_vm.itemTag,{ref:"bottom-boundary",tag:"component",attrs:{"aria-hidden":"true"}}),_vm._v(" "),_c(_vm.itemTag,{tag:"component",staticClass:"bottom-scrim-wrapper",attrs:{"aria-hidden":"true","data-testid":"bottom-scrim"}},[_c('div',{staticClass:"bottom-scrim",class:{ '!gl-rounded-none': _vm.hasFooter }})])],2):_vm._e(),_vm._v(" "),(_vm.announceSRSearchResults)?_c('span',{staticClass:"gl-sr-only",attrs:{"data-testid":"listbox-number-of-results","aria-live":"assertive"}},[_vm._t("search-summary-sr-only",function(){return [_vm._v("\n "+_vm._s(_vm.srOnlyResultsLabel(_vm.flattenedOptions.length))+"\n ")]})],2):(_vm.showNoResultsText)?_c('div',{staticClass:"gl-py-3 gl-pl-7 gl-pr-5 gl-text-base gl-text-subtle",attrs:{"aria-live":"assertive","data-testid":"listbox-no-results-text"}},[_vm._v("\n "+_vm._s(_vm.noResultsText)+"\n ")]):_vm._e(),_vm._v(" "),_vm._t("footer")],2)};
877
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('gl-base-dropdown',{ref:"baseDropdown",attrs:{"aria-haspopup":"listbox","aria-labelledby":_vm.toggleAriaLabelledBy,"block":_vm.block,"toggle-id":_vm.toggleIdComputed,"toggle-text":_vm.listboxToggleText,"toggle-class":_vm.toggleButtonClasses,"text-sr-only":_vm.textSrOnly,"category":_vm.category,"variant":_vm.variant,"size":_vm.size,"icon":_vm.icon,"disabled":_vm.disabled,"loading":_vm.loading,"no-caret":_vm.noCaret,"placement":_vm.placement,"offset":_vm.dropdownOffset,"fluid-width":_vm.fluidWidth,"positioning-strategy":_vm.positioningStrategy},on:_vm._d({},[_vm.$options.events.GL_DROPDOWN_SHOWN,_vm.onShow,_vm.$options.events.GL_DROPDOWN_HIDDEN,_vm.onHide]),scopedSlots:_vm._u([(_vm.hasCustomToggle)?{key:"toggle",fn:function(){return [_vm._t("toggle")]},proxy:true}:null,{key:"default",fn:function(ref){
878
+ var visible = ref.visible;
879
+ return [(_vm.headerText)?_c('div',{staticClass:"gl-flex gl-min-h-8 gl-items-center !gl-p-4",class:_vm.$options.HEADER_ITEMS_BORDER_CLASSES},[_c('div',{staticClass:"gl-grow gl-pr-2 gl-text-sm gl-font-bold gl-text-strong",attrs:{"id":_vm.headerId,"data-testid":"listbox-header-text"}},[_vm._v("\n "+_vm._s(_vm.headerText)+"\n ")]),_vm._v(" "),(_vm.showResetButton)?_c('gl-button',{staticClass:"!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset",attrs:{"category":"tertiary","size":"small","data-testid":"listbox-reset-button"},on:{"click":_vm.onResetButtonClicked}},[_vm._v("\n "+_vm._s(_vm.resetButtonLabel)+"\n ")]):_vm._e(),_vm._v(" "),(_vm.showSelectAllButton)?_c('gl-button',{staticClass:"!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset",attrs:{"category":"tertiary","size":"small","data-testid":"listbox-select-all-button"},on:{"click":_vm.onSelectAllButtonClicked}},[_vm._v("\n "+_vm._s(_vm.showSelectAllButtonLabel)+"\n ")]):_vm._e()],1):_vm._e(),_vm._v(" "),(_vm.searchable)?_c('div',{class:_vm.$options.HEADER_ITEMS_BORDER_CLASSES},[_c('gl-listbox-search-input',{ref:"searchBox",class:{ 'gl-listbox-topmost': !_vm.headerText },attrs:{"id":_vm.searchInputId,"data-testid":"listbox-search-input","role":"combobox","aria-expanded":String(visible),"aria-controls":_vm.listboxId,"aria-activedescendant":_vm.activeItemId,"aria-haspopup":"listbox","placeholder":_vm.searchPlaceholder},on:{"input":_vm.search,"keydown":[function($event){if(!$event.type.indexOf('key')&&_vm._k($event.keyCode,"enter",13,$event.key,"Enter")){ return null; }$event.preventDefault();},_vm.onKeydown]},model:{value:(_vm.searchStr),callback:function ($$v) {_vm.searchStr=$$v;},expression:"searchStr"}}),_vm._v(" "),(_vm.searching)?_c('gl-loading-icon',{staticClass:"gl-my-3",attrs:{"data-testid":"listbox-search-loader","size":"md"}}):_vm._e()],1):_vm._e(),_vm._v(" "),(_vm.showList)?_c(_vm.listboxTag,{ref:"list",tag:"component",staticClass:"gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay",class:_vm.listboxClasses,attrs:{"id":_vm.listboxId,"aria-busy":_vm.isBusy,"aria-labelledby":_vm.ariaLabelledByID,"aria-multiselectable":_vm.multiple ? 'true' : undefined,"role":"listbox","tabindex":"0"},on:{"keydown":_vm.onKeydown}},[_c(_vm.itemTag,{tag:"component",staticClass:"top-scrim-wrapper",attrs:{"aria-hidden":"true","data-testid":"top-scrim"}},[_c('div',{staticClass:"top-scrim",class:{ 'top-scrim-light': !_vm.hasHeader, 'top-scrim-dark': _vm.hasHeader }})]),_vm._v(" "),_c(_vm.itemTag,{ref:"top-boundary",tag:"component",attrs:{"aria-hidden":"true"}}),_vm._v(" "),_vm._l((_vm.items),function(item,index){return [(_vm.isOption(item))?[_c('gl-listbox-item',_vm._b({key:_vm.listboxItemKey(item),attrs:{"id":_vm.generateItemId(item),"data-testid":("listbox-item-" + (item.value)),"is-highlighted":_vm.isHighlighted(item),"is-selected":_vm.isSelected(item),"is-focused":_vm.isFocused(item),"is-check-centered":_vm.isCheckCentered},on:{"select":function($event){return _vm.onSelect(item, $event)}}},'gl-listbox-item',_vm.listboxItemMoreItemsAriaAttributes(index),false),[_vm._t("list-item",function(){return [_vm._v("\n "+_vm._s(item.text)+"\n ")]},{"item":item})],2)]:[_c('gl-listbox-group',{key:item.text,class:_vm.groupClasses(index),attrs:{"name":item.text,"text-sr-only":item.textSrOnly},scopedSlots:_vm._u([(_vm.$scopedSlots['group-label'])?{key:"group-label",fn:function(){return [_vm._t("group-label",null,{"group":item})]},proxy:true}:null],null,true)},[_vm._v(" "),_vm._l((item.options),function(option){return _c('gl-listbox-item',{key:_vm.listboxItemKey(option),attrs:{"id":_vm.generateItemId(option),"data-testid":("listbox-item-" + (option.value)),"is-highlighted":_vm.isHighlighted(option),"is-selected":_vm.isSelected(option),"is-focused":_vm.isFocused(option),"is-check-centered":_vm.isCheckCentered},on:{"select":function($event){return _vm.onSelect(option, $event)}}},[_vm._t("list-item",function(){return [_vm._v("\n "+_vm._s(option.text)+"\n ")]},{"item":option})],2)})],2)]]}),_vm._v(" "),(_vm.infiniteScrollLoading)?_c(_vm.itemTag,{tag:"component"},[_c('gl-loading-icon',{staticClass:"gl-my-3",attrs:{"data-testid":"listbox-infinite-scroll-loader","size":"md"}})],1):_vm._e(),_vm._v(" "),(_vm.showIntersectionObserver)?_c('gl-intersection-observer',{on:{"appear":_vm.onIntersectionObserverAppear}}):_vm._e(),_vm._v(" "),_c(_vm.itemTag,{ref:"bottom-boundary",tag:"component",attrs:{"aria-hidden":"true"}}),_vm._v(" "),_c(_vm.itemTag,{tag:"component",staticClass:"bottom-scrim-wrapper",attrs:{"aria-hidden":"true","data-testid":"bottom-scrim"}},[_c('div',{staticClass:"bottom-scrim",class:{ '!gl-rounded-none': _vm.hasFooter }})])],2):_vm._e(),_vm._v(" "),(_vm.announceSRSearchResults)?_c('span',{staticClass:"gl-sr-only",attrs:{"data-testid":"listbox-number-of-results","aria-live":"assertive"}},[_vm._t("search-summary-sr-only",function(){return [_vm._v("\n "+_vm._s(_vm.srOnlyResultsLabel(_vm.flattenedOptions.length))+"\n ")]})],2):_vm._e(),_vm._v(" "),(_vm.isBusy)?_c('span',{staticClass:"gl-sr-only",attrs:{"aria-live":"polite","data-testid":"listbox-loading-announcement"}},[_vm._v("\n "+_vm._s(_vm.loadingAnnouncementText)+"\n ")]):(_vm.showNoResultsText)?_c('div',{staticClass:"gl-py-3 gl-pl-7 gl-pr-5 gl-text-base gl-text-subtle",attrs:{"aria-live":"assertive","data-testid":"listbox-no-results-text"}},[_vm._v("\n "+_vm._s(_vm.noResultsText)+"\n ")]):_vm._e(),_vm._v(" "),_vm._t("footer")]}}],null,true)})};
779
880
  var __vue_staticRenderFns__ = [];
780
881
 
781
882
  /* style */
@@ -8,6 +8,7 @@ var script = {
8
8
  GlClearIconButton,
9
9
  GlIcon
10
10
  },
11
+ inheritAttrs: false,
11
12
  model: {
12
13
  prop: 'value',
13
14
  event: 'input'
@@ -58,7 +59,7 @@ var script = {
58
59
  const __vue_script__ = script;
59
60
 
60
61
  /* template */
61
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"gl-listbox-search"},[_c('gl-icon',{staticClass:"gl-listbox-search-icon",attrs:{"name":"search-sm","size":12}}),_vm._v(" "),_c('input',_vm._g({ref:"input",staticClass:"gl-listbox-search-input",attrs:{"type":"search","aria-label":_vm.placeholder,"placeholder":_vm.placeholder},domProps:{"value":_vm.value}},_vm.inputListeners)),_vm._v(" "),(_vm.hasValue)?_c('gl-clear-icon-button',{staticClass:"gl-listbox-search-clear-button",on:{"click":function($event){$event.stopPropagation();return _vm.clearInput.apply(null, arguments)}}}):_vm._e()],1)};
62
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"gl-listbox-search"},[_c('gl-icon',{staticClass:"gl-listbox-search-icon",attrs:{"name":"search-sm","size":12}}),_vm._v(" "),_c('input',_vm._g(_vm._b({ref:"input",staticClass:"gl-listbox-search-input",attrs:{"type":"search","aria-label":_vm.placeholder,"placeholder":_vm.placeholder},domProps:{"value":_vm.value}},'input',_vm.$attrs,false),_vm.inputListeners)),_vm._v(" "),(_vm.hasValue)?_c('gl-clear-icon-button',{staticClass:"gl-listbox-search-clear-button",on:{"click":function($event){$event.stopPropagation();return _vm.clearInput.apply(null, arguments)}}}):_vm._e()],1)};
62
63
  var __vue_staticRenderFns__ = [];
63
64
 
64
65
  /* style */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "113.4.0",
3
+ "version": "113.5.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -572,7 +572,7 @@ export default {
572
572
  >
573
573
  <div ref="dropdownArrow" class="gl-new-dropdown-arrow"></div>
574
574
  <div class="gl-new-dropdown-inner">
575
- <slot></slot>
575
+ <slot :visible="visible"></slot>
576
576
  </div>
577
577
  </div>
578
578
  </div>
@@ -26,7 +26,7 @@ import GlLoadingIcon from '../../loading_icon/loading_icon.vue';
26
26
  import GlIntersectionObserver from '../../../utilities/intersection_observer/intersection_observer.vue';
27
27
  import GlSearchBoxByType from '../../search_box_by_type/search_box_by_type.vue';
28
28
  import GlBaseDropdown from '../base_dropdown/base_dropdown.vue';
29
- import { translatePlural } from '../../../../utils/i18n';
29
+ import { translate, translatePlural } from '../../../../utils/i18n';
30
30
  import GlListboxItem from './listbox_item.vue';
31
31
  import GlListboxSearchInput from './listbox_search_input.vue';
32
32
  import GlListboxGroup from './listbox_group.vue';
@@ -373,13 +373,22 @@ export default {
373
373
  return {
374
374
  selectedValues: [],
375
375
  listboxId: uniqueId('listbox-'),
376
+ searchInputId: uniqueId('listbox-search-input-'),
376
377
  nextFocusedItemIndex: null,
377
378
  searchStr: '',
378
379
  topBoundaryVisible: true,
379
380
  bottomBoundaryVisible: true,
381
+ activeItemId: null,
382
+ itemIds: new Map(),
380
383
  };
381
384
  },
382
385
  computed: {
386
+ ariaLabelledByID() {
387
+ if (this.searchable) {
388
+ return this.searchInputId;
389
+ }
390
+ return this.listAriaLabelledBy || this.headerId || this.toggleIdComputed;
391
+ },
383
392
  toggleIdComputed() {
384
393
  return this.toggleId || uniqueId('dropdown-toggle-btn-');
385
394
  },
@@ -477,6 +486,9 @@ export default {
477
486
  showIntersectionObserver() {
478
487
  return this.infiniteScroll && !this.infiniteScrollLoading && !this.loading && !this.searching;
479
488
  },
489
+ isBusy() {
490
+ return this.infiniteScrollLoading || this.loading || this.searching;
491
+ },
480
492
  hasCustomToggle() {
481
493
  return Boolean(this.$scopedSlots.toggle);
482
494
  },
@@ -497,6 +509,24 @@ export default {
497
509
  hasFooter() {
498
510
  return Boolean(this.$scopedSlots.footer);
499
511
  },
512
+ loadingAnnouncementText() {
513
+ if (this.infiniteScrollLoading) {
514
+ return translate(
515
+ 'GlCollapsibleListbox.loadingAnnouncementText.loadingMoreItems',
516
+ 'Loading more items'
517
+ );
518
+ }
519
+ if (this.searching) {
520
+ return translate('GlCollapsibleListbox.loadingAnnouncementText.searching', 'Searching');
521
+ }
522
+ if (this.loading) {
523
+ return translate(
524
+ 'GlCollapsibleListbox.loadingAnnouncementText.loadingItems',
525
+ 'Loading items'
526
+ );
527
+ }
528
+ return '';
529
+ },
500
530
  },
501
531
  watch: {
502
532
  selected: {
@@ -595,6 +625,9 @@ export default {
595
625
  */
596
626
  if (this.searchHasOptions) {
597
627
  this.nextFocusedItemIndex = 0;
628
+ // Set activeItemId for the first item
629
+ const firstItem = this.flattenedOptions[0];
630
+ this.activeItemId = this.generateItemId(firstItem);
598
631
  }
599
632
  } else {
600
633
  this.focusItem(this.selectedIndices[0] ?? 0, this.getFocusableListItemElements());
@@ -615,54 +648,80 @@ export default {
615
648
  this.$emit(GL_DROPDOWN_HIDDEN);
616
649
  this.nextFocusedItemIndex = null;
617
650
  },
651
+ getNextIndex(currentIndex, keyCode, totalLength) {
652
+ // For UP: move up or wrap to end
653
+ if (keyCode === ARROW_UP) {
654
+ return currentIndex > 0 ? currentIndex - 1 : totalLength - 1;
655
+ }
656
+
657
+ // For DOWN: move down or wrap to start
658
+ return currentIndex < totalLength - 1 ? currentIndex + 1 : 0;
659
+ },
660
+ handleListNavigation(keyCode, elements) {
661
+ const currentIndex = this.nextFocusedItemIndex ?? -1;
662
+ const nextIndex = this.getNextIndex(currentIndex, keyCode, elements.length);
663
+ this.focusItem(nextIndex, elements, this.searchable);
664
+ },
618
665
  onKeydown(event) {
619
666
  const { code, target } = event;
620
667
  const elements = this.getFocusableListItemElements();
621
668
 
622
669
  if (elements.length < 1) return;
623
670
 
624
- let stop = true;
625
671
  const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
672
+ let stop = true;
626
673
 
627
- if (code === HOME) {
628
- if (isSearchInput) {
629
- return;
630
- }
631
- this.focusItem(0, elements);
632
- } else if (code === END) {
633
- if (isSearchInput) {
634
- return;
635
- }
636
- this.focusItem(elements.length - 1, elements);
637
- } else if (code === ARROW_UP) {
638
- if (isSearchInput) {
639
- return;
640
- }
641
- if (this.searchable && elements.indexOf(target) === 0) {
642
- this.focusSearchInput();
643
- if (!this.searchHasOptions) {
644
- this.nextFocusedItemIndex = null;
674
+ switch (code) {
675
+ case HOME:
676
+ // Jump to first item if searchable or not in search input
677
+ if (this.searchable || !isSearchInput) {
678
+ this.focusItem(0, elements, this.searchable);
645
679
  }
646
- } else {
647
- this.focusNextItem(event, elements, -1);
648
- }
649
- } else if (code === ARROW_DOWN) {
650
- if (isSearchInput) {
651
- this.focusItem(0, elements);
652
- } else {
653
- this.focusNextItem(event, elements, 1);
654
- }
655
- } else if (code === ENTER && isSearchInput) {
656
- if (this.searchHasOptions && elements.length > 0) {
657
- // Toggle selection state of the first item
658
- const firstItem = this.flattenedOptions[0];
659
- this.onSelect(firstItem, !this.isSelected(firstItem));
660
- }
661
- stop = true;
662
- } else {
663
- stop = false;
680
+ break;
681
+
682
+ case END:
683
+ // Jump to last item if searchable or not in search input
684
+ if (this.searchable || !isSearchInput) {
685
+ this.focusItem(elements.length - 1, elements, this.searchable);
686
+ }
687
+ break;
688
+
689
+ case ARROW_UP:
690
+ // Let default behavior work for non-searchable input
691
+ if (isSearchInput && !this.searchable) {
692
+ return;
693
+ }
694
+ this.handleListNavigation(ARROW_UP, elements);
695
+ break;
696
+
697
+ case ARROW_DOWN:
698
+ // Focus first item from search input, otherwise navigate down
699
+ if (isSearchInput && !this.searchable) {
700
+ this.focusItem(0, elements);
701
+ } else {
702
+ this.handleListNavigation(ARROW_DOWN, elements);
703
+ }
704
+ break;
705
+
706
+ case ENTER:
707
+ if (isSearchInput) {
708
+ // Toggle selection of highlighted item if one exists
709
+ if (elements.length > 0 && this.nextFocusedItemIndex !== null) {
710
+ const highlightedItem = this.flattenedOptions[this.nextFocusedItemIndex];
711
+ this.onSelect(highlightedItem, !this.isSelected(highlightedItem));
712
+ }
713
+ } else {
714
+ stop = false;
715
+ }
716
+ break;
717
+
718
+ default:
719
+ // Allow default behavior for unhandled keys
720
+ stop = false;
721
+ break;
664
722
  }
665
723
 
724
+ // Prevent default behavior for handled keys
666
725
  if (stop) {
667
726
  stopEvent(event);
668
727
  }
@@ -678,10 +737,25 @@ export default {
678
737
 
679
738
  this.focusItem(nextIndex, elements);
680
739
  },
681
- focusItem(index, elements) {
740
+ focusItem(index, elements, keepSearchFocused = false) {
682
741
  this.nextFocusedItemIndex = index;
683
742
 
684
- elements[index]?.focus();
743
+ // Always update the activeItemId when focus changes
744
+ const item = this.flattenedOptions[index];
745
+ if (item) {
746
+ this.activeItemId = this.generateItemId(item);
747
+ } else {
748
+ this.activeItemId = null;
749
+ }
750
+
751
+ // If we're not keeping the search focused, focus the item
752
+ if (!keepSearchFocused) {
753
+ elements[index]?.focus();
754
+ }
755
+
756
+ this.$nextTick(() => {
757
+ this.scrollActiveItemIntoView();
758
+ });
685
759
  },
686
760
  focusSearchInput() {
687
761
  this.$refs.searchBox.focusInput();
@@ -806,6 +880,41 @@ export default {
806
880
  this.scrollObserver = observer;
807
881
  },
808
882
  isOption,
883
+ generateItemId(item) {
884
+ const key = item.value === null ? ITEM_NULL_KEY : item.value;
885
+ if (!this.itemIds.has(key)) {
886
+ this.itemIds.set(key, uniqueId('listbox-item-'));
887
+ }
888
+ return this.itemIds.get(key);
889
+ },
890
+ scrollActiveItemIntoView() {
891
+ const listContainer = this.$refs.list;
892
+ if (!this.activeItemId || !this.searchable || !listContainer) return;
893
+
894
+ const activeElement = document.getElementById(this.activeItemId);
895
+ if (!activeElement) return;
896
+
897
+ const containerRect = listContainer.getBoundingClientRect();
898
+ const itemRect = activeElement.getBoundingClientRect();
899
+ const itemTop = activeElement.offsetTop;
900
+ const padding = 30;
901
+
902
+ // If item is above the visible area
903
+ if (itemRect.top < containerRect.top) {
904
+ listContainer.scrollTo({
905
+ top: itemTop - padding,
906
+ behavior: 'smooth',
907
+ });
908
+ }
909
+
910
+ // If item is below the visible area
911
+ else if (itemRect.bottom > containerRect.bottom) {
912
+ listContainer.scrollTo({
913
+ top: itemTop - containerRect.height + activeElement.offsetHeight + padding,
914
+ behavior: 'smooth',
915
+ });
916
+ }
917
+ },
809
918
  },
810
919
  };
811
920
  </script>
@@ -839,165 +948,191 @@ export default {
839
948
  <slot name="toggle"></slot>
840
949
  </template>
841
950
 
842
- <div
843
- v-if="headerText"
844
- class="gl-flex gl-min-h-8 gl-items-center !gl-p-4"
845
- :class="$options.HEADER_ITEMS_BORDER_CLASSES"
846
- >
951
+ <template #default="{ visible }">
847
952
  <div
848
- :id="headerId"
849
- class="gl-grow gl-pr-2 gl-text-sm gl-font-bold gl-text-strong"
850
- data-testid="listbox-header-text"
851
- >
852
- {{ headerText }}
853
- </div>
854
- <gl-button
855
- v-if="showResetButton"
856
- category="tertiary"
857
- class="!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset"
858
- size="small"
859
- data-testid="listbox-reset-button"
860
- @click="onResetButtonClicked"
953
+ v-if="headerText"
954
+ class="gl-flex gl-min-h-8 gl-items-center !gl-p-4"
955
+ :class="$options.HEADER_ITEMS_BORDER_CLASSES"
861
956
  >
862
- {{ resetButtonLabel }}
863
- </gl-button>
864
- <gl-button
865
- v-if="showSelectAllButton"
866
- category="tertiary"
867
- class="!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset"
868
- size="small"
869
- data-testid="listbox-select-all-button"
870
- @click="onSelectAllButtonClicked"
871
- >
872
- {{ showSelectAllButtonLabel }}
873
- </gl-button>
874
- </div>
875
-
876
- <div v-if="searchable" :class="$options.HEADER_ITEMS_BORDER_CLASSES">
877
- <gl-listbox-search-input
878
- ref="searchBox"
879
- v-model="searchStr"
880
- data-testid="listbox-search-input"
881
- :placeholder="searchPlaceholder"
882
- :class="{ 'gl-listbox-topmost': !headerText }"
883
- @input="search"
884
- @keydown.enter.prevent
885
- @keydown="onKeydown"
886
- />
887
- <gl-loading-icon
888
- v-if="searching"
889
- data-testid="listbox-search-loader"
890
- size="md"
891
- class="gl-my-3"
892
- />
893
- </div>
894
-
895
- <component
896
- :is="listboxTag"
897
- v-if="showList"
898
- :id="listboxId"
899
- ref="list"
900
- :aria-labelledby="listAriaLabelledBy || headerId || toggleIdComputed"
901
- role="listbox"
902
- class="gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay"
903
- :class="listboxClasses"
904
- tabindex="0"
905
- @keydown="onKeydown"
906
- >
907
- <component :is="itemTag" class="top-scrim-wrapper" aria-hidden="true" data-testid="top-scrim">
908
957
  <div
909
- class="top-scrim"
910
- :class="{ 'top-scrim-light': !hasHeader, 'top-scrim-dark': hasHeader }"
911
- ></div>
912
- </component>
913
- <component :is="itemTag" ref="top-boundary" aria-hidden="true" />
914
- <template v-for="(item, index) in items">
915
- <template v-if="isOption(item)">
916
- <gl-listbox-item
917
- :key="listboxItemKey(item)"
918
- :data-testid="`listbox-item-${item.value}`"
919
- :is-highlighted="isHighlighted(item)"
920
- :is-selected="isSelected(item)"
921
- :is-focused="isFocused(item)"
922
- :is-check-centered="isCheckCentered"
923
- v-bind="listboxItemMoreItemsAriaAttributes(index)"
924
- @select="onSelect(item, $event)"
925
- >
926
- <!-- @slot Custom template of the listbox item -->
927
- <slot name="list-item" :item="item">
928
- {{ item.text }}
929
- </slot>
930
- </gl-listbox-item>
931
- </template>
958
+ :id="headerId"
959
+ class="gl-grow gl-pr-2 gl-text-sm gl-font-bold gl-text-strong"
960
+ data-testid="listbox-header-text"
961
+ >
962
+ {{ headerText }}
963
+ </div>
964
+ <gl-button
965
+ v-if="showResetButton"
966
+ category="tertiary"
967
+ class="!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset"
968
+ size="small"
969
+ data-testid="listbox-reset-button"
970
+ @click="onResetButtonClicked"
971
+ >
972
+ {{ resetButtonLabel }}
973
+ </gl-button>
974
+ <gl-button
975
+ v-if="showSelectAllButton"
976
+ category="tertiary"
977
+ class="!gl-m-0 !gl-w-auto gl-max-w-1/2 gl-flex-shrink-0 gl-text-ellipsis !gl-px-2 !gl-text-sm focus:!gl-focus-inset"
978
+ size="small"
979
+ data-testid="listbox-select-all-button"
980
+ @click="onSelectAllButtonClicked"
981
+ >
982
+ {{ showSelectAllButtonLabel }}
983
+ </gl-button>
984
+ </div>
932
985
 
933
- <template v-else>
934
- <gl-listbox-group
935
- :key="item.text"
936
- :name="item.text"
937
- :text-sr-only="item.textSrOnly"
938
- :class="groupClasses(index)"
939
- >
940
- <template v-if="$scopedSlots['group-label']" #group-label>
941
- <!-- @slot Custom template for group names -->
942
- <slot name="group-label" :group="item"></slot>
943
- </template>
986
+ <div v-if="searchable" :class="$options.HEADER_ITEMS_BORDER_CLASSES">
987
+ <gl-listbox-search-input
988
+ :id="searchInputId"
989
+ ref="searchBox"
990
+ v-model="searchStr"
991
+ data-testid="listbox-search-input"
992
+ role="combobox"
993
+ :aria-expanded="String(visible)"
994
+ :aria-controls="listboxId"
995
+ :aria-activedescendant="activeItemId"
996
+ aria-haspopup="listbox"
997
+ :placeholder="searchPlaceholder"
998
+ :class="{ 'gl-listbox-topmost': !headerText }"
999
+ @input="search"
1000
+ @keydown.enter.prevent
1001
+ @keydown="onKeydown"
1002
+ />
1003
+ <gl-loading-icon
1004
+ v-if="searching"
1005
+ data-testid="listbox-search-loader"
1006
+ size="md"
1007
+ class="gl-my-3"
1008
+ />
1009
+ </div>
944
1010
 
1011
+ <component
1012
+ :is="listboxTag"
1013
+ v-if="showList"
1014
+ :id="listboxId"
1015
+ ref="list"
1016
+ :aria-busy="isBusy"
1017
+ :aria-labelledby="ariaLabelledByID"
1018
+ :aria-multiselectable="multiple ? 'true' : undefined"
1019
+ role="listbox"
1020
+ class="gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay"
1021
+ :class="listboxClasses"
1022
+ tabindex="0"
1023
+ @keydown="onKeydown"
1024
+ >
1025
+ <component
1026
+ :is="itemTag"
1027
+ class="top-scrim-wrapper"
1028
+ aria-hidden="true"
1029
+ data-testid="top-scrim"
1030
+ >
1031
+ <div
1032
+ class="top-scrim"
1033
+ :class="{ 'top-scrim-light': !hasHeader, 'top-scrim-dark': hasHeader }"
1034
+ ></div>
1035
+ </component>
1036
+ <component :is="itemTag" ref="top-boundary" aria-hidden="true" />
1037
+ <template v-for="(item, index) in items">
1038
+ <template v-if="isOption(item)">
945
1039
  <gl-listbox-item
946
- v-for="option in item.options"
947
- :key="listboxItemKey(option)"
948
- :data-testid="`listbox-item-${option.value}`"
949
- :is-highlighted="isHighlighted(option)"
950
- :is-selected="isSelected(option)"
951
- :is-focused="isFocused(option)"
1040
+ :id="generateItemId(item)"
1041
+ :key="listboxItemKey(item)"
1042
+ :data-testid="`listbox-item-${item.value}`"
1043
+ :is-highlighted="isHighlighted(item)"
1044
+ :is-selected="isSelected(item)"
1045
+ :is-focused="isFocused(item)"
952
1046
  :is-check-centered="isCheckCentered"
953
- @select="onSelect(option, $event)"
1047
+ v-bind="listboxItemMoreItemsAriaAttributes(index)"
1048
+ @select="onSelect(item, $event)"
954
1049
  >
955
1050
  <!-- @slot Custom template of the listbox item -->
956
- <slot name="list-item" :item="option">
957
- {{ option.text }}
1051
+ <slot name="list-item" :item="item">
1052
+ {{ item.text }}
958
1053
  </slot>
959
1054
  </gl-listbox-item>
960
- </gl-listbox-group>
1055
+ </template>
1056
+
1057
+ <template v-else>
1058
+ <gl-listbox-group
1059
+ :key="item.text"
1060
+ :name="item.text"
1061
+ :text-sr-only="item.textSrOnly"
1062
+ :class="groupClasses(index)"
1063
+ >
1064
+ <template v-if="$scopedSlots['group-label']" #group-label>
1065
+ <!-- @slot Custom template for group names -->
1066
+ <slot name="group-label" :group="item"></slot>
1067
+ </template>
1068
+
1069
+ <gl-listbox-item
1070
+ v-for="option in item.options"
1071
+ :id="generateItemId(option)"
1072
+ :key="listboxItemKey(option)"
1073
+ :data-testid="`listbox-item-${option.value}`"
1074
+ :is-highlighted="isHighlighted(option)"
1075
+ :is-selected="isSelected(option)"
1076
+ :is-focused="isFocused(option)"
1077
+ :is-check-centered="isCheckCentered"
1078
+ @select="onSelect(option, $event)"
1079
+ >
1080
+ <!-- @slot Custom template of the listbox item -->
1081
+ <slot name="list-item" :item="option">
1082
+ {{ option.text }}
1083
+ </slot>
1084
+ </gl-listbox-item>
1085
+ </gl-listbox-group>
1086
+ </template>
961
1087
  </template>
962
- </template>
963
- <component :is="itemTag" v-if="infiniteScrollLoading">
964
- <gl-loading-icon data-testid="listbox-infinite-scroll-loader" size="md" class="gl-my-3" />
1088
+ <component :is="itemTag" v-if="infiniteScrollLoading">
1089
+ <gl-loading-icon data-testid="listbox-infinite-scroll-loader" size="md" class="gl-my-3" />
1090
+ </component>
1091
+ <gl-intersection-observer
1092
+ v-if="showIntersectionObserver"
1093
+ @appear="onIntersectionObserverAppear"
1094
+ />
1095
+ <component :is="itemTag" ref="bottom-boundary" aria-hidden="true" />
1096
+ <component
1097
+ :is="itemTag"
1098
+ class="bottom-scrim-wrapper"
1099
+ aria-hidden="true"
1100
+ data-testid="bottom-scrim"
1101
+ >
1102
+ <div class="bottom-scrim" :class="{ '!gl-rounded-none': hasFooter }"></div>
1103
+ </component>
965
1104
  </component>
966
- <gl-intersection-observer
967
- v-if="showIntersectionObserver"
968
- @appear="onIntersectionObserverAppear"
969
- />
970
- <component :is="itemTag" ref="bottom-boundary" aria-hidden="true" />
971
- <component
972
- :is="itemTag"
973
- class="bottom-scrim-wrapper"
974
- aria-hidden="true"
975
- data-testid="bottom-scrim"
1105
+ <span
1106
+ v-if="announceSRSearchResults"
1107
+ data-testid="listbox-number-of-results"
1108
+ class="gl-sr-only"
1109
+ aria-live="assertive"
976
1110
  >
977
- <div class="bottom-scrim" :class="{ '!gl-rounded-none': hasFooter }"></div>
978
- </component>
979
- </component>
980
- <span
981
- v-if="announceSRSearchResults"
982
- data-testid="listbox-number-of-results"
983
- class="gl-sr-only"
984
- aria-live="assertive"
985
- >
986
- <!-- @slot Text read by screen reader announcing a number of search results -->
987
- <slot name="search-summary-sr-only">
988
- {{ srOnlyResultsLabel(flattenedOptions.length) }}
989
- </slot>
990
- </span>
991
-
992
- <div
993
- v-else-if="showNoResultsText"
994
- aria-live="assertive"
995
- class="gl-py-3 gl-pl-7 gl-pr-5 gl-text-base gl-text-subtle"
996
- data-testid="listbox-no-results-text"
997
- >
998
- {{ noResultsText }}
999
- </div>
1000
- <!-- @slot Content to display in dropdown footer -->
1001
- <slot name="footer"></slot>
1111
+ <!-- @slot Text read by screen reader announcing a number of search results -->
1112
+ <slot name="search-summary-sr-only">
1113
+ {{ srOnlyResultsLabel(flattenedOptions.length) }}
1114
+ </slot>
1115
+ </span>
1116
+ <span
1117
+ v-if="isBusy"
1118
+ class="gl-sr-only"
1119
+ aria-live="polite"
1120
+ data-testid="listbox-loading-announcement"
1121
+ >
1122
+ {{ loadingAnnouncementText }}
1123
+ </span>
1124
+
1125
+ <div
1126
+ v-else-if="showNoResultsText"
1127
+ aria-live="assertive"
1128
+ class="gl-py-3 gl-pl-7 gl-pr-5 gl-text-base gl-text-subtle"
1129
+ data-testid="listbox-no-results-text"
1130
+ >
1131
+ {{ noResultsText }}
1132
+ </div>
1133
+
1134
+ <!-- @slot Content to display in dropdown footer -->
1135
+ <slot name="footer"></slot>
1136
+ </template>
1002
1137
  </gl-base-dropdown>
1003
1138
  </template>
@@ -8,6 +8,7 @@ export default {
8
8
  GlClearIconButton,
9
9
  GlIcon,
10
10
  },
11
+ inheritAttrs: false,
11
12
  model: {
12
13
  prop: 'value',
13
14
  event: 'input',
@@ -65,6 +66,7 @@ export default {
65
66
  class="gl-listbox-search-input"
66
67
  :aria-label="placeholder"
67
68
  :placeholder="placeholder"
69
+ v-bind="$attrs"
68
70
  v-on="inputListeners"
69
71
  />
70
72
  <gl-clear-icon-button
package/translations.js CHANGED
@@ -10,6 +10,9 @@ export default {
10
10
  'GlChartLegend.current': 'Current',
11
11
  'GlChartLegend.max': 'Max',
12
12
  'GlChartLegend.min': 'Min',
13
+ 'GlCollapsibleListbox.loadingAnnouncementText.loadingItems': 'Loading items',
14
+ 'GlCollapsibleListbox.loadingAnnouncementText.loadingMoreItems': 'Loading more items',
15
+ 'GlCollapsibleListbox.loadingAnnouncementText.searching': 'Searching',
13
16
  'GlCollapsibleListbox.srOnlyResultsLabel': null,
14
17
  'GlDatepicker.monthLabel': 'Month',
15
18
  'GlDatepicker.yearLabel': 'Year',