Skip to content

Commit 71eaaac

Browse files
authored
Merge pull request #271 from joyofrails/feat/iterate-combobox
Feat/iterate combobox
2 parents c346fbf + 86587e2 commit 71eaaac

File tree

39 files changed

+777
-305
lines changed

39 files changed

+777
-305
lines changed

app/controllers/searches_controller.rb

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,47 @@
22

33
class SearchesController < ApplicationController
44
rescue_from Searches::ParseFailed do |error|
5+
query = params.fetch(:query, "")
6+
57
respond_to do |format|
6-
format.html { render Searches::ShowView.new(pages: [], query: params[:query]) }
8+
format.html { render Searches::ShowView.new(query:, name:) }
79
format.turbo_stream {
810
render turbo_stream: [
9-
turbo_stream.replace("search-listbox", Searches::Listbox.new(pages: [], query: params[:query]))
11+
turbo_stream.replace(Searches::Listbox.dom_id(name), Searches::Listbox.new(query:, name:))
1012
]
1113
}
1214
end
1315
end
1416

1517
def show
16-
raw_query = params[:query] || ""
17-
pages = if raw_query.present?
18-
query = Searches::Query.parse!(raw_query)
19-
Page.search("#{query}*").with_snippets.ranked.limit(3)
18+
results = if query.present?
19+
parsed_query = Searches::Query.parse!(query)
20+
Page.search("#{parsed_query}*").with_snippets.ranked.limit(3)
2021
else
2122
[]
2223
end
2324

2425
respond_to do |format|
25-
format.html {
26-
render Searches::ShowView.new(pages:, query: raw_query)
27-
}
26+
format.html { render Searches::ShowView.new(results:, query:) }
27+
2828
format.turbo_stream {
2929
render turbo_stream: [
30-
turbo_stream.replace("search-listbox", Searches::Listbox.new(pages: pages, query: raw_query))
30+
turbo_stream.replace(
31+
Searches::Listbox.dom_id(name),
32+
Searches::Listbox.new(results:, query:, name:)
33+
)
3134
]
3235
}
3336
end
3437
end
38+
39+
private
40+
41+
def query
42+
params.fetch(:query, "")
43+
end
44+
45+
def name
46+
params.fetch(:name, "search")
47+
end
3548
end

app/javascript/controllers/dialog.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ export default class extends Controller {
3232
open() {
3333
console.log('Opening dialog');
3434
this.element.showModal();
35+
this.dispatch('open');
3536
}
3637

3738
close() {
3839
console.log('Closing dialog');
3940
this.element.close();
41+
this.dispatch('close');
4042
}
4143

4244
tryClose(event) {

app/javascript/controllers/forms/autosubmit.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export default class extends Controller {
1010
type: Number,
1111
default: 0,
1212
},
13+
mininumLength: {
14+
type: Number,
15+
default: 0,
16+
},
1317
};
1418

1519
connect() {
@@ -28,10 +32,14 @@ export default class extends Controller {
2832
return;
2933
}
3034

31-
if (event.target.value.length >= 3) {
35+
if (event.target.value.length >= this.mininumLengthValue) {
3236
this.element.requestSubmit();
3337
} else {
34-
console.log('Not submitting until 3 or more characters');
38+
console.log(
39+
'Not submitting until',
40+
this.mininumLengthValue,
41+
'characters',
42+
);
3543
}
3644
}
3745
}

app/javascript/controllers/searches/combobox.js

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,26 @@ function cyclingValueAt(array, index) {
2020
}
2121

2222
export default class extends Controller {
23+
static values = {
24+
expanded: Boolean,
25+
};
26+
2327
connect() {
2428
console.log('Connected');
29+
30+
this.tryOpen();
31+
}
32+
33+
// Many Comboboxes act like fancy select inputs for forms. The Search Combobox
34+
// is currently hard-coded for for navigation: when an option is selected and
35+
// enabled with Enter key, we expect it to have a anchor tag and follow its link.
36+
go(event) {
37+
let anchor = this.selectedItem?.querySelector('a');
38+
39+
if (anchor) {
40+
this.close();
41+
anchor.click();
42+
}
2543
}
2644

2745
selectIndex(index) {
@@ -55,13 +73,63 @@ export default class extends Controller {
5573
this.combobox.setAttribute('aria-activedescendant', id);
5674
}
5775

58-
listboxOpen({ detail }) {
59-
console.log('Listbox open', this.options.length > 0);
60-
this.combobox.setAttribute('aria-expanded', this.options.length > 0);
76+
expandedValueChanged() {
77+
console.log('Expanded value changed', this.expandedValue);
78+
if (this.expandedValue) {
79+
this.expand();
80+
} else {
81+
this.collapse();
82+
}
6183
}
6284

63-
navigate(event) {
64-
this.navigationKeyHandlers[event.key]?.call(this, event);
85+
tryOpen() {
86+
if (this.options.length > 0) {
87+
this.open();
88+
} else {
89+
this.close();
90+
}
91+
}
92+
93+
open() {
94+
this.expandedValue = true;
95+
}
96+
97+
expand() {
98+
console.log('Expand');
99+
this.listbox.classList.remove('hidden');
100+
this.combobox.setAttribute('aria-expanded', true);
101+
}
102+
103+
close() {
104+
this.expandedValue = false;
105+
}
106+
107+
openInTarget(event) {
108+
if (!event.target) return;
109+
110+
if (event.target.contains(this.element)) {
111+
this.tryOpen();
112+
}
113+
}
114+
115+
closeInTarget(event) {
116+
if (!event.target) return;
117+
118+
if (event.target.contains(this.element)) {
119+
this.close();
120+
}
121+
}
122+
123+
collapse() {
124+
console.log('Collapse');
125+
this.combobox.setAttribute('aria-expanded', false);
126+
this.listbox.classList.add('hidden');
127+
}
128+
129+
closeAndBlur() {
130+
this.close();
131+
this.combobox.blur();
132+
this.dispatch('close');
65133
}
66134

67135
get options() {
@@ -84,6 +152,10 @@ export default class extends Controller {
84152
return this.element.querySelector('[role="listbox"]');
85153
}
86154

155+
navigate(event) {
156+
this.navigationKeyHandlers[event.key]?.call(this, event);
157+
}
158+
87159
get navigationKeyHandlers() {
88160
return {
89161
ArrowDown: (event) => {
@@ -103,9 +175,13 @@ export default class extends Controller {
103175
cancel(event);
104176
},
105177
Enter: (event) => {
106-
this.selectedItem?.querySelector('a')?.click();
178+
this.go(event);
107179
cancel(event);
108180
},
181+
Escape: (event) => {
182+
this.closeAndBlur();
183+
cancel(event); // Prevent ESC from clearing the search input as is the behavior in some browsers
184+
},
109185
};
110186
}
111187
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
render,
6+
startStimulus,
7+
screen,
8+
userEvent,
9+
beforeEach,
10+
} from '../../test/utils';
11+
12+
import SearchCombobox from './combobox';
13+
14+
import html from '../../test/fixtures/views/searches/combobox.html?raw';
15+
16+
describe('Combobox', () => {
17+
beforeEach(() => {
18+
startStimulus('search-combobox', SearchCombobox);
19+
});
20+
21+
it('should open the combobox', async () => {
22+
const user = userEvent.setup();
23+
24+
await render(html);
25+
26+
const combobox = await screen.findByRole('combobox');
27+
const listbox = await screen.findByRole('listbox');
28+
29+
expect(combobox).toHaveAttribute('aria-expanded');
30+
31+
user.click(combobox);
32+
33+
expect(combobox).toHaveAttribute('aria-expanded', 'true');
34+
expect(listbox).not.toHaveClass('hidden');
35+
expect(listbox).toBeVisible();
36+
});
37+
38+
it('should close the combobox', async () => {
39+
const user = userEvent.setup();
40+
41+
await render(html);
42+
43+
const combobox = await screen.findByRole('combobox');
44+
const listbox = await screen.findByRole('listbox');
45+
46+
await user.click(combobox);
47+
await user.keyboard('[Escape]');
48+
49+
expect(combobox).toHaveAttribute('aria-expanded', 'false');
50+
expect(listbox).toHaveClass('hidden');
51+
});
52+
53+
it('should select option', async () => {
54+
const user = userEvent.setup();
55+
56+
await render(html);
57+
58+
const combobox = await screen.findByRole('combobox');
59+
60+
const option = await screen.findByRole('option', {
61+
name: 'Introducing Joy of Rails',
62+
});
63+
64+
await user.click(combobox);
65+
66+
// Select second option
67+
await user.keyboard('[ArrowDown]');
68+
await user.keyboard('[ArrowDown]');
69+
70+
expect(combobox).toHaveAttribute('aria-expanded', 'true');
71+
expect(combobox).toHaveAttribute('aria-activedescendant', option.id);
72+
73+
expect(option).toHaveClass('selected');
74+
expect(option).toHaveAttribute('aria-selected', 'true');
75+
});
76+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<div class="combobox grid gap-2" data-controller="search-combobox" data-action="
2+
keydown->search-combobox#navigate
3+
search-listbox:connected->search-combobox#tryOpen
4+
dialog:close@window->search-combobox#closeInTarget
5+
dialog:open@window->search-combobox#openInTarget
6+
"><form data-controller="autosubmit-form" data-autosubmit-delay-value="300" data-autosubmit-minimum-length-value="3" data-turbo-frame="search" action="/search" accept-charset="UTF-8" method="post"><div class="flex items-center flex-row pl-2 col-gap-xs"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1200 1200" class="w-[32px] fill-current text-theme">
7+
<path d="m1135.2 986.4-254.4-226.8c124.8-181.2 105.6-430.8-55.199-591.6-181.2-182.4-476.4-182.4-657.6 0-182.4 181.2-182.4 476.4 0 658.8 160.8 160.8 410.4 178.8 591.6 55.199l226.8 253.2c38.398 43.199 105.6 45.602 146.4 3.6016l6-6c40.801-40.801 39.598-108-3.6016-146.4zm-422.4-273.6c-120 120-313.2 120-432 0-120-120-120-313.2 0-432 120-120 313.2-120 432 0 120 120 120 313.2 0 432z"></path>
8+
</svg><label for="query" class="sr-only">Query</label> <input value="Rails" autofocus="autofocus" role="combobox" aria-expanded="false" aria-autocomplete="none" aria-controls="search-listbox" aria-owns="search-listbox" aria-haspopup="listbox" aria-activedescendant="" id="search-combobox" data-action="
9+
focus-&gt;combobox#tryOpen
10+
input-&gt;autosubmit-form#submit
11+
" placeholder="Search Joy of Rails" class="w-full step-1" type="search" name="query" /></div><input value="search" autocomplete="off" type="hidden" name="name" id="name" /></form><ul id="search-listbox" role="listbox" class="grid" data-controller="search-listbox"><li aria-label="Here’the thing" role="option" id="search-option_search_result_articles-heres-the-thing" class="rounded"><a href="/articles/heres-the-thing"><div>Here’the thing</div><div>Ruby is a programming language</div></a></li><li aria-label="Introducing Joy of Rails" role="option" id="search-option_search_result_articles-joy-of-rails" class="rounded"><a href="/articles/joy-of-rails"><div>Introducing Joy of Rails</div><div>Rails is a web application framework</div></a></li></ul></div>

app/models/page.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ def body_text
3131
def resource
3232
Sitepress.site.get(request_path)
3333
end
34+
35+
def url = request_path
3436
end

app/notifiers/newsletter_notifier.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def assert_test_recipients!
5050
test_recipients = User.test_recipients.pluck(:email)
5151

5252
if recipients.any? { |user| test_recipients.exclude?(user.email) }
53-
raise Error, "Attempted to deliver test newsletter to non-test recipients: #{id}"
53+
raise Error, "Attempted to deliver test newsletter to non-test recipients: #{id}"
5454
end
5555
end
5656
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<%= render Searches::Dialog.new unless current_page?(search_path) %>
1+
<%= render Searches::Dialog.new unless current_page?(search_path) || request.post? %>

0 commit comments

Comments
 (0)