ListControl.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. export class ListDelegate{createElementForItem(item){}
  2. heightForItem(item){}
  3. isItemSelectable(item){}
  4. selectedItemChanged(from,to,fromElement,toElement){}}
  5. export const ListMode={NonViewport:Symbol('UI.ListMode.NonViewport'),EqualHeightItems:Symbol('UI.ListMode.EqualHeightItems'),VariousHeightItems:Symbol('UI.ListMode.VariousHeightItems')};export default class ListControl{constructor(model,delegate,mode){this.element=createElement('div');this.element.style.overflowY='auto';this._topElement=this.element.createChild('div');this._bottomElement=this.element.createChild('div');this._firstIndex=0;this._lastIndex=0;this._renderedHeight=0;this._topHeight=0;this._bottomHeight=0;this._model=model;this._model.addEventListener(UI.ListModel.Events.ItemsReplaced,this._replacedItemsInRange,this);this._itemToElement=new Map();this._selectedIndex=-1;this._selectedItem=null;this.element.tabIndex=-1;this.element.addEventListener('click',this._onClick.bind(this),false);this.element.addEventListener('keydown',this._onKeyDown.bind(this),false);this._delegate=delegate;this._mode=mode||UI.ListMode.EqualHeightItems;this._fixedHeight=0;this._variableOffsets=new Int32Array(0);this._clearContents();if(this._mode!==UI.ListMode.NonViewport){this.element.addEventListener('scroll',()=>{this._updateViewport(this.element.scrollTop,this.element.offsetHeight);},false);}}
  6. setModel(model){this._itemToElement.clear();const length=this._model.length;this._model.removeEventListener(UI.ListModel.Events.ItemsReplaced,this._replacedItemsInRange,this);this._model=model;this._model.addEventListener(UI.ListModel.Events.ItemsReplaced,this._replacedItemsInRange,this);this.invalidateRange(0,length);}
  7. _replacedItemsInRange(event){const data=(event.data);const from=data.index;const to=from+data.removed.length;const oldSelectedItem=this._selectedItem;const oldSelectedElement=oldSelectedItem?(this._itemToElement.get(oldSelectedItem)||null):null;for(let i=0;i<data.removed.length;i++){this._itemToElement.delete(data.removed[i]);}
  8. this._invalidate(from,to,data.inserted);if(this._selectedIndex>=to){this._selectedIndex+=data.inserted-(to-from);this._selectedItem=this._model.at(this._selectedIndex);}else if(this._selectedIndex>=from){let index=this._findFirstSelectable(from+data.inserted,+1,false);if(index===-1){index=this._findFirstSelectable(from-1,-1,false);}
  9. this._select(index,oldSelectedItem,oldSelectedElement);}}
  10. refreshItem(item){const index=this._model.indexOf(item);if(index===-1){console.error('Item to refresh is not present');return;}
  11. this.refreshItemByIndex(index);}
  12. refreshItemByIndex(index){const item=this._model.at(index);this._itemToElement.delete(item);this.invalidateRange(index,index+1);if(this._selectedIndex!==-1){this._select(this._selectedIndex,null,null);}}
  13. invalidateRange(from,to){this._invalidate(from,to,to-from);}
  14. viewportResized(){if(this._mode===UI.ListMode.NonViewport){return;}
  15. const scrollTop=this.element.scrollTop;const viewportHeight=this.element.offsetHeight;this._clearViewport();this._updateViewport(Number.constrain(scrollTop,0,this._totalHeight()-viewportHeight),viewportHeight);}
  16. invalidateItemHeight(){if(this._mode!==UI.ListMode.EqualHeightItems){console.error('Only supported in equal height items mode');return;}
  17. this._fixedHeight=0;if(this._model.length){this._itemToElement.clear();this._invalidate(0,this._model.length,this._model.length);}}
  18. itemForNode(node){while(node&&node.parentNodeOrShadowHost()!==this.element){node=node.parentNodeOrShadowHost();}
  19. if(!node){return null;}
  20. const element=(node);const index=this._model.findIndex(item=>this._itemToElement.get(item)===element);return index!==-1?this._model.at(index):null;}
  21. scrollItemIntoView(item,center){const index=this._model.indexOf(item);if(index===-1){console.error('Attempt to scroll onto missing item');return;}
  22. this._scrollIntoView(index,center);}
  23. selectedItem(){return this._selectedItem;}
  24. selectedIndex(){return this._selectedIndex;}
  25. selectItem(item,center,dontScroll){let index=-1;if(item!==null){index=this._model.indexOf(item);if(index===-1){console.error('Attempt to select missing item');return;}
  26. if(!this._delegate.isItemSelectable(item)){console.error('Attempt to select non-selectable item');return;}}
  27. if(index!==-1&&!dontScroll){this._scrollIntoView(index,center);}
  28. if(this._selectedIndex!==index){this._select(index);}}
  29. selectPreviousItem(canWrap,center){if(this._selectedIndex===-1&&!canWrap){return false;}
  30. let index=this._selectedIndex===-1?this._model.length-1:this._selectedIndex-1;index=this._findFirstSelectable(index,-1,!!canWrap);if(index!==-1){this._scrollIntoView(index,center);this._select(index);return true;}
  31. return false;}
  32. selectNextItem(canWrap,center){if(this._selectedIndex===-1&&!canWrap){return false;}
  33. let index=this._selectedIndex===-1?0:this._selectedIndex+1;index=this._findFirstSelectable(index,+1,!!canWrap);if(index!==-1){this._scrollIntoView(index,center);this._select(index);return true;}
  34. return false;}
  35. selectItemPreviousPage(center){if(this._mode===UI.ListMode.NonViewport){return false;}
  36. let index=this._selectedIndex===-1?this._model.length-1:this._selectedIndex;index=this._findPageSelectable(index,-1);if(index!==-1){this._scrollIntoView(index,center);this._select(index);return true;}
  37. return false;}
  38. selectItemNextPage(center){if(this._mode===UI.ListMode.NonViewport){return false;}
  39. let index=this._selectedIndex===-1?0:this._selectedIndex;index=this._findPageSelectable(index,+1);if(index!==-1){this._scrollIntoView(index,center);this._select(index);return true;}
  40. return false;}
  41. _scrollIntoView(index,center){if(this._mode===UI.ListMode.NonViewport){this._elementAtIndex(index).scrollIntoViewIfNeeded(!!center);return;}
  42. const top=this._offsetAtIndex(index);const bottom=this._offsetAtIndex(index+1);const viewportHeight=this.element.offsetHeight;if(center){const scrollTo=(top+bottom)/2-viewportHeight/2;this._updateViewport(Number.constrain(scrollTo,0,this._totalHeight()-viewportHeight),viewportHeight);return;}
  43. const scrollTop=this.element.scrollTop;if(top<scrollTop){this._updateViewport(top,viewportHeight);}else if(bottom>scrollTop+viewportHeight){this._updateViewport(bottom-viewportHeight,viewportHeight);}}
  44. _onClick(event){const item=this.itemForNode((event.target));if(item&&this._delegate.isItemSelectable(item)){this.selectItem(item);}}
  45. _onKeyDown(event){let selected=false;switch(event.key){case'ArrowUp':selected=this.selectPreviousItem(true,false);break;case'ArrowDown':selected=this.selectNextItem(true,false);break;case'PageUp':selected=this.selectItemPreviousPage(false);break;case'PageDown':selected=this.selectItemNextPage(false);break;}
  46. if(selected){event.consume();}}
  47. _totalHeight(){return this._offsetAtIndex(this._model.length);}
  48. _indexAtOffset(offset){if(this._mode===UI.ListMode.NonViewport){throw'There should be no offset conversions in non-viewport mode';}
  49. if(!this._model.length||offset<0){return 0;}
  50. if(this._mode===UI.ListMode.VariousHeightItems){return Math.min(this._model.length-1,this._variableOffsets.lowerBound(offset,undefined,0,this._model.length));}
  51. if(!this._fixedHeight){this._measureHeight();}
  52. return Math.min(this._model.length-1,Math.floor(offset/this._fixedHeight));}
  53. _elementAtIndex(index){const item=this._model.at(index);let element=this._itemToElement.get(item);if(!element){element=this._delegate.createElementForItem(item);this._itemToElement.set(item,element);}
  54. return element;}
  55. _offsetAtIndex(index){if(this._mode===UI.ListMode.NonViewport){throw'There should be no offset conversions in non-viewport mode';}
  56. if(!this._model.length){return 0;}
  57. if(this._mode===UI.ListMode.VariousHeightItems){return this._variableOffsets[index];}
  58. if(!this._fixedHeight){this._measureHeight();}
  59. return index*this._fixedHeight;}
  60. _measureHeight(){this._fixedHeight=this._delegate.heightForItem(this._model.at(0));if(!this._fixedHeight){this._fixedHeight=UI.measurePreferredSize(this._elementAtIndex(0),this.element).height;}}
  61. _select(index,oldItem,oldElement){if(oldItem===undefined){oldItem=this._selectedItem;}
  62. if(oldElement===undefined){oldElement=this._itemToElement.get(oldItem)||null;}
  63. this._selectedIndex=index;this._selectedItem=index===-1?null:this._model.at(index);const newItem=this._selectedItem;const newElement=this._selectedIndex!==-1?this._elementAtIndex(index):null;if(oldElement){UI.ARIAUtils.setSelected(oldElement,false);}
  64. if(newElement){UI.ARIAUtils.setSelected(newElement,true);}
  65. UI.ARIAUtils.setActiveDescendant(this.element,newElement);this._delegate.selectedItemChanged(oldItem,newItem,(oldElement),newElement);}
  66. _findFirstSelectable(index,direction,canWrap){const length=this._model.length;if(!length){return-1;}
  67. for(let step=0;step<=length;step++){if(index<0||index>=length){if(!canWrap){return-1;}
  68. index=(index+length)%length;}
  69. if(this._delegate.isItemSelectable(this._model.at(index))){return index;}
  70. index+=direction;}
  71. return-1;}
  72. _findPageSelectable(index,direction){let lastSelectable=-1;const startOffset=this._offsetAtIndex(index);const viewportHeight=this.element.offsetHeight-1;while(index>=0&&index<this._model.length){if(this._delegate.isItemSelectable(this._model.at(index))){if(Math.abs(this._offsetAtIndex(index)-startOffset)>=viewportHeight){return index;}
  73. lastSelectable=index;}
  74. index+=direction;}
  75. return lastSelectable;}
  76. _reallocateVariableOffsets(length,copyTo){if(this._variableOffsets.length<length){const variableOffsets=new Int32Array(Math.max(length,this._variableOffsets.length*2));variableOffsets.set(this._variableOffsets.slice(0,copyTo),0);this._variableOffsets=variableOffsets;}else if(this._variableOffsets.length>=2*length){const variableOffsets=new Int32Array(length);variableOffsets.set(this._variableOffsets.slice(0,copyTo),0);this._variableOffsets=variableOffsets;}}
  77. _invalidate(from,to,inserted){if(this._mode===UI.ListMode.NonViewport){this._invalidateNonViewportMode(from,to-from,inserted);return;}
  78. if(this._mode===UI.ListMode.VariousHeightItems){this._reallocateVariableOffsets(this._model.length+1,from+1);for(let i=from+1;i<=this._model.length;i++){this._variableOffsets[i]=this._variableOffsets[i-1]+this._delegate.heightForItem(this._model.at(i-1));}}
  79. const viewportHeight=this.element.offsetHeight;const totalHeight=this._totalHeight();const scrollTop=this.element.scrollTop;if(this._renderedHeight<viewportHeight||totalHeight<viewportHeight){this._clearViewport();this._updateViewport(Number.constrain(scrollTop,0,totalHeight-viewportHeight),viewportHeight);return;}
  80. const heightDelta=totalHeight-this._renderedHeight;if(to<=this._firstIndex){const topHeight=this._topHeight+heightDelta;this._topElement.style.height=topHeight+'px';this.element.scrollTop=scrollTop+heightDelta;this._topHeight=topHeight;this._renderedHeight=totalHeight;const indexDelta=inserted-(to-from);this._firstIndex+=indexDelta;this._lastIndex+=indexDelta;return;}
  81. if(from>=this._lastIndex){const bottomHeight=this._bottomHeight+heightDelta;this._bottomElement.style.height=bottomHeight+'px';this._bottomHeight=bottomHeight;this._renderedHeight=totalHeight;return;}
  82. this._clearViewport();this._updateViewport(Number.constrain(scrollTop,0,totalHeight-viewportHeight),viewportHeight);}
  83. _invalidateNonViewportMode(start,remove,add){let startElement=this._topElement;for(let index=0;index<start;index++){startElement=startElement.nextElementSibling;}
  84. while(remove--){startElement.nextElementSibling.remove();}
  85. while(add--){this.element.insertBefore(this._elementAtIndex(start+add),startElement.nextElementSibling);}}
  86. _clearViewport(){if(this._mode===UI.ListMode.NonViewport){console.error('There should be no viewport updates in non-viewport mode');return;}
  87. this._firstIndex=0;this._lastIndex=0;this._renderedHeight=0;this._topHeight=0;this._bottomHeight=0;this._clearContents();}
  88. _clearContents(){this._topElement.style.height='0';this._bottomElement.style.height='0';this.element.removeChildren();this.element.appendChild(this._topElement);this.element.appendChild(this._bottomElement);}
  89. _updateViewport(scrollTop,viewportHeight){if(this._mode===UI.ListMode.NonViewport){console.error('There should be no viewport updates in non-viewport mode');return;}
  90. const totalHeight=this._totalHeight();if(!totalHeight){this._firstIndex=0;this._lastIndex=0;this._topHeight=0;this._bottomHeight=0;this._renderedHeight=0;this._topElement.style.height='0';this._bottomElement.style.height='0';return;}
  91. const firstIndex=this._indexAtOffset(scrollTop-viewportHeight);const lastIndex=this._indexAtOffset(scrollTop+2*viewportHeight)+1;while(this._firstIndex<Math.min(firstIndex,this._lastIndex)){this._elementAtIndex(this._firstIndex).remove();this._firstIndex++;}
  92. while(this._lastIndex>Math.max(lastIndex,this._firstIndex)){this._elementAtIndex(this._lastIndex-1).remove();this._lastIndex--;}
  93. this._firstIndex=Math.min(this._firstIndex,lastIndex);this._lastIndex=Math.max(this._lastIndex,firstIndex);for(let index=this._firstIndex-1;index>=firstIndex;index--){const element=this._elementAtIndex(index);this.element.insertBefore(element,this._topElement.nextSibling);}
  94. for(let index=this._lastIndex;index<lastIndex;index++){const element=this._elementAtIndex(index);this.element.insertBefore(element,this._bottomElement);}
  95. this._firstIndex=firstIndex;this._lastIndex=lastIndex;this._topHeight=this._offsetAtIndex(firstIndex);this._topElement.style.height=this._topHeight+'px';this._bottomHeight=totalHeight-this._offsetAtIndex(lastIndex);this._bottomElement.style.height=this._bottomHeight+'px';this._renderedHeight=totalHeight;this.element.scrollTop=scrollTop;}}
  96. self.UI=self.UI||{};UI=UI||{};UI.ListControl=ListControl;UI.ListDelegate=ListDelegate;UI.ListMode=ListMode;