Skip to content

Instantly share code, notes, and snippets.

@jmccartie
Last active January 25, 2016 00:22
Show Gist options
  • Save jmccartie/2c76022b8cade2e0aa27 to your computer and use it in GitHub Desktop.
Save jmccartie/2c76022b8cade2e0aa27 to your computer and use it in GitHub Desktop.
First shot at some React
(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 />&nbsp;- City, State or Zip Code<br />&nbsp;- National Park, National Forest, State Park<br />&nbsp;- 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 />&nbsp;- City, State or Zip Code<br />&nbsp;- National Park, National Forest, State Park<br />&nbsp;- 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>;
}
});
@jmccartie
Copy link
Author

There's got to be a better way to hide/show elements than my "hideHelp" / "showHelp" functions...

@jmccartie
Copy link
Author

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...

@jmccartie
Copy link
Author

Lastly, where would I handle up/down/enter key binding in this menu? The top Component? Or inside the SearchFieldResult component?

@danott
Copy link

danott commented Jan 24, 2016

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>;

@jmccartie
Copy link
Author

Thanks, @danott!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment