import * as React from 'react'; import * as Bloodhound from 'bloodhound-js'; import { addPopoverBehavior } from '../../mmui-widget'; export interface MmuiTokenTypeaheadProps { elmtId; placeHolder: string; data: any; url: string; hasReset: boolean; isSingleSelect: boolean; inputName: string; } export interface MmuiTokenTypeaheadState { elmtId; placeHolder: string; hasReset: boolean; tokens: any[]; tokensInital: any[]; typeaheadInput: string; suggestions: any[]; keepSuggestions; isExpanded: boolean; hasChanged: boolean; inputName: string; } /** * Parse token object from given token HTMLElement * @param tokenElmt - html element representing the token. * @param tokenElmt.dataset.tokenId - unique identifier for the token * @param tokenElmt.dataset.tokenDisplay - what is displayed in the token */ function getToken(tokenElmt: HTMLElement): any { let token: any; if (tokenElmt) { token = { id: tokenElmt.dataset.tokenId, display: tokenElmt.dataset.tokenDisplay, }; } return token; } /** * Parse Tokenfield html, with provided id attr, into Tokenfield data object * @param id */ export function getMmuiTokenfieldData(id: string): any { let data = { tokens: [] }, elmt = document.getElementById(id), tokenElmt, tokenElmts = elmt.querySelectorAll('.mmui-token'), token; for (let i = 0; i < tokenElmts.length; i++) { tokenElmt = tokenElmts[i]; token = getToken(tokenElmt); data.tokens.push(token); } return data; } /** * Token Typeahead Component * It is an input field that asynchronously queries retrieves results, from a provided url, * that matches the users input as they type. If the user selects a result, the result is displayed as a token. * If this Token Typeahead is part of a submitted form, the tokens will be submitted with that form as hidden inputs. * * token: a thing serving as a visible or tangible representation of a fact */ export class MmuiTokenTypeaheadComponent< P extends MmuiTokenTypeaheadProps, S extends MmuiTokenTypeaheadState > extends React.Component { typeaheadEngine; typeaheadPromise; typeaheadEngineRemotePrepare; constructor(props) { super(props); // init state const state: any = { elmtId: props.elmtId, placeHolder: props.placeHolder, hasReset: false, tokens: props.data.tokens, tokensInital: props.data.tokens, typeaheadInput: '', suggestions: [], keepSuggestions: false, isExpanded: false, hasChanged: false, inputName: props.elmtId, }; if (props.inputName) { state.inputName = props.inputName; } if (props.hasReset) { state.hasReset = props.hasReset; } if (props.remotePrepare) { this.typeaheadEngineRemotePrepare = (query, settings) => { return props.remotePrepare(query, settings, this.state); }; } else { this.typeaheadEngineRemotePrepare = (query, settings) => { let paramStr = '', queryStr = encodeURIComponent(query), tokenStr = this.state.tokens .map((t) => encodeURIComponent(t.id)) .map((t) => `t=${t}`) .join('&'); if (tokenStr) { paramStr = `${tokenStr}&q=${queryStr}`; } else { paramStr = `q=${queryStr}`; } return `${settings.url}?${paramStr}`; }; } // setup Bloodhound typeahead engine this.typeaheadEngine = new Bloodhound({ // local: [{ id: 1, display: 'one' }, { id: 2, display: 'two' }, { id: 3, display: 'three' }], remote: { url: props.url, // A function to prepare the settings object passed to transport when a request is about to be made prepare: this.typeaheadEngineRemotePrepare, }, queryTokenizer: Bloodhound.tokenizers.whitespace, // A function that transforms a datum into an array of string tokens datumTokenizer: function (d) { return Bloodhound.tokenizers.whitespace(d.display); }, }); this.typeaheadPromise = this.typeaheadEngine.initialize(); this.state = state; } /** * Put the UI focus on the typeahead input field. */ focusOnTypeaheadInput() { const typeaheadInputSelector = `#${this.state.elmtId} .mmui-typeahead-input`; ( document.querySelector(typeaheadInputSelector) as HTMLAnchorElement ).focus(); } /** * Clear typeahead suggestions and input field. */ clearInputSuggestions() { this.setState({ suggestions: [], typeaheadInput: '' }); } /** * Call typeaheadEngine.search with typeaheadInput value. * Then update state with suggestions returned by typeahead engine. * @param e: onChange event */ onTypeaheadInput = (e: React.SyntheticEvent) => { const typeaheadInputElmt = e.target as HTMLInputElement, typeaheadInput = typeaheadInputElmt.value; this.setState({ typeaheadInput: typeaheadInput }); this.typeaheadPromise.then(() => { this.typeaheadEngine.search( typeaheadInput, // synchronous response callback // eslint-disable-next-line @typescript-eslint/no-unused-vars (rArray) => { // console.log("sync", rArray); }, // asynchronous response callback (rArray) => { if (rArray) { this.setState({ suggestions: rArray }); } } ); }); }; /** * Add the target suggested to state tokens. * @param e: onClick event */ onSelectSuggestion = (e: React.SyntheticEvent) => { e.preventDefault(); let s, tokenElmt = e.target as HTMLElement, newToken = getToken(tokenElmt), newTokenArray = [...this.state.tokens], newSuggestions = []; if (this.state.keepSuggestions) { for (s of this.state.suggestions) { if (s.id !== newToken.id) { newSuggestions.push(s); } } } newTokenArray.push(newToken); this.setState({ tokens: newTokenArray, hasChanged: true }); //wait to update the new suggestions so that the component doesn't re-render before click event. setTimeout(() => { if (!this.state.keepSuggestions) { this.clearInputSuggestions(); } else { this.setState({ suggestions: newSuggestions }); } if (this.props.isSingleSelect) { this.setState({ isExpanded: false }); } else { this.focusOnTypeaheadInput(); } }, 100); }; /** * Reset the tokenfield back to its original state * @param e */ onReset = (e: React.SyntheticEvent) => { e.preventDefault(); this.setState({ tokens: this.state.tokensInital, hasChanged: false }); }; /** * Remove the target token from state * @param e: onClick event */ onTokenRemove = (e: React.SyntheticEvent) => { e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); const tokenElmt = e.target as HTMLElement, tokenId = tokenElmt.dataset.tokenId; setTimeout(() => { this.removeToken(tokenId); }, 100); }; removeToken(tokenId: string) { let newTokenArray = [], token; for (token of this.state.tokens) { if (token.id !== tokenId) { newTokenArray.push(token); } } this.setState({ tokens: newTokenArray, hasChanged: true }); const popover = document.querySelector('.popover'); if (popover) { popover.remove(); } } /** * This function is used to detect when the user clicks outside of the visual area of the Component * and then sets state to collapsed (isExpanded == false) and cleans up the event listeners. * @param e */ onBlur = (e) => { this.blur(e); }; /** * Check if this Component element the event target * @param e */ getIsComponentElmtEventTarget(e) { let elmt = e.target, isComponentElmtTarget = false; while (elmt) { isComponentElmtTarget = isComponentElmtTarget || elmt.id === this.state.elmtId; elmt = elmt.parentElement; } return isComponentElmtTarget; } blur(e) { if (this.state.isExpanded) { if (!this.getIsComponentElmtEventTarget(e)) { this.setState({ isExpanded: false }); document.removeEventListener('click', this.onBlur); document.removeEventListener('keyup', this.onNextSelection); this.clearInputSuggestions(); } } } /** * This function is used to detect ArrowUp and ArrowDown keyboard * and moves up the Selection item list on ArrowUp and down Selection item list on ArrowDown * @param e */ onNextSelection = (e) => { if (this.state.isExpanded && this.state.suggestions.length > 0) { const suggestionsSelector = `#${this.state.elmtId} .mmui-token-suggestion`, isArrowUp = e.code === 'ArrowUp', isArrowDown = e.code === 'ArrowDown'; if (isArrowUp || isArrowDown) { const focusedSuggestion = document.querySelector( suggestionsSelector + ':focus' ) as HTMLElement; if (focusedSuggestion) { if (isArrowUp && focusedSuggestion.previousSibling) { ( focusedSuggestion.previousSibling as HTMLAnchorElement ).focus(); } else if (isArrowDown && focusedSuggestion.nextSibling) { ( focusedSuggestion.nextSibling as HTMLAnchorElement ).focus(); } } else { ( document.querySelector( suggestionsSelector ) as HTMLAnchorElement ).focus(); } } } }; /** * This function is used to detect when the Component has been clicked into * and setups up the event listeners * @param e */ onExpandTokenfield = (e: React.SyntheticEvent) => { e.preventDefault(); addPopoverBehavior(); if (this.props.isSingleSelect && this.state.tokens.length > 0) { return; } if (!this.state.isExpanded) { document.addEventListener('click', this.onBlur); document.addEventListener('keyup', this.onNextSelection); this.setState({ isExpanded: true }); setTimeout(() => { this.focusOnTypeaheadInput(); }, 100); } }; tokenDisplayRender(token) { const key = `token-${token.id}`; let popover; const dataContent = 'data-content'; const dataToggle = 'data-toggle'; const dataPlacement = 'data-placement'; const variableAttribute = { [dataContent]: token.display, [dataToggle]: 'popover', [dataPlacement]: 'top', }; if (token.display.length > 14) { popover = variableAttribute; } return (
{token.display}
); } suggestionRender(suggestion) { const key = `mmui-suggestion-${suggestion.id}`; return ( {suggestion.display} ); } getQueryInputName(){ return `${this.state.elmtId}-input`; } renderTokenField( tokenElmtFirst, summaryDisplay, summaryInput, tokenElmts, suggestionElmts, tokenInputs){ const tokenfieldClassArray = ['mmui-tokenfield']; if (this.state.isExpanded) { tokenfieldClassArray.push('mmui-tokenfield-expanded'); } else { tokenfieldClassArray.push('mmui-tokenfield-collapsed'); } const tokenfieldClassNames = tokenfieldClassArray.join(' '); let resetLink, resetLinkStyle = { display: 'none' }, suggestionsStyle = { display: 'none' } ; if (this.state.hasChanged) { resetLinkStyle.display = 'block'; } if (this.state.suggestions.length > 0) { suggestionsStyle.display = 'block'; } if (this.state.hasReset) { resetLink = ( Reset ); } return (
{tokenElmtFirst} {summaryDisplay} {summaryInput} {resetLink}
{tokenElmts}
{suggestionElmts}
{tokenInputs}
); } /** * Render the Component */ render() { let tokenInputs, tokenElmts, tokenElmtFirst, suggestionElmts, summaryDisplay, summaryInput, tokenElmtMapper = (token) => { return this.tokenDisplayRender(token); } ; tokenInputs = this.state.tokens.map((token) => { const key = `token-input-${token.id}`; return ( ); }); if (this.state.tokens.length > 0) { if (this.state.tokens.length > 1) { const moreCountDisplay = `+${ this.state.tokens.length - 1 } more`; summaryDisplay = ( {moreCountDisplay} ); } /** * Map state tokens to html render. */ tokenElmtFirst = tokenElmtMapper(this.state.tokens[0]); tokenElmts = this.state.tokens.map(tokenElmtMapper); } else { summaryInput = ( ); } /** * Map state suggestions to html render. */ if (this.state.suggestions.length > 0) { suggestionElmts = this.state.suggestions.map((suggestion) => { return this.suggestionRender(suggestion); }); } return this.renderTokenField( tokenElmtFirst, summaryDisplay, summaryInput, tokenElmts, suggestionElmts, tokenInputs ); } }