-
-
Save jmccartie/2c76022b8cade2e0aa27 to your computer and use it in GitHub Desktop.
(function($){ | |
$.fn.searchField = function(){ | |
this.each(function(_index, entry){ | |
var $entry = $(entry); | |
//1. Add the results dropdown page | |
// - add the pane | |
// - add the help message | |
// - add the results list | |
//2. Handle the dropdown events | |
// - show and hide the dropdown | |
//3. Handle the visibility of the contents of the dropdown | |
//4. Populate the results list upon text entry | |
//5. Handle the down and up arrows | |
//6. Handle the item navigation upon selection | |
var data = { | |
oldEntryText: "", | |
entryText: "", | |
results: [], | |
grouped: {campgrounds: [], places: [], cities: [], states: []}, | |
hasFocus: false, | |
selectedIndex: -1 | |
}; | |
var dropdownTemplate = "<div class='search-field-dropdown'><div class='search-field-dropdown-help'></div><div class='search-field-dropdown-results'></div></div>"; | |
var helpTemplate = "<div class='search-field-help'>Start typing...<br /> - City, State or Zip Code<br /> - National Park, National Forest, State Park<br /> - Campground Name</div>"; | |
var resultsTemplate = "<div class='search-field-results'></div>"; | |
// DOM manipulation part | |
$entry.attr('autocomplete', 'off'); | |
$entry.after(dropdownTemplate); | |
var $dropdown = $entry.next(); | |
$dropdown.append(helpTemplate).append(resultsTemplate); | |
var $help = $dropdown.find('.search-field-help'); | |
var $results = $dropdown.find('.search-field-results'); | |
var groupResults = function(results){ | |
var pregrouped = _.groupBy(results, 'type'); | |
var grouped = {}; | |
grouped.campgrounds = pregrouped['Campground']; | |
grouped.places = _.sortBy(pregrouped['Place'], "title"); | |
grouped.cities = _.sortBy(pregrouped['City'], "title"); | |
grouped.states = _.sortBy(pregrouped['State'], "title"); | |
return grouped; | |
}; | |
var updateResults = _.debounce(function(){ | |
$.getJSON('/search.json', { phrase: data.entryText }, function(reply){ | |
data.results = reply; | |
data.grouped = groupResults(data.results); | |
data.selectedIndex = -1; | |
synchronize(false); | |
}); | |
}, 300); | |
var updateData = function(fromEvent){ | |
if(fromEvent){ | |
data.oldEntryText = data.entryText; | |
data.entryText = $entry.val(); | |
if(data.oldEntryText !== data.entryText){ | |
updateResults(); | |
} | |
} | |
}; | |
var handleItemSelect = function(e){ | |
var $el = $(e.currentTarget); | |
window.location.href = $el.data('url'); | |
return false; | |
}; | |
var updateContentsVisibility = function(){ | |
var funcs = data.results.length > 0 ? ['hide', 'show'] : ['show', 'hide']; | |
$help[funcs[0]](); | |
$results[funcs[1]](); | |
var dropdownFunc = data.hasFocus ? 'show' : 'hide'; | |
$dropdown[dropdownFunc](); | |
}; | |
var updateResultsList = function(){ | |
var entities = [['campgrounds', 'Campgrounds'], ['places', 'Places'], ['cities', 'Cities'], ['states', 'States']].reverse(); | |
$results.html(_.map(entities, function(o){ | |
return "<div class='results-" + o[0] + "'><h3>" + o[1] + "</h3><ul></ul></div>"; | |
}).join("\n")); | |
_.each(entities, function(e){ | |
var $list = $results.find(".results-" + e[0] + " ul"); | |
if(data.grouped[e[0]] !== undefined && data.grouped[e[0]].length > 0){ | |
_.each(data.grouped[e[0]], function(result){ | |
$list.append("<li data-url='" + result.url + "'>" + result.title + "</li>"); | |
}); | |
$('li', $list).on('click', handleItemSelect); | |
} | |
else { | |
$list.parent().hide(); | |
} | |
}); | |
}; | |
var synchronize = function(fromEvent){ | |
updateData(fromEvent); | |
updateContentsVisibility(); | |
if(fromEvent == false){ | |
updateResultsList(); | |
} | |
}; | |
var updateSelection = function(delta){ | |
data.selectedIndex = data.selectedIndex + delta; | |
if(data.selectedIndex < 0) data.selectedIndex = 0; | |
if(data.selectedIndex >= data.results.length) data.selectedIndex = data.results.length - 1; | |
$results.find('li').removeClass('hovered'); | |
$($results.find('li')[data.selectedIndex]).addClass('hovered'); | |
}; | |
var navigateSelected = function(){ | |
if(data.selectedIndex !== -1){ | |
var url = $($results.find('li')[data.selectedIndex]).data('url'); | |
window.location.href = url; | |
} | |
else { | |
$entry.parents('form').first().submit(); | |
} | |
return false; | |
}; | |
// Basic spatial positioning part | |
var updateSpatialPositioning = function(){ | |
var entryBottom = $entry.position().top + $entry.outerHeight(true); | |
$dropdown.css('width', $entry.outerWidth(true)); | |
$dropdown.css('top', entryBottom + 2); | |
} | |
var initialize = function(){ | |
$entry.on('keyup', function(){ | |
synchronize(true); | |
}); | |
$entry.on('focusin', function(){ | |
data.hasFocus = true; | |
synchronize(true); | |
}); | |
$entry.on('keydown', function(e){ | |
switch(e.which){ | |
case 38: // up | |
updateSelection(-1); | |
e.preventDefault(); | |
break; | |
case 40: // down | |
updateSelection(1); | |
e.preventDefault(); | |
break; | |
case 13: // enter | |
navigateSelected(); | |
e.preventDefault(); | |
break; | |
default: return; | |
} | |
}); | |
$(window).on('click', function(e){ | |
if(e.target !== $entry[0] && $(e.target).parents('.search-field-dropdown').length == 0 ){ | |
data.hasFocus = false; | |
synchronize(true); | |
} | |
}); | |
$(window).on('resize', updateSpatialPositioning); | |
updateSpatialPositioning(); | |
synchronize(false); | |
return $entry; | |
}; | |
return initialize(); | |
}); | |
return this; | |
}; | |
$(document).ready(function(){ | |
$('.search-field').searchField(); | |
}); | |
}(jQuery)); |
var SearchField = React.createClass({ | |
getInitialState: function() { | |
return {dropdownVisibility: 'none', helpVisibility: 'block', results: []}; | |
}, | |
fetchResults: function(event) { | |
$.getJSON('/search.json', { q: event.target.value }, function(data){ | |
this.setState({ results: data }); | |
if (data.length > 0) { | |
this.hideHelp(); | |
} else { | |
this.showHelp(); | |
} | |
}.bind(this)); | |
}, | |
showHelp: function() { | |
this.setState({ helpVisibility: 'block'}); | |
}, | |
hideHelp: function() { | |
this.setState({ helpVisibility: 'none'}); | |
}, | |
showDropdown: function() { | |
this.setState({dropdownVisibility: 'block'}); | |
}, | |
hideDropdown: function () { | |
this.setState({dropdownVisibility: 'none'}); | |
}, | |
render: function() { | |
return <div> | |
<input id="q_ftx_search_cont" className="string required form-control search-field" | |
autoComplete="off" name="q[ftx_search_cont]" placeholder="Search" type="Text" | |
onKeyUp={this.fetchResults} onFocus={this.showDropdown} /> | |
<div className='search-field-dropdown' style={{display: this.state.dropdownVisibility}}> | |
<div className='search-field-dropdown-help'></div> | |
<div className='search-field-dropdown-results'> | |
{this.state.results.map(function(result) { | |
return <SearchFieldGroup key={result.title} data={result}/>; | |
})} | |
</div> | |
<div className='search-field-help' style={{display: this.state.helpVisibility}}>Start typing...<br /> - City, State or Zip Code<br /> - National Park, National Forest, State Park<br /> - Campground Name</div> | |
</div> | |
</div> | |
; | |
} | |
}); |
var SearchFieldGroup = React.createClass({ | |
render: function() { | |
return <div> | |
<h3>{this.props.data.title}</h3> | |
<ul> | |
{this.props.data.results.map(function(result) { | |
return <SearchFieldResult key={result.id} data={result} /> | |
})} | |
</ul> | |
</div> | |
; | |
} | |
}); |
var SearchFieldResult = React.createClass({ | |
visitPath: function() { | |
window.location = this.props.data.url; | |
}, | |
render: function() { | |
return <li><span onClick={this.visitPath}>{this.props.data.title}</span></li>; | |
} | |
}); |
Also, do I need 3 components here? I tried to just create the SearchFieldResult li
's inside the SearchFieldGroup, but React didn't seem to like me doing that without assigning ID's. Click handling didn't work, either...
Lastly, where would I handle up/down/enter key binding in this menu? The top Component? Or inside the SearchFieldResult component?
This looks like the right path to me! My thoughts on your specific questions:
There's got to be a better way to hide/show elements than my "hideHelp" / "showHelp" functions...
I think your state
is reaching too deep into how to handle "help being visible". Instead of relying on toggling styles, If "help isn't visible", you can omit the node completely with something like
// toggle the concept, not the implementation
toggleHelpVisiblity: function() {
this.setState({ isHelpVisible: !this.state.isHelpVisible })
}
// inside render
{this.state.isHelpVisible && <div className='search-field-help'>Start typing</div>}
Also, do I need 3 components here? I tried to just create the SearchFieldResult li's inside the SearchFieldGroup, but React didn't seem to like me doing that without assigning ID's. Click handling didn't work, either...
Are you talking about the key
prop? If so, that is a bit of boilerplate that we have to live with. As far as I know, it helps the DOM diffing, == performance
Lastly, where would I handle up/down/enter key binding in this menu? The top Component? Or inside the SearchFieldResult component?
I've built similar components, and I handle it in the topmost component. Assuming that is where the user's focus still is, that's the appropriate place to handle the event.
What was your motivation for:
return <li><span onClick={this.visitPath}>{this.props.data.title}</span></li>;
Is there a reason you want to avoid the anchor tag with?
return <li><a href={this.props.data.url}>{this.props.data.title}</a></li>;
Thanks, @danott!
There's got to be a better way to hide/show elements than my "hideHelp" / "showHelp" functions...