With all of the design goals and coding standards laid out, it's time to get into the coding of our app.
HTML
As with any Web application template, we need an HTML page with the HTML5 doctype, <head>
, and <body>
elements.
<!doctype html> <html> <head> <title></title> <meta charset="utf-8" /> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1"> <!-- stylesheets go here --> </head> <body> <!-- HTML app structure goes here --> <!-- javascript goes here --> </body> </html>
We'll be using jQuery Mobile as the JavaScript framework. That means we must pull in the CSS and JavaScript resources required by jQuery Mobile:
<!-- stylesheets --> <link rel="stylesheet" href="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.css" /> <!-- scripts --> <script src="http://code.jquery.com/jquery-1.7.1.min.js"></script> <script src="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.js"></script>
With our basic application boilerplate and jQuery Mobile resource in place, it's time to add content and widgets to the page. Since we've decided to use jQuery Mobile for our application framework, our app's HTML structure will follow their prescribed widget structures. The first pane provides a search box and a history list:
<!-- home pane --> <div data-role="page" id="home"> <div data-role="header"> <h1>Area Tweet</h1> </div> <div data-role="content"> <div class="ui-body ui-body-b"> <h2>Location Search</h2> <form id="locationForm"> <input type="search" name="location" id="locationInput" placeholder="Your Location" /> <button type="submit" data-role="button" data-theme="b">Search</button> </form> </div> <div id="prevLocationsContainer" class="opaque"> <h2> Previous Locations <a href="#" id="clearHistoryButton" data-role="button" data-icon="delete" data-iconpos="notext" data-theme="b" data-inline="true" style="top: 5px;">Clear History</a> </h2> <ul id="prevLocationsList" data-role="listview" data-inset="true" data-filter="true"></ul> </div> </div> </div>
There are a few things to notice with the code above:
- The structure of the HTML5 sticks to semantics, which ensures maximum accessibility.
- As with any Web-based app, we assign CSS classes and IDs to elements we want to style and to access to from JavaScript code.
If you have questions about individual jQuery Mobile capabilities, node attributes, or structures, please consult the jQuery Mobile Documentation.
The second pane will be used for displaying a list of tweets:
<!-- tweets pane --> <div data-role="page" id="tweets"> <div data-role="header"> <a href="#home" id="tweetsBackButton">Back</a> <h1><span id="tweetsHeadTerm"></span> Tweets</h1> </div> <ul id="tweetsList" data-role="listview" data-inset="true"></ul> </div>
CSS
Our Web app's CSS contains a CSS animation for fading in any element, as well as for overriding jQuery Mobile styles and general element styling.
/* animations */ @-moz-keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } @-webkit-keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } .fadeIn { -moz-animation-name: fadeIn; -moz-animation-duration: 2s; -webkit-animation-name: fadeIn; -webkit-animation-duration: 2s; opacity: 1 !important; } /* Custom CSS classes */ .clear { clear: both; } .hidden { display: none; } .opaque { opacity: 0; } /* Basic styling */ #prevLocationsContainer { margin-top: 40px; } /* Customizing jQuery Styles */ #tweetsList li.ui-li-divider, #tweetsList li.ui-li-static { font-weight: normal !important; } /* Tweet list */ #tweetsList li { } #tweetsList li .tweetImage { float: left; margin: 10px 10px 0 10px; } #tweetsList li .tweetContent { float: left; /* margin: 10px 0 0 0; */ } #tweetsList li .tweetContent strong { display: block; padding-bottom: 5px; } /* Media Queries for device support */ @media screen and (max-device-width: 480px) { #prevLocationsContainer { margin-top: 10px; } }
JavaScript
JavaScript is the last major component to add to our Web app. The following encompasses all capabilities for the app:
$(document).ready(function() { // Start from scratch on page load if(window.location.hash) { window.location = "index.html"; } // Feature tests and settings var hasLocalStorage = "localStorage" in window, maxHistory = 10; // List of elements we'll use var $locationForm = $("#locationForm"), $locationInput = $("#locationInput"), $prevLocationsContainer = $("#prevLocationsContainer"), $tweetsHeadTerm = $("#tweetsHeadTerm"), $tweetsList = $("#tweetsList"); // Hold last request jqXHR's so we can cancel to prevent multiple requests var lastRequest; // Create an application object app = { // App initialization init: function() { var self = this; // Focus on the search box focusOnLocationBox(); // Add the form submission event $locationForm.on("submit", onFormSubmit); // Show history if there are items there this.history.init(); // When the back button is clicked in the tweets pane, reset the form and focus $("#tweetsBackButton").on("click", function(e) { $locationInput.val(""); setTimeout(focusOnLocationBox, 1000); }); // Geolocate! geolocate(); // When the tweets pane is swiped, go back to home $("#tweets").on("swiperight", function() { window.location.hash = ""; }); // Clear history when button clicked $("#clearHistoryButton").on("click", function(e) { e.preventDefault(); localStorage.removeItem("history"); self.history.hideList(); }) }, // History modules history: { $listNode: $("#prevLocationsList"), $blockNode: $("#homePrev"), init: function() { var history = this.getItemsFromHistory(), self = this; // Add items to the list if(history.length) { history.forEach(function(item) { self.addItemToList(item); }); self.showList(); } // Use event delegation to look for list items clicked this.$listNode.delegate("a", "click", function(e) { $locationInput.val(e.target.textContent); onFormSubmit(); }); }, getItemsFromHistory: function() { var history = ""; if(hasLocalStorage) { history = localStorage.getItem("history"); } return history ? JSON.parse(history) : []; }, addItemToList: function(text, addToTop) { var $li = $("<li><a href='#'>" + text + "</a></li>"), listNode = this.$listNode[0]; if(addToTop && listNode.childNodes.length) { $li.insertBefore(listNode.childNodes[0]); } else { $li.appendTo(this.$listNode); } this.$listNode.listview("refresh"); }, addItemToHistory: function(text, addListItem) { var currentItems = this.getItemsFromHistory(), newHistory = [text], self = this, found = false; // Cycle through the history, see if this is there $.each(currentItems, function(index, item) { if(item.toLowerCase() != text.toLowerCase()) { newHistory.push(item); } else { // We've hit a "repeater": signal to remove from list found = true; self.moveItemToTop(text); } }); // Add a new item to the top of the list if(!found && addListItem) { this.addItemToList(text, true); } // Limit history to 10 items if(newHistory.length > maxHistory) { newHistory.length = maxHistory; } // Set new history if(hasLocalStorage) { // Wrap in try/catch block to prevent mobile safari issues with private browsing // http://frederictorres.blogspot.com/2011/11/quotaexceedederr-with-safari-mobile.html try { localStorage.setItem("history", JSON.stringify(newHistory)); } catch(e){} } // Show the list this.showList(); }, showList: function() { $prevLocationsContainer.addClass("fadeIn"); this.$listNode.listview("refresh"); }, hideList: function() { $prevLocationsContainer.removeClass("fadeIn"); }, moveItemToTop: function(text) { var self = this, $listNode = this.$listNode; $listNode.children().each(function() { if($.trim(this.textContent.toLowerCase()) == text.toLowerCase()) { $listNode[0].removeChild(this); self.addItemToList(text, true); } }); $listNode.listview("refresh"); } } }; // Search submission function onFormSubmit(e) { if(e) e.preventDefault(); // Trim the value var value = $.trim($locationInput.val()); // Move to the tweets pane if(value) { // Add the search to history app.history.addItemToHistory(value, true); // Update the pane 2 header $tweetsHeadTerm.html(value); // If there's another request at the moment, cancel it if(lastRequest && lastRequest.readyState != 4) { lastRequest.abort(); } // Make the JSONP call to Twitter lastRequest = $.ajax("http://search.twitter.com/search.json", { cache: false, crossDomain: true, data: { q: value }, dataType: "jsonp", jsonpCallback: "twitterCallback", timeout: 3000 }); } else { // Focus on the search box focusOnLocationBox(); } return false; } // Twitter reception window.twitterCallback = function(json) { var template = "<li><img src='{profile_image_url}' class='tweetImage' /><div class='tweetContent'>" +"<strong>{from_user}</strong>{text}</div><div class='clear'></div></li>", tweetHTMLs = []; // Basic error handling if(json.error) { // Error for twitter showDialog("Twitter Error", "Twitter cannot provide tweet data."); return; } else if(!json.results.length) { // No results showDialog("Twitter Error", "No tweets could be found in your area."); return; } // Format the tweets $.each(json.results, function(index, item) { item.text = item.text. replace(/(https?:\/\/\S+)/gi,'<a href="$1" target="_blank">$1</a>'). replace(/(^|\s)@(\w+)/g,'$1<a href="http://twitter.com/$2" target="_blank">@$2</a>'). replace(/(^|\s)#(\w+)/g,'$1<a href="http://search.twitter.com/search?q=%23$2" target="_blank">#$2</a>') tweetHTMLs.push(substitute(template, item)); }); // Place tweet data into the form $tweetsList.html(tweetHTMLs.join("")); // Refresh the list view for proper formatting try { $tweetsList.listview("refresh"); } catch(e) {} // Go to the tweets view window.location.hash = "tweets"; }; // Template substitution function substitute(str, obj) { return str.replace((/\\?{([^{}]+)}/g), function(match, name){ if (match.charAt(0) == '\\') return match.slice(1); return (obj[name] != null) ? obj[name] : ""; }); } // Geolocates the user function geolocate() { if("geolocation" in navigator) { // Attempt to get the user position navigator.geolocation.getCurrentPosition(function(position) { // Set the address position if(position.address && position.address.city) { $locationInput.val(position.address.city); } }); } } // Focuses on the input box function focusOnLocationBox() { $locationInput[0].focus(); } // Modal function function showDialog(title, message) { $("#errorDialog h2.error-title").html(title); $("#errorDialog p.error-message").html(message); $.mobile.changePage("#errorDialog"); } // Initialize the app app.init(); });
We won't go over every part of the app's JavaScript, but a few items are worth pointing out:
- The script uses feature testing for both
localStorage
andgeolocation
. The app will not attempt to use either feature if they aren't present in the browser. - The
geolocation
API is used to detect the user's current location and thelocalStorage
API is used to keep the user's search history. - The JavaScript has been written in a modular manner for easier testing.
- A swipe event is listened for on the tweet pane. When the pane is swiped, the user is taken back to the first pane.
- Twitter is the source of all data. No custom server-side processing is used for this app.