Skip to content

Commit 1c20761

Browse files
committed
Keep focused combobox option scrolled in view
1 parent 16aad4a commit 1c20761

File tree

6 files changed

+197
-113
lines changed

6 files changed

+197
-113
lines changed

assets/js/hooks/combobox.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export default {
228228
this.refs.searchInput.setAttribute('aria-activedescendant', el.id)
229229
}
230230

231-
el.scrollIntoView({ block: 'nearest' })
231+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
232232
},
233233

234234
focusFirstOption() {
@@ -535,7 +535,10 @@ export default {
535535
this.positionOptions()
536536
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-show'));
537537

538-
this.focusFirstOption()
538+
this.refs.optionsContainer.addEventListener('phx:show-end', () => {
539+
this.focusFirstOption()
540+
}, {once: true})
541+
539542
this.setupClickOutsideHandler()
540543
},
541544

lib/prima_web/live/fixtures_live.html.heex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,7 @@
4949
<div :if={@live_action == :combobox_form_tab}>
5050
<.combobox_form_tab_fixture />
5151
</div>
52+
53+
<div :if={@live_action == :overflow_combobox}>
54+
<.overflow_combobox_fixture />
55+
</div>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<form>
2+
<.combobox id="overflow-combobox">
3+
<.combobox_input
4+
name="overflow-combobox[item]"
5+
placeholder="Type to search..."
6+
/>
7+
8+
<.combobox_options id="overflow-combobox-options" class="max-h-48 overflow-y-auto">
9+
<%= for option <- 1..50 do %>
10+
<.combobox_option value={"Item #{option}"}>
11+
Item <%= option %>
12+
</.combobox_option>
13+
<% end %>
14+
</.combobox_options>
15+
</.combobox>
16+
</form>

lib/prima_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ defmodule PrimaWeb.Router do
3434
live "/fixtures/flexible-markup-combobox", FixturesLive, :flexible_markup_combobox
3535
live "/fixtures/multi-select-combobox", FixturesLive, :multi_select_combobox
3636
live "/fixtures/combobox-form-tab", FixturesLive, :combobox_form_tab
37+
live "/fixtures/overflow-combobox", FixturesLive, :overflow_combobox
3738
end
3839
end
3940
end

test/wallaby/prima_web/combobox_keyboard_navigation_test.exs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,93 @@ defmodule PrimaWeb.ComboboxKeyboardNavigationTest do
55
@options_container Query.css("#demo-combobox [data-prima-ref=options]")
66
@all_options Query.css("#demo-combobox [role=option]")
77

8+
feature "navigates options with keyboard arrows", %{session: session} do
9+
session
10+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
11+
|> click(@search_input)
12+
|> assert_has(@options_container |> Query.visible(true))
13+
|> assert_has(@all_options |> Query.count(4))
14+
# First option should be focused by default
15+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Apple'][data-focus=true]"))
16+
# Arrow down to next option - use correct key codes and send to the focused element
17+
|> send_keys([:down_arrow])
18+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
19+
# Arrow down again
20+
|> send_keys([:down_arrow])
21+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Mango'][data-focus=true]"))
22+
# Arrow up back to previous
23+
|> send_keys([:up_arrow])
24+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
25+
end
26+
27+
feature "keyboard navigation wrapping (last to first, first to last)", %{session: session} do
28+
session
29+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
30+
|> click(@search_input)
31+
|> assert_has(@options_container |> Query.visible(true))
32+
|> assert_has(@all_options |> Query.count(4))
33+
# First option should be focused by default
34+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Apple'][data-focus=true]"))
35+
# Arrow up from first should wrap to last (Pineapple)
36+
|> send_keys([:up_arrow])
37+
|> assert_has(
38+
Query.css("#demo-combobox [role=option][data-value='Pineapple'][data-focus=true]")
39+
)
40+
# Arrow down from last should wrap to first
41+
|> send_keys([:down_arrow])
42+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Apple'][data-focus=true]"))
43+
end
44+
45+
feature "selects focused option with Enter key", %{session: session} do
46+
session
47+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
48+
|> click(@search_input)
49+
|> assert_has(@options_container |> Query.visible(true))
50+
# Navigate to second option
51+
|> send_keys([:down_arrow])
52+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
53+
# Select with Enter
54+
|> send_keys([:enter])
55+
# Options should be hidden after selection
56+
|> assert_has(@options_container |> Query.visible(false))
57+
# Check that both inputs have the selected value
58+
|> execute_script(
59+
"const searchVal = document.querySelector('#demo-combobox input[data-prima-ref=search_input]').value; const hiddenInput = document.querySelector('#demo-combobox [data-prima-ref=submit_container] input[type=hidden]'); return {search: searchVal, submit: hiddenInput ? hiddenInput.value : ''}",
60+
fn values ->
61+
assert values["search"] == "Pear",
62+
"Expected search input value to be 'Pear', got '#{values["search"]}'"
63+
64+
assert values["submit"] == "Pear",
65+
"Expected submit input value to be 'Pear', got '#{values["submit"]}'"
66+
end
67+
)
68+
end
69+
70+
feature "selects focused option with Tab key", %{session: session} do
71+
session
72+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
73+
|> click(@search_input)
74+
|> assert_has(@options_container |> Query.visible(true))
75+
# Navigate to third option
76+
|> send_keys([:down_arrow, :down_arrow])
77+
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Mango'][data-focus=true]"))
78+
# Select with Tab
79+
|> send_keys([:tab])
80+
# Options should be hidden after selection
81+
|> assert_has(@options_container |> Query.visible(false))
82+
# Check that both inputs have the selected value
83+
|> execute_script(
84+
"const searchVal = document.querySelector('#demo-combobox input[data-prima-ref=search_input]').value; const hiddenInput = document.querySelector('#demo-combobox [data-prima-ref=submit_container] input[type=hidden]'); return {search: searchVal, submit: hiddenInput ? hiddenInput.value : ''}",
85+
fn values ->
86+
assert values["search"] == "Mango",
87+
"Expected search input value to be 'Mango', got '#{values["search"]}'"
88+
89+
assert values["submit"] == "Mango",
90+
"Expected submit input value to be 'Mango', got '#{values["submit"]}'"
91+
end
92+
)
93+
end
94+
895
feature "ArrowDown opens combobox when closed and input is focused", %{session: session} do
996
session
1097
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
@@ -289,4 +376,88 @@ defmodule PrimaWeb.ComboboxKeyboardNavigationTest do
289376
end
290377
)
291378
end
379+
380+
feature "focused option scrolls into view when using up arrow in overflow container", %{
381+
session: session
382+
} do
383+
session
384+
|> visit_fixture("/fixtures/overflow-combobox", "#overflow-combobox")
385+
|> click(Query.css("#overflow-combobox input[data-prima-ref=search_input]"))
386+
|> assert_has(Query.css("#overflow-combobox-options") |> Query.visible(true))
387+
# First option should be focused by default
388+
|> assert_has(
389+
Query.css("#overflow-combobox [role=option][data-value='Item 1'][data-focus=true]")
390+
)
391+
# Press up arrow to wrap to last option (Item 50)
392+
|> send_keys([:up_arrow])
393+
|> assert_has(
394+
Query.css("#overflow-combobox [role=option][data-value='Item 50'][data-focus=true]")
395+
)
396+
# Verify that Item 50 is visible (scrolled into view)
397+
|> execute_script(
398+
"""
399+
const option = document.querySelector('#overflow-combobox [role=option][data-value=\"Item 50\"]');
400+
const container = document.querySelector('#overflow-combobox-options');
401+
const optionRect = option.getBoundingClientRect();
402+
const containerRect = container.getBoundingClientRect();
403+
return {
404+
isVisible: optionRect.top >= containerRect.top && optionRect.bottom <= containerRect.bottom,
405+
optionTop: optionRect.top,
406+
optionBottom: optionRect.bottom,
407+
containerTop: containerRect.top,
408+
containerBottom: containerRect.bottom
409+
};
410+
""",
411+
fn result ->
412+
assert result["isVisible"] == true,
413+
"Expected Item 50 to be scrolled into view. Option top: #{result["optionTop"]}, bottom: #{result["optionBottom"]}, Container top: #{result["containerTop"]}, bottom: #{result["containerBottom"]}"
414+
end
415+
)
416+
end
417+
418+
feature "scroll position resets when reopening combobox after navigating to bottom", %{
419+
session: session
420+
} do
421+
session
422+
|> visit_fixture("/fixtures/overflow-combobox", "#overflow-combobox")
423+
# Open combobox
424+
|> click(Query.css("#overflow-combobox input[data-prima-ref=search_input]"))
425+
|> assert_has(Query.css("#overflow-combobox-options") |> Query.visible(true))
426+
# Navigate to last option with up arrow
427+
|> send_keys([:up_arrow])
428+
|> assert_has(
429+
Query.css("#overflow-combobox [role=option][data-value='Item 50'][data-focus=true]")
430+
)
431+
# Close combobox
432+
|> send_keys([:escape])
433+
|> assert_has(Query.css("#overflow-combobox-options") |> Query.visible(false))
434+
# Reopen combobox
435+
|> click(Query.css("#overflow-combobox input[data-prima-ref=search_input]"))
436+
|> assert_has(Query.css("#overflow-combobox-options") |> Query.visible(true))
437+
# First option should be focused
438+
|> assert_has(
439+
Query.css("#overflow-combobox [role=option][data-value='Item 1'][data-focus=true]")
440+
)
441+
# Verify that Item 1 is visible (container scrolled to top)
442+
|> execute_script(
443+
"""
444+
const option = document.querySelector('#overflow-combobox [role=option][data-value=\"Item 1\"]');
445+
const container = document.querySelector('#overflow-combobox-options');
446+
const optionRect = option.getBoundingClientRect();
447+
const containerRect = container.getBoundingClientRect();
448+
return {
449+
isVisible: optionRect.top >= containerRect.top && optionRect.bottom <= containerRect.bottom,
450+
optionTop: optionRect.top,
451+
optionBottom: optionRect.bottom,
452+
containerTop: containerRect.top,
453+
containerBottom: containerRect.bottom,
454+
scrollTop: container.scrollTop
455+
};
456+
""",
457+
fn result ->
458+
assert result["isVisible"] == true,
459+
"Expected Item 1 to be scrolled into view. Option top: #{result["optionTop"]}, bottom: #{result["optionBottom"]}, Container top: #{result["containerTop"]}, bottom: #{result["containerBottom"]}, scrollTop: #{result["scrollTop"]}"
460+
end
461+
)
462+
end
292463
end

test/wallaby/prima_web/combobox_test.exs

Lines changed: 0 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -72,75 +72,6 @@ defmodule PrimaWeb.ComboboxTest do
7272
)
7373
end
7474

75-
feature "navigates options with keyboard arrows", %{session: session} do
76-
session
77-
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
78-
|> click(@search_input)
79-
|> assert_has(@options_container |> Query.visible(true))
80-
|> assert_has(@all_options |> Query.count(4))
81-
# First option should be focused by default
82-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Apple'][data-focus=true]"))
83-
# Arrow down to next option - use correct key codes and send to the focused element
84-
|> send_keys([:down_arrow])
85-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
86-
# Arrow down again
87-
|> send_keys([:down_arrow])
88-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Mango'][data-focus=true]"))
89-
# Arrow up back to previous
90-
|> send_keys([:up_arrow])
91-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
92-
end
93-
94-
feature "selects focused option with Enter key", %{session: session} do
95-
session
96-
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
97-
|> click(@search_input)
98-
|> assert_has(@options_container |> Query.visible(true))
99-
# Navigate to second option
100-
|> send_keys([:down_arrow])
101-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
102-
# Select with Enter
103-
|> send_keys([:enter])
104-
# Options should be hidden after selection
105-
|> assert_has(@options_container |> Query.visible(false))
106-
# Check that both inputs have the selected value
107-
|> execute_script(
108-
"const searchVal = document.querySelector('#demo-combobox input[data-prima-ref=search_input]').value; const hiddenInput = document.querySelector('#demo-combobox [data-prima-ref=submit_container] input[type=hidden]'); return {search: searchVal, submit: hiddenInput ? hiddenInput.value : ''}",
109-
fn values ->
110-
assert values["search"] == "Pear",
111-
"Expected search input value to be 'Pear', got '#{values["search"]}'"
112-
113-
assert values["submit"] == "Pear",
114-
"Expected submit input value to be 'Pear', got '#{values["submit"]}'"
115-
end
116-
)
117-
end
118-
119-
feature "selects focused option with Tab key", %{session: session} do
120-
session
121-
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
122-
|> click(@search_input)
123-
|> assert_has(@options_container |> Query.visible(true))
124-
# Navigate to third option
125-
|> send_keys([:down_arrow, :down_arrow])
126-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Mango'][data-focus=true]"))
127-
# Select with Tab
128-
|> send_keys([:tab])
129-
# Options should be hidden after selection
130-
|> assert_has(@options_container |> Query.visible(false))
131-
# Check that both inputs have the selected value
132-
|> execute_script(
133-
"const searchVal = document.querySelector('#demo-combobox input[data-prima-ref=search_input]').value; const hiddenInput = document.querySelector('#demo-combobox [data-prima-ref=submit_container] input[type=hidden]'); return {search: searchVal, submit: hiddenInput ? hiddenInput.value : ''}",
134-
fn values ->
135-
assert values["search"] == "Mango",
136-
"Expected search input value to be 'Mango', got '#{values["search"]}'"
137-
138-
assert values["submit"] == "Mango",
139-
"Expected submit input value to be 'Mango', got '#{values["submit"]}'"
140-
end
141-
)
142-
end
143-
14475
feature "focuses option on mouse hover", %{session: session} do
14576
session
14677
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
@@ -236,24 +167,6 @@ defmodule PrimaWeb.ComboboxTest do
236167
|> assert_missing(Query.css("#demo-async-combobox [role=option][data-value='Banana']"))
237168
end
238169

239-
feature "keyboard navigation wrapping (last to first, first to last)", %{session: session} do
240-
session
241-
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
242-
|> click(@search_input)
243-
|> assert_has(@options_container |> Query.visible(true))
244-
|> assert_has(@all_options |> Query.count(4))
245-
# First option should be focused by default
246-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Apple'][data-focus=true]"))
247-
# Arrow up from first should wrap to last (Pineapple)
248-
|> send_keys([:up_arrow])
249-
|> assert_has(
250-
Query.css("#demo-combobox [role=option][data-value='Pineapple'][data-focus=true]")
251-
)
252-
# Arrow down from last should wrap to first
253-
|> send_keys([:down_arrow])
254-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Apple'][data-focus=true]"))
255-
end
256-
257170
feature "form integration - selected value is available for submission", %{session: session} do
258171
session
259172
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
@@ -347,30 +260,6 @@ defmodule PrimaWeb.ComboboxTest do
347260
|> assert_has(Query.css("#demo-combobox [role=option][data-selected]") |> Query.count(1))
348261
end
349262

350-
feature "selected option has data-selected attribute (keyboard selection)", %{
351-
session: session
352-
} do
353-
session
354-
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
355-
|> click(@search_input)
356-
|> assert_has(@options_container |> Query.visible(true))
357-
# Navigate to Pear and select with Enter
358-
|> send_keys([:down_arrow])
359-
|> assert_has(Query.css("#demo-combobox [role=option][data-value='Pear'][data-focus=true]"))
360-
|> send_keys([:enter])
361-
|> assert_has(@options_container |> Query.visible(false))
362-
# Click outside to fully blur
363-
|> click(Query.css("body"))
364-
# Open options again to verify data-selected is set
365-
|> click(@search_input)
366-
|> assert_has(@options_container |> Query.visible(true))
367-
|> assert_has(
368-
Query.css("#demo-combobox [role=option][data-value='Pear'][data-selected=true]")
369-
)
370-
# Verify only one option has data-selected
371-
|> assert_has(Query.css("#demo-combobox [role=option][data-selected]") |> Query.count(1))
372-
end
373-
374263
feature "only one option has data-selected at a time", %{session: session} do
375264
session
376265
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")

0 commit comments

Comments
 (0)