Skip to content

Commit 90eece7

Browse files
authored
Implement grouped options (#114)
1 parent e386ea0 commit 90eece7

File tree

12 files changed

+295
-111
lines changed

12 files changed

+295
-111
lines changed

app/assets/javascripts/hw_combobox/models/combobox/navigation.js

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Combobox.Navigation = Base => class extends Base {
1515
},
1616
ArrowDown: (event) => {
1717
this._selectIndex(this._selectedOptionIndex + 1)
18+
19+
if (this._selectedOptionIndex === 0) {
20+
this._actingListbox.scrollTop = 0
21+
}
22+
1823
cancel(event)
1924
},
2025
Home: (event) => {

app/assets/stylesheets/hotwire_combobox.css

+19-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
:root {
22
--hw-active-bg-color: #F3F4F6;
33
--hw-border-color: #D1D5DB;
4+
--hw-group-color: #57595C;
45
--hw-invalid-color: #EF4444;
56
--hw-dialog-label-color: #1D1D1D;
67
--hw-focus-color: #2563EB;
@@ -70,6 +71,10 @@
7071
&:focus-within {
7172
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
7273
}
74+
75+
&:has(.hw-combobox__input--invalid) {
76+
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-invalid-color);
77+
}
7378
}
7479

7580
.hw-combobox__input {
@@ -86,10 +91,6 @@
8691
outline: none;
8792
}
8893

89-
.hw-combobox__input--invalid {
90-
border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
91-
}
92-
9394
.hw-combobox__handle {
9495
height: 100%;
9596
position: absolute;
@@ -138,6 +139,20 @@
138139
}
139140
}
140141

142+
.hw-combobox__group {
143+
display: none;
144+
padding: 0;
145+
}
146+
147+
.hw-combobox__group__label {
148+
color: var(--hw-group-color);
149+
padding: var(--hw-padding--slim);
150+
}
151+
152+
.hw-combobox__group:has(.hw-combobox__option:not([hidden])) {
153+
display: block;
154+
}
155+
141156
.hw-combobox__option {
142157
background-color: var(--hw-option-bg-color);
143158
padding: var(--hw-padding--slim) var(--hw-padding--thick);

app/presenters/hotwire_combobox/component.rb

+17-17
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,23 @@ class HotwireCombobox::Component
99
validate :name_when_new_on_multiselect_must_match_original_name
1010

1111
def initialize \
12-
view, name,
13-
association_name: nil,
14-
async_src: nil,
15-
autocomplete: :both,
16-
data: {},
17-
dialog_label: nil,
18-
form: nil,
19-
id: nil,
20-
input: {},
21-
label: nil,
22-
mobile_at: "640px",
23-
multiselect_chip_src: nil,
24-
name_when_new: nil,
25-
open: false,
26-
options: [],
27-
value: nil,
28-
**rest
12+
view, name,
13+
association_name: nil,
14+
async_src: nil,
15+
autocomplete: :both,
16+
data: {},
17+
dialog_label: nil,
18+
form: nil,
19+
id: nil,
20+
input: {},
21+
label: nil,
22+
mobile_at: "640px",
23+
multiselect_chip_src: nil,
24+
name_when_new: nil,
25+
open: false,
26+
options: [],
27+
value: nil,
28+
**rest
2929
@view, @autocomplete, @id, @name, @value, @form, @async_src, @label,
3030
@name_when_new, @open, @data, @mobile_at, @multiselect_chip_src, @options, @dialog_label =
3131
view, autocomplete, id, name.to_s, value, form, async_src, label,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require "securerandom"
2+
3+
class HotwireCombobox::Listbox::Group
4+
def initialize(name, options:)
5+
@name = name
6+
@options = options
7+
end
8+
9+
def render_in(view)
10+
view.tag.ul **group_attrs do
11+
view.concat view.tag.li(name, **label_attrs)
12+
13+
options.map do |option|
14+
view.concat view.render(option)
15+
end
16+
end
17+
end
18+
19+
private
20+
attr_reader :name, :options
21+
22+
def id
23+
@id ||= SecureRandom.uuid
24+
end
25+
26+
def group_attrs
27+
{
28+
class: "hw-combobox__group",
29+
role: :group,
30+
aria: group_aria
31+
}
32+
end
33+
34+
def group_aria
35+
{ labelledby: id }
36+
end
37+
38+
def label_attrs
39+
{
40+
id: id,
41+
class: "hw-combobox__group__label",
42+
role: :presentation
43+
}
44+
end
45+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
class HotwireCombobox::Listbox::Item
2+
class << self
3+
def collection_for(view, options, render_in:, include_blank:, **custom_methods)
4+
new(view, options, render_in: render_in, include_blank: include_blank, **custom_methods).items
5+
end
6+
end
7+
8+
def initialize(view, options, render_in:, include_blank:, **custom_methods)
9+
@view = view
10+
@options = options
11+
@render_in = render_in
12+
@include_blank = include_blank
13+
@custom_methods = custom_methods
14+
end
15+
16+
def items
17+
items = groups_or_options
18+
items.unshift(blank_option) if include_blank.present?
19+
items
20+
end
21+
22+
private
23+
attr_reader :view, :options, :render_in, :include_blank, :custom_methods
24+
25+
def groups_or_options
26+
if grouped?
27+
create_listbox_group options
28+
else
29+
create_listbox_options options
30+
end
31+
end
32+
33+
def grouped?
34+
key, value = options.to_a.first
35+
value.is_a? Array
36+
end
37+
38+
def create_listbox_group(options)
39+
options.map do |group_name, group_options|
40+
HotwireCombobox::Listbox::Group.new group_name,
41+
options: create_listbox_options(group_options)
42+
end
43+
end
44+
45+
def create_listbox_options(options)
46+
options.map do |option|
47+
HotwireCombobox::Listbox::Option.new **option_attrs(option)
48+
end
49+
end
50+
51+
def option_attrs(option)
52+
case option
53+
when Hash
54+
option.tap do |attrs|
55+
attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
56+
end
57+
when String
58+
{}.tap do |attrs|
59+
attrs[:display] = option
60+
attrs[:value] = option
61+
attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
62+
end
63+
when Array
64+
{}.tap do |attrs|
65+
attrs[:display] = option.first
66+
attrs[:value] = option.last
67+
attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
68+
end
69+
else
70+
{}.tap do |attrs|
71+
attrs[:id] = view.hw_call_method_or_proc(option, custom_methods[:id]) if custom_methods[:id]
72+
attrs[:display] = view.hw_call_method_or_proc(option, custom_methods[:display]) if custom_methods[:display]
73+
attrs[:value] = view.hw_call_method_or_proc(option, custom_methods[:value] || :id)
74+
75+
if render_in.present?
76+
attrs[:content] = render_content(object: option, attrs: attrs)
77+
elsif custom_methods[:content]
78+
attrs[:content] = view.hw_call_method_or_proc(option, custom_methods[:content])
79+
end
80+
end
81+
end
82+
end
83+
84+
def render_content(render_opts: render_in, object:, attrs:)
85+
view.render **render_opts.reverse_merge(
86+
object: object,
87+
locals: { combobox_display: attrs[:display], combobox_value: attrs[:value] })
88+
end
89+
90+
def blank_option
91+
display, content = extract_blank_display_and_content
92+
HotwireCombobox::Listbox::Option.new display: display, content: content, value: "", blank: true
93+
end
94+
95+
def extract_blank_display_and_content
96+
if include_blank.is_a? Hash
97+
text = include_blank.delete(:text)
98+
99+
[ text, render_content(render_opts: include_blank, object: text, attrs: { display: text, value: "" }) ]
100+
else
101+
[ include_blank, include_blank ]
102+
end
103+
end
104+
end

0 commit comments

Comments
 (0)