diff --git a/app/assets/javascripts/hw_combobox/models/combobox/navigation.js b/app/assets/javascripts/hw_combobox/models/combobox/navigation.js index d1d299e..e6b6890 100644 --- a/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +++ b/app/assets/javascripts/hw_combobox/models/combobox/navigation.js @@ -15,6 +15,11 @@ Combobox.Navigation = Base => class extends Base { }, ArrowDown: (event) => { this._selectIndex(this._selectedOptionIndex + 1) + + if (this._selectedOptionIndex === 0) { + this._actingListbox.scrollTop = 0 + } + cancel(event) }, Home: (event) => { diff --git a/app/assets/stylesheets/hotwire_combobox.css b/app/assets/stylesheets/hotwire_combobox.css index 4721788..9cbcaac 100644 --- a/app/assets/stylesheets/hotwire_combobox.css +++ b/app/assets/stylesheets/hotwire_combobox.css @@ -1,6 +1,7 @@ :root { --hw-active-bg-color: #F3F4F6; --hw-border-color: #D1D5DB; + --hw-group-color: #57595C; --hw-invalid-color: #EF4444; --hw-dialog-label-color: #1D1D1D; --hw-focus-color: #2563EB; @@ -138,6 +139,20 @@ } } +.hw-combobox__group { + display: none; + padding: 0; +} + +.hw-combobox__group__label { + color: var(--hw-group-color); + padding: var(--hw-padding--slim); +} + +.hw-combobox__group:has(.hw-combobox__option:not([hidden])) { + display: block; +} + .hw-combobox__option { background-color: var(--hw-option-bg-color); padding: var(--hw-padding--slim) var(--hw-padding--thick); diff --git a/app/presenters/hotwire_combobox/listbox/group.rb b/app/presenters/hotwire_combobox/listbox/group.rb new file mode 100644 index 0000000..bc4fbd3 --- /dev/null +++ b/app/presenters/hotwire_combobox/listbox/group.rb @@ -0,0 +1,45 @@ +require "securerandom" + +class HotwireCombobox::Listbox::Group + def initialize(name, options:) + @name = name + @options = options + end + + def render_in(view) + view.tag.ul **group_attrs do + view.concat view.tag.li(name, **label_attrs) + + options.map do |option| + view.concat view.render(option) + end + end + end + + private + attr_reader :name, :options + + def id + @id ||= SecureRandom.uuid + end + + def group_attrs + { + class: "hw-combobox__group", + role: :group, + aria: group_aria + } + end + + def group_aria + { labelledby: id } + end + + def label_attrs + { + id: id, + class: "hw-combobox__group__label", + role: :presentation + } + end +end diff --git a/lib/hotwire_combobox/helper.rb b/lib/hotwire_combobox/helper.rb index b2a329a..cefb636 100644 --- a/lib/hotwire_combobox/helper.rb +++ b/lib/hotwire_combobox/helper.rb @@ -178,9 +178,22 @@ def hw_extract_options_and_src(options_or_src, render_in, include_blank) end def hw_parse_combobox_options(options, render_in_proc: nil, **methods) - options.map do |option| - HotwireCombobox::Listbox::Option.new \ - **hw_option_attrs_for(option, render_in_proc: render_in_proc, **methods) + are_groups = options.is_a?(Hash) && options.values.all?(Array) + + if are_groups + options.map do |group_name, group_options| + group_options = group_options.map do |option| + HotwireCombobox::Listbox::Option.new \ + **hw_option_attrs_for(option, render_in_proc: render_in_proc, **methods) + end + + HotwireCombobox::Listbox::Group.new group_name, options: group_options + end + else + options.map do |option| + HotwireCombobox::Listbox::Option.new \ + **hw_option_attrs_for(option, render_in_proc: render_in_proc, **methods) + end end end diff --git a/test/dummy/app/controllers/comboboxes_controller.rb b/test/dummy/app/controllers/comboboxes_controller.rb index f4f66a7..ccb3c5f 100644 --- a/test/dummy/app/controllers/comboboxes_controller.rb +++ b/test/dummy/app/controllers/comboboxes_controller.rb @@ -1,5 +1,5 @@ class ComboboxesController < ApplicationController - before_action :set_states, except: :new_options + before_action :set_states, except: %i[ new_options grouped_options ] def plain end @@ -90,6 +90,9 @@ def multiselect_new_values @user = User.first || raise("No user found, load fixtures first.") end + def grouped_options + end + private delegate :combobox_options, :html_combobox_options, to: "ApplicationController.helpers", private: true diff --git a/test/dummy/app/views/comboboxes/grouped_options.html.erb b/test/dummy/app/views/comboboxes/grouped_options.html.erb new file mode 100644 index 0000000..f60d9a4 --- /dev/null +++ b/test/dummy/app/views/comboboxes/grouped_options.html.erb @@ -0,0 +1 @@ +<%= combobox_tag "state", State.all.group_by(&:location), id: "state-field" %> diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 4057171..0ce30e5 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -27,6 +27,7 @@ get "multiselect_prefilled_form", to: "comboboxes#multiselect_prefilled_form" get "multiselect_custom_events", to: "comboboxes#multiselect_custom_events" get "multiselect_new_values", to: "comboboxes#multiselect_new_values" + get "grouped_options", to: "comboboxes#grouped_options" resources :movies, only: %i[ index update ] get "movies_html", to: "movies#index_html" diff --git a/test/system/hotwire_combobox_test.rb b/test/system/hotwire_combobox_test.rb index f1b9bf1..bba85dd 100644 --- a/test/system/hotwire_combobox_test.rb +++ b/test/system/hotwire_combobox_test.rb @@ -1068,6 +1068,18 @@ class HotwireComboboxTest < ApplicationSystemTestCase assert_equal %w[ Alabama Newplace ], User.first.visited_states.map(&:name) end + test "grouped options" do + visit grouped_options_path + + open_combobox "#state-field" + + assert_group_with text: "South" + assert_option_with text: "Alabama" + + type_in_combobox "#state-field", :down + assert_selected_option_with text: "Alabama" + end + private def open_combobox(selector) find(selector).click @@ -1183,6 +1195,10 @@ def assert_focused_combobox(selector) page.evaluate_script("document.activeElement.id") == locator_for(selector) end + def assert_group_with(**kwargs) + assert_selector "ul[role=group] li[role=presentation]", **kwargs + end + def remove_chip(text) find("[aria-label='Remove #{text}']").click end