on master help sign in
Merge pull request #133 from urbit/search-bar-release

Search bar release

commite409c2f8b72ab7b6d268b6003480de91b83b9def authorMorgan Sutherland <msutherl@users.noreply.github.com> dateSat 20 Jul 2019, 22:00 (7 years ago) parent50c9ceb4296dc2
11 files changed+479−42
content/posts/2016-5-16-roadmap.md+1 content/posts/_index.md+1 sass/_main.scss+138−6 sass/_responsive.scss+14 static/scripts.js+303−29 templates/base.html+4 templates/doc.html+2 templates/partials/navigation-docs.html+5−2 templates/partials/search-overlay.html+7−5 templates/sections/docs.html+2 templates/sections/docs/chapters.html+2
content/posts/2016-5-16-roadmap.md+1
@@ -3,6 +3,7 @@ title = "~2016.5.16 Urbit Roadmap 2016"
33date = 2016-05-16
44author = "Curtis Yarvin"
55description = "Our near-term goals for the Urbit community, documentation, maturity, utility and governance."
6+search = false
67[taxonomies]
78posts = ["Updates"]
89+++
content/posts/_index.md+1
@@ -1,4 +1,5 @@
11+++
22render = false
33sort_by = "date"
4+in_search_index = false
45+++
sass/_main.scss+138−6
@@ -42,6 +42,13 @@ iframe {
4242 padding-bottom: (32rem / 4);
4343}
4444
45+.pop-up {
46+ width: 100%;
47+ max-width: 600px;
48+ margin: (28rem / 4) auto;
49+ background: #fff;
50+}
51+
4552.container--fixed {
4653 position: fixed;
4754 margin-top: -(24rem / 4);
@@ -91,7 +98,19 @@ iframe {
9198 height: 100%;
9299 z-index: 0;
93100 .content-nav {
94− overflow-y: scroll;
101+ &__search-bar {
102+ width: 100%;
103+ padding-left: 0rem;
104+ }
105+ &__index {
106+ height: calc(100% - 48px);
107+ overflow-y: scroll;
108+ &__item {
109+ &:last-child{
110+ padding-bottom: 8rem;
111+ }
112+ }
113+ }
95114 .list:first-child {
96115 padding-bottom: (14rem / 4);
97116 }
@@ -229,6 +248,10 @@ iframe {
229248 transform: translate(0, 7px);
230249 }
231250 }
251+ &__search-icon {
252+ -webkit-transform: rotate(-45deg);
253+ vertical-align: bottom;
254+ }
232255}
233256
234257.has-active-main-nav {
@@ -278,6 +301,55 @@ iframe {
278301 max-width: 320px;
279302}
280303
304+.padding-none {
305+ padding: 0 !important;
306+}
307+
308+.border-none {
309+ border: none !important;
310+}
311+
312+.b-transparent {
313+ background: rgba(0,0,0,0.4);
314+}
315+
316+.top0 {
317+ top: 0;
318+}
319+
320+.float-right {
321+ float: right;
322+}
323+
324+.search-overlay {
325+ .search-bar {
326+ &__input {
327+ padding-left: 40px;
328+ }
329+ }
330+}
331+
332+.search-results {
333+ max-height: 400px;
334+ &__item a:hover {
335+ background: #F9F9F9;
336+ span {
337+ display:inline-block;
338+ }
339+ }
340+}
341+
342+.has-active-search-window {
343+ .search-overlay {
344+ display: block;
345+ }
346+}
347+
348+#search-reset {
349+ cursor: pointer;
350+ user-select: none;
351+}
352+
281353.form--big {
282354 max-width: 390px;
283355 .btn {
@@ -286,21 +358,47 @@ iframe {
286358}
287359
288360.hoonschool-application {
289− &__experience {
290− resize: vertical;
291− }
361+ &__experience {
362+ resize: vertical;
363+ }
292364}
293365
294366/*
295367 * styling
296368 */
297369
298−textarea, input[type="text"], input[type="email"], input[type="number"], select {
370+// remove default x icon
371+ input[type="search"]::-webkit-search-decoration,
372+ input[type="search"]::-webkit-search-cancel-button,
373+ input[type="search"]::-webkit-search-results-button,
374+ input[type="search"]::-webkit-search-results-decoration {
375+ display: none;
376+}
377+
378+input[type="search"] {
379+ border: 1px solid #B1B2B3;
380+ font-size: (4rem / 4);
381+ line-height: (6rem / 4);
382+ transition: all 0.1s linear;
383+ padding-right: 40px;
384+ position: relative;
385+ border-radius: 0 !important;
386+}
387+
388+textarea, input[type="text"], input[type="email"], input[type="number"], input[type="search"], select {
299389 border: 1px solid #000;
300390 padding: 12px;
301391 width: 100%;
302392 outline: none;
303393 box-sizing: border-box;
394+ &:active, &:focus {
395+ border: 1px solid #000000;
396+ }
397+ &:required, &:invalid {
398+ box-shadow: none !important;
399+ border: 1px solid #EE892B;
400+ color: #EE892B;
401+ }
304402}
305403
306404textarea {
@@ -319,6 +417,38 @@ button, .btn {
319417 cursor: pointer;
320418}
321419
420+.search-bar {
421+ border: 1px solid #B1B2B3;
422+ background: #fff;
423+ font-size: (4rem / 4);
424+ transition: all 0.1s linear;
425+ padding: 12px 12px 12px 40px;
426+ position: relative;
427+ font-weight: normal;
428+ text-align: left;
429+ box-shadow: none !important;
430+ color: #B1B2B3;
431+}
432+
433+.has-valid-input {
434+ position: relative;
435+ &:after {
436+ content: '✓';
437+ color: #2AA779;
438+ position: absolute;
439+ right: 10px;
440+ top: 50%;
441+ transform: translate(0,-50%);
442+ width: 15px;
443+ height: 24px;
444+ margin-top: -4px;
445+ }
446+ input[type="text"], input[type="email"], input[type="number"] {
447+ border: 1px solid #2AA779;
448+ }
449+}
450+
451+
322452/*
323453 * typography
324454 */
@@ -493,9 +623,11 @@ summary {
493623 user-select: none;
494624 -webkit-user-select: none;
495625}
626+
496627summary:focus {
497628 outline: none;
498629}
630+
499631details {
500632 padding-bottom: (1rem / 4);
501633 padding-top: (1rem / 4);
@@ -503,4 +635,4 @@ details {
503635 padding-left: 0;
504636 list-style: none;
505637 }
506−}
638+}
sass/_responsive.scss+14
@@ -4,6 +4,9 @@
44 }
55}
66@media only screen and (max-width: 800px) {
7+ .has-active-search-window .search-overlay, .search-bar {
8+ display: none;
9+ }
710 .container--fixed, .content-nav {
811 margin: 0;
912 }
@@ -69,6 +72,13 @@
6972 display: block;
7073 margin: (7rem) / 4;
7174 overflow: initial;
75+ width: 100%;
76+ margin: 0;
77+ margin-top: (-7rem / 4);
78+ &__index {
79+ margin: (7rem /4) 0 0 (7rem / 4);
80+ padding: (7rem /4) (7rem / 4) 0 0;
81+ }
7282 }
7383 .content-nav-icon {
7484 &:before {
@@ -78,6 +88,10 @@
7888 border-right: 10px solid transparent;
7989 }
8090 }
91+ .content, .content-spacer {
92+ flex-basis: auto;
93+ max-width: none;
94+ }
8195 }
8296}
8397
static/scripts.js+303−29
@@ -1,7 +1,4 @@
1−//
2− // toggling of navigation menus
3−//
4−
1+// toggling of navigation menus
52var toggleMainNav = document.getElementById('js-main-nav-toggle');
63if (toggleMainNav !== null) {
74 toggleMainNav.onclick = function() {
@@ -16,34 +13,311 @@ if (toggleContentNav !== null) {
1613 }
1714}
1815
19−//
20− // styling of input fields
21−//
22−
23−var inputElements = document.querySelectorAll('input.tlon-input');
24−for (var i = 0; i < inputElements.length; i++) {
25− //listen for changes in input
26− inputElements[i].addEventListener('input', function() {
27− // if input has a value do this
28− if (this.value) {
29− var inputContainer = this.parentNode;
30− var validInputs = inputContainer.querySelectorAll('input.tlon-input:valid');
31− var inputs = inputContainer.querySelectorAll('input.tlon-input');
32− if (validInputs.length !== 0) {
33− // has valid input
34− for (var i = 0; i < validInputs.length; i++) {
35− if (this.classList.contains('is-valid') == false ) {
36− validInputs[i].parentNode.classList.add('has-valid-input');
37− }
16+function initHotKeys() {
17+
18+ var bodyEl = document.body;
19+ var searchInput = document.getElementById('search');
20+
21+ document.addEventListener('keydown', function (event) {
22+ if (event.defaultPrevented) {
23+ return;
24+ }
25+
26+ var key = event.key || event.keyCode;
27+
28+ // ESC to close search window
29+ if (key === 'Escape' || key === 'Esc' || key === 27) {
30+ document.body.classList.remove('has-active-search-window');
31+ console.log('esc');
32+ }
33+
34+ // / to close toggle window
35+ if (key === '/' || key === 'Period' || key === 58) {
36+ event.preventDefault();
37+ document.body.classList.toggle('has-active-search-window');
38+ if (bodyEl.classList.contains('has-active-search-window')) {
39+ searchInput.focus();
40+ }
41+ }
42+
43+ //when search is active TAB puts search input into focus
44+ if (bodyEl.classList.contains('has-active-search-window')) {
45+ if (key === 'Tab' || key === 'Tab' || key === 9) {
46+ searchInput.focus();
3847 }
39− } else {
40− // doesn't have valid input
41− for (var i = 0; i < inputs.length; i++) {
42− inputs[i].parentNode.classList.remove('has-valid-input');
48+ }
49+ });
50+};
51+
52+
53+// search functionality
54+function debounce(func, wait) {
55+ var timeout;
56+
57+ return function () {
58+ var context = this;
59+ var args = arguments;
60+ clearTimeout(timeout);
61+
62+ timeout = setTimeout(function () {
63+ timeout = null;
64+ func.apply(context, args);
65+ }, wait);
66+ };
67+}
68+
69+// The strategy is as follows:
70+// First, assign a value to each word in the document:
71+// Words that correspond to search terms (stemmer aware): 40
72+// Normal words: 2
73+// First word in a sentence: 8
74+// Then use a sliding window with a constant number of words and count the
75+// sum of the values of the words within the window. Then use the window that got the
76+// maximum sum. If there are multiple maximas, then get the last one.
77+// Enclose the terms in <b>.
78+function makeTeaser(body, terms) {
79+ var TERM_WEIGHT = 40;
80+ var NORMAL_WORD_WEIGHT = 2;
81+ var FIRST_WORD_WEIGHT = 8;
82+ //var TEASER_MAX_WORDS = 17;
83+ var TEASER_MAX_CHARS = 63;
84+
85+ var stemmedTerms = terms.map(function (w) {
86+ return elasticlunr.stemmer(w.toLowerCase());
87+ });
88+
89+ var termFound = false;
90+ var index = 0;
91+ var weighted = []; // contains elements of ["word", weight, index_in_document]
92+
93+ // split in sentences, then words
94+ var sentences = body.toLowerCase().split(". ");
95+
96+ for (var i in sentences) {
97+ var words = sentences[i].split(" ");
98+ var value = FIRST_WORD_WEIGHT;
99+
100+ for (var j in words) {
101+ var word = words[j];
102+
103+ if (word.length > 0) {
104+ for (var k in stemmedTerms) {
105+ if (elasticlunr.stemmer(word).startsWith(stemmedTerms[k])) {
106+ value = TERM_WEIGHT;
107+ termFound = true;
108+ }
43109 }
110+ weighted.push([word, value, index]);
111+ value = NORMAL_WORD_WEIGHT;
112+ }
113+
114+ index += word.length;
115+ index += 1; // ' ' or '.' if last word in sentence
116+ }
117+
118+ index += 1; // because we split at a two-char boundary '. '
119+ }
120+
121+ if (weighted.length === 0) {
122+ return body;
123+ }
124+
125+ var windowWeights = [];
126+ var windowSize = 0;
127+
128+ // loop over words (weighted) till max chars is reached adding a counter to windowSize every time a new word has passed
129+ var sumChars = 0;
130+ for (var i = 0; i < weighted.length; i++) {
131+ sumChars += weighted[i][0].length;
132+ if (sumChars <= TEASER_MAX_CHARS) {
133+ windowSize++;
134+ }
135+ }
136+
137+ // We add a window with all the weights first
138+ var curSum = 0;
139+ for (var i = 0; i < windowSize; i++) {
140+ curSum += weighted[i][1];
141+ }
142+ windowWeights.push(curSum);
143+
144+ for (var i = 0; i < weighted.length - windowSize; i++) {
145+ curSum -= weighted[i][1];
146+ curSum += weighted[i + windowSize][1];
147+ windowWeights.push(curSum);
148+ }
149+
150+ // If we didn't find the term, just pick the first window
151+ var maxSumIndex = 0;
152+ if (termFound) {
153+ var maxFound = 0;
154+ // backwards
155+ for (var i = windowWeights.length - 1; i >= 0; i--) {
156+ if (windowWeights[i] > maxFound) {
157+ maxFound = windowWeights[i];
158+ maxSumIndex = i;
44159 }
45− } else {
46− this.parentNode.classList.remove('has-valid-input');
47160 }
161+ }
162+
163+ var teaser = [];
164+ var startIndex = weighted[maxSumIndex][2];
165+ for (var i = maxSumIndex; i < maxSumIndex + windowSize; i++) {
166+ var word = weighted[i];
167+ if (startIndex < word[2]) {
168+ // missing text from index to start of `word`
169+ teaser.push(body.substring(startIndex, word[2]));
170+ startIndex = word[2];
171+ }
172+
173+ startIndex = word[2] + word[0].length;
174+ teaser.push(body.substring(word[2], startIndex));
175+
176+ }
177+ teaser.push("…");
178+ return teaser.join("");
179+}
180+
181+function formatSearchResultTitle(item) {
182+ var pathArray = item.ref.split( '/' );
183+
184+ // first directory is in 4th element of split path array
185+ var firstPartTitle = pathArray[3];
186+
187+ // last directory is in 2nd to last element of array
188+ var lastPartTitle = item.doc.title;
189+
190+ var fullTitle = firstPartTitle + '<span class="gray90 inline-block pl2">' + ' / ' + lastPartTitle + '</span>';
191+
192+ return fullTitle;
193+}
194+
195+function formatSearchResultItem(item, terms) {
196+ console.log(item);
197+ var li = document.createElement("li");
198+ var createA = document.createElement("a");
199+ li.appendChild(createA);
200+ var teaserTitle = formatSearchResultTitle(item);
201+ li.classList.add("search-results__item");
202+ li.classList.add("ph3");
203+ var hrefA = item.ref;
204+ createA.setAttribute('href', hrefA);
205+ createA.setAttribute('class','no-underline block pl6 pv3');
206+ createA.innerHTML = `<span class="fs45 capitalize">${teaserTitle}</span>`;
207+ createA.innerHTML += `<span class='pr7 fs5 none float-right'>→</span>`;
208+ createA.innerHTML += `<div class="fs35 truncate mr10">${makeTeaser(item.doc.body, terms)}</div>`;
209+ return li;
210+}
211+
212+function initSearch() {
213+ var searchInput = document.getElementById("search");
214+ var searchForm = document.getElementById("search-form");
215+ var inputReset = searchForm.nextElementSibling;
216+
217+ if (!searchInput) {
218+ return;
219+ }
220+
221+ var searchResults = document.querySelector(".search-results");
222+ var searchResultsHeader = document.querySelector(".search-results__header");
223+ var searchResultsItems = document.querySelector(".search-results__items");
224+ var MAX_ITEMS = 200;
225+ var options = {
226+ bool: "AND",
227+ expand: true, // turn on partial word search
228+ fields: {
229+ title: {boost: 2},
230+ body: {boost: 1},
231+ }
232+ };
233+ var currentTerm = "";
234+ var index = elasticlunr.Index.load(window.searchIndex);
235+ // toggle search window
236+ var toggleSearchWindow = document.getElementById('js-search-window-toggle');
237+ var bodyEl = document.body;
238+ var searchOverlay = document.getElementById('search-overlay');
239+ var searchWindow = document.getElementById('search-window');
240+ var searchActive = bodyEl.classList.contains('has-active-search-window');
241+
242+ if (toggleSearchWindow !== null) {
243+ toggleSearchWindow.onclick = function() {
244+ inputReset.style.display="none";
245+
246+ //activate search
247+ bodyEl.classList.toggle('has-active-search-window');
248+ //put input in focus when activating search
249+ searchInput.focus();
250+
251+ searchInput.value = "";
252+ searchResultsHeader.value = "";
253+ searchResults.style.display = searchInput.value.trim() === "" ? "none" : "block";
254+ }
255+ searchOverlay.addEventListener('click', function(e){
256+ if (!searchWindow.contains(e.target)){
257+ bodyEl.classList.toggle('has-active-search-window');
258+ }
259+ })
260+ }
261+
262+
263+ searchInput.addEventListener("keyup", debounce(function() {
264+ inputReset.style.display="block";
265+
266+ searchResultsHeader.value = "";
267+ var term = searchInput.value.trim();
268+ /*
269+ //removed to fix: a state where there’s a valid search term, but no results
270+
271+ if (term === currentTerm || !index) {
272+ return;
273+ }*/
274+
275+ searchResults.style.display = term === "" ? "none" : "block";
276+ searchResults.style.overflowY = term === "" ? "hidden" : "scroll";
277+ searchResultsItems.innerHTML = "";
278+ if (term === "") {
279+ inputReset.style.display="none";
280+ return;
281+ }
282+
283+ var results = index.search(term, options).filter(function (r) {
284+ return r.doc.body !== "";
285+ });
286+
287+ if (results.length === 0) {
288+ searchResultsHeader.innerText = `No search results for '${term}'.`;
289+ return;
290+ }
291+
292+ currentTerm = term;
293+ searchResultsHeader.innerText = `${results.length} search results for '${term}':`;
294+ for (var i = 0; i < Math.min(results.length, MAX_ITEMS); i++) {
295+ if (!results[i].doc.body) {
296+ continue;
297+ }
298+ searchResultsItems.appendChild(formatSearchResultItem(results[i], term.split(" ")));
299+ }
300+ }, 150));
301+
302+ // reset input button
303+ inputReset.onclick=function(){
304+ document.getElementById('search-form').reset();
305+ }
306+ searchForm.addEventListener("reset", function(event){
307+ var e = document.createEvent('KeyboardEvent');
308+ e.initEvent("keyup", false, true);
309+ searchInput.dispatchEvent(e);
310+ });
311+
312+}
313+
314+if (document.readyState === "complete" ||
315+ (document.readyState !== "loading" && !document.documentElement.doScroll)
316+) {
317+ initToggleSearchWindow();
318+} else {
319+ document.addEventListener("DOMContentLoaded", function () {
320+ initSearch();
321+ initHotKeys();
48322 });
49323}
templates/base.html+4
@@ -32,6 +32,10 @@
3232 {% block content %}
3333 {% endblock content %}
3434 <!-- scripts -->
35+ {% if config.build_search_index %}
36+ <script type="text/javascript" src="{{ get_url(path="elasticlunr.min.js") | safe }}"></script>
37+ <script type="text/javascript" src="{{ get_url(path="search_index.en.js") | safe }}"></script>
38+ {% endif %}
3539 <script src="{{ config.base_url }}/scripts.js"></script>
3640 </body>
3741</html>
templates/doc.html+2
@@ -3,6 +3,8 @@
33{% block content %}
44<!-- header -->
55{% include "partials/navigation.html" %}
6+<!-- search overlay (btn to activate located in navigations-docs partial) -->
7+{% include "partials/search-overlay.html" %}
68<!-- content -->
79<div class="main-wrapper">
810 <div class="wrapper">
templates/partials/navigation-docs.html+5−2
@@ -1,12 +1,15 @@
11{% block content %}
22<div id="js-content-nav-toggle" class="content-nav-icon"></div>
33<nav class="content-nav">
4− <ul class="list">
4+ <button id="js-search-window-toggle" type="button" class="search-bar content-nav__search-bar inline align-center" style="outline:none">
5+ <div class="inline-block"><span class="header__search-icon mh3 inline-block fs6">⚲</span></div><span class="gray50">Search</span>
6+ </button>
7+ <ul class="list content-nav__index">
58 {% block menu %}
69 {% set index = get_section(path="docs/_index.md") %}
710 {% for s in index.subsections %}
811 {% set subsection = get_section(path=s) %}
9− <li class="list__item">
12+ <li class="list__item content-nav__index__item mt5">
1013 <a class="text-600" href="{{ subsection.permalink }}">
1114 {{ subsection.title }}
1215 </a>
templates/partials/search-overlay.html+7−5
@@ -1,12 +1,14 @@
11{% block content %}
2−<div id="search-overlay" class="search-overlay">
2+<div id="search-overlay" class="search-overlay fix vh100 w-full b-transparent z10 none top0">
33 <div class="wrapper">
44 <div id="search-window" class="pop-up">
5− <div class="search-bar">
6− <input id="search" autocomplete="off" type="search" class="search-bar__input" placeholder="Go to...">
5+ <div class="search-bar padding-none border-none z1 inline align-center w-full">
6+ <span class="header__search-icon ml3 abs z1 fs6 gray50">⚲</span>
7+ <form id="search-form" class="w-full"><input id="search" autocomplete="off" type="search" class="search-bar__input border-none" placeholder="Go to..."></form>
8+ <a id="search-reset" class="fs6 pr3 gray50 no-underline pointer:hover">&#57503;</a>
79 </div>
8− <div class="search-results">
9− <div class="search-results__header mb2"></div>
10+ <div id="search-results" class="search-results bt1 b-gray40">
11+ <div class="search-results__header ml9 mv2 gray60 fs3"></div>
1012 <ul class="list search-results__items"></ul>
1113 </div>
1214 </div>
templates/sections/docs.html+2
@@ -3,6 +3,8 @@
33{% block content %}
44<!-- header -->
55{% include "partials/navigation.html" %}
6+<!-- search overlay (btn to activate located in navigations-docs partial) -->
7+{% include "partials/search-overlay.html" %}
68<!-- content -->
79<div class="main-wrapper">
810 <div class="wrapper">
templates/sections/docs/chapters.html+2
@@ -3,6 +3,8 @@
33{% block content %}
44<!-- header -->
55{% include "partials/navigation.html" %}
6+<!-- search overlay (btn to activate located in navigations-docs partial) -->
7+{% include "partials/search-overlay.html" %}
68<!-- content -->
79<div class="main-wrapper">
810 <div class="wrapper">