From 14dcd5631ae3ab96fe50487a929db2d0d715f9fc Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Thu, 1 Feb 2024 20:25:48 -0800 Subject: [PATCH] Stimulus autocomplete --- app/helpers/application_helper.rb | 36 ++++-- app/javascript/application.js | 1 - .../controllers/autocomplete_controller.js | 103 ++++++++++++++++++ app/javascript/src/autocomplete.js | 82 -------------- app/javascript/src/search.js | 2 +- app/views/home/index.html.erb | 14 +-- app/views/layouts/_search.html.erb | 24 ++++ app/views/layouts/application.html.erb | 11 +- app/views/searches/advanced.html.erb | 6 +- test/system/advanced_search_test.rb | 2 +- test/system/autocompletes_test.rb | 10 +- 11 files changed, 167 insertions(+), 124 deletions(-) create mode 100644 app/javascript/controllers/autocomplete_controller.js delete mode 100644 app/javascript/src/autocomplete.js create mode 100644 app/views/layouts/_search.html.erb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f70493f8a0..e29e309dc89 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -51,14 +51,6 @@ def stats_graph_meter(gem, count) gem.downloads * 1.0 / count * 100 end - def search_form_class - if [root_path, advanced_search_path].include? request.path_info - "header__search-wrap--home" - else - "header__search-wrap" - end - end - def active?(path) "is-active" if request.path_info == path end @@ -79,6 +71,34 @@ def flash_message(name, msg) msg end + def search_field(home: false) + data = { + autocomplete_target: "query", + action: %w[ + autocomplete#suggest + keydown.down->autocomplete#next + keydown.up->autocomplete#prev + keydown.esc->autocomplete#hide + keydown.enter->autocomplete#clear + click@window->autocomplete#hide + focus->autocomplete#suggest + blur->autocomplete#hide + ].join(" ") + } + data[:nav_target] = "search" unless home + + search_field_tag( + :query, + params[:query], + placeholder: t("layouts.application.header.search_gem_html"), + autofocus: current_page?(root_url), + class: home ? "home__search" : "header__search", + autocomplete: "off", + aria: { autocomplete: "list" }, + data: data + ) + end + private def default_avatar(theme:) diff --git a/app/javascript/application.js b/app/javascript/application.js index 9f8ab98263b..25a991a9917 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -3,7 +3,6 @@ import Rails from "@rails/ujs"; Rails.start(); import "controllers" -import "src/autocomplete"; import "src/clipboard_buttons"; import "src/multifactor_auths"; import "src/oidc_api_key_role_form"; diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js new file mode 100644 index 00000000000..a840f4eb1d1 --- /dev/null +++ b/app/javascript/controllers/autocomplete_controller.js @@ -0,0 +1,103 @@ +import { Controller } from "@hotwired/stimulus" + +// TODO: Add suggest help text and aria-live +// https://accessibility.huit.harvard.edu/technique-aria-autocomplete +export default class extends Controller { + static targets = ["query", "suggestions", "template", "item"] + static classes = ["selected"] + + connect() { + this.indexNumber = -1; + this.suggestLength = 0; + } + + disconnect() { clear() } + + clear() { + this.suggestionsTarget.innerHTML = "" + this.suggestionsTarget.removeAttribute('tabindex'); + this.suggestionsTarget.removeAttribute('aria-activedescendant'); + } + + hide(e) { + // Allows adjusting the cursor in the input without hiding the suggestions. + if (!this.queryTarget.contains(e.target)) this.clear() + } + + next() { + this.indexNumber++; + if (this.indexNumber >= this.suggestLength) this.indexNumber = 0; + this.focusItem(this.itemTargets[this.indexNumber]); + } + + prev() { + this.indexNumber--; + if (this.indexNumber < 0) this.indexNumber = this.suggestLength - 1; + this.focusItem(this.itemTargets[this.indexNumber]); + } + + // On mouseover, highlight the item, shifting the index, + // but don't change the input because it causes an undesireable feedback loop. + highlight(e) { + this.indexNumber = this.itemTargets.indexOf(e.currentTarget) + this.focusItem(e.currentTarget, false) + } + + choose(e) { + this.clear(); + this.queryTarget.value = e.target.textContent; + this.queryTarget.form.submit(); + } + + async suggest(e) { + const el = e.currentTarget; + const term = el.value.trim(); + + if (term.length >= 2) { + el.classList.remove('autocomplete-done'); + el.classList.add('autocomplete-loading'); + const query = new URLSearchParams({ query: term }) + + try { + const response = await fetch('/api/v1/search/autocomplete?' + query, { method: 'GET' }) + const data = await response.json() + this.showSuggestions(data.slice(0, 10)) + } catch (error) { } + el.classList.remove('autocomplete-loading'); + el.classList.add('autocomplete-done'); + } else { + this.clear() + } + } + + showSuggestions(items) { + this.clear(); + if (items.length === 0) { + return; + } + items.forEach((item, idx) => this.appendItem(item, idx)); + this.suggestionsTarget.setAttribute('tabindex', 0); + this.suggestionsTarget.setAttribute('role', 'listbox'); + + this.suggestLength = items.length; + this.indexNumber = -1; + }; + + appendItem(text, idx) { + const clone = this.templateTarget.content.cloneNode(true); + const li = clone.querySelector('li') + li.textContent = text; + li.id = `suggest-${idx}`; + this.suggestionsTarget.appendChild(clone) + } + + focusItem(el, change = true) { + this.itemTargets.forEach(el => el.classList.remove(this.selectedClass)) + el.classList.add(this.selectedClass); + this.suggestionsTarget.setAttribute('aria-activedescendant', el.id); + if (change) { + this.queryTarget.value = el.textContent; + this.queryTarget.focus(); + } + } +} diff --git a/app/javascript/src/autocomplete.js b/app/javascript/src/autocomplete.js deleted file mode 100644 index b8523c4b391..00000000000 --- a/app/javascript/src/autocomplete.js +++ /dev/null @@ -1,82 +0,0 @@ -import $ from "jquery"; - -$(function() { - if ($('#home_query').length){ - autocomplete($('#home_query')); - var suggest = $('#suggest-home'); - } else { - autocomplete($('#query')); - var suggest = $('#suggest'); - } - - var indexNumber = -1; - - function autocomplete(search) { - search.bind('input', function(e) { - var term = $.trim($(search).val()); - if (term.length >= 2) { - search.removeClass('autocomplete-done'); - search.addClass('autocomplete-loading'); - $.ajax({ - url: '/api/v1/search/autocomplete', - type: 'GET', - data: ('query=' + term), - processData: false, - dataType: 'json' - }).done(function(data) { - search.removeClass('autocomplete-loading'); - search.addClass('autocomplete-done'); - addToSuggestList(search, data); - }); - } else { - suggest.find('li').remove(); - } - }); - - search.keydown(function(e) { - if (e.keyCode == 38) { - indexNumber--; - focusItem(search); - } else if (e.keyCode == 40) { - indexNumber++; - focusItem(search); - }; - }); - }; - - function addToSuggestList(search, data) { - suggest.find('li').remove(); - - for (var i = 0; i < data.length && i < 10; i++) { - var newItem = $('
  • ').text(data[i]); - $(newItem).attr('class', 'menu-item'); - suggest.append(newItem); - - /* submit the search form if li item was clicked */ - newItem.click(function() { - search.val($(this).html()); - search.parent().submit() - }); - - newItem.hover(function () { - $('li').removeClass('selected'); - $(this).addClass("selected"); - }); - } - - indexNumber = -1; - }; - - function focusItem(search){ - var suggestLength = suggest.find('li').length; - if (indexNumber >= suggestLength) indexNumber = 0; - if (indexNumber < 0) indexNumber = suggestLength - 1; - - $('li').removeClass('selected'); - suggest.find('li').eq(indexNumber).addClass('selected'); - search.val(suggest.find('.selected').text()); - }; - - /* remove suggest drop down if clicked anywhere on page */ - $('html').click(function(e) { suggest.find('li').remove(); }); -}); diff --git a/app/javascript/src/search.js b/app/javascript/src/search.js index 22f8283ed11..d9ddae734bf 100644 --- a/app/javascript/src/search.js +++ b/app/javascript/src/search.js @@ -1,7 +1,7 @@ import $ from "jquery"; if($("#advanced-search").length){ - var $main = $('#home_query'); + var $main = $('#query'); var $name = $('input#name'); var $summary = $('input#summary'); var $description = $('input#description'); diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 22134450d73..e7b01649894 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -2,19 +2,7 @@

    <%= t '.find_blurb' %>

    -
    - <%= form_tag search_path, :method => :get do %> - <%= search_field_tag :query, params[:query], :placeholder => t('layouts.application.header.search_gem_html'), autofocus: current_page?(root_url), :id => 'home_query', :class => "home__search", :autocomplete => "off" %> -
      - <%= label_tag :home_query do %> - <%= t('layouts.application.header.search_gem_html') %> -
      - <%= link_to t("advanced_search"), advanced_search_path, class: "home__advanced__search t-link--has-arrow"%> -
      - <% end %> - <%= submit_tag '⌕', :name => nil, :class => "home__search__icon" %> - <% end %> -
      +<%= render "layouts/search" %>
      <% if @downloads_count %>

      diff --git a/app/views/layouts/_search.html.erb b/app/views/layouts/_search.html.erb new file mode 100644 index 00000000000..d7f395b2818 --- /dev/null +++ b/app/views/layouts/_search.html.erb @@ -0,0 +1,24 @@ +<% home = current_page?(root_path) || current_page?(advanced_search_path) %> +
      " role="search"> + <%= form_tag search_path, method: :get, data: { controller: "autocomplete", autocomplete_selected_class: "selected", } do %> + <%= search_field(home: home) %> + +
        + + + + <%= label_tag :query, id: "querylabel" do %> + <%= t('layouts.application.header.search_gem_html') %> + <% end %> + + <%= submit_tag '⌕', id: "search_submit", name: nil, class: home ? "home__search__icon" : "header__search__icon", aria: { labelledby: "querylabel" } %> + + <% if home %> +
        + <%= link_to t("advanced_search"), advanced_search_path, class: "home__advanced__search t-link--has-arrow"%> +
        + <% end %> + <% end %> +
        diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1dd042bb0a2..41f147901d4 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -42,16 +42,7 @@