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 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.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..e489c5a8 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_badge_trigger.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxBadgeTrigger < Base + 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: "hidden") + input( + type: "text", + 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", + action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace" + } + ) + 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 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 focusin->ruby-ui--combobox#openPopover" + }, + aria: { + haspopup: "listbox", + expanded: "false" + } + } + 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..9ed27726 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_clear_button.rb @@ -0,0 +1,40 @@ +# 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-3.5" + ) 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 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", + # JS implementation in combobox_controller.js + action: "ruby-ui--combobox#clearAll" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index d1932772..3655b111 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -15,90 +15,276 @@ export default class extends Controller { "emptyState", "searchInput", "trigger", - "triggerContent" + "triggerContent", + "badgeContainer", + "clearButton", + "badgeInput", + "inputTrigger" ] selectedItemIndex = null connect() { this.updateTriggerContent() + 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() { 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.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() + this.triggerTarget.ariaExpanded = "true" + this.selectedItemIndex = null + 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 = "" + } + } + + closePopover() { + this._closingPopover = true + this.triggerTarget.ariaExpanded = "false" + this.popoverTarget.hidePopover() + setTimeout(() => this._closingPopover = false, 200) + } + 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() if (e.target.type == "radio") { this.closePopover() + this.updateInputTrigger() } if (this.hasToggleAllTarget && !e.target.checked) { this.toggleAllTarget.checked = false } - } - inputContent(input) { - return input.dataset.text || input.parentElement.textContent + this.updateBadges() + this.updateClearButton() } toggleAllItems() { const isChecked = this.toggleAllTarget.checked this.inputTargets.forEach(input => input.checked = isChecked) this.updateTriggerContent() + this.updateBadges() + this.updateClearButton() + } + + clearAll(event) { + if (event) event.preventDefault() + + this.inputTargets.forEach(input => input.checked = false) + this.updateBadges() + this.updateClearButton() + this.updateTriggerContent() + this.updateInputTrigger() + } + + 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 })) + } + } + + // Display + + inputContent(input) { + return input.dataset.text || input.parentElement.textContent } 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") } } - togglePopover(event) { - event.preventDefault() + updateInputTrigger() { + if (!this.hasInputTriggerTarget) return + const checked = this.inputTargets.find(i => i.checked) + this.inputTriggerTarget.value = checked ? this.inputContent(checked) : "" + } - if (this.triggerTarget.ariaExpanded === "true") { - this.closePopover() + // NOTE: badge classes mirror ComboboxBadge Ruby component. Update both if styles change. + updateBadges() { + if (!this.hasBadgeContainerTarget) return + + // 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.openPopover(event) + this.triggerTarget.classList.remove("h-auto", "min-h-9", "pt-1.5") + this.triggerTarget.classList.add("h-9") } - } - openPopover(event) { - if (event) event.preventDefault() + 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 + + badge.appendChild(document.createTextNode(this.inputContent(input).trim())) + + const btn = document.createElement("button") + btn.type = "button" + btn.setAttribute("aria-label", "Remove") + 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") + 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") + svg.classList.add("pointer-events-none") + + 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) + + // Insert badge directly in trigger, before the text input + this.badgeInputTarget.insertAdjacentElement("beforebegin", badge) + }) - this.updatePopoverPosition() - this.updatePopoverWidth() - this.triggerTarget.ariaExpanded = "true" - this.selectedItemIndex = null - this.itemTargets.forEach(item => item.ariaCurrent = "false") - this.popoverTarget.showPopover() + // 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") + } } - closePopover() { - this.triggerTarget.ariaExpanded = "false" - this.popoverTarget.hidePopover() + updateClearButton() { + if (!this.hasClearButtonTarget) return + + const hasChecked = this.inputTargets.some(input => input.checked) + 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 term = this.hasBadgeInputTarget + ? this.badgeInputTarget.value + : this.hasInputTriggerTarget + ? this.inputTriggerTarget.value + : this.searchInputTarget.value + + this.applyFilter(term) + } - const filterTerm = this.searchInputTarget.value.toLowerCase() + applyFilter(term) { + const filterTerm = term.toLowerCase() if (this.hasToggleAllTarget) { if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden") @@ -106,12 +292,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++ @@ -121,9 +305,20 @@ export default class extends Controller { }) this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) + + // 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 + firstVisible.parentElement.ariaCurrent = "true" + } } - keyDownPressed() { + // Keyboard + + keyDownPressed(event) { + event.preventDefault() if (this.selectedItemIndex !== null) { this.selectedItemIndex++ } else { @@ -133,7 +328,8 @@ export default class extends Controller { this.focusSelectedInput() } - keyUpPressed() { + keyUpPressed(event) { + event.preventDefault() if (this.selectedItemIndex !== null) { this.selectedItemIndex-- } else { @@ -143,6 +339,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 +363,19 @@ 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 })) + } } } diff --git a/lib/ruby_ui/combobox/combobox_docs.rb b/lib/ruby_ui/combobox/combobox_docs.rb index 2fd1d422..a5d504b9 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,159 @@ def view_template Heading(level: 2) { "Usage" } - render Docs::VisualCodeExample.new(title: "Single option", 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: "Pick value" + ComboboxInputTrigger(placeholder: "Select framework...") ComboboxPopover do - ComboboxSearchInput(placeholder: "Pick value or type anything") + ComboboxList do + ComboboxEmptyState { "No results found." } + + 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: "nextjs") + span { "Next.js" } + end + ComboboxItem do + ComboboxRadio(name: "framework", value: "nuxt") + span { "Nuxt" } + end + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Popup", context: self) do + <<~RUBY + div(class: "w-96") do + Combobox do + ComboboxTrigger(placeholder: "Select framework...") + + ComboboxPopover do + ComboboxSearchInput(placeholder: "Search framework...") ComboboxList do - ComboboxEmptyState { "No result" } + 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(clear_button: true) + + ComboboxPopover do + ComboboxList do + ComboboxEmptyState { "No results found." } + + 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: "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 + end + end + 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: "Vegetable") do + ComboboxListGroup(label: "Vegetables") do ComboboxItem do - ComboboxRadio(name: "food", value: "brocoli") + ComboboxRadio(name: "food", value: "broccoli") span { "Broccoli" } end - ComboboxItem do ComboboxRadio(name: "food", value: "carrot") span { "Carrot" } end end - ComboboxListGroup(label: "Others") do + ComboboxListGroup(label: "Grains") do ComboboxItem do - ComboboxRadio(name: "food", value: "chocolate") - span { "Chocolate" } + ComboboxRadio(name: "food", value: "rice") + span { "Rice" } end - ComboboxItem do - ComboboxRadio(name: "food", value: "milk") - span { "Milk" } + ComboboxRadio(name: "food", value: "wheat") + span { "Wheat" } end end end @@ -64,57 +170,55 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Multiple options", context: self) do + render Docs::VisualCodeExample.new(title: "Custom Items", context: self) do <<~RUBY - div class: "w-96" do - Combobox term: "things" do - ComboboxTrigger placeholder: "Pick value" + div(class: "w-96") do + Combobox do + ComboboxInputTrigger(placeholder: "Select status...") ComboboxPopover do - ComboboxSearchInput(placeholder: "Pick value or type anything") - ComboboxList do - ComboboxEmptyState { "No result" } + ComboboxEmptyState { "No results found." } - ComboboxItem(class: "mt-3") do - ComboboxToggleAllCheckbox(name: "all", value: "all") - span { "Select all" } + 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 - - ComboboxListGroup label: "Fruits" do - ComboboxItem do - ComboboxCheckbox(name: "food", value: "apple") - span { "Apple" } - end - - ComboboxItem do - ComboboxCheckbox(name: "food", value: "banana") - span { "Banana" } - 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 - - ComboboxListGroup label: "Vegetable" do - ComboboxItem do - ComboboxCheckbox(name: "food", value: "brocoli") - span { "Broccoli" } - end - - ComboboxItem do - ComboboxCheckbox(name: "food", value: "carrot") - span { "Carrot" } - 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 - ComboboxListGroup label: "Others" do - ComboboxItem do - ComboboxCheckbox(name: "food", value: "chocolate") - span { "Chocolate" } - 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"}) - ComboboxItem do - ComboboxCheckbox(name: "food", value: "milk") - span { "Milk" } - end + 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 @@ -125,27 +229,58 @@ def view_template render Docs::VisualCodeExample.new(title: "Disabled", context: self) do <<~RUBY - div(class: "w-96") do + div(class: "w-96 space-y-2") do Combobox do - ComboboxTrigger(disabled: true, placeholder: "Pick value") + ComboboxTrigger(disabled: true, placeholder: "Disabled trigger") + end + + Combobox do + ComboboxInputTrigger(placeholder: "Disabled input", disabled: true) end end RUBY end - render Docs::VisualCodeExample.new(title: "Aria Disabled", context: self) do + render Docs::VisualCodeExample.new(title: "Auto Highlight", context: self) do <<~RUBY div(class: "w-96") do Combobox do - ComboboxTrigger(aria: {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 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 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..bb48f5cc --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_input_trigger.rb @@ -0,0 +1,64 @@ +# 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 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" + } + ) + 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, + action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover" + }, + aria: { + haspopup: "listbox", + expanded: "false" + } + } + end + + def chevron_icon + 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 +end diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb index 9effb093..645e907a 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 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_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_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_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/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/lib/ruby_ui/combobox/combobox_trigger.rb b/lib/ruby_ui/combobox/combobox_trigger.rb index 56ea757b..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 @@ -26,12 +26,13 @@ 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, ruby_ui__combobox_target: "trigger", - action: "ruby-ui--combobox#togglePopover" + action: "click->ruby-ui--combobox#togglePopover focus->ruby-ui--combobox#openPopover" }, aria: { haspopup: "listbox", @@ -41,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 9b3c3bdd..8380b842 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -70,4 +70,176 @@ 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(/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