/// /// /// /// /// /// /// /// /// /// // namespace namespace cf { export const ControlElementsEvents = { ON_RESIZE: "cf-on-control-elements-resize", CHANGED: "cf-on-control-elements-changed" } export interface ControlElementsDTO{ height: number; } export interface IControlElementsOptions{ el: HTMLElement; cfReference: ConversationalForm; infoEl: HTMLElement; eventTarget: EventDispatcher; } export class ControlElements { private cfReference: ConversationalForm; private elements: Array; private eventTarget: EventDispatcher; private el: HTMLElement; private list: HTMLElement; private infoElement: HTMLElement; private currentControlElement: IControlElement; private animateInFromResponseTimer: any; private ignoreKeyboardInput: boolean = false; private rowIndex: number = -1; private columnIndex: number = 0; private tableableRows: Array>; private userInputUpdateCallback: () => void; private onChatReponsesUpdatedCallback: () => void; private onUserInputKeyChangeCallback: () => void; private onElementFocusCallback: () => void; private onScrollCallback: () => void; private onElementLoadedCallback: () => void; private onResizeCallback: () => void; private elementWidth: number = 0; private filterListNumberOfVisible: number = 0; private listScrollController: ScrollController; private listWidth: number = 0; public get active():boolean{ return this.elements && this.elements.length > 0; } public get focus():boolean{ if(!this.elements) return false; const elements: Array = this.getElements(); for (var i = 0; i < elements.length; i++) { let element: ControlElement = elements[i]; if(element.focus){ return true; } } return false; } public get highlighted():boolean{ if(!this.elements) return false; const elements: Array = this.getElements(); for (var i = 0; i < elements.length; i++) { let element: ControlElement = elements[i]; if(element.highlight){ return true; } } return false; } public set disabled(value: boolean){ if(value) this.list.classList.add("disabled"); else this.list.classList.remove("disabled"); } public get length(): number{ const elements: Array = this.getElements(); return elements.length; } constructor(options: IControlElementsOptions){ this.el = options.el; this.eventTarget = options.eventTarget; this.cfReference = options.cfReference; this.list = this.el.getElementsByTagName("cf-list")[0]; this.infoElement = options.infoEl; this.onScrollCallback = this.onScroll.bind(this); this.el.addEventListener('scroll', this.onScrollCallback, false); this.onResizeCallback = this.onResize.bind(this); window.addEventListener('resize', this.onResizeCallback, false); this.onElementFocusCallback = this.onElementFocus.bind(this); this.eventTarget.addEventListener(ControlElementEvents.ON_FOCUS, this.onElementFocusCallback, false); this.onElementLoadedCallback = this.onElementLoaded.bind(this); this.eventTarget.addEventListener(ControlElementEvents.ON_LOADED, this.onElementLoadedCallback, false); this.onChatReponsesUpdatedCallback = this.onChatReponsesUpdated.bind(this); this.eventTarget.addEventListener(ChatListEvents.CHATLIST_UPDATED, this.onChatReponsesUpdatedCallback, false); this.onUserInputKeyChangeCallback = this.onUserInputKeyChange.bind(this); this.eventTarget.addEventListener(UserInputEvents.KEY_CHANGE, this.onUserInputKeyChangeCallback, false); // user input update this.userInputUpdateCallback = this.onUserInputUpdate.bind(this); this.eventTarget.addEventListener(FlowEvents.USER_INPUT_UPDATE, this.userInputUpdateCallback, false); this.listScrollController = new ScrollController({ interactionListener: this.el, listToScroll: this.list, eventTarget: this.eventTarget, listNavButtons: this.el.getElementsByTagName("cf-list-button"), }); } private onScroll(event: Event){ // some times the tabbing will result in el scroll, reset this. this.el.scrollLeft = 0; } /** * @name onElementLoaded * when element is loaded, usally image loaded. */ private onElementLoaded(event: CustomEvent){ this.onResize(null); } private onElementFocus(event: CustomEvent){ const vector: ControlElementVector = event.detail; let x: number = (vector.x + vector.width < this.elementWidth ? 0 : vector.x - vector.width); x *= -1; this.updateRowColIndexFromVector(vector); this.listScrollController.setScroll(x, 0); } private updateRowColIndexFromVector(vector: ControlElementVector){ for (let i = 0; i < this.tableableRows.length; i++) { let items: Array = this.tableableRows[i]; for (let j = 0; j < items.length; j++) { let item: IControlElement = items[j]; if(item == vector.el){ this.rowIndex = i; this.columnIndex = j; break; } } } } private onChatReponsesUpdated(event:CustomEvent){ clearTimeout(this.animateInFromResponseTimer); // only show when user response if(!( event.detail).currentResponse.isRobotResponse){ this.animateInFromResponseTimer = setTimeout(() => { this.animateElementsIn(); }, this.cfReference.uiOptions.controlElementsInAnimationDelay); } } private onListChanged(){ // reflow this.list.offsetHeight; requestAnimationFrame(() => { ConversationalForm.illustrateFlow(this, "dispatch", ControlElementsEvents.CHANGED); this.eventTarget.dispatchEvent(new CustomEvent(ControlElementsEvents.CHANGED)); }) } private onUserInputKeyChange(event: CustomEvent){ if(this.ignoreKeyboardInput){ this.ignoreKeyboardInput = false; return; } const dto: InputKeyChangeDTO = event.detail; const userInput: UserTextInput = dto.dto.input; if(this.active){ const isNavKey: boolean = [Dictionary.keyCodes["left"], Dictionary.keyCodes["right"], Dictionary.keyCodes["down"], Dictionary.keyCodes["up"]].indexOf(dto.keyCode) != -1; const shouldFilter: boolean = dto.inputFieldActive && !isNavKey; if(shouldFilter){ // input field is active, so we should filter.. const dto: FlowDTO = ( event.detail).dto; const inputValue: string = ( dto.input).getInputValue(); this.filterElementsFrom(inputValue); }else{ if(dto.keyCode == Dictionary.keyCodes["left"]){ this.columnIndex--; }else if(dto.keyCode == Dictionary.keyCodes["right"]){ this.columnIndex++; }else if(dto.keyCode == Dictionary.keyCodes["down"]){ this.updateRowIndex(1); }else if(dto.keyCode == Dictionary.keyCodes["up"]){ this.updateRowIndex(-1); }else if(dto.keyCode == Dictionary.keyCodes["enter"] || dto.keyCode == Dictionary.keyCodes["space"]){ if(this.tableableRows[this.rowIndex] && this.tableableRows[this.rowIndex][this.columnIndex]){ this.tableableRows[this.rowIndex][this.columnIndex].el.click(); }else if(this.tableableRows[0] && this.tableableRows[0].length == 1){ // this is when only one element in a filter, then we click it! this.tableableRows[0][0].el.click(); } } if(!this.validateRowColIndexes()){ userInput.setFocusOnInput(); } } } if(!userInput.active && this.validateRowColIndexes() && this.tableableRows && (this.rowIndex == 0 || this.rowIndex == 1)){ this.tableableRows[this.rowIndex][this.columnIndex].focus = true; }else if(!userInput.active){ userInput.setFocusOnInput(); } } private validateRowColIndexes():boolean{ const maxRowIndex: number = (this.el.classList.contains("two-row") ? 1 : 0) if(this.rowIndex != -1 && this.tableableRows[this.rowIndex]){ // columnIndex is only valid if rowIndex is valid if(this.columnIndex < 0){ this.columnIndex = this.tableableRows[this.rowIndex].length - 1; } if(this.columnIndex > this.tableableRows[this.rowIndex].length - 1){ this.columnIndex = 0; } return true; }else{ this.resetTabList(); return false; } } private updateRowIndex(direction: number){ const oldRowIndex: number = this.rowIndex; this.rowIndex += direction; if(this.tableableRows[this.rowIndex]){ // when row index is changed we need to find the closest column element, we cannot expect them to be indexly aligned const centerX: number = this.tableableRows[oldRowIndex] ? this.tableableRows[oldRowIndex][this.columnIndex].positionVector.centerX : 0 const items: Array = this.tableableRows[this.rowIndex]; let currentDistance: number = 10000000000000; for (let i = 0; i < items.length; i++) { let element: IControlElement = items[i]; if(currentDistance > Math.abs(centerX - element.positionVector.centerX)){ currentDistance = Math.abs(centerX - element.positionVector.centerX); this.columnIndex = i; } } } } private resetTabList(){ this.rowIndex = -1; this.columnIndex = -1; } private onUserInputUpdate(event: CustomEvent){ this.el.classList.remove("animate-in"); this.infoElement.classList.remove("show"); if(this.elements){ const elements: Array = this.getElements(); for (var i = 0; i < elements.length; i++) { let element: ControlElement = elements[i]; element.animateOut(); } } } private filterElementsFrom(value:string){ const inputValuesLowerCase: Array = value.toLowerCase().split(" "); if(inputValuesLowerCase.indexOf("") != -1) inputValuesLowerCase.splice(inputValuesLowerCase.indexOf(""), 1); const elements: Array = this.getElements(); if(elements.length > 1){ // the type is not strong with this one.. let itemsVisible: Array = []; for (let i = 0; i < elements.length; i++) { let element: ControlElement = elements[i]; element.highlight = false; let elementVisibility: boolean = true; // check for all words of input for (let i = 0; i < inputValuesLowerCase.length; i++) { let inputWord: string = inputValuesLowerCase[i]; if(elementVisibility){ elementVisibility = element.value.toLowerCase().indexOf(inputWord) != -1; } } // set element visibility. element.visible = elementVisibility; if(elementVisibility && element.visible) itemsVisible.push(element); } // set feedback text for filter.. this.infoElement.innerHTML = itemsVisible.length == 0 ? Dictionary.get("input-no-filter").split("{input-value}").join(value) : ""; if(itemsVisible.length == 0){ this.infoElement.classList.add("show"); }else{ this.infoElement.classList.remove("show"); } // crude way of checking if list has changed... const hasListChanged: boolean = this.filterListNumberOfVisible != itemsVisible.length; if(hasListChanged){ this.animateElementsIn(); } this.filterListNumberOfVisible = itemsVisible.length; // highlight first item if(value != "" && this.filterListNumberOfVisible > 0) itemsVisible[0].highlight = true; } } public clickOnHighlighted(){ const elements: Array = this.getElements(); for (let i = 0; i < elements.length; i++) { let element: ControlElement = elements[i]; if(element.highlight){ element.el.click(); break; } } } public animateElementsIn(){ if(this.elements.length > 0){ this.resize(); // this.el.style.transition = 'height 0.35s ease-out 0.2s'; this.list.style.height = '0px'; setTimeout(() => { this.list.style.height = this.list.scrollHeight + 'px'; const elements: Array = this.getElements(); setTimeout(() => { if(elements.length > 0){ if(!this.el.classList.contains("animate-in")) this.el.classList.add("animate-in"); for (let i = 0; i < elements.length; i++) { let element: ControlElement = elements[i]; element.animateIn(); } } document.querySelector('.scrollableInner').classList.remove('scroll'); // Check if chatlist is scrolled to the bottom - if not we need to do it manually (pertains to Chrome) const scrollContainer:HTMLElement = document.querySelector('scrollable'); if (scrollContainer.scrollTop < scrollContainer.scrollHeight) { scrollContainer.scrollTop = scrollContainer.scrollHeight; } }, 300); }, 200); } } private getElements(): Array { if(this.elements && this.elements.length > 0 && this.elements[0].type == "OptionsList") return ( this.elements[0]).elements; return > this.elements; } /** * @name buildTabableRows * build the tabable array index */ private buildTabableRows(): void { this.tableableRows = []; this.resetTabList(); const elements: Array = this.getElements(); if(this.el.classList.contains("two-row")){ // two rows this.tableableRows[0] = []; this.tableableRows[1] = []; for (let i = 0; i < elements.length; i++) { let element: IControlElement = elements[i]; if(element.visible){ // crude way of checking if element is top row or bottom row.. if(element.positionVector.y < 30) this.tableableRows[0].push(element); else this.tableableRows[1].push(element); } } }else{ // single row this.tableableRows[0] = []; for (let i = 0; i < elements.length; i++) { let element: IControlElement = elements[i]; if(element.visible) this.tableableRows[0].push(element); } } } public resetAfterErrorMessage(){ this.currentControlElement = null; this.disabled = false; } public focusFrom(angle: string){ if(!this.tableableRows) return; this.columnIndex = 0; if(angle == "bottom"){ this.rowIndex = this.el.classList.contains("two-row") ? 1 : 0; }else if(angle == "top"){ this.rowIndex = 0; } if(this.tableableRows[this.rowIndex] && this.tableableRows[this.rowIndex][this.columnIndex]){ this.ignoreKeyboardInput = true; if (!this.cfReference.options.preventAutoFocus) { this.tableableRows[this.rowIndex][this.columnIndex].focus = true; } }else{ this.resetTabList(); } } public updateStateOnElementsFromTag(tag: ITag){ for (var index = 0; index < this.elements.length; index++) { var element: any = this.elements[index]; if(element.referenceTag == tag){ this.updateStateOnElements(element); break; } } } public updateStateOnElements(controlElement: IControlElement){ this.currentControlElement = controlElement; if(this.currentControlElement.type == "RadioButton"){ // uncheck other radio buttons... const elements: Array = this.getElements(); for (let i = 0; i < elements.length; i++) { let element: RadioButton = elements[i]; if(element != controlElement){ element.checked = false; }else{ element.checked = true; } } }else if(this.currentControlElement.type == "CheckboxButton"){ // change only the changed input const elements: Array = this.getElements(); for (let i = 0; i < elements.length; i++) { let element: CheckboxButton = elements[i]; if(element == controlElement){ const isChecked: boolean = ( element.referenceTag.domElement).checked; element.checked = isChecked; } } } } public reset(){ this.infoElement.classList.remove("show"); this.el.classList.remove("one-row"); this.el.classList.remove("two-row"); // this.el.style.transition = 'height 0.35s ease-out 0.2s'; this.list.style.height = '0px'; } public getElement(index: number):IControlElement | OptionsList{ return this.elements[index]; } public getDTO(): FlowDTO{ let dto: FlowDTO = { text: undefined, controlElements: [], } // generate text value for ChatReponse if(this.elements && this.elements.length > 0){ switch(this.elements[0].type){ case "CheckboxButton" : let numChecked: number = 0;// check if more than 1 is checked. var values: Array = []; for (var i = 0; i < this.elements.length; i++) { let element: CheckboxButton = this.elements[i]; if(element.checked){ if(numChecked++ > 1) break; } } for (var i = 0; i < this.elements.length; i++) { let element: CheckboxButton = this.elements[i]; if(element.checked){ if(numChecked > 1) element.partOfSeveralChoices = true; values.push(element.value); } dto.controlElements.push(element); } dto.text = Dictionary.parseAndGetMultiValueString(values); break; case "RadioButton" : for (var i = 0; i < this.elements.length; i++) { let element: RadioButton = this.elements[i]; if(element.checked){ dto.text = element.value; } dto.controlElements.push(element); } break; case "OptionsList": var element: OptionsList = this.elements[0]; dto.controlElements = element.getValue(); var values: Array = []; if(dto.controlElements && dto.controlElements[0]){ for (let i = 0; i < dto.controlElements.length; i++) { let element: IControlElement = dto.controlElements[i]; values.push(dto.controlElements[i].value); } } // after value is created then set to all elements dto.controlElements = element.elements; dto.text = Dictionary.parseAndGetMultiValueString(values); break; case "UploadFileUI": dto.text = ( this.elements[0]).getFilesAsString();//Dictionary.parseAndGetMultiValueString(values); dto.controlElements.push( this.elements[0]); break; } } return dto; } public clearTagsAndReset(){ this.reset(); if(this.elements){ while(this.elements.length > 0){ this.elements.pop().dealloc(); } } this.list.innerHTML = ""; this.onListChanged(); } public buildTags(tags: Array){ this.disabled = false; const topList: HTMLUListElement = ( this.el.parentNode).getElementsByTagName("ul")[0]; const bottomList: HTMLUListElement = ( this.el.parentNode).getElementsByTagName("ul")[1]; // remove old elements this.clearTagsAndReset(); this.elements = []; for (var i = 0; i < tags.length; i++) { var tag: ITag = tags[i]; switch(tag.type){ case "radio" : this.elements.push(new RadioButton({ referenceTag: tag, eventTarget: this.eventTarget })); break; case "checkbox" : this.elements.push(new CheckboxButton({ referenceTag: tag, eventTarget: this.eventTarget })); break; case "select" : this.elements.push(new OptionsList({ referenceTag: tag, context: this.list, eventTarget: this.eventTarget })); break; case "input" : default : if(tag.type == "file"){ this.elements.push(new UploadFileUI({ referenceTag: tag, eventTarget: this.eventTarget })); } // nothing to add. break; } if(tag.type != "select" && this.elements.length > 0){ const element: IControlElement = this.elements[this.elements.length - 1]; this.list.appendChild(element.el); } } const isElementsOptionsList: boolean = this.elements[0] && this.elements[0].type == "OptionsList"; if(isElementsOptionsList){ this.filterListNumberOfVisible = ( this.elements[0]).elements.length; }else{ this.filterListNumberOfVisible = tags.length; } new Promise((resolve: any, reject: any) => this.resize(resolve, reject)).then(() => { const h: number = this.list.offsetHeight;//this.el.classList.contains("one-row") ? 52 : this.el.classList.contains("two-row") ? 102 : 0; const controlElementsAddedDTO: ControlElementsDTO = { height: h, }; this.onListChanged(); ConversationalForm.illustrateFlow(this, "dispatch", UserInputEvents.CONTROL_ELEMENTS_ADDED, controlElementsAddedDTO); this.eventTarget.dispatchEvent(new CustomEvent(UserInputEvents.CONTROL_ELEMENTS_ADDED, { detail: controlElementsAddedDTO })); }); } private onResize(event: Event){ this.resize(); } public resize(resolve?: any, reject?: any){ // scrollbar things // Element.offsetWidth - Element.clientWidth this.list.style.width = "100%"; this.el.classList.remove("resized") this.el.classList.remove("one-row"); this.el.classList.remove("two-row"); this.elementWidth = 0; this.listWidth = 0; const elements: Array = this.getElements(); if(elements && elements.length > 0){ const listWidthValues: Array = []; const listWidthValues2: Array = []; let containsElementWithImage: boolean = false; for (let i = 0; i < elements.length; i++) { let element: IControlElement = elements[i]; if(element.visible){ element.calcPosition(); this.listWidth += element.positionVector.width; listWidthValues.push(element.positionVector.x + element.positionVector.width); listWidthValues2.push(element); } if(element.hasImage()) containsElementWithImage = true; } let elOffsetWidth: number = this.el.offsetWidth; let isListWidthOverElementWidth: boolean = this.listWidth > elOffsetWidth; if(isListWidthOverElementWidth && !containsElementWithImage){ this.el.classList.add("two-row"); this.listWidth = Math.max(elOffsetWidth, Math.round((listWidthValues[Math.floor(listWidthValues.length / 2)]) + 50)); this.list.style.width = this.listWidth + "px"; }else{ this.el.classList.add("one-row"); } // recalc after LIST classes has been added for (let i = 0; i < elements.length; i++) { let element: IControlElement = elements[i]; if(element.visible){ element.calcPosition(); } } // check again after classes are set. elOffsetWidth = this.el.offsetWidth; isListWidthOverElementWidth = this.listWidth > elOffsetWidth; // sort the list so we can set tabIndex properly var elementsCopyForSorting: Array = elements.slice(); const tabIndexFilteredElements: Array = elementsCopyForSorting.sort((a: IControlElement, b: IControlElement) => { const aOverB: boolean = a.positionVector.y > b.positionVector.y; return a.positionVector.x == b.positionVector.x ? (aOverB ? 1 : -1) : a.positionVector.x < b.positionVector.x ? -1 : 1; }); let tabIndex: number = 0; for (let i = 0; i < tabIndexFilteredElements.length; i++) { let element: IControlElement = tabIndexFilteredElements[i]; if(element.visible){ //tabindex 1 are the UserTextInput element element.tabIndex = 2 + (tabIndex++); }else{ element.tabIndex = -1; } } // toggle nav button visiblity if(isListWidthOverElementWidth){ this.el.classList.remove("hide-nav-buttons"); }else{ this.el.classList.add("hide-nav-buttons"); } this.elementWidth = elOffsetWidth; // resize scroll this.listScrollController.resize(this.listWidth, this.elementWidth); this.el.classList.add("resized"); this.eventTarget.dispatchEvent(new CustomEvent(ControlElementsEvents.ON_RESIZE)); if(resolve){ // only build when there is something to resolve this.buildTabableRows(); resolve(); } } } public dealloc(){ this.currentControlElement = null; this.tableableRows = null; window.removeEventListener('resize', this.onResizeCallback, false); this.onResizeCallback = null; this.el.removeEventListener('scroll', this.onScrollCallback, false); this.onScrollCallback = null; this.eventTarget.removeEventListener(ControlElementEvents.ON_FOCUS, this.onElementFocusCallback, false); this.onElementFocusCallback = null; this.eventTarget.removeEventListener(ChatListEvents.CHATLIST_UPDATED, this.onChatReponsesUpdatedCallback, false); this.onChatReponsesUpdatedCallback = null; this.eventTarget.removeEventListener(UserInputEvents.KEY_CHANGE, this.onUserInputKeyChangeCallback, false); this.onUserInputKeyChangeCallback = null; this.eventTarget.removeEventListener(FlowEvents.USER_INPUT_UPDATE, this.userInputUpdateCallback, false); this.userInputUpdateCallback = null; this.eventTarget.removeEventListener(ControlElementEvents.ON_LOADED, this.onElementLoadedCallback, false); this.onElementLoadedCallback = null; this.listScrollController.dealloc(); } } }