From 4b5d8b5b3373ddca0f580b5836b6db435fbe76dc Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 12:12:02 -0300 Subject: [PATCH 01/11] chore: ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7948b668..215fd002 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store node_modules /app/assets/builds/* +.worktrees From 184c8956613a4f34b94bc765bcd09046d5a09a06 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 12:20:07 -0300 Subject: [PATCH 02/11] feat(combobox): revamp visual design to match shadcn/ui --- lib/ruby_ui/combobox/combobox_badge.rb | 17 +++++ .../combobox/combobox_badge_trigger.rb | 62 +++++++++++++++++++ lib/ruby_ui/combobox/combobox_checkbox.rb | 8 +-- lib/ruby_ui/combobox/combobox_clear_button.rb | 38 ++++++++++++ lib/ruby_ui/combobox/combobox_item.rb | 12 ++-- .../combobox/combobox_item_indicator.rb | 30 +++++++++ lib/ruby_ui/combobox/combobox_list_group.rb | 2 +- lib/ruby_ui/combobox/combobox_radio.rb | 9 +-- test/ruby_ui/combobox_test.rb | 51 +++++++++++++++ 9 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 lib/ruby_ui/combobox/combobox_badge.rb create mode 100644 lib/ruby_ui/combobox/combobox_badge_trigger.rb create mode 100644 lib/ruby_ui/combobox/combobox_clear_button.rb create mode 100644 lib/ruby_ui/combobox/combobox_item_indicator.rb diff --git a/lib/ruby_ui/combobox/combobox_badge.rb b/lib/ruby_ui/combobox/combobox_badge.rb new file mode 100644 index 00000000..67ce250c --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_badge.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxBadge < Base + def view_template(&) + span(**attrs, &) + end + + private + + def default_attrs + { + class: "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground" + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_badge_trigger.rb b/lib/ruby_ui/combobox/combobox_badge_trigger.rb new file mode 100644 index 00000000..2aa8df16 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_badge_trigger.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxBadgeTrigger < Base + def initialize(placeholder: "", **) + @placeholder = placeholder + super(**) + end + + def view_template(&) + div(**attrs) do + div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "contents") + input( + type: "text", + class: "flex-1 min-w-[80px] bg-transparent outline-none placeholder:text-muted-foreground text-sm", + autocomplete: "off", + autocorrect: "off", + spellcheck: "false", + placeholder: @placeholder, + data: { + ruby_ui__combobox_target: "badgeInput", + action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace" + } + ) + yield if block_given? + chevron_icon + end + end + + private + + def default_attrs + { + 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", + data: { + ruby_ui__combobox_target: "trigger", + action: "click->ruby-ui--combobox#openPopover" + }, + aria: { + haspopup: "listbox", + expanded: "false" + } + } + end + + def chevron_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + class: "ml-2 h-4 w-4 shrink-0 opacity-50", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round" + ) do |s| + s.path(d: "m7 15 5 5 5-5") + s.path(d: "m7 9 5-5 5 5") + end + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_checkbox.rb b/lib/ruby_ui/combobox/combobox_checkbox.rb index 141c432d..01cdc143 100644 --- a/lib/ruby_ui/combobox/combobox_checkbox.rb +++ b/lib/ruby_ui/combobox/combobox_checkbox.rb @@ -10,13 +10,7 @@ def view_template def default_attrs { - class: [ - "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary", - "disabled:cursor-not-allowed disabled:opacity-50", - "checked:bg-primary checked:text-primary-foreground", - "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - ], + class: "peer sr-only", data: { ruby_ui__combobox_target: "input", action: "ruby-ui--combobox#inputChanged" diff --git a/lib/ruby_ui/combobox/combobox_clear_button.rb b/lib/ruby_ui/combobox/combobox_clear_button.rb new file mode 100644 index 00000000..6e0fa472 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_clear_button.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxClearButton < Base + def view_template + button(**attrs) do + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "size-4" + ) do |s| + s.path(d: "M18 6 6 18") + s.path(d: "m6 6 12 12") + end + end + end + + private + + def default_attrs + { + type: "button", + class: "ml-auto shrink-0 rounded-sm opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring hidden", + data: { + ruby_ui__combobox_target: "clearButton", + action: "ruby-ui--combobox#clearAll" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb index 9effb093..3887c328 100644 --- a/lib/ruby_ui/combobox/combobox_item.rb +++ b/lib/ruby_ui/combobox/combobox_item.rb @@ -3,19 +3,17 @@ module RubyUI class ComboboxItem < Base def view_template(&) - label(**attrs, &) + label(**attrs) do + yield if block_given? + render ComboboxItemIndicator.new + end end private def default_attrs { - class: [ - "flex flex-row w-full text-wrap [&>span,&>div]:truncate gap-2 items-center rounded-sm px-2 py-1 text-sm outline-none cursor-pointer", - "select-none has-[:checked]:bg-accent hover:bg-accent p-2", - "[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0 aria-[current=true]:bg-accent aria-[current=true]:ring aria-[current=true]:ring-offset-2", - "has-disabled:opacity-50 has-disabled:cursor-not-allowed" - ], + class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground", role: "option", data: { ruby_ui__combobox_target: "item" diff --git a/lib/ruby_ui/combobox/combobox_item_indicator.rb b/lib/ruby_ui/combobox/combobox_item_indicator.rb new file mode 100644 index 00000000..b8190fe5 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_item_indicator.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxItemIndicator < Base + def view_template + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + **attrs + ) do |s| + s.path(d: "M20 6 9 17l-5-5") + end + end + + private + + def default_attrs + { + class: "ml-auto size-4 shrink-0 opacity-0 peer-checked:opacity-100" + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_list_group.rb b/lib/ruby_ui/combobox/combobox_list_group.rb index d1b62bb8..ce34fdb8 100644 --- a/lib/ruby_ui/combobox/combobox_list_group.rb +++ b/lib/ruby_ui/combobox/combobox_list_group.rb @@ -12,7 +12,7 @@ def view_template(&) def default_attrs { - class: ["hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1 border-b", LABEL_CLASSES], + class: ["hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1", LABEL_CLASSES], role: "group" } end diff --git a/lib/ruby_ui/combobox/combobox_radio.rb b/lib/ruby_ui/combobox/combobox_radio.rb index e7b77d82..cd25827d 100644 --- a/lib/ruby_ui/combobox/combobox_radio.rb +++ b/lib/ruby_ui/combobox/combobox_radio.rb @@ -10,14 +10,7 @@ def view_template def default_attrs { - class: [ - "aspect-square h-4 w-4 rounded-full border border-primary accent-primary text-primary shadow", - "focus:outline-none", - "focus-visible:ring-1 focus-visible:ring-ring", - "disabled:cursor-not-allowed disabled:opacity-50", - "checked:bg-primary checked:text-primary-foreground", - "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none" - ], + class: "peer sr-only", data: { ruby_ui__combobox_target: "input", ruby_ui__form_field_target: "input", diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index 9b3c3bdd..51327cfe 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -70,4 +70,55 @@ def test_render_with_checkbox_items assert_match(/Hanami/, output) end + + def test_combobox_item_renders_indicator + output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } + assert_match(/peer-checked:opacity-100/, output) + assert_match(/sr-only/, output) + assert_match(/peer/, output) + end + + def test_combobox_radio_is_peer_sr_only + output = phlex { RubyUI.ComboboxRadio(name: "x", value: "1") } + assert_match(/\bpeer\b/, output) + assert_match(/sr-only/, output) + refute_match(/border-primary/, output) + refute_match(/rounded-full/, output) + end + + def test_combobox_checkbox_is_peer_sr_only + output = phlex { RubyUI.ComboboxCheckbox(name: "x", value: "1") } + assert_match(/\bpeer\b/, output) + assert_match(/sr-only/, output) + refute_match(/border-primary/, output) + refute_match(/rounded-sm border/, output) + end + + def test_combobox_item_indicator_renders_check_svg + output = phlex { RubyUI.ComboboxItemIndicator() } + assert_match(/peer-checked:opacity-100/, output) + assert_match(/opacity-0/, output) + assert_match(/M20 6 9 17l-5-5/, output) + end + + def test_combobox_badge_trigger_renders_targets + output = phlex { RubyUI.ComboboxBadgeTrigger() } + assert_match(/badgeContainer/, output) + assert_match(/badgeInput/, output) + assert_match(/openPopover/, output) + end + + def test_combobox_badge_renders_span + output = phlex { RubyUI.ComboboxBadge { "Item" } } + assert_match(/bg-secondary/, output) + assert_match(/Item/, output) + assert_match(/ Date: Tue, 31 Mar 2026 12:27:47 -0300 Subject: [PATCH 03/11] fix(combobox): restore selected, keyboard highlight, and disabled states --- .../combobox/combobox_badge_trigger.rb | 1 + lib/ruby_ui/combobox/combobox_clear_button.rb | 4 +++- lib/ruby_ui/combobox/combobox_item.rb | 2 +- .../combobox/combobox_toggle_all_checkbox.rb | 8 +------ test/ruby_ui/combobox_test.rb | 21 +++++++++++++++++++ 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_badge_trigger.rb b/lib/ruby_ui/combobox/combobox_badge_trigger.rb index 2aa8df16..e84b6d79 100644 --- a/lib/ruby_ui/combobox/combobox_badge_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_badge_trigger.rb @@ -19,6 +19,7 @@ def view_template(&) placeholder: @placeholder, data: { ruby_ui__combobox_target: "badgeInput", + # JS implementation in combobox_controller.js action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace" } ) diff --git a/lib/ruby_ui/combobox/combobox_clear_button.rb b/lib/ruby_ui/combobox/combobox_clear_button.rb index 6e0fa472..be8cf424 100644 --- a/lib/ruby_ui/combobox/combobox_clear_button.rb +++ b/lib/ruby_ui/combobox/combobox_clear_button.rb @@ -27,9 +27,11 @@ def view_template def default_attrs { type: "button", - class: "ml-auto shrink-0 rounded-sm opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring hidden", + class: "ml-auto shrink-0 rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hidden", + aria: {label: "Clear selection"}, data: { ruby_ui__combobox_target: "clearButton", + # JS implementation in combobox_controller.js action: "ruby-ui--combobox#clearAll" } } diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb index 3887c328..644994fa 100644 --- a/lib/ruby_ui/combobox/combobox_item.rb +++ b/lib/ruby_ui/combobox/combobox_item.rb @@ -13,7 +13,7 @@ def view_template(&) def default_attrs { - class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground", + class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground has-[:checked]:bg-accent aria-[current=true]:bg-accent aria-[current=true]:ring aria-[current=true]:ring-offset-2 has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed", role: "option", data: { ruby_ui__combobox_target: "item" diff --git a/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb b/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb index 8cb97c0d..2e37d023 100644 --- a/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +++ b/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb @@ -10,13 +10,7 @@ def view_template def default_attrs { - class: [ - "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary", - "disabled:cursor-not-allowed disabled:opacity-50", - "checked:bg-primary checked:text-primary-foreground", - "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - ], + class: "peer sr-only disabled:cursor-not-allowed", data: { ruby_ui__combobox_target: "toggleAll", action: "change->ruby-ui--combobox#toggleAllItems" diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index 51327cfe..80c58275 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -121,4 +121,25 @@ def test_combobox_clear_button_renders assert_match(/\bhidden\b/, output) assert_match(/clearButton/, output) end + + def test_combobox_item_has_selected_state + output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } + assert_match(/has-\[:checked\]:bg-accent/, output) + end + + def test_combobox_item_has_keyboard_highlight + output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } + assert_match(/aria-\[current=true\]:bg-accent/, output) + end + + def test_combobox_item_has_disabled_state + output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } + assert_match(/has-\[input:disabled\]:opacity-50/, output) + end + + def test_combobox_toggle_all_checkbox_is_peer_sr_only + output = phlex { RubyUI.ComboboxToggleAllCheckbox() } + assert_match(/\bpeer\b/, output) + assert_match(/sr-only/, output) + end end From dfcdd7e494972733a63f8e282f759842b5eae7a8 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 12:32:48 -0300 Subject: [PATCH 04/11] feat(combobox): add badge management to Stimulus controller --- lib/ruby_ui/combobox/combobox_controller.js | 219 +++++++++++++++----- 1 file changed, 170 insertions(+), 49 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index d1932772..d26cb551 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -15,24 +15,82 @@ export default class extends Controller { "emptyState", "searchInput", "trigger", - "triggerContent" + "triggerContent", + "badgeContainer", + "clearButton", + "badgeInput" ] selectedItemIndex = null connect() { this.updateTriggerContent() + this.updateBadges() + this.updateClearButton() } disconnect() { if (this.cleanup) { this.cleanup() } } + // Popover + + togglePopover(event) { + event.preventDefault() + + if (this.triggerTarget.ariaExpanded === "true") { + this.closePopover() + } else { + this.openPopover(event) + } + } + + openPopover(event) { + if (event) event.preventDefault() + + this.updatePopoverPosition() + this.updatePopoverWidth() + this.triggerTarget.ariaExpanded = "true" + this.selectedItemIndex = null + this.itemTargets.forEach(item => item.ariaCurrent = "false") + this.popoverTarget.showPopover() + + if (this.hasBadgeInputTarget) { + this.badgeInputTarget.value = "" + this.filterItems({ key: "" }) + } + } + + closePopover() { + this.triggerTarget.ariaExpanded = "false" + this.popoverTarget.hidePopover() + } + handlePopoverToggle(event) { // Keep ariaExpanded in sync with the actual popover state this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false' } + updatePopoverPosition() { + this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { + computePosition(this.triggerTarget, this.popoverTarget, { + placement: 'bottom-start', + middleware: [offset(4), flip()], + }).then(({ x, y }) => { + Object.assign(this.popoverTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + }); + } + + updatePopoverWidth() { + this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px` + } + + // Selection + inputChanged(e) { this.updateTriggerContent() @@ -43,10 +101,9 @@ export default class extends Controller { if (this.hasToggleAllTarget && !e.target.checked) { this.toggleAllTarget.checked = false } - } - inputContent(input) { - return input.dataset.text || input.parentElement.textContent + this.updateBadges() + this.updateClearButton() } toggleAllItems() { @@ -55,6 +112,37 @@ export default class extends Controller { this.updateTriggerContent() } + clearAll(event) { + if (event) event.preventDefault() + + this.inputTargets.forEach(input => input.checked = false) + this.updateBadges() + this.updateClearButton() + this.updateTriggerContent() + } + + removeBadge(event) { + event.preventDefault() + event.stopPropagation() + + const value = event.currentTarget.closest('[data-value]').dataset.value + const input = this.inputTargets.find(input => input.value === value) + + if (input) { + input.checked = false + input.dispatchEvent(new Event("change", { bubbles: true })) + } + + this.updateBadges() + this.updateClearButton() + } + + // Display + + inputContent(input) { + return input.dataset.text || input.parentElement.textContent + } + updateTriggerContent() { const checkedInputs = this.inputTargets.filter(input => input.checked) @@ -67,38 +155,72 @@ export default class extends Controller { } } - togglePopover(event) { - event.preventDefault() - - if (this.triggerTarget.ariaExpanded === "true") { - this.closePopover() - } else { - this.openPopover(event) - } + updateBadges() { + if (!this.hasBadgeContainerTarget) return + + this.badgeContainerTarget.innerHTML = "" + + this.inputTargets.filter(input => input.checked).forEach(input => { + const badge = document.createElement("span") + 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" + badge.dataset.value = input.value + + const label = document.createTextNode(this.inputContent(input)) + badge.appendChild(label) + + const btn = document.createElement("button") + btn.type = "button" + btn.dataset.action = "ruby-ui--combobox#removeBadge" + btn.setAttribute("aria-label", "Remove") + btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") + svg.setAttribute("width", "12") + svg.setAttribute("height", "12") + svg.setAttribute("viewBox", "0 0 24 24") + svg.setAttribute("fill", "none") + svg.setAttribute("stroke", "currentColor") + svg.setAttribute("stroke-width", "2") + svg.setAttribute("stroke-linecap", "round") + svg.setAttribute("stroke-linejoin", "round") + + const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path") + path1.setAttribute("d", "M18 6 6 18") + const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path") + path2.setAttribute("d", "m6 6 12 12") + + svg.appendChild(path1) + svg.appendChild(path2) + btn.appendChild(svg) + badge.appendChild(btn) + + this.badgeContainerTarget.appendChild(badge) + }) } - openPopover(event) { - if (event) event.preventDefault() + updateClearButton() { + if (!this.hasClearButtonTarget) return - this.updatePopoverPosition() - this.updatePopoverWidth() - this.triggerTarget.ariaExpanded = "true" - this.selectedItemIndex = null - this.itemTargets.forEach(item => item.ariaCurrent = "false") - this.popoverTarget.showPopover() - } + const hasChecked = this.inputTargets.some(input => input.checked) - closePopover() { - this.triggerTarget.ariaExpanded = "false" - this.popoverTarget.hidePopover() + if (hasChecked) { + this.clearButtonTarget.classList.remove("hidden") + } else { + this.clearButtonTarget.classList.add("hidden") + } } + // Filter + filterItems(e) { if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) { return } - const filterTerm = this.searchInputTarget.value.toLowerCase() + const filterTerm = this.hasBadgeInputTarget + ? this.badgeInputTarget.value.toLowerCase() + : this.searchInputTarget.value.toLowerCase() if (this.hasToggleAllTarget) { if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden") @@ -123,6 +245,8 @@ export default class extends Controller { this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) } + // Keyboard + keyDownPressed() { if (this.selectedItemIndex !== null) { this.selectedItemIndex++ @@ -143,6 +267,15 @@ export default class extends Controller { this.focusSelectedInput() } + keyEnterPressed(event) { + event.preventDefault() + const option = this.itemTargets.find(item => item.ariaCurrent === "true") + + if (option) { + option.click() + } + } + focusSelectedInput() { const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden")) @@ -158,34 +291,22 @@ export default class extends Controller { }) } - keyEnterPressed(event) { - event.preventDefault() - const option = this.itemTargets.find(item => item.ariaCurrent === "true") - - if (option) { - option.click() - } - } - wrapSelectedInputIndex(length) { this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length } - updatePopoverPosition() { - this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { - computePosition(this.triggerTarget, this.popoverTarget, { - placement: 'bottom-start', - middleware: [offset(4), flip()], - }).then(({ x, y }) => { - Object.assign(this.popoverTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - }); - } + handleBadgeInputBackspace(event) { + if (this.badgeInputTarget.value !== "") return - updatePopoverWidth() { - this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px` + const checkedInputs = this.inputTargets.filter(input => input.checked) + const lastChecked = checkedInputs[checkedInputs.length - 1] + + if (lastChecked) { + lastChecked.checked = false + lastChecked.dispatchEvent(new Event("change", { bubbles: true })) + } + + this.updateBadges() + this.updateClearButton() } } From c6ad0854c639a91ef8aad8361c85aab6f68f85cf Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 12:36:29 -0300 Subject: [PATCH 05/11] fix(combobox): address controller quality issues --- lib/ruby_ui/combobox/combobox_controller.js | 36 +++++++++------------ 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index d26cb551..717d8f72 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -57,7 +57,7 @@ export default class extends Controller { if (this.hasBadgeInputTarget) { this.badgeInputTarget.value = "" - this.filterItems({ key: "" }) + this.applyFilter("") } } @@ -110,6 +110,8 @@ export default class extends Controller { const isChecked = this.toggleAllTarget.checked this.inputTargets.forEach(input => input.checked = isChecked) this.updateTriggerContent() + this.updateBadges() + this.updateClearButton() } clearAll(event) { @@ -132,9 +134,6 @@ export default class extends Controller { input.checked = false input.dispatchEvent(new Event("change", { bubbles: true })) } - - this.updateBadges() - this.updateClearButton() } // Display @@ -155,6 +154,7 @@ export default class extends Controller { } } + // NOTE: badge HTML mirrors ComboboxBadge Ruby component. Update both if styles change. updateBadges() { if (!this.hasBadgeContainerTarget) return @@ -203,24 +203,23 @@ export default class extends Controller { if (!this.hasClearButtonTarget) return const hasChecked = this.inputTargets.some(input => input.checked) - - if (hasChecked) { - this.clearButtonTarget.classList.remove("hidden") - } else { - this.clearButtonTarget.classList.add("hidden") - } + this.clearButtonTarget.classList.toggle("hidden", !hasChecked) } // Filter filterItems(e) { - if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) { - return - } + if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) return - const filterTerm = this.hasBadgeInputTarget - ? this.badgeInputTarget.value.toLowerCase() - : this.searchInputTarget.value.toLowerCase() + const term = this.hasBadgeInputTarget + ? this.badgeInputTarget.value + : this.searchInputTarget.value + + this.applyFilter(term) + } + + applyFilter(term) { + const filterTerm = term.toLowerCase() if (this.hasToggleAllTarget) { if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden") @@ -228,12 +227,10 @@ export default class extends Controller { } let resultCount = 0 - this.selectedItemIndex = null this.inputTargets.forEach((input) => { const text = this.inputContent(input).toLowerCase() - if (text.indexOf(filterTerm) > -1) { input.parentElement.classList.remove("hidden") resultCount++ @@ -305,8 +302,5 @@ export default class extends Controller { lastChecked.checked = false lastChecked.dispatchEvent(new Event("change", { bubbles: true })) } - - this.updateBadges() - this.updateClearButton() } } From 067e1d9750c1dc977f95d35855dc7dff060a1313 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 12:38:36 -0300 Subject: [PATCH 06/11] docs(combobox): update examples for shadcn-style revamp --- lib/ruby_ui/combobox/combobox_docs.rb | 109 ++++++++------------------ 1 file changed, 32 insertions(+), 77 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_docs.rb b/lib/ruby_ui/combobox/combobox_docs.rb index 2fd1d422..abd46d62 100644 --- a/lib/ruby_ui/combobox/combobox_docs.rb +++ b/lib/ruby_ui/combobox/combobox_docs.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Views::Docs::Combobox < Views::Base - @@code_example = nil - def view_template component = "Combobox" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do @@ -10,51 +8,37 @@ def view_template Heading(level: 2) { "Usage" } - render Docs::VisualCodeExample.new(title: "Single option", context: self) do + render Docs::VisualCodeExample.new(title: "Combobox", context: self) do <<~RUBY div class: "w-96" do Combobox do - ComboboxTrigger placeholder: "Pick value" + ComboboxTrigger placeholder: "Select framework" ComboboxPopover do - ComboboxSearchInput(placeholder: "Pick value or type anything") + ComboboxSearchInput(placeholder: "Search framework...") ComboboxList do - ComboboxEmptyState { "No result" } - - ComboboxListGroup(label: "Fruits") do - ComboboxItem do - ComboboxRadio(name: "food", value: "apple") - span { "Apple" } - end - - ComboboxItem do - ComboboxRadio(name: "food", value: "banana") - span { "Banana" } - end - end + ComboboxEmptyState { "No results found." } - ComboboxListGroup(label: "Vegetable") do + ComboboxListGroup(label: "Ruby") do ComboboxItem do - ComboboxRadio(name: "food", value: "brocoli") - span { "Broccoli" } + ComboboxRadio(name: "framework", value: "rails") + span { "Rails" } end - ComboboxItem do - ComboboxRadio(name: "food", value: "carrot") - span { "Carrot" } + ComboboxRadio(name: "framework", value: "hanami") + span { "Hanami" } end end - ComboboxListGroup(label: "Others") do + ComboboxListGroup(label: "JavaScript") do ComboboxItem do - ComboboxRadio(name: "food", value: "chocolate") - span { "Chocolate" } + ComboboxRadio(name: "framework", value: "nextjs") + span { "Next.js" } end - ComboboxItem do - ComboboxRadio(name: "food", value: "milk") - span { "Milk" } + ComboboxRadio(name: "framework", value: "nuxt") + span { "Nuxt" } end end end @@ -64,56 +48,37 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Multiple options", context: self) do + render Docs::VisualCodeExample.new(title: "Multiselect", context: self) do <<~RUBY div class: "w-96" do - Combobox term: "things" do - ComboboxTrigger placeholder: "Pick value" + Combobox do + ComboboxBadgeTrigger(placeholder: "Select frameworks...") do + ComboboxClearButton() + end ComboboxPopover do - ComboboxSearchInput(placeholder: "Pick value or type anything") - ComboboxList do - ComboboxEmptyState { "No result" } - - ComboboxItem(class: "mt-3") do - ComboboxToggleAllCheckbox(name: "all", value: "all") - span { "Select all" } - end - - ComboboxListGroup label: "Fruits" do - ComboboxItem do - ComboboxCheckbox(name: "food", value: "apple") - span { "Apple" } - end - - ComboboxItem do - ComboboxCheckbox(name: "food", value: "banana") - span { "Banana" } - end - end + ComboboxEmptyState { "No results found." } - ComboboxListGroup label: "Vegetable" do + ComboboxListGroup(label: "Ruby") do ComboboxItem do - ComboboxCheckbox(name: "food", value: "brocoli") - span { "Broccoli" } + ComboboxCheckbox(name: "frameworks[]", value: "rails") + span { "Rails" } end - ComboboxItem do - ComboboxCheckbox(name: "food", value: "carrot") - span { "Carrot" } + ComboboxCheckbox(name: "frameworks[]", value: "hanami") + span { "Hanami" } end end - ComboboxListGroup label: "Others" do + ComboboxListGroup(label: "JavaScript") do ComboboxItem do - ComboboxCheckbox(name: "food", value: "chocolate") - span { "Chocolate" } + ComboboxCheckbox(name: "frameworks[]", value: "nextjs") + span { "Next.js" } end - ComboboxItem do - ComboboxCheckbox(name: "food", value: "milk") - span { "Milk" } + ComboboxCheckbox(name: "frameworks[]", value: "nuxt") + span { "Nuxt" } end end end @@ -133,19 +98,9 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Aria Disabled", context: self) do - <<~RUBY - div(class: "w-96") do - Combobox do - ComboboxTrigger(aria: {disabled: "true"}, placeholder: "Pick value") - end - end - RUBY - end - - render Components::ComponentSetup::Tabs.new(component_name: "Combobox") + render Components::ComponentSetup::Tabs.new(component_name: component) - render Docs::ComponentsTable.new(component_files("Combobox")) + render Docs::ComponentsTable.new(component_files(component)) end end end From 422f2b7cd78c6877d8a3533ef69af72e79e8e4ac Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 13:28:42 -0300 Subject: [PATCH 07/11] feat(combobox): add ComboboxInputTrigger, fix badge border, clear button style --- .../combobox/combobox_badge_trigger.rb | 2 +- lib/ruby_ui/combobox/combobox_clear_button.rb | 4 +- .../combobox/combobox_input_trigger.rb | 60 +++++++++++++++++++ lib/ruby_ui/combobox/combobox_trigger.rb | 3 +- test/ruby_ui/combobox_test.rb | 27 +++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 lib/ruby_ui/combobox/combobox_input_trigger.rb diff --git a/lib/ruby_ui/combobox/combobox_badge_trigger.rb b/lib/ruby_ui/combobox/combobox_badge_trigger.rb index e84b6d79..eb91a5a2 100644 --- a/lib/ruby_ui/combobox/combobox_badge_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_badge_trigger.rb @@ -12,7 +12,7 @@ def view_template(&) div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "contents") input( type: "text", - class: "flex-1 min-w-[80px] bg-transparent outline-none placeholder:text-muted-foreground text-sm", + class: "flex-1 min-w-[80px] bg-transparent border-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm", autocomplete: "off", autocorrect: "off", spellcheck: "false", diff --git a/lib/ruby_ui/combobox/combobox_clear_button.rb b/lib/ruby_ui/combobox/combobox_clear_button.rb index be8cf424..7b84c13c 100644 --- a/lib/ruby_ui/combobox/combobox_clear_button.rb +++ b/lib/ruby_ui/combobox/combobox_clear_button.rb @@ -14,7 +14,7 @@ def view_template stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", - class: "size-4" + class: "size-3.5" ) do |s| s.path(d: "M18 6 6 18") s.path(d: "m6 6 12 12") @@ -27,7 +27,7 @@ def view_template def default_attrs { type: "button", - class: "ml-auto shrink-0 rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hidden", + class: "ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none hidden", aria: {label: "Clear selection"}, data: { ruby_ui__combobox_target: "clearButton", diff --git a/lib/ruby_ui/combobox/combobox_input_trigger.rb b/lib/ruby_ui/combobox/combobox_input_trigger.rb new file mode 100644 index 00000000..e6ac7f2a --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_input_trigger.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxInputTrigger < Base + def initialize(placeholder: "", **) + @placeholder = placeholder + super(**) + end + + def view_template + div(**attrs) do + input( + type: "text", + placeholder: @placeholder, + autocomplete: "off", + autocorrect: "off", + spellcheck: "false", + class: "flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed", + data: { + ruby_ui__combobox_target: "inputTrigger", + action: "focus->ruby-ui--combobox#openPopover keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems" + } + ) + chevron_icon + end + end + + private + + def default_attrs + { + class: "flex h-9 w-full items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 aria-invalid:border-destructive", + data: { + ruby_ui__combobox_target: "trigger", + placeholder: @placeholder + }, + aria: { + haspopup: "listbox", + expanded: "false" + } + } + end + + def chevron_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + class: "ml-2 h-4 w-4 shrink-0 opacity-50", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round" + ) do |s| + s.path(d: "m7 15 5 5 5-5") + s.path(d: "m7 9 5-5 5 5") + end + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_trigger.rb b/lib/ruby_ui/combobox/combobox_trigger.rb index 56ea757b..81572dd1 100644 --- a/lib/ruby_ui/combobox/combobox_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_trigger.rb @@ -26,7 +26,8 @@ def default_attrs "hover:bg-accent hover:text-accent-foreground", "disabled:pointer-events-none disabled:opacity-50", "aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "aria-invalid:border-destructive" ], data: { placeholder: @placeholder, diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index 80c58275..af9a36a3 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -142,4 +142,31 @@ def test_combobox_toggle_all_checkbox_is_peer_sr_only assert_match(/\bpeer\b/, output) assert_match(/sr-only/, output) end + + def test_combobox_input_trigger_renders + output = phlex { RubyUI.ComboboxInputTrigger(placeholder: "Pick one") } + assert_match(/inputTrigger/, output) # inputTrigger target on input + assert_match(/trigger/, output) # trigger target on wrapper + assert_match(/Pick one/, output) # placeholder + assert_match(/openPopover/, output) # focus action + assert_match(/filterItems/, output) # keyup action + assert_match(/chevron|path.*d="m7/, output) # chevron SVG present + end + + def test_combobox_input_trigger_invalid_state + output = phlex { RubyUI.ComboboxInputTrigger(aria: {invalid: "true"}, placeholder: "Pick") } + assert_match(/aria-invalid.*true|invalid.*true/, output) + assert_match(/aria-invalid:border-destructive/, output) + end + + def test_combobox_clear_button_is_subtle + output = phlex { RubyUI.ComboboxClearButton() } + assert_match(/text-muted-foreground/, output) + refute_match(/ring-ring/, output) + end + + def test_combobox_badge_trigger_input_has_no_border + output = phlex { RubyUI.ComboboxBadgeTrigger(placeholder: "Select") } + assert_match(/border-0/, output) + end end From c07ffb93802cf3afdae1c51a30c955bc2d8dc0f5 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 13:30:16 -0300 Subject: [PATCH 08/11] feat(combobox): JS controller support for ComboboxInputTrigger + auto-highlight --- lib/ruby_ui/combobox/combobox_controller.js | 27 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index 717d8f72..77103240 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -18,7 +18,8 @@ export default class extends Controller { "triggerContent", "badgeContainer", "clearButton", - "badgeInput" + "badgeInput", + "inputTrigger" ] selectedItemIndex = null @@ -27,6 +28,7 @@ export default class extends Controller { this.updateTriggerContent() this.updateBadges() this.updateClearButton() + this.updateInputTrigger() } disconnect() { @@ -46,7 +48,7 @@ export default class extends Controller { } openPopover(event) { - if (event) event.preventDefault() + if (event && event.type !== "focus") event.preventDefault() this.updatePopoverPosition() this.updatePopoverWidth() @@ -58,6 +60,8 @@ export default class extends Controller { if (this.hasBadgeInputTarget) { this.badgeInputTarget.value = "" this.applyFilter("") + } else if (this.hasInputTriggerTarget) { + this.applyFilter(this.inputTriggerTarget.value) } } @@ -96,6 +100,7 @@ export default class extends Controller { if (e.target.type == "radio") { this.closePopover() + this.updateInputTrigger() } if (this.hasToggleAllTarget && !e.target.checked) { @@ -121,6 +126,7 @@ export default class extends Controller { this.updateBadges() this.updateClearButton() this.updateTriggerContent() + this.updateInputTrigger() } removeBadge(event) { @@ -154,6 +160,12 @@ export default class extends Controller { } } + updateInputTrigger() { + if (!this.hasInputTriggerTarget) return + const checked = this.inputTargets.find(i => i.checked) + this.inputTriggerTarget.value = checked ? this.inputContent(checked) : "" + } + // NOTE: badge HTML mirrors ComboboxBadge Ruby component. Update both if styles change. updateBadges() { if (!this.hasBadgeContainerTarget) return @@ -213,7 +225,9 @@ export default class extends Controller { const term = this.hasBadgeInputTarget ? this.badgeInputTarget.value - : this.searchInputTarget.value + : this.hasInputTriggerTarget + ? this.inputTriggerTarget.value + : this.searchInputTarget.value this.applyFilter(term) } @@ -240,6 +254,13 @@ export default class extends Controller { }) this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) + + // Auto-highlight first visible result + const firstVisible = this.inputTargets.find(i => !i.parentElement.classList.contains("hidden")) + if (firstVisible) { + this.selectedItemIndex = 0 + this.focusSelectedInput() + } } // Keyboard From 9c824fbd2c30fd10d5cbad238763191000800919 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 13:34:36 -0300 Subject: [PATCH 09/11] docs(combobox): 8 examples matching shadcn docs --- lib/ruby_ui/combobox/combobox_docs.rb | 192 ++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 8 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_docs.rb b/lib/ruby_ui/combobox/combobox_docs.rb index abd46d62..070a6b90 100644 --- a/lib/ruby_ui/combobox/combobox_docs.rb +++ b/lib/ruby_ui/combobox/combobox_docs.rb @@ -8,15 +8,13 @@ def view_template Heading(level: 2) { "Usage" } - render Docs::VisualCodeExample.new(title: "Combobox", context: self) do + render Docs::VisualCodeExample.new(title: "Basic", context: self) do <<~RUBY - div class: "w-96" do + div(class: "w-96") do Combobox do - ComboboxTrigger placeholder: "Select framework" + ComboboxInputTrigger(placeholder: "Select framework...") ComboboxPopover do - ComboboxSearchInput(placeholder: "Search framework...") - ComboboxList do ComboboxEmptyState { "No results found." } @@ -48,9 +46,40 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Multiselect", context: self) do + render Docs::VisualCodeExample.new(title: "Popup", context: self) do <<~RUBY - div class: "w-96" do + div(class: "w-96") do + Combobox do + ComboboxTrigger(placeholder: "Select framework...") + + ComboboxPopover do + ComboboxSearchInput(placeholder: "Search framework...") + + ComboboxList do + ComboboxEmptyState { "No results found." } + + ComboboxItem do + ComboboxRadio(name: "fw2", value: "rails") + span { "Rails" } + end + ComboboxItem do + ComboboxRadio(name: "fw2", value: "hanami") + span { "Hanami" } + end + ComboboxItem do + ComboboxRadio(name: "fw2", value: "nextjs") + span { "Next.js" } + end + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Multiple", context: self) do + <<~RUBY + div(class: "w-96") do Combobox do ComboboxBadgeTrigger(placeholder: "Select frameworks...") do ComboboxClearButton() @@ -88,11 +117,158 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Groups", context: self) do + <<~RUBY + div(class: "w-96") do + Combobox do + ComboboxInputTrigger(placeholder: "Select food...") + + ComboboxPopover do + ComboboxList do + ComboboxEmptyState { "No results found." } + + ComboboxListGroup(label: "Fruits") do + ComboboxItem do + ComboboxRadio(name: "food", value: "apple") + span { "Apple" } + end + ComboboxItem do + ComboboxRadio(name: "food", value: "banana") + span { "Banana" } + end + end + + ComboboxListGroup(label: "Vegetables") do + ComboboxItem do + ComboboxRadio(name: "food", value: "broccoli") + span { "Broccoli" } + end + ComboboxItem do + ComboboxRadio(name: "food", value: "carrot") + span { "Carrot" } + end + end + + ComboboxListGroup(label: "Grains") do + ComboboxItem do + ComboboxRadio(name: "food", value: "rice") + span { "Rice" } + end + ComboboxItem do + ComboboxRadio(name: "food", value: "wheat") + span { "Wheat" } + end + end + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Custom Items", context: self) do + <<~RUBY + div(class: "w-96") do + Combobox do + ComboboxInputTrigger(placeholder: "Select status...") + + ComboboxPopover do + ComboboxList do + ComboboxEmptyState { "No results found." } + + ComboboxItem do + ComboboxRadio(name: "status", value: "backlog", data: {text: "Backlog"}) + svg(xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", class: "text-muted-foreground") { |s| s.circle(cx: "12", cy: "12", r: "10") } + span { "Backlog" } + end + ComboboxItem do + ComboboxRadio(name: "status", value: "todo", data: {text: "Todo"}) + svg(xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", class: "text-blue-500") { |s| s.circle(cx: "12", cy: "12", r: "10") } + span { "Todo" } + end + ComboboxItem do + ComboboxRadio(name: "status", value: "done", data: {text: "Done"}) + svg(xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", class: "text-green-500") { |s| s.path(d: "M22 11.08V12a10 10 0 1 1-5.93-9.14"); s.path(d: "m9 11 3 3L22 4") } + span { "Done" } + end + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Invalid", context: self) do + <<~RUBY + div(class: "w-96") do + Combobox do + ComboboxInputTrigger(placeholder: "Required field", aria: {invalid: "true"}) + + ComboboxPopover do + ComboboxList do + ComboboxEmptyState { "No results found." } + + ComboboxItem do + ComboboxRadio(name: "req", value: "option1") + span { "Option 1" } + end + ComboboxItem do + ComboboxRadio(name: "req", value: "option2") + span { "Option 2" } + end + end + end + end + end + RUBY + end + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + div(class: "w-96 space-y-2") do + Combobox do + ComboboxTrigger(disabled: true, placeholder: "Disabled trigger") + end + + Combobox do + ComboboxInputTrigger(placeholder: "Disabled input", disabled: true) + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Auto Highlight", context: self) do <<~RUBY div(class: "w-96") do Combobox do - ComboboxTrigger(disabled: true, placeholder: "Pick value") + ComboboxInputTrigger(placeholder: "Type to search...") + + ComboboxPopover do + ComboboxList do + ComboboxEmptyState { "No results found." } + + ComboboxItem do + ComboboxRadio(name: "color", value: "red") + span { "Red" } + end + ComboboxItem do + ComboboxRadio(name: "color", value: "green") + span { "Green" } + end + ComboboxItem do + ComboboxRadio(name: "color", value: "blue") + span { "Blue" } + end + ComboboxItem do + ComboboxRadio(name: "color", value: "yellow") + span { "Yellow" } + end + ComboboxItem do + ComboboxRadio(name: "color", value: "purple") + span { "Purple" } + end + end + end end end RUBY From 4a639f2818d052d147f6fac2f8256f931879fb08 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Tue, 31 Mar 2026 19:51:38 -0300 Subject: [PATCH 10/11] fix(combobox): remove border and outline from ComboboxInputTrigger input --- lib/ruby_ui/combobox/combobox_input_trigger.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_input_trigger.rb b/lib/ruby_ui/combobox/combobox_input_trigger.rb index e6ac7f2a..a78d1b8c 100644 --- a/lib/ruby_ui/combobox/combobox_input_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_input_trigger.rb @@ -15,10 +15,10 @@ def view_template autocomplete: "off", autocorrect: "off", spellcheck: "false", - class: "flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed", + class: "flex-1 border-0 bg-transparent outline-none focus:ring-0 placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed", data: { ruby_ui__combobox_target: "inputTrigger", - action: "focus->ruby-ui--combobox#openPopover keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems" + action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems" } ) chevron_icon @@ -32,7 +32,8 @@ def default_attrs class: "flex h-9 w-full items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 aria-invalid:border-destructive", data: { ruby_ui__combobox_target: "trigger", - placeholder: @placeholder + placeholder: @placeholder, + action: "click->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", From 4cbfd4d800df130dac396fa6652b7c69badccbe5 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Wed, 1 Apr 2026 01:10:53 -0300 Subject: [PATCH 11/11] feat(combobox): shadcn visual revamp with chips, keyboard nav, and accessibility --- lib/ruby_ui/combobox/combobox.rb | 8 +- .../combobox/combobox_badge_trigger.rb | 32 ++----- lib/ruby_ui/combobox/combobox_clear_button.rb | 2 +- lib/ruby_ui/combobox/combobox_controller.js | 86 +++++++++++++++---- lib/ruby_ui/combobox/combobox_docs.rb | 86 ++++++++++--------- .../combobox/combobox_input_trigger.rb | 31 ++++--- lib/ruby_ui/combobox/combobox_item.rb | 2 +- lib/ruby_ui/combobox/combobox_popover.rb | 5 -- lib/ruby_ui/combobox/combobox_trigger.rb | 35 ++++---- test/ruby_ui/combobox_test.rb | 81 ++++++++++++++++- 10 files changed, 243 insertions(+), 125 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox.rb b/lib/ruby_ui/combobox/combobox.rb index fa440c49..903b0fa0 100644 --- a/lib/ruby_ui/combobox/combobox.rb +++ b/lib/ruby_ui/combobox/combobox.rb @@ -19,7 +19,13 @@ def default_attrs data: { controller: "ruby-ui--combobox", ruby_ui__combobox_term_value: @term, - action: "turbo:morph@window->ruby-ui--combobox#updateTriggerContent" + action: %w[ + turbo:morph@window->ruby-ui--combobox#updateTriggerContent + keydown.down->ruby-ui--combobox#keyDownPressed + keydown.up->ruby-ui--combobox#keyUpPressed + keydown.enter->ruby-ui--combobox#keyEnterPressed + keydown.esc->ruby-ui--combobox#closePopover:prevent + ] } } end diff --git a/lib/ruby_ui/combobox/combobox_badge_trigger.rb b/lib/ruby_ui/combobox/combobox_badge_trigger.rb index eb91a5a2..e489c5a8 100644 --- a/lib/ruby_ui/combobox/combobox_badge_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_badge_trigger.rb @@ -2,40 +2,40 @@ module RubyUI class ComboboxBadgeTrigger < Base - def initialize(placeholder: "", **) + def initialize(placeholder: "", clear_button: false, **) @placeholder = placeholder + @clear_button = clear_button super(**) end def view_template(&) div(**attrs) do - div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "contents") + div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "hidden") input( type: "text", - class: "flex-1 min-w-[80px] bg-transparent border-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm", + class: "flex-1 min-w-8 bg-transparent border-0 px-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm", autocomplete: "off", autocorrect: "off", spellcheck: "false", placeholder: @placeholder, data: { ruby_ui__combobox_target: "badgeInput", - # JS implementation in combobox_controller.js action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace" } ) - yield if block_given? - chevron_icon + render ComboboxClearButton.new if @clear_button end end private + # JS-toggled classes (referenced here so Tailwind compiles them): h-auto min-h-9 pt-1.5 def default_attrs { - 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", + 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", data: { ruby_ui__combobox_target: "trigger", - action: "click->ruby-ui--combobox#openPopover" + action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", @@ -43,21 +43,5 @@ def default_attrs } } end - - def chevron_icon - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - class: "ml-2 h-4 w-4 shrink-0 opacity-50", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round" - ) do |s| - s.path(d: "m7 15 5 5 5-5") - s.path(d: "m7 9 5-5 5 5") - end - end end end diff --git a/lib/ruby_ui/combobox/combobox_clear_button.rb b/lib/ruby_ui/combobox/combobox_clear_button.rb index 7b84c13c..9ed27726 100644 --- a/lib/ruby_ui/combobox/combobox_clear_button.rb +++ b/lib/ruby_ui/combobox/combobox_clear_button.rb @@ -27,7 +27,7 @@ def view_template def default_attrs { type: "button", - class: "ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none hidden", + 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", aria: {label: "Clear selection"}, data: { ruby_ui__combobox_target: "clearButton", diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index 77103240..3655b111 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -29,6 +29,11 @@ export default class extends Controller { this.updateBadges() this.updateClearButton() this.updateInputTrigger() + + // Track mouse state to distinguish click-focus from tab-focus + this._mouseDown = false + this.element.addEventListener("mousedown", () => { this._mouseDown = true }) + this.element.addEventListener("mouseup", () => { setTimeout(() => { this._mouseDown = false }, 0) }) } disconnect() { @@ -48,7 +53,12 @@ export default class extends Controller { } openPopover(event) { - if (event && event.type !== "focus") event.preventDefault() + if (event && event.type !== "focusin" && event.type !== "focus") event.preventDefault() + + // focusin/focus: only open on keyboard focus (tab), not mouse click + if (event && (event.type === "focusin" || event.type === "focus")) { + if (this._mouseDown || this.triggerTarget.ariaExpanded === "true" || this._closingPopover) return + } this.updatePopoverPosition() this.updatePopoverWidth() @@ -57,17 +67,19 @@ export default class extends Controller { this.itemTargets.forEach(item => item.ariaCurrent = "false") this.popoverTarget.showPopover() + // Always show all items on open; filter only on user typing + this.applyFilter("") + if (this.hasBadgeInputTarget) { this.badgeInputTarget.value = "" - this.applyFilter("") - } else if (this.hasInputTriggerTarget) { - this.applyFilter(this.inputTriggerTarget.value) } } closePopover() { + this._closingPopover = true this.triggerTarget.ariaExpanded = "false" this.popoverTarget.hidePopover() + setTimeout(() => this._closingPopover = false, 200) } handlePopoverToggle(event) { @@ -149,14 +161,19 @@ export default class extends Controller { } updateTriggerContent() { + if (!this.hasTriggerContentTarget) return + const checkedInputs = this.inputTargets.filter(input => input.checked) if (checkedInputs.length === 0) { this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder + this.triggerContentTarget.classList.add("text-muted-foreground") } else if (this.termValue && checkedInputs.length > 1) { this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}` + this.triggerContentTarget.classList.remove("text-muted-foreground") } else { this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ") + this.triggerContentTarget.classList.remove("text-muted-foreground") } } @@ -166,25 +183,48 @@ export default class extends Controller { this.inputTriggerTarget.value = checked ? this.inputContent(checked) : "" } - // NOTE: badge HTML mirrors ComboboxBadge Ruby component. Update both if styles change. + // NOTE: badge classes mirror ComboboxBadge Ruby component. Update both if styles change. updateBadges() { if (!this.hasBadgeContainerTarget) return - this.badgeContainerTarget.innerHTML = "" + // Remove existing badges + this.triggerTarget.querySelectorAll("[data-combobox-badge]").forEach(el => el.remove()) + + const checkedInputs = this.inputTargets.filter(input => input.checked) + + // Toggle trigger height: h-9 when empty, h-auto min-h-9 when badges exist + if (checkedInputs.length > 0) { + this.triggerTarget.classList.remove("h-9") + this.triggerTarget.classList.add("h-auto", "min-h-9") + } else { + this.triggerTarget.classList.remove("h-auto", "min-h-9", "pt-1.5") + this.triggerTarget.classList.add("h-9") + } - this.inputTargets.filter(input => input.checked).forEach(input => { + checkedInputs.forEach(input => { const badge = document.createElement("span") + badge.setAttribute("data-combobox-badge", "") 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" badge.dataset.value = input.value - const label = document.createTextNode(this.inputContent(input)) - badge.appendChild(label) + badge.appendChild(document.createTextNode(this.inputContent(input).trim())) const btn = document.createElement("button") btn.type = "button" - btn.dataset.action = "ruby-ui--combobox#removeBadge" btn.setAttribute("aria-label", "Remove") - btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none" + + btn.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + const target = this.inputTargets.find(i => i.value === input.value) + if (target) { + target.checked = false + this.updateBadges() + this.updateClearButton() + } + }) const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") @@ -196,6 +236,7 @@ export default class extends Controller { svg.setAttribute("stroke-width", "2") svg.setAttribute("stroke-linecap", "round") svg.setAttribute("stroke-linejoin", "round") + svg.classList.add("pointer-events-none") const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path") path1.setAttribute("d", "M18 6 6 18") @@ -207,8 +248,18 @@ export default class extends Controller { btn.appendChild(svg) badge.appendChild(btn) - this.badgeContainerTarget.appendChild(badge) + // Insert badge directly in trigger, before the text input + this.badgeInputTarget.insertAdjacentElement("beforebegin", badge) }) + + // Add top padding only when badges wrap to multiple lines + // Class "pt-1.5" is referenced in ComboboxBadgeTrigger for Tailwind to compile it + const badges = this.triggerTarget.querySelectorAll("[data-combobox-badge]") + if (badges.length > 0 && this.badgeInputTarget.offsetTop > badges[0].offsetTop) { + this.triggerTarget.classList.add("pt-1.5") + } else { + this.triggerTarget.classList.remove("pt-1.5") + } } updateClearButton() { @@ -255,17 +306,19 @@ export default class extends Controller { this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) - // Auto-highlight first visible result + // Auto-highlight first visible result (without scrolling to avoid page jump) + this.itemTargets.forEach(item => item.ariaCurrent = "false") const firstVisible = this.inputTargets.find(i => !i.parentElement.classList.contains("hidden")) if (firstVisible) { this.selectedItemIndex = 0 - this.focusSelectedInput() + firstVisible.parentElement.ariaCurrent = "true" } } // Keyboard - keyDownPressed() { + keyDownPressed(event) { + event.preventDefault() if (this.selectedItemIndex !== null) { this.selectedItemIndex++ } else { @@ -275,7 +328,8 @@ export default class extends Controller { this.focusSelectedInput() } - keyUpPressed() { + keyUpPressed(event) { + event.preventDefault() if (this.selectedItemIndex !== null) { this.selectedItemIndex-- } else { diff --git a/lib/ruby_ui/combobox/combobox_docs.rb b/lib/ruby_ui/combobox/combobox_docs.rb index 070a6b90..a5d504b9 100644 --- a/lib/ruby_ui/combobox/combobox_docs.rb +++ b/lib/ruby_ui/combobox/combobox_docs.rb @@ -18,26 +18,21 @@ def view_template ComboboxList do ComboboxEmptyState { "No results found." } - ComboboxListGroup(label: "Ruby") do - ComboboxItem do - ComboboxRadio(name: "framework", value: "rails") - span { "Rails" } - end - ComboboxItem do - ComboboxRadio(name: "framework", value: "hanami") - span { "Hanami" } - end + ComboboxItem do + ComboboxRadio(name: "framework", value: "rails") + span { "Rails" } end - - ComboboxListGroup(label: "JavaScript") do - ComboboxItem do - ComboboxRadio(name: "framework", value: "nextjs") - span { "Next.js" } - end - ComboboxItem do - ComboboxRadio(name: "framework", value: "nuxt") - span { "Nuxt" } - end + ComboboxItem do + ComboboxRadio(name: "framework", value: "hanami") + span { "Hanami" } + end + ComboboxItem do + ComboboxRadio(name: "framework", value: "nextjs") + span { "Next.js" } + end + ComboboxItem do + ComboboxRadio(name: "framework", value: "nuxt") + span { "Nuxt" } end end end @@ -81,34 +76,43 @@ def view_template <<~RUBY div(class: "w-96") do Combobox do - ComboboxBadgeTrigger(placeholder: "Select frameworks...") do - ComboboxClearButton() - end + ComboboxBadgeTrigger(clear_button: true) ComboboxPopover do ComboboxList do ComboboxEmptyState { "No results found." } - ComboboxListGroup(label: "Ruby") do - ComboboxItem do - ComboboxCheckbox(name: "frameworks[]", value: "rails") - span { "Rails" } - end - ComboboxItem do - ComboboxCheckbox(name: "frameworks[]", value: "hanami") - span { "Hanami" } - end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "rails") + span { "Rails" } end - - ComboboxListGroup(label: "JavaScript") do - ComboboxItem do - ComboboxCheckbox(name: "frameworks[]", value: "nextjs") - span { "Next.js" } - end - ComboboxItem do - ComboboxCheckbox(name: "frameworks[]", value: "nuxt") - span { "Nuxt" } - end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "hanami") + span { "Hanami" } + end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "sinatra") + span { "Sinatra" } + end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "nextjs", checked: true) + span { "Next.js" } + end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "nuxt") + span { "Nuxt" } + end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "svelte") + span { "SvelteKit" } + end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "remix") + span { "Remix" } + end + ComboboxItem do + ComboboxCheckbox(name: "frameworks[]", value: "astro") + span { "Astro" } end end end diff --git a/lib/ruby_ui/combobox/combobox_input_trigger.rb b/lib/ruby_ui/combobox/combobox_input_trigger.rb index a78d1b8c..bb48f5cc 100644 --- a/lib/ruby_ui/combobox/combobox_input_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_input_trigger.rb @@ -15,7 +15,7 @@ def view_template autocomplete: "off", autocorrect: "off", spellcheck: "false", - class: "flex-1 border-0 bg-transparent outline-none focus:ring-0 placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed", + class: "flex-1 border-0 px-0 bg-transparent outline-none focus:ring-0 placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed", data: { ruby_ui__combobox_target: "inputTrigger", action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems" @@ -33,7 +33,7 @@ def default_attrs data: { ruby_ui__combobox_target: "trigger", placeholder: @placeholder, - action: "click->ruby-ui--combobox#openPopover" + action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", @@ -43,18 +43,21 @@ def default_attrs end def chevron_icon - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - class: "ml-2 h-4 w-4 shrink-0 opacity-50", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round" - ) do |s| - s.path(d: "m7 15 5 5 5-5") - s.path(d: "m7 9 5-5 5 5") + span(class: "shrink-0 flex items-center justify-center size-6 rounded-sm hover:bg-muted hover:text-foreground") do + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "pointer-events-none size-4 text-muted-foreground" + ) do |s| + s.path(d: "m6 9 6 6 6-6") + end end end end diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb index 644994fa..645e907a 100644 --- a/lib/ruby_ui/combobox/combobox_item.rb +++ b/lib/ruby_ui/combobox/combobox_item.rb @@ -13,7 +13,7 @@ def view_template(&) def default_attrs { - class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground has-[:checked]:bg-accent aria-[current=true]:bg-accent aria-[current=true]:ring aria-[current=true]:ring-offset-2 has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed", + class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-[current=true]:bg-accent has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed", role: "option", data: { ruby_ui__combobox_target: "item" diff --git a/lib/ruby_ui/combobox/combobox_popover.rb b/lib/ruby_ui/combobox/combobox_popover.rb index 4d439e2f..121f7dc7 100644 --- a/lib/ruby_ui/combobox/combobox_popover.rb +++ b/lib/ruby_ui/combobox/combobox_popover.rb @@ -12,16 +12,11 @@ def default_attrs { class: "inset-auto m-0 absolute border bg-background shadow-lg rounded-lg", role: "popover", - autofocus: true, popover: true, data: { ruby_ui__combobox_target: "popover", action: %w[ toggle->ruby-ui--combobox#handlePopoverToggle - keydown.down->ruby-ui--combobox#keyDownPressed - keydown.up->ruby-ui--combobox#keyUpPressed - keydown.enter->ruby-ui--combobox#keyEnterPressed - keydown.esc->ruby-ui--combobox#closePopover:prevent resize@window->ruby-ui--combobox#updatePopoverWidth ] } diff --git a/lib/ruby_ui/combobox/combobox_trigger.rb b/lib/ruby_ui/combobox/combobox_trigger.rb index 81572dd1..41fb5284 100644 --- a/lib/ruby_ui/combobox/combobox_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_trigger.rb @@ -9,7 +9,7 @@ def initialize(placeholder: "", **) def view_template button(**attrs) do - span(class: "truncate", data: {ruby_ui__combobox_target: "triggerContent"}) do + span(class: "truncate text-muted-foreground", data: {ruby_ui__combobox_target: "triggerContent"}) do @placeholder end icon @@ -32,7 +32,7 @@ def default_attrs data: { placeholder: @placeholder, ruby_ui__combobox_target: "trigger", - action: "ruby-ui--combobox#togglePopover" + action: "click->ruby-ui--combobox#togglePopover focus->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", @@ -42,22 +42,21 @@ def default_attrs end def icon - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - class: "ml-2 h-4 w-4 shrink-0 opacity-50", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round" - ) do |s| - s.path( - d: "m7 15 5 5 5-5" - ) - s.path( - d: "m7 9 5-5 5 5" - ) + span(class: "shrink-0 flex items-center justify-center size-6 rounded-sm hover:bg-muted hover:text-foreground") do + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "pointer-events-none size-4 text-muted-foreground" + ) do |s| + s.path(d: "m6 9 6 6 6-6") + end end end end diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index af9a36a3..8380b842 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -122,9 +122,9 @@ def test_combobox_clear_button_renders assert_match(/clearButton/, output) end - def test_combobox_item_has_selected_state + def test_combobox_item_has_hover_state output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } - assert_match(/has-\[:checked\]:bg-accent/, output) + assert_match(/hover:bg-accent/, output) end def test_combobox_item_has_keyboard_highlight @@ -150,7 +150,7 @@ def test_combobox_input_trigger_renders assert_match(/Pick one/, output) # placeholder assert_match(/openPopover/, output) # focus action assert_match(/filterItems/, output) # keyup action - assert_match(/chevron|path.*d="m7/, output) # chevron SVG present + assert_match(/path.*d="m6 9 6 6 6-6"/, output) # chevron-down SVG present end def test_combobox_input_trigger_invalid_state @@ -162,11 +162,84 @@ def test_combobox_input_trigger_invalid_state def test_combobox_clear_button_is_subtle output = phlex { RubyUI.ComboboxClearButton() } assert_match(/text-muted-foreground/, output) - refute_match(/ring-ring/, output) + assert_match(/focus-visible:ring-2/, output) end def test_combobox_badge_trigger_input_has_no_border output = phlex { RubyUI.ComboboxBadgeTrigger(placeholder: "Select") } assert_match(/border-0/, output) end + + def test_combobox_badge_trigger_clear_button_prop + output = phlex { RubyUI.ComboboxBadgeTrigger(clear_button: true) } + assert_match(/clearButton/, output) + assert_match(/clearAll/, output) + end + + def test_combobox_badge_trigger_no_clear_button_by_default + output = phlex { RubyUI.ComboboxBadgeTrigger() } + refute_match(/clearButton/, output) + end + + def test_combobox_badge_trigger_no_chevron + output = phlex { RubyUI.ComboboxBadgeTrigger() } + refute_match(/chevron|m6 9 6 6 6-6/, output) + end + + def test_combobox_trigger_chevron_down + output = phlex { RubyUI.ComboboxTrigger(placeholder: "Pick") } + assert_match(/m6 9 6 6 6-6/, output) + end + + def test_combobox_trigger_placeholder_muted + output = phlex { RubyUI.ComboboxTrigger(placeholder: "Pick") } + assert_match(/text-muted-foreground/, output) + end + + def test_combobox_trigger_chevron_hover_effect + output = phlex { RubyUI.ComboboxTrigger(placeholder: "Pick") } + assert_match(/hover:bg-muted/, output) + assert_match(/size-6/, output) + assert_match(/rounded-sm/, output) + end + + def test_combobox_input_trigger_chevron_hover_effect + output = phlex { RubyUI.ComboboxInputTrigger(placeholder: "Pick") } + assert_match(/hover:bg-muted/, output) + assert_match(/size-6/, output) + assert_match(/rounded-sm/, output) + end + + def test_combobox_input_trigger_no_inner_padding + output = phlex { RubyUI.ComboboxInputTrigger(placeholder: "Pick") } + assert_match(/px-0/, output) + end + + def test_combobox_keyboard_actions_on_controller + output = phlex { RubyUI.Combobox { "" } } + assert_match(/keydown\.down/, output) + assert_match(/keydown\.up/, output) + assert_match(/keydown\.enter/, output) + assert_match(/keydown\.esc/, output) + end + + def test_combobox_input_trigger_focusin_action + output = phlex { RubyUI.ComboboxInputTrigger(placeholder: "Pick") } + assert_match(/focusin->ruby-ui--combobox#openPopover/, output) + end + + def test_combobox_item_no_selected_background + output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } + refute_match(/has-\[:checked\]:bg-accent/, output) + end + + def test_combobox_item_no_ring_on_current + output = phlex { RubyUI.ComboboxItem { RubyUI.ComboboxRadio(name: "x", value: "1") } } + refute_match(/aria-\[current=true\]:ring\b/, output) + end + + def test_combobox_popover_no_autofocus + output = phlex { RubyUI.ComboboxPopover { "" } } + refute_match(/autofocus/, output) + end end