Skip to content

Commit 4cbfd4d

Browse files
committed
feat(combobox): shadcn visual revamp with chips, keyboard nav, and accessibility
1 parent 4a639f2 commit 4cbfd4d

File tree

10 files changed

+243
-125
lines changed

10 files changed

+243
-125
lines changed

lib/ruby_ui/combobox/combobox.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ def default_attrs
1919
data: {
2020
controller: "ruby-ui--combobox",
2121
ruby_ui__combobox_term_value: @term,
22-
action: "turbo:morph@window->ruby-ui--combobox#updateTriggerContent"
22+
action: %w[
23+
turbo:morph@window->ruby-ui--combobox#updateTriggerContent
24+
keydown.down->ruby-ui--combobox#keyDownPressed
25+
keydown.up->ruby-ui--combobox#keyUpPressed
26+
keydown.enter->ruby-ui--combobox#keyEnterPressed
27+
keydown.esc->ruby-ui--combobox#closePopover:prevent
28+
]
2329
}
2430
}
2531
end

lib/ruby_ui/combobox/combobox_badge_trigger.rb

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,62 +2,46 @@
22

33
module RubyUI
44
class ComboboxBadgeTrigger < Base
5-
def initialize(placeholder: "", **)
5+
def initialize(placeholder: "", clear_button: false, **)
66
@placeholder = placeholder
7+
@clear_button = clear_button
78
super(**)
89
end
910

1011
def view_template(&)
1112
div(**attrs) do
12-
div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "contents")
13+
div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "hidden")
1314
input(
1415
type: "text",
15-
class: "flex-1 min-w-[80px] bg-transparent border-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm",
16+
class: "flex-1 min-w-8 bg-transparent border-0 px-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm",
1617
autocomplete: "off",
1718
autocorrect: "off",
1819
spellcheck: "false",
1920
placeholder: @placeholder,
2021
data: {
2122
ruby_ui__combobox_target: "badgeInput",
22-
# JS implementation in combobox_controller.js
2323
action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace"
2424
}
2525
)
26-
yield if block_given?
27-
chevron_icon
26+
render ComboboxClearButton.new if @clear_button
2827
end
2928
end
3029

3130
private
3231

32+
# JS-toggled classes (referenced here so Tailwind compiles them): h-auto min-h-9 pt-1.5
3333
def default_attrs
3434
{
35-
class: "flex min-h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 cursor-text",
35+
class: "flex h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 cursor-text",
3636
data: {
3737
ruby_ui__combobox_target: "trigger",
38-
action: "click->ruby-ui--combobox#openPopover"
38+
action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover"
3939
},
4040
aria: {
4141
haspopup: "listbox",
4242
expanded: "false"
4343
}
4444
}
4545
end
46-
47-
def chevron_icon
48-
svg(
49-
xmlns: "http://www.w3.org/2000/svg",
50-
viewbox: "0 0 24 24",
51-
fill: "none",
52-
stroke: "currentColor",
53-
class: "ml-2 h-4 w-4 shrink-0 opacity-50",
54-
stroke_width: "2",
55-
stroke_linecap: "round",
56-
stroke_linejoin: "round"
57-
) do |s|
58-
s.path(d: "m7 15 5 5 5-5")
59-
s.path(d: "m7 9 5-5 5 5")
60-
end
61-
end
6246
end
6347
end

lib/ruby_ui/combobox/combobox_clear_button.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def view_template
2727
def default_attrs
2828
{
2929
type: "button",
30-
class: "ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none hidden",
30+
class: "ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hidden",
3131
aria: {label: "Clear selection"},
3232
data: {
3333
ruby_ui__combobox_target: "clearButton",

lib/ruby_ui/combobox/combobox_controller.js

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export default class extends Controller {
2929
this.updateBadges()
3030
this.updateClearButton()
3131
this.updateInputTrigger()
32+
33+
// Track mouse state to distinguish click-focus from tab-focus
34+
this._mouseDown = false
35+
this.element.addEventListener("mousedown", () => { this._mouseDown = true })
36+
this.element.addEventListener("mouseup", () => { setTimeout(() => { this._mouseDown = false }, 0) })
3237
}
3338

3439
disconnect() {
@@ -48,7 +53,12 @@ export default class extends Controller {
4853
}
4954

5055
openPopover(event) {
51-
if (event && event.type !== "focus") event.preventDefault()
56+
if (event && event.type !== "focusin" && event.type !== "focus") event.preventDefault()
57+
58+
// focusin/focus: only open on keyboard focus (tab), not mouse click
59+
if (event && (event.type === "focusin" || event.type === "focus")) {
60+
if (this._mouseDown || this.triggerTarget.ariaExpanded === "true" || this._closingPopover) return
61+
}
5262

5363
this.updatePopoverPosition()
5464
this.updatePopoverWidth()
@@ -57,17 +67,19 @@ export default class extends Controller {
5767
this.itemTargets.forEach(item => item.ariaCurrent = "false")
5868
this.popoverTarget.showPopover()
5969

70+
// Always show all items on open; filter only on user typing
71+
this.applyFilter("")
72+
6073
if (this.hasBadgeInputTarget) {
6174
this.badgeInputTarget.value = ""
62-
this.applyFilter("")
63-
} else if (this.hasInputTriggerTarget) {
64-
this.applyFilter(this.inputTriggerTarget.value)
6575
}
6676
}
6777

6878
closePopover() {
79+
this._closingPopover = true
6980
this.triggerTarget.ariaExpanded = "false"
7081
this.popoverTarget.hidePopover()
82+
setTimeout(() => this._closingPopover = false, 200)
7183
}
7284

7385
handlePopoverToggle(event) {
@@ -149,14 +161,19 @@ export default class extends Controller {
149161
}
150162

151163
updateTriggerContent() {
164+
if (!this.hasTriggerContentTarget) return
165+
152166
const checkedInputs = this.inputTargets.filter(input => input.checked)
153167

154168
if (checkedInputs.length === 0) {
155169
this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder
170+
this.triggerContentTarget.classList.add("text-muted-foreground")
156171
} else if (this.termValue && checkedInputs.length > 1) {
157172
this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`
173+
this.triggerContentTarget.classList.remove("text-muted-foreground")
158174
} else {
159175
this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ")
176+
this.triggerContentTarget.classList.remove("text-muted-foreground")
160177
}
161178
}
162179

@@ -166,25 +183,48 @@ export default class extends Controller {
166183
this.inputTriggerTarget.value = checked ? this.inputContent(checked) : ""
167184
}
168185

169-
// NOTE: badge HTML mirrors ComboboxBadge Ruby component. Update both if styles change.
186+
// NOTE: badge classes mirror ComboboxBadge Ruby component. Update both if styles change.
170187
updateBadges() {
171188
if (!this.hasBadgeContainerTarget) return
172189

173-
this.badgeContainerTarget.innerHTML = ""
190+
// Remove existing badges
191+
this.triggerTarget.querySelectorAll("[data-combobox-badge]").forEach(el => el.remove())
192+
193+
const checkedInputs = this.inputTargets.filter(input => input.checked)
194+
195+
// Toggle trigger height: h-9 when empty, h-auto min-h-9 when badges exist
196+
if (checkedInputs.length > 0) {
197+
this.triggerTarget.classList.remove("h-9")
198+
this.triggerTarget.classList.add("h-auto", "min-h-9")
199+
} else {
200+
this.triggerTarget.classList.remove("h-auto", "min-h-9", "pt-1.5")
201+
this.triggerTarget.classList.add("h-9")
202+
}
174203

175-
this.inputTargets.filter(input => input.checked).forEach(input => {
204+
checkedInputs.forEach(input => {
176205
const badge = document.createElement("span")
206+
badge.setAttribute("data-combobox-badge", "")
177207
badge.className = "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
178208
badge.dataset.value = input.value
179209

180-
const label = document.createTextNode(this.inputContent(input))
181-
badge.appendChild(label)
210+
badge.appendChild(document.createTextNode(this.inputContent(input).trim()))
182211

183212
const btn = document.createElement("button")
184213
btn.type = "button"
185-
btn.dataset.action = "ruby-ui--combobox#removeBadge"
186214
btn.setAttribute("aria-label", "Remove")
187-
btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
215+
btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none"
216+
217+
btn.addEventListener("click", (e) => {
218+
e.preventDefault()
219+
e.stopPropagation()
220+
e.stopImmediatePropagation()
221+
const target = this.inputTargets.find(i => i.value === input.value)
222+
if (target) {
223+
target.checked = false
224+
this.updateBadges()
225+
this.updateClearButton()
226+
}
227+
})
188228

189229
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
190230
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
@@ -196,6 +236,7 @@ export default class extends Controller {
196236
svg.setAttribute("stroke-width", "2")
197237
svg.setAttribute("stroke-linecap", "round")
198238
svg.setAttribute("stroke-linejoin", "round")
239+
svg.classList.add("pointer-events-none")
199240

200241
const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path")
201242
path1.setAttribute("d", "M18 6 6 18")
@@ -207,8 +248,18 @@ export default class extends Controller {
207248
btn.appendChild(svg)
208249
badge.appendChild(btn)
209250

210-
this.badgeContainerTarget.appendChild(badge)
251+
// Insert badge directly in trigger, before the text input
252+
this.badgeInputTarget.insertAdjacentElement("beforebegin", badge)
211253
})
254+
255+
// Add top padding only when badges wrap to multiple lines
256+
// Class "pt-1.5" is referenced in ComboboxBadgeTrigger for Tailwind to compile it
257+
const badges = this.triggerTarget.querySelectorAll("[data-combobox-badge]")
258+
if (badges.length > 0 && this.badgeInputTarget.offsetTop > badges[0].offsetTop) {
259+
this.triggerTarget.classList.add("pt-1.5")
260+
} else {
261+
this.triggerTarget.classList.remove("pt-1.5")
262+
}
212263
}
213264

214265
updateClearButton() {
@@ -255,17 +306,19 @@ export default class extends Controller {
255306

256307
this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0)
257308

258-
// Auto-highlight first visible result
309+
// Auto-highlight first visible result (without scrolling to avoid page jump)
310+
this.itemTargets.forEach(item => item.ariaCurrent = "false")
259311
const firstVisible = this.inputTargets.find(i => !i.parentElement.classList.contains("hidden"))
260312
if (firstVisible) {
261313
this.selectedItemIndex = 0
262-
this.focusSelectedInput()
314+
firstVisible.parentElement.ariaCurrent = "true"
263315
}
264316
}
265317

266318
// Keyboard
267319

268-
keyDownPressed() {
320+
keyDownPressed(event) {
321+
event.preventDefault()
269322
if (this.selectedItemIndex !== null) {
270323
this.selectedItemIndex++
271324
} else {
@@ -275,7 +328,8 @@ export default class extends Controller {
275328
this.focusSelectedInput()
276329
}
277330

278-
keyUpPressed() {
331+
keyUpPressed(event) {
332+
event.preventDefault()
279333
if (this.selectedItemIndex !== null) {
280334
this.selectedItemIndex--
281335
} else {

lib/ruby_ui/combobox/combobox_docs.rb

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,21 @@ def view_template
1818
ComboboxList do
1919
ComboboxEmptyState { "No results found." }
2020
21-
ComboboxListGroup(label: "Ruby") do
22-
ComboboxItem do
23-
ComboboxRadio(name: "framework", value: "rails")
24-
span { "Rails" }
25-
end
26-
ComboboxItem do
27-
ComboboxRadio(name: "framework", value: "hanami")
28-
span { "Hanami" }
29-
end
21+
ComboboxItem do
22+
ComboboxRadio(name: "framework", value: "rails")
23+
span { "Rails" }
3024
end
31-
32-
ComboboxListGroup(label: "JavaScript") do
33-
ComboboxItem do
34-
ComboboxRadio(name: "framework", value: "nextjs")
35-
span { "Next.js" }
36-
end
37-
ComboboxItem do
38-
ComboboxRadio(name: "framework", value: "nuxt")
39-
span { "Nuxt" }
40-
end
25+
ComboboxItem do
26+
ComboboxRadio(name: "framework", value: "hanami")
27+
span { "Hanami" }
28+
end
29+
ComboboxItem do
30+
ComboboxRadio(name: "framework", value: "nextjs")
31+
span { "Next.js" }
32+
end
33+
ComboboxItem do
34+
ComboboxRadio(name: "framework", value: "nuxt")
35+
span { "Nuxt" }
4136
end
4237
end
4338
end
@@ -81,34 +76,43 @@ def view_template
8176
<<~RUBY
8277
div(class: "w-96") do
8378
Combobox do
84-
ComboboxBadgeTrigger(placeholder: "Select frameworks...") do
85-
ComboboxClearButton()
86-
end
79+
ComboboxBadgeTrigger(clear_button: true)
8780
8881
ComboboxPopover do
8982
ComboboxList do
9083
ComboboxEmptyState { "No results found." }
9184
92-
ComboboxListGroup(label: "Ruby") do
93-
ComboboxItem do
94-
ComboboxCheckbox(name: "frameworks[]", value: "rails")
95-
span { "Rails" }
96-
end
97-
ComboboxItem do
98-
ComboboxCheckbox(name: "frameworks[]", value: "hanami")
99-
span { "Hanami" }
100-
end
85+
ComboboxItem do
86+
ComboboxCheckbox(name: "frameworks[]", value: "rails")
87+
span { "Rails" }
10188
end
102-
103-
ComboboxListGroup(label: "JavaScript") do
104-
ComboboxItem do
105-
ComboboxCheckbox(name: "frameworks[]", value: "nextjs")
106-
span { "Next.js" }
107-
end
108-
ComboboxItem do
109-
ComboboxCheckbox(name: "frameworks[]", value: "nuxt")
110-
span { "Nuxt" }
111-
end
89+
ComboboxItem do
90+
ComboboxCheckbox(name: "frameworks[]", value: "hanami")
91+
span { "Hanami" }
92+
end
93+
ComboboxItem do
94+
ComboboxCheckbox(name: "frameworks[]", value: "sinatra")
95+
span { "Sinatra" }
96+
end
97+
ComboboxItem do
98+
ComboboxCheckbox(name: "frameworks[]", value: "nextjs", checked: true)
99+
span { "Next.js" }
100+
end
101+
ComboboxItem do
102+
ComboboxCheckbox(name: "frameworks[]", value: "nuxt")
103+
span { "Nuxt" }
104+
end
105+
ComboboxItem do
106+
ComboboxCheckbox(name: "frameworks[]", value: "svelte")
107+
span { "SvelteKit" }
108+
end
109+
ComboboxItem do
110+
ComboboxCheckbox(name: "frameworks[]", value: "remix")
111+
span { "Remix" }
112+
end
113+
ComboboxItem do
114+
ComboboxCheckbox(name: "frameworks[]", value: "astro")
115+
span { "Astro" }
112116
end
113117
end
114118
end

0 commit comments

Comments
 (0)