Skip to content

Commit 3ab8269

Browse files
ukutahtclaude
andcommitted
Fix keyboard navigation bugs in creatable combobox
- Fix arrow key navigation to skip hidden options by filtering :not([data-hidden]) - Fix focus handling when create option disappears due to exact match - Add comprehensive tests for keyboard navigation edge cases - Update CLAUDE.md with correct Wallaby arrow key syntax - Add creatable_option component for user-created options 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 4aa2da7 commit 3ab8269

File tree

11 files changed

+389
-11
lines changed

11 files changed

+389
-11
lines changed

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ end
132132
# Instead of slow:
133133
|> refute_has(Query.css("#element[data-focus]")) # waits 3000ms
134134
```
135+
- **Arrow key navigation**: Use correct Wallaby syntax for arrow keys in tests:
136+
```elixir
137+
# Correct syntax:
138+
|> send_keys([:down_arrow])
139+
|> send_keys([:up_arrow])
140+
|> send_keys([:left_arrow])
141+
|> send_keys([:right_arrow])
142+
143+
# Incorrect (doesn't work):
144+
|> send_keys([:arrow_down]) # Wrong
145+
|> send_keys([:arrow_up]) # Wrong
146+
```
135147
- **JavaScript debugging**: Enable JS console logging in a test by adding `Application.put_env(:wallaby, :js_logger, :stdio)` at the beginning of the test feature. This allows you to see `console.log()` output from JavaScript hooks during test execution. More convenient than modifying config files:
136148
```elixir
137149
feature "my test with logging", %{session: session} do
@@ -148,4 +160,4 @@ end
148160
### Code Comments
149161
- Remember to not add unnecessary comments
150162
- Only add comments to document tricky operations or add additional context
151-
- Pedantic comments are useless
163+
- Pedantic comments are useless

assets/js/hooks/combobox.js

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
export default {
22
mounted() {
33
this.mode = this.getMode()
4+
this.createOption = this.el.querySelector('[data-prima-ref=create-option]')
5+
this.hasCreateOption = !!this.createOption
46
this.el.addEventListener('mouseover', this.onHover.bind(this))
57
this.el.addEventListener('keydown', this.onKey.bind(this))
68
this.el.addEventListener('click', this.onClick.bind(this))
79
this.el.querySelector('input[data-prima-ref=search_input]').addEventListener('focus', this.showOptions.bind(this))
810

11+
this.initializeCreateOption()
12+
913
if(document.activeElement === this.el.querySelector('input[data-prima-ref=search_input]')) {
1014
this.showOptions()
1115
}
@@ -29,8 +33,18 @@ export default {
2933

3034
selectOption(el) {
3135
const value = el.getAttribute('data-value')
32-
this.el.querySelector('input[data-prima-ref=submit_input]').value = value
33-
this.el.querySelector('input[data-prima-ref=search_input]').value = value
36+
const searchInput = this.el.querySelector('input[data-prima-ref=search_input]')
37+
const submitInput = this.el.querySelector('input[data-prima-ref=submit_input]')
38+
39+
if (value === '__CREATE__') {
40+
const searchValue = searchInput.value
41+
submitInput.value = searchValue
42+
searchInput.value = searchValue
43+
} else {
44+
submitInput.value = value
45+
searchInput.value = value
46+
}
47+
3448
this.hideOptions()
3549
},
3650

@@ -41,24 +55,25 @@ export default {
4155
},
4256

4357
onKey(e) {
44-
const allOptions = Array.from(this.el.querySelectorAll('[role=option]'))
45-
const firstOption = allOptions[0]
46-
const lastOption = allOptions[allOptions.length - 1]
47-
const currentFocusIndex = allOptions.findIndex(option => option.getAttribute('data-focus') === 'true')
58+
const visibleOptions = Array.from(this.el.querySelectorAll('[role=option]:not([data-hidden])'))
59+
const firstOption = visibleOptions[0]
60+
const lastOption = visibleOptions[visibleOptions.length - 1]
61+
const currentFocusIndex = visibleOptions.findIndex(option => option.getAttribute('data-focus') === 'true')
62+
4863

4964
if (e.key === 'ArrowUp') {
5065
e.preventDefault()
5166
if (firstOption.getAttribute('data-focus') === 'true') {
5267
this.setFocus(lastOption)
5368
} else {
54-
this.setFocus(allOptions[currentFocusIndex - 1])
69+
this.setFocus(visibleOptions[currentFocusIndex - 1])
5570
}
5671
} else if (e.key === 'ArrowDown') {
5772
e.preventDefault()
5873
if (lastOption.getAttribute('data-focus') === 'true') {
5974
this.setFocus(firstOption)
6075
} else {
61-
this.setFocus(allOptions[currentFocusIndex + 1])
76+
this.setFocus(visibleOptions[currentFocusIndex + 1])
6277
}
6378
} else if (e.key === "Enter" || e.key === "Tab") {
6479
e.preventDefault()
@@ -81,13 +96,21 @@ export default {
8196
},
8297

8398
onInput(e) {
99+
const searchValue = e.target.value
100+
101+
// Update create option content and visibility
102+
if (this.hasCreateOption) {
103+
this.updateCreateOption(searchValue)
104+
this.updateCreateOptionVisibility(searchValue)
105+
}
106+
84107
if (this.mode === 'async') {
85108
const options = this.el.querySelector('[data-prima-ref=options]')
86109
this.liveSocket.execJS(options, options.getAttribute('js-show'));
87110
this.focusedOptionBeforeUpdate = this.currentlyFocusedOption()?.dataset.value
88111
} else {
89-
const q = e.target.value.toLowerCase()
90-
const allOptions = this.el.querySelectorAll('[role=option]')
112+
const q = searchValue.toLowerCase()
113+
const allOptions = this.el.querySelectorAll('[role=option]:not([data-prima-ref=create-option])')
91114
let previouslyFocusedOptionIsHidden = false
92115

93116
for (const option of allOptions) {
@@ -123,6 +146,13 @@ export default {
123146
this.liveSocket.execJS(options, options.getAttribute('js-show'));
124147
this.el.querySelector('input[data-prima-ref=search_input]').select()
125148

149+
// Update create option when showing options
150+
if (this.hasCreateOption) {
151+
const searchValue = this.el.querySelector('input[data-prima-ref=search_input]').value
152+
this.updateCreateOption(searchValue)
153+
this.updateCreateOptionVisibility(searchValue)
154+
}
155+
126156
this.focusFirstOption()
127157

128158
const handleClickOutside = (event) => {
@@ -167,5 +197,48 @@ export default {
167197

168198
currentlyFocusedOption() {
169199
return this.el.querySelector('[role=option][data-focus=true]')
200+
},
201+
202+
updateCreateOption(searchValue) {
203+
if (!this.createOption) return
204+
this.createOption.textContent = `Create "${searchValue}"`
205+
},
206+
207+
updateCreateOptionVisibility(searchValue) {
208+
if (!this.createOption) return
209+
210+
if (searchValue.length > 0 && !this.hasExactMatch(searchValue)) {
211+
this.showOption(this.createOption)
212+
} else {
213+
// Check if create option is currently focused before hiding it
214+
const createOptionHasFocus = this.createOption.getAttribute('data-focus') === 'true'
215+
this.hideOption(this.createOption)
216+
217+
// If create option was focused, move focus to first visible option
218+
if (createOptionHasFocus) {
219+
this.focusFirstOption()
220+
}
221+
}
222+
},
223+
224+
hasExactMatch(searchValue) {
225+
const regularOptions = this.el.querySelectorAll('[role=option]:not([data-prima-ref=create-option])')
226+
const hasStaticMatch = Array.from(regularOptions).some(option =>
227+
option.getAttribute('data-value') === searchValue
228+
)
229+
230+
// Also check if search value matches current selected value (submit input)
231+
const submitInput = this.el.querySelector('input[data-prima-ref=submit_input]')
232+
const hasSelectedMatch = submitInput.value === searchValue
233+
234+
235+
return hasStaticMatch || hasSelectedMatch
236+
},
237+
238+
initializeCreateOption() {
239+
if (!this.hasCreateOption) return
240+
241+
// Hide create option initially since search input starts empty
242+
this.hideOption(this.createOption)
170243
}
171244
}

lib/prima/combobox.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,18 @@ defmodule Prima.Combobox do
7474
</div>
7575
"""
7676
end
77+
78+
attr :class, :string, default: ""
79+
80+
def creatable_option(assigns) do
81+
~H"""
82+
<div
83+
role="option"
84+
data-prima-ref="create-option"
85+
data-value="__CREATE__"
86+
class={@class}
87+
>
88+
</div>
89+
"""
90+
end
7791
end

lib/prima_web/live/demo_live/combobox_demo.html.heex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
{option}
1919
</.combobox_option>
2020
<% end %>
21+
2122
</.combobox_options>
2223
</.combobox>
2324
</form>

lib/prima_web/live/demo_live/combobox_page.html.heex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
<.combobox_demo {assigns} />
1616
</.demo_section>
1717

18+
<.demo_section title="Creatable Combobox" id="combobox-creatable">
19+
<.creatable_combobox_demo {assigns} />
20+
</.demo_section>
21+
1822
<.demo_section title="Async Combobox with Search" id="combobox-async">
1923
<.async_combobox_demo {assigns} />
2024
</.demo_section>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<form>
2+
<.combobox class="relative w-64" id="creatable-combobox">
3+
<.combobox_input
4+
name="creatable-combobox[fruit]"
5+
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 cursor-default placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
6+
placeholder="Type to search or create..."
7+
/>
8+
9+
<.combobox_options
10+
transition_leave={{"ease-in duration-100", "opacity-100", "opacity-0"}}
11+
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
12+
>
13+
<%= for option <- ["Apple", "Pear", "Mango", "Pineapple"] do %>
14+
<.combobox_option
15+
value={option}
16+
class="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 data-focus:bg-indigo-600 data-focus:text-white"
17+
>
18+
{option}
19+
</.combobox_option>
20+
<% end %>
21+
22+
<.creatable_option class="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-500 data-focus:bg-indigo-600 data-focus:text-white italic border-t border-gray-200" />
23+
</.combobox_options>
24+
</.combobox>
25+
</form>

lib/prima_web/live/fixtures_live.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ defmodule PrimaWeb.FixturesLive do
2323
{:ok, socket}
2424
end
2525

26+
@impl true
27+
def handle_params(%{"live_action" => live_action}, _uri, socket) do
28+
{:noreply, assign(socket, live_action: live_action)}
29+
end
30+
2631
@impl true
2732
def handle_params(_params, _uri, socket) do
2833
{:noreply, socket}

lib/prima_web/live/fixtures_live.html.heex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@
1717
<div :if={@live_action == :async_combobox}>
1818
<.async_combobox_fixture {assigns} />
1919
</div>
20+
21+
<div :if={@live_action == :creatable_combobox}>
22+
<.creatable_combobox_fixture />
23+
</div>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<form>
2+
<.combobox class="relative w-64" id="demo-creatable-combobox">
3+
<.combobox_input
4+
name="demo-creatable-combobox[fruit]"
5+
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 cursor-default placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
6+
placeholder="Type to search or create..."
7+
/>
8+
9+
<.combobox_options
10+
id="demo-creatable-combobox-options"
11+
transition_leave={{"ease-in duration-100", "opacity-100", "opacity-0"}}
12+
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
13+
>
14+
<%= for option <- ["Apple", "Pear", "Mango", "Pineapple"] do %>
15+
<.combobox_option
16+
value={option}
17+
class="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 data-focus:bg-indigo-600 data-focus:text-white"
18+
>
19+
{option}
20+
</.combobox_option>
21+
<% end %>
22+
23+
<.creatable_option class="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-500 data-focus:bg-indigo-600 data-focus:text-white italic border-t border-gray-200" />
24+
</.combobox_options>
25+
</.combobox>
26+
</form>

lib/prima_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ defmodule PrimaWeb.Router do
2727
live "/fixtures/async-modal", FixturesLive, :async_modal
2828
live "/fixtures/simple-combobox", FixturesLive, :simple_combobox
2929
live "/fixtures/async-combobox", FixturesLive, :async_combobox
30+
live "/fixtures/creatable-combobox", FixturesLive, :creatable_combobox
3031
end
3132
end
3233
end

0 commit comments

Comments
 (0)