From 1e28b5b90e0cd46df6f341e6ea6f539e58b9fd4e Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Fri, 29 Nov 2024 13:50:53 -0300 Subject: [PATCH 1/5] New combobox component --- lib/generators/ruby_ui/dependencies.yml | 4 - lib/ruby_ui/combobox/combobox.rb | 16 +- lib/ruby_ui/combobox/combobox_content.rb | 31 -- .../combobox/combobox_content_controller.js | 42 --- lib/ruby_ui/combobox/combobox_controller.js | 285 +++++++++--------- lib/ruby_ui/combobox/combobox_datalist.rb | 18 ++ lib/ruby_ui/combobox/combobox_dialog.rb | 25 ++ ...bobox_empty.rb => combobox_empty_state.rb} | 4 +- lib/ruby_ui/combobox/combobox_group.rb | 38 --- lib/ruby_ui/combobox/combobox_input.rb | 6 +- lib/ruby_ui/combobox/combobox_item.rb | 53 ---- .../combobox/combobox_item_controller.js | 11 - lib/ruby_ui/combobox/combobox_list.rb | 27 -- lib/ruby_ui/combobox/combobox_optgroup.rb | 20 ++ lib/ruby_ui/combobox/combobox_option.rb | 41 +++ lib/ruby_ui/combobox/combobox_search_input.rb | 45 ++- lib/ruby_ui/combobox/combobox_separator.rb | 15 - lib/ruby_ui/combobox/combobox_trigger.rb | 42 +-- lib/ruby_ui/combobox/combobox_value.rb | 27 -- test/ruby_ui/combobox_test.rb | 58 ++-- 20 files changed, 324 insertions(+), 484 deletions(-) delete mode 100644 lib/ruby_ui/combobox/combobox_content.rb delete mode 100644 lib/ruby_ui/combobox/combobox_content_controller.js create mode 100644 lib/ruby_ui/combobox/combobox_datalist.rb create mode 100644 lib/ruby_ui/combobox/combobox_dialog.rb rename lib/ruby_ui/combobox/{combobox_empty.rb => combobox_empty_state.rb} (76%) delete mode 100644 lib/ruby_ui/combobox/combobox_group.rb delete mode 100644 lib/ruby_ui/combobox/combobox_item.rb delete mode 100644 lib/ruby_ui/combobox/combobox_item_controller.js delete mode 100644 lib/ruby_ui/combobox/combobox_list.rb create mode 100644 lib/ruby_ui/combobox/combobox_optgroup.rb create mode 100644 lib/ruby_ui/combobox/combobox_option.rb delete mode 100644 lib/ruby_ui/combobox/combobox_separator.rb delete mode 100644 lib/ruby_ui/combobox/combobox_value.rb diff --git a/lib/generators/ruby_ui/dependencies.yml b/lib/generators/ruby_ui/dependencies.yml index 9db5eca0..fea62bb9 100644 --- a/lib/generators/ruby_ui/dependencies.yml +++ b/lib/generators/ruby_ui/dependencies.yml @@ -26,10 +26,6 @@ codeblock: gems: - "rouge" -combobox: - js_packages: - - "@floating-ui/dom" - command: js_packages: - "fuse.js" diff --git a/lib/ruby_ui/combobox/combobox.rb b/lib/ruby_ui/combobox/combobox.rb index c77ff785..c221d75a 100644 --- a/lib/ruby_ui/combobox/combobox.rb +++ b/lib/ruby_ui/combobox/combobox.rb @@ -2,6 +2,12 @@ module RubyUI class Combobox < Base + def initialize(multiple: false, term: "items", **) + @multiple = multiple + @term = term + super(**) + end + def view_template(&) div(**attrs, &) end @@ -10,14 +16,12 @@ def view_template(&) def default_attrs { + role: "combobox", data: { controller: "ruby-ui--combobox", - ruby_ui__combobox_open_value: "false", - action: "click@window->ruby-ui--combobox#onClickOutside", - ruby_ui__combobox_ruby_ui__combobox_content_outlet: ".combobox-content", - ruby_ui__combobox_ruby_ui__combobox_item_outlet: ".combobox-item" - }, - class: "group/combobox w-full relative" + ruby_ui__combobox_multiple_value: @multiple.to_s, + ruby_ui__combobox_term_value: @term.to_s + } } end end diff --git a/lib/ruby_ui/combobox/combobox_content.rb b/lib/ruby_ui/combobox/combobox_content.rb deleted file mode 100644 index 090083fa..00000000 --- a/lib/ruby_ui/combobox/combobox_content.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxContent < Base - def initialize(**attrs) - @id = "content#{SecureRandom.hex(4)}" - super - end - - def view_template(&) - div(**attrs) do - div(class: "min-w-max max-h-[300px] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-out group-data-[ruby-ui--combobox-open-value=true]/combobox:animate-in fade-out-0 group-data-[ruby-ui--combobox-open-value=true]/combobox:fade-in-0 zoom-out-95 group-data-[ruby-ui--combobox-open-value=true]/combobox:zoom-in-95 slide-in-from-top-2", &) - end - end - - private - - def default_attrs - { - id: @id, - role: "listbox", - data: { - controller: "ruby-ui--combobox-content", - ruby_ui__combobox_target: "content", - action: "keydown.enter->ruby-ui--combobox#onKeyEnter keydown.esc->ruby-ui--combobox#onEscKey keydown.down->ruby-ui--combobox#onKeyDown keydown.up->ruby-ui--combobox#onKeyUp" - }, - class: "combobox-content hidden w-full absolute top-0 left-0 z-50" - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_content_controller.js b/lib/ruby_ui/combobox/combobox_content_controller.js deleted file mode 100644 index e93d5e42..00000000 --- a/lib/ruby_ui/combobox/combobox_content_controller.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - static targets = ["item", "empty", "group"]; - - handleSearchInput(value) { - const query = this.#sanitizeStr(value); - - this.#toggleVisibility(this.itemTargets, false); - - const visibleItems = this.#filterItems(query); - this.#toggleVisibility(visibleItems, true); - - this.#toggleVisibility(this.emptyTargets, visibleItems.length === 0); - - this.#updateGroupVisibility(); - } - - #updateGroupVisibility() { - this.groupTargets.forEach((group) => { - const hasVisibleItems = - group.querySelectorAll( - "[data-ruby-ui--combobox-content-target='item']:not(.hidden)", - ).length > 0; - this.#toggleVisibility([group], hasVisibleItems); - }); - } - - #filterItems(query) { - return this.itemTargets.filter((item) => - this.#sanitizeStr(item.innerText).includes(query), - ); - } - - #toggleVisibility(elements, isVisible) { - elements.forEach((el) => el.classList.toggle("hidden", !isVisible)); - } - - #sanitizeStr(str) { - return str.toLowerCase().trim(); - } -} diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index c2876053..b7d14377 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -1,201 +1,190 @@ import { Controller } from "@hotwired/stimulus"; -import { computePosition, autoUpdate, offset } from "@floating-ui/dom"; - -export const POPOVER_OPENED = "ruby-ui--combobox#popoverOpened"; +// Connects to data-controller="ruby-ui--combobox" export default class extends Controller { - static targets = [ - "input", - "trigger", - "value", - "content", - "search", - "list", - "item", - ]; - static values = { open: Boolean }; - static outlets = ["ruby-ui--combobox-item", "ruby-ui--combobox-content"]; - - constructor(...args) { - super(...args); - this.cleanup; + static values = { + multiple: Boolean, // controls if select input accepts multiple selected options or not + term: String // text used on multiple combobox to indicate how many items are selected, eg. `4 items` } - connect() { - this.#setFloatingElement(); - this.#generateItemsIds(); - } + static targets = [ + "dialog", // dialog that shows combobox options + "datalistOption", // visual options inside dialog + "emptyState", // element displayed when the search doesn't return results + "searchInput", // search input used for filtering options + "select", // hidden select that control combobox value + "trigger", // button that opens dialog + "triggerContent" // button content used to display selected values + ] - disconnect() { - this.cleanup(); + connect() { + this.initSelect() + this.updateTriggerContent() } - onTriggerClick(event) { - event.preventDefault(); - - if (this.openValue) { - this.#closeContent(); - } else { - this.#openContent(); + // Init select using datalist options + initSelect() { + if (this.multipleValue) { + this.selectTarget.setAttribute("multiple", true) } - } - - onItemSelected(event) { - event.preventDefault(); - this.#setValueDispatchEventAndCloseContent(event.target); + this.datalistOptionTargets.forEach((datalistOption) => { + const selectOption = document.createElement("option") + selectOption.value = datalistOption.dataset.value + selectOption.selected = datalistOption.ariaSelected === "true" + this.selectTarget.appendChild(selectOption) + }) } - onKeyEnter(event) { - event.preventDefault(); - - const currentItem = this.itemTargets.find( - (item) => item.getAttribute("aria-current") === "true", - ); - - if (!currentItem) this.#closeContent(); + openDialog() { + document.body.classList.add('overflow-hidden') + this.triggerTarget.ariaExpanded = "true" + this.dialogTarget.showModal() + } - this.#setValueDispatchEventAndCloseContent(currentItem); + closeDialog() { + document.body.classList.remove('overflow-hidden') + this.triggerTarget.ariaExpanded = "false" + this.dialogTarget.close() } - onSearchInput(event) { - this.rubyUiComboboxContentOutlet.handleSearchInput(event.target.value); - this.#findAndSetCurrentAndActiveDescendant(); + // Close dialog When dialog backdrop is clicked + handleOutsideClick(event) { + if (event.target === this.dialogTarget) { + this.closeDialog() + } } - onClickOutside(event) { - if (!this.openValue) return; - if (this.element.contains(event.target)) return; + toggleOption(event) { + const datalistOption = event.currentTarget - event.preventDefault(); - this.#closeContent(); - } + if (this.multipleValue) { + this.toggleOptionInMultiple(datalistOption) + } else { + this.toggleOptionInSingle(datalistOption) + } - onEscKey(event) { - event.preventDefault(); + this.updateTriggerContent() + } - this.#closeContent(); + toggleOptionInMultiple(datalistOption) { + if (datalistOption.ariaSelected !== "true") { + this.selectSelectOption(datalistOption.dataset.value) + this.selectDatalistOption(datalistOption) + } else { + this.unselectSelectOption(datalistOption.dataset.value) + this.unselectDatalistOption(datalistOption) + } } - onKeyDown(event) { - event.preventDefault(); + toggleOptionInSingle(datalistOption) { + if (datalistOption.ariaSelected !== "true") { + this.selectSelectOption(datalistOption.dataset.value) + this.selectDatalistOption(datalistOption) + this.unselectOtherDatalistOptions(datalistOption) + } else { + this.unselectSelectOption(datalistOption.dataset.value) + this.unselectDatalistOption(datalistOption) + } - const currentIndex = this.itemTargets.findIndex( - (item) => item.getAttribute("aria-current") === "true", - ); + this.closeDialog() + } - if (currentIndex + 1 < this.itemTargets.length) { - this.itemTargets[currentIndex].removeAttribute("aria-current"); + selectSelectOption(value) { + const options = this.selectTarget.options - const currentItem = this.itemTargets[currentIndex + 1]; - this.#setCurrentAndActiveDescendant(currentItem); + for (let i = 0; i < options.length; i++) { + const option = options[i] + if (!option.selected && option.value == value) { + option.selected = true + break + } } } - onKeyUp(event) { - event.preventDefault(); - const currentIndex = this.itemTargets.findIndex( - (item) => item.getAttribute("aria-current") === "true", - ); - - if (currentIndex > 0) { - this.itemTargets[currentIndex].removeAttribute("aria-current"); + unselectSelectOption(value) { + const options = this.selectTarget.options - const currentItem = this.itemTargets[currentIndex - 1]; - this.#setCurrentAndActiveDescendant(currentItem); + for (let i = 0; i < options.length; i++) { + const option = options[i] + if (option.selected && option.value == value) { + option.selected = false + break + } } } - #closeContent() { - this.openValue = false; - this.contentTarget.classList.add("hidden"); - this.triggerTarget.setAttribute("aria-expanded", false); - this.triggerTarget.setAttribute("aria-activedescendant", true); - this.itemTargets.forEach((item) => item.removeAttribute("aria-current")); - - this.triggerTarget.focus({ preventScroll: true }); + unselectOtherDatalistOptions(selectedOption) { + this.datalistOptionTargets + .filter(other => other !== selectedOption && other.ariaSelected === "true") + .forEach(other => this.unselectDatalistOption(other)) } - #openContent() { - this.openValue = true; - this.contentTarget.classList.remove("hidden"); - this.triggerTarget.setAttribute("aria-expanded", true); - - this.#findAndSetCurrentAndActiveDescendant(); - this.searchTarget.focus({ preventScroll: true }); + selectDatalistOption(option) { + option.ariaSelected = "true" } - #findAndSetCurrentAndActiveDescendant() { - const selectedItem = this.itemTargets.find( - (item) => item.getAttribute("aria-selected") === "true", - ); + unselectDatalistOption(option) { + option.ariaSelected = "false" + } - if (selectedItem) { - this.#setCurrentAndActiveDescendant(selectedItem); - return; + updateTriggerContent() { + if (this.multipleValue) { + this.updateTriggerContentInMultiple() + } else { + this.updateTriggerContentInSingle() } - - const selectedVisible = this.itemTargets.find( - (item) => !item.classList.contains("hidden"), - ); - this.#setCurrentAndActiveDescendant(selectedVisible); } - #setCurrentAndActiveDescendant(item) { - if (!item) return; + // Get option data-text or textContent and updates ComboboxTrigger content. + updateTriggerContentInSingle() { + const selectedOption = this.datalistOptionTargets.find(option => option.ariaSelected === "true") - item.setAttribute("aria-current", "true"); - this.triggerTarget.setAttribute( - "aria-activedescendant", - item.getAttribute("id"), - ); + if (selectedOption) { + const text = selectedOption.dataset.text || selectedOption.innerText + this.triggerContentTarget.innerText = text + } else { + this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder + } } - #setValueDispatchEventAndCloseContent(item) { - const oldValue = this.inputTarget.value; - const newValue = item.dataset.value; + updateTriggerContentInMultiple() { + let selectedCount = 0 + let selectedOption - this.rubyUiComboboxItemOutlets.forEach((item) => - item.handleItemSelected(newValue), - ); + this.datalistOptionTargets.forEach((option) => { + if (option.ariaSelected === "true") { + selectedCount++ + selectedOption = option + } + }) - this.inputTarget.value = item.dataset.value; - this.valueTarget.innerText = item.innerText; - - this.#dispatchOnChange(oldValue, newValue); - this.#closeContent(); + if (selectedCount === 0) { + this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder + } else if (selectedCount === 1) { + const text = selectedOption.dataset.text || selectedOption.innerText + this.triggerContentTarget.innerText = text + } else { + this.triggerContentTarget.innerText = `${selectedCount} ${this.termValue}` + } } - #dispatchOnChange(oldValue, newValue) { - if (oldValue === newValue) return; - - const event = new InputEvent("change", { - bubbles: true, - cancelable: true, - }); + filterOptions() { + const filterTerm = this.searchInputTarget.value.toLowerCase() - this.inputTarget.dispatchEvent(event); - } + let resultCount = 0 - #generateItemsIds() { - const listId = this.listTarget.getAttribute("id"); - this.triggerTarget.setAttribute("aria-controls", listId); + this.datalistOptionTargets.forEach((option) => { + const text = option.dataset.text?.toLowerCase() || option.innerText.toLowerCase() - this.itemTargets.forEach((item, index) => { - item.id = `${listId}-${index}`; - }); - } + if (text.indexOf(filterTerm) > -1) { + option.classList.remove("hidden") + resultCount++ + } else { + option.classList.add("hidden") + } + }) - #setFloatingElement() { - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - middleware: [offset(4)], - }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); - }); + this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) } } diff --git a/lib/ruby_ui/combobox/combobox_datalist.rb b/lib/ruby_ui/combobox/combobox_datalist.rb new file mode 100644 index 00000000..9ebe6c75 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_datalist.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxDatalist < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex flex-col gap-1 p-1 max-h-72 overflow-y-auto text-foreground", + role: "listbox" + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_dialog.rb b/lib/ruby_ui/combobox/combobox_dialog.rb new file mode 100644 index 00000000..4ed4a883 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_dialog.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxDialog < Base + BACKDROP_CLASSES = "backdrop:bg-foreground/40" + + def view_template(&) + dialog(**attrs, &) + end + + private + + def default_attrs + { + class: ["w-11/12 md:w-full md:max-w-md border bg-background shadow-lg rounded-lg", BACKDROP_CLASSES], + role: "dialog", + autofocus: true, + data: { + ruby_ui__combobox_target: "dialog", + action: "click->ruby-ui--combobox#handleOutsideClick" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_empty.rb b/lib/ruby_ui/combobox/combobox_empty_state.rb similarity index 76% rename from lib/ruby_ui/combobox/combobox_empty.rb rename to lib/ruby_ui/combobox/combobox_empty_state.rb index 552c5a3c..c2c6e110 100644 --- a/lib/ruby_ui/combobox/combobox_empty.rb +++ b/lib/ruby_ui/combobox/combobox_empty_state.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module RubyUI - class ComboboxEmpty < Base + class ComboboxEmptyState < Base def view_template(&) div(**attrs, &) end @@ -13,7 +13,7 @@ def default_attrs role: "presentation", class: "hidden py-6 text-center text-sm", data: { - ruby_ui__combobox_content_target: "empty" + ruby_ui__combobox_target: "emptyState" } } end diff --git a/lib/ruby_ui/combobox/combobox_group.rb b/lib/ruby_ui/combobox/combobox_group.rb deleted file mode 100644 index 1620d032..00000000 --- a/lib/ruby_ui/combobox/combobox_group.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxGroup < Base - def initialize(heading: nil, **attrs) - @heading = heading - super(**attrs) - end - - def view_template(&block) - div(**attrs) do - render_header if @heading - render_items(&block) - end - end - - private - - def render_header - div(group_heading: @heading, class: "px-2 py-1.5 text-xs font-medium text-muted-foreground") { @heading } - end - - def render_items(&) - div(group_items: "", role: "group", &) - end - - def default_attrs - { - class: "overflow-hidden p-1 text-foreground", - role: "presentation", - data: { - value: @heading, - ruby_ui__combobox_content_target: "group" - } - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_input.rb b/lib/ruby_ui/combobox/combobox_input.rb index 9de6d7f8..924e0136 100644 --- a/lib/ruby_ui/combobox/combobox_input.rb +++ b/lib/ruby_ui/combobox/combobox_input.rb @@ -3,7 +3,7 @@ module RubyUI class ComboboxInput < Base def view_template - input(**attrs) + select(**attrs) end private @@ -12,9 +12,7 @@ def default_attrs { class: "hidden", data: { - ruby_ui__combobox_target: "input", - ruby_ui__form_field_target: "input", - action: "change->ruby-ui--form-field#onChange invalid->ruby-ui--form-field#onInvalid" + ruby_ui__combobox_target: "select" } } end diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb deleted file mode 100644 index d8ed4f08..00000000 --- a/lib/ruby_ui/combobox/combobox_item.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxItem < Base - def initialize(value: nil, **attrs) - @value = value - super(**attrs) - end - - def view_template(&block) - div(**attrs) do - div(class: "invisible group-aria-selected:visible") { icon } - block.call - end - end - - private - - def icon - svg( - xmlns: "http://www.w3.org/2000/svg", - viewbox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - class: "mr-2 h-4 w-4", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round" - ) do |s| - s.path( - d: "M20 6 9 17l-5-5" - ) - end - end - - def default_attrs - { - role: "option", - tabindex: "0", - class: - "combobox-item group relative flex cursor-pointer select-none items-center gap-x-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-[current]:bg-accent aria-[current]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - data: { - value: @value, - ruby_ui__combobox_target: "item", - ruby_ui__combobox_content_target: "item", - controller: "ruby-ui--combobox-item", - action: "click->ruby-ui--combobox#onItemSelected" - }, - aria_selected: "false" - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_item_controller.js b/lib/ruby_ui/combobox/combobox_item_controller.js deleted file mode 100644 index 48f3c771..00000000 --- a/lib/ruby_ui/combobox/combobox_item_controller.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - handleItemSelected(value) { - if (this.element.dataset.value == value) { - this.element.setAttribute("aria-selected", true); - } else { - this.element.removeAttribute("aria-selected"); - } - } -} diff --git a/lib/ruby_ui/combobox/combobox_list.rb b/lib/ruby_ui/combobox/combobox_list.rb deleted file mode 100644 index 3770d043..00000000 --- a/lib/ruby_ui/combobox/combobox_list.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxList < Base - def initialize(**attrs) - @id = "list#{SecureRandom.hex(4)}" - super - end - - def view_template(&) - div(**attrs, &) - end - - private - - def default_attrs - { - id: @id, - data: { - ruby_ui__combobox_target: "list" - }, - role: "listbox", - tabindex: "-1" - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_optgroup.rb b/lib/ruby_ui/combobox/combobox_optgroup.rb new file mode 100644 index 00000000..e74fdc60 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_optgroup.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxOptgroup < Base + LABEL_CLASSES = "before:content-[attr(label)] before:px-2 before:py-1.5 before:text-xs before:font-medium before:text-muted-foreground before:not-italic" + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: ["hidden has-[div:not(.hidden)]:flex flex-col gap-1", LABEL_CLASSES], + role: "group" + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_option.rb b/lib/ruby_ui/combobox/combobox_option.rb new file mode 100644 index 00000000..78763a84 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_option.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxOption < Base + def initialize(value:, selected: false, **) + @value = value + @selected = selected + super(**) + end + + def view_template(&) + div(**attrs) do + span(class: "invisible group-aria-selected:visible") { check_icon } + span(&) + end + end + + private + + def check_icon + svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", class: "mr-2 h-4 w-4", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path( + d: "M20 6 9 17l-5-5" + ) + end + end + + def default_attrs + { + class: "group flex flex-row gap-2 items-center rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer select-none aria-selected:bg-accent hover:bg-accent p-2 [&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0", + role: "option", + aria: {selected: @selected.to_s}, + data: { + value: @value.to_s, + ruby_ui__combobox_target: "datalistOption", + action: "click->ruby-ui--combobox#toggleOption" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_search_input.rb b/lib/ruby_ui/combobox/combobox_search_input.rb index cce5cbb0..9aa36983 100644 --- a/lib/ruby_ui/combobox/combobox_search_input.rb +++ b/lib/ruby_ui/combobox/combobox_search_input.rb @@ -2,21 +2,37 @@ module RubyUI class ComboboxSearchInput < Base - def initialize(placeholder:, **attrs) + def initialize(placeholder:, **) @placeholder = placeholder - super(**attrs) + super(**) end def view_template - input_container do - search_icon + div class: "flex text-muted-foreground items-center border-b px-3" do + icon input(**attrs) end end private - def search_icon + def default_attrs + { + type: "search", + class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + role: "searchbox", + placeholder: @placeholder, + data: { + ruby_ui__combobox_target: "searchInput", + action: "keyup->ruby-ui--combobox#filterOptions" + }, + autocomplete: "off", + autocorrect: "off", + spellcheck: "false" + } + end + + def icon svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", @@ -33,24 +49,5 @@ def search_icon ) end end - - def input_container(&) - div(class: "flex items-center border-b px-3", &) - end - - def default_attrs - { - class: - "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", - placeholder: @placeholder, - data: { - action: "input->ruby-ui--combobox#onSearchInput", - ruby_ui__combobox_target: "search" - }, - autocomplete: "off", - autocorrect: "off", - spellcheck: false - } - end end end diff --git a/lib/ruby_ui/combobox/combobox_separator.rb b/lib/ruby_ui/combobox/combobox_separator.rb deleted file mode 100644 index a2a18732..00000000 --- a/lib/ruby_ui/combobox/combobox_separator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxSeparator < Base - def view_template(&) - div(**attrs, &) - end - - private - - def default_attrs - {class: "-mx-1 h-px bg-border"} - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_trigger.rb b/lib/ruby_ui/combobox/combobox_trigger.rb index 5b73584c..5dd3d65e 100644 --- a/lib/ruby_ui/combobox/combobox_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_trigger.rb @@ -2,15 +2,35 @@ module RubyUI class ComboboxTrigger < Base - def view_template(&block) + def initialize(placeholder: "", **) + @placeholder = placeholder + super(**) + end + + def view_template button(**attrs) do - block&.call + span(data: {ruby_ui__combobox_target: "triggerContent"}) { @placeholder } icon end end private + def default_attrs + { + class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-[200px] justify-between", + data: { + placeholder: @placeholder, + ruby_ui__combobox_target: "trigger", + action: "ruby-ui--combobox#openDialog" + }, + aria: { + haspopup: "dialog", + expanded: "false" + } + } + end + def icon svg( xmlns: "http://www.w3.org/2000/svg", @@ -30,23 +50,5 @@ def icon ) end end - - def default_attrs - { - data: { - action: "ruby-ui--combobox#onTriggerClick", - ruby_ui__combobox_target: "trigger" - }, - type: "button", - role: "combobox", - aria: { - expanded: "false", - haspopup: "listbox", - autocomplete: "none", - activedescendant: true - }, - class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-[200px] justify-between" - } - end end end diff --git a/lib/ruby_ui/combobox/combobox_value.rb b/lib/ruby_ui/combobox/combobox_value.rb deleted file mode 100644 index f011a5b1..00000000 --- a/lib/ruby_ui/combobox/combobox_value.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxValue < Base - def initialize(placeholder: nil, **attrs) - @placeholder = placeholder - super(**attrs) - end - - def view_template(&block) - span(**attrs) do - block ? block.call : @placeholder - end - end - - private - - def default_attrs - { - data: { - ruby_ui__combobox_target: "value" - }, - class: "pointer-events-none" - } - end - end -end diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index 1354096c..f0ca4514 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -5,45 +5,39 @@ class RubyUI::ComboboxTest < ComponentTest def test_render_with_all_items output = phlex do - RubyUI.Combobox do - RubyUI.ComboboxInput() - RubyUI.ComboboxTrigger do - RubyUI.ComboboxValue(placeholder: "Select event...") - end - RubyUI.ComboboxContent do - RubyUI.ComboboxSearchInput(placeholder: "Search event...") - RubyUI.ComboboxList do - RubyUI.ComboboxEmpty { "No results found." } - RubyUI.ComboboxGroup(heading: "Suggestions") do - RubyUI.ComboboxItem(value: "railsworld") do |item| - item.span { "Rails World" } - end - RubyUI.ComboboxItem(value: "tropicalrb") do |item| - item.span { "Tropical.rb" } - end - RubyUI.ComboboxItem(value: "friendly.rb") do |item| - item.span { "Friendly.rb" } - end + RubyUI.Combobox(multiple: true, term: "frameworks") do + RubyUI.ComboboxInput(name: "multiple") + + RubyUI.ComboboxTrigger placeholder: "Select your framework" + + RubyUI.ComboboxDialog do + RubyUI.ComboboxSearchInput(placeholder: "Type the framework name") + + RubyUI.ComboboxDatalist do + RubyUI.ComboboxEmptyState { "No results" } + + RubyUI.ComboboxOptgroup label: "Ruby" do + RubyUI.ComboboxOption(value: "rails") { "Rails" } + RubyUI.ComboboxOption(value: "hanami") { "Hanami" } + end + + RubyUI.ComboboxOptgroup label: "Crystal" do + RubyUI.ComboboxOption(value: "lucky", selected: true) { "Lucky" } + RubyUI.ComboboxOption(value: "kemal") { "Kemal" } end - RubyUI.ComboboxSeparator() - - RubyUI.ComboboxGroup(heading: "Others") do - RubyUI.ComboboxItem(value: "railsconf") do |item| - item.span { "RailsConf" } - end - RubyUI.ComboboxItem(value: "euruko") do |item| - item.span { "Euruko" } - end - RubyUI.ComboboxItem(value: "rubykaigi") do |item| - item.span { "RubyKaigi" } - end + RubyUI.ComboboxOptgroup label: "Others" do + RubyUI.ComboboxOption(value: "django") { "Django" } + RubyUI.ComboboxOption(value: "laravel") { "Laravel" } end + + RubyUI.ComboboxOption(value: "spring") { "Spring" } + RubyUI.ComboboxOption(value: "vraptor") { "VRaptor" } end end end end - assert_match(/Tropical.rb/, output) + assert_match(/Hanami/, output) end end From f908668d9b2d7f5c246c6f7c759e4f27e97671ca Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Sat, 30 Nov 2024 09:34:28 -0300 Subject: [PATCH 2/5] Add keyboard interactions --- lib/ruby_ui/combobox/combobox_controller.js | 65 ++++++++++++++++++++- lib/ruby_ui/combobox/combobox_dialog.rb | 2 +- lib/ruby_ui/combobox/combobox_option.rb | 2 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index b7d14377..f8807455 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -17,6 +17,8 @@ export default class extends Controller { "triggerContent" // button content used to display selected values ] + selectedOptionIndex = null + connect() { this.initSelect() this.updateTriggerContent() @@ -36,7 +38,9 @@ export default class extends Controller { }) } - openDialog() { + openDialog(event) { + event.preventDefault() + document.body.classList.add('overflow-hidden') this.triggerTarget.ariaExpanded = "true" this.dialogTarget.showModal() @@ -45,6 +49,8 @@ export default class extends Controller { closeDialog() { document.body.classList.remove('overflow-hidden') this.triggerTarget.ariaExpanded = "false" + this.selectedOptionIndex = null + this.datalistOptionTargets.forEach(option => option.ariaCurrent = "false") this.dialogTarget.close() } @@ -169,14 +175,21 @@ export default class extends Controller { } } - filterOptions() { - const filterTerm = this.searchInputTarget.value.toLowerCase() + filterOptions(e) { + if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) { + return + } + const filterTerm = this.searchInputTarget.value.toLowerCase() let resultCount = 0 + this.selectedOptionIndex = null + this.datalistOptionTargets.forEach((option) => { const text = option.dataset.text?.toLowerCase() || option.innerText.toLowerCase() + option.ariaCurrent = "false" + if (text.indexOf(filterTerm) > -1) { option.classList.remove("hidden") resultCount++ @@ -187,4 +200,50 @@ export default class extends Controller { this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0) } + + keyDownPressed() { + if (this.selectedOptionIndex !== null) { + this.selectedOptionIndex++ + } else { + this.selectedOptionIndex = 0 + } + + this.focusSelectedOption() + } + + keyUpPressed() { + if (this.selectedOptionIndex !== null) { + this.selectedOptionIndex-- + } else { + this.selectedOptionIndex = -1 + } + + this.focusSelectedOption() + } + + focusSelectedOption() { + const visibleOptions = this.datalistOptionTargets.filter(option => !option.classList.contains("hidden")) + + this.wrapSelectedOptionIndex(visibleOptions.length) + + visibleOptions.forEach((option, index) => { + option.ariaCurrent = index == this.selectedOptionIndex ? "true" : "false" + if (option.ariaCurrent === "true") { + option.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + } + }) + } + + keyEnterPressed(event) { + event.preventDefault() + const option = this.datalistOptionTargets.find(option => option.ariaCurrent === "true") + + if (option) { + option.click() + } + } + + wrapSelectedOptionIndex(length) { + this.selectedOptionIndex = ((this.selectedOptionIndex % length) + length) % length + } } diff --git a/lib/ruby_ui/combobox/combobox_dialog.rb b/lib/ruby_ui/combobox/combobox_dialog.rb index 4ed4a883..f61f43c0 100644 --- a/lib/ruby_ui/combobox/combobox_dialog.rb +++ b/lib/ruby_ui/combobox/combobox_dialog.rb @@ -17,7 +17,7 @@ def default_attrs autofocus: true, data: { ruby_ui__combobox_target: "dialog", - action: "click->ruby-ui--combobox#handleOutsideClick" + action: "click->ruby-ui--combobox#handleOutsideClick keydown.down->ruby-ui--combobox#keyDownPressed keydown.up->ruby-ui--combobox#keyUpPressed keydown.enter->ruby-ui--combobox#keyEnterPressed keydown.esc->ruby-ui--combobox#closeDialog:prevent" } } end diff --git a/lib/ruby_ui/combobox/combobox_option.rb b/lib/ruby_ui/combobox/combobox_option.rb index 78763a84..47485626 100644 --- a/lib/ruby_ui/combobox/combobox_option.rb +++ b/lib/ruby_ui/combobox/combobox_option.rb @@ -27,7 +27,7 @@ def check_icon def default_attrs { - class: "group flex flex-row gap-2 items-center rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer select-none aria-selected:bg-accent hover:bg-accent p-2 [&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0", + class: "group flex flex-row gap-2 items-center rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer select-none aria-selected: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 ", role: "option", aria: {selected: @selected.to_s}, data: { From 1d1bc6bc7a03c36b2dc7657e8305ed6ce0c6996d Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:37:23 -0300 Subject: [PATCH 3/5] Use checkboxes and radios instead hidden select --- lib/ruby_ui/combobox/combobox.rb | 4 +- lib/ruby_ui/combobox/combobox_checkbox.rb | 21 ++ lib/ruby_ui/combobox/combobox_controller.js | 221 +++++------------- lib/ruby_ui/combobox/combobox_input.rb | 20 -- lib/ruby_ui/combobox/combobox_item.rb | 21 ++ ...{combobox_datalist.rb => combobox_list.rb} | 2 +- ...box_optgroup.rb => combobox_list_group.rb} | 4 +- lib/ruby_ui/combobox/combobox_option.rb | 41 ---- lib/ruby_ui/combobox/combobox_radio.rb | 21 ++ lib/ruby_ui/combobox/combobox_search_input.rb | 2 +- test/ruby_ui/combobox_test.rb | 60 +++-- 11 files changed, 170 insertions(+), 247 deletions(-) create mode 100644 lib/ruby_ui/combobox/combobox_checkbox.rb delete mode 100644 lib/ruby_ui/combobox/combobox_input.rb create mode 100644 lib/ruby_ui/combobox/combobox_item.rb rename lib/ruby_ui/combobox/{combobox_datalist.rb => combobox_list.rb} (89%) rename lib/ruby_ui/combobox/{combobox_optgroup.rb => combobox_list_group.rb} (74%) delete mode 100644 lib/ruby_ui/combobox/combobox_option.rb create mode 100644 lib/ruby_ui/combobox/combobox_radio.rb diff --git a/lib/ruby_ui/combobox/combobox.rb b/lib/ruby_ui/combobox/combobox.rb index c221d75a..e3156e93 100644 --- a/lib/ruby_ui/combobox/combobox.rb +++ b/lib/ruby_ui/combobox/combobox.rb @@ -2,8 +2,7 @@ module RubyUI class Combobox < Base - def initialize(multiple: false, term: "items", **) - @multiple = multiple + def initialize(term: "items", **) @term = term super(**) end @@ -19,7 +18,6 @@ def default_attrs role: "combobox", data: { controller: "ruby-ui--combobox", - ruby_ui__combobox_multiple_value: @multiple.to_s, ruby_ui__combobox_term_value: @term.to_s } } diff --git a/lib/ruby_ui/combobox/combobox_checkbox.rb b/lib/ruby_ui/combobox/combobox_checkbox.rb new file mode 100644 index 00000000..c23dcee7 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_checkbox.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxCheckbox < Base + def view_template + input(type: "checkbox", **attrs) + end + + private + + def default_attrs + { + class: "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary", + data: { + ruby_ui__combobox_target: "input", + action: "ruby-ui--combobox#inputChanged" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index f8807455..c14d1f6c 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -3,39 +3,47 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="ruby-ui--combobox" export default class extends Controller { static values = { - multiple: Boolean, // controls if select input accepts multiple selected options or not - term: String // text used on multiple combobox to indicate how many items are selected, eg. `4 items` + term: String } static targets = [ - "dialog", // dialog that shows combobox options - "datalistOption", // visual options inside dialog - "emptyState", // element displayed when the search doesn't return results - "searchInput", // search input used for filtering options - "select", // hidden select that control combobox value - "trigger", // button that opens dialog - "triggerContent" // button content used to display selected values + "input", + "dialog", + "item", + "emptyState", + "searchInput", + "trigger", + "triggerContent" ] - selectedOptionIndex = null + selectedItemIndex = null connect() { - this.initSelect() this.updateTriggerContent() } - // Init select using datalist options - initSelect() { - if (this.multipleValue) { - this.selectTarget.setAttribute("multiple", true) + inputChanged(e) { + this.updateTriggerContent() + + if (e.target.type == "radio") { + this.closeDialog() } + } - this.datalistOptionTargets.forEach((datalistOption) => { - const selectOption = document.createElement("option") - selectOption.value = datalistOption.dataset.value - selectOption.selected = datalistOption.ariaSelected === "true" - this.selectTarget.appendChild(selectOption) - }) + inputContent(input) { + return input.dataset.text || input.parentElement.innerText + } + + updateTriggerContent() { + const checkedInputs = this.inputTargets.filter(input => input.checked) + + if (checkedInputs.length == 0) { + this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder + } else if (checkedInputs.length === 1) { + this.triggerContentTarget.innerText = this.inputContent(checkedInputs[0]) + } else { + this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}` + } } openDialog(event) { @@ -49,133 +57,18 @@ export default class extends Controller { closeDialog() { document.body.classList.remove('overflow-hidden') this.triggerTarget.ariaExpanded = "false" - this.selectedOptionIndex = null - this.datalistOptionTargets.forEach(option => option.ariaCurrent = "false") + this.selectedItemIndex = null + this.itemTargets.forEach(item => item.ariaCurrent = "false") this.dialogTarget.close() } - // Close dialog When dialog backdrop is clicked handleOutsideClick(event) { if (event.target === this.dialogTarget) { this.closeDialog() } } - toggleOption(event) { - const datalistOption = event.currentTarget - - if (this.multipleValue) { - this.toggleOptionInMultiple(datalistOption) - } else { - this.toggleOptionInSingle(datalistOption) - } - - this.updateTriggerContent() - } - - toggleOptionInMultiple(datalistOption) { - if (datalistOption.ariaSelected !== "true") { - this.selectSelectOption(datalistOption.dataset.value) - this.selectDatalistOption(datalistOption) - } else { - this.unselectSelectOption(datalistOption.dataset.value) - this.unselectDatalistOption(datalistOption) - } - } - - toggleOptionInSingle(datalistOption) { - if (datalistOption.ariaSelected !== "true") { - this.selectSelectOption(datalistOption.dataset.value) - this.selectDatalistOption(datalistOption) - this.unselectOtherDatalistOptions(datalistOption) - } else { - this.unselectSelectOption(datalistOption.dataset.value) - this.unselectDatalistOption(datalistOption) - } - - this.closeDialog() - } - - selectSelectOption(value) { - const options = this.selectTarget.options - - for (let i = 0; i < options.length; i++) { - const option = options[i] - if (!option.selected && option.value == value) { - option.selected = true - break - } - } - } - - unselectSelectOption(value) { - const options = this.selectTarget.options - - for (let i = 0; i < options.length; i++) { - const option = options[i] - if (option.selected && option.value == value) { - option.selected = false - break - } - } - } - - unselectOtherDatalistOptions(selectedOption) { - this.datalistOptionTargets - .filter(other => other !== selectedOption && other.ariaSelected === "true") - .forEach(other => this.unselectDatalistOption(other)) - } - - selectDatalistOption(option) { - option.ariaSelected = "true" - } - - unselectDatalistOption(option) { - option.ariaSelected = "false" - } - - updateTriggerContent() { - if (this.multipleValue) { - this.updateTriggerContentInMultiple() - } else { - this.updateTriggerContentInSingle() - } - } - - // Get option data-text or textContent and updates ComboboxTrigger content. - updateTriggerContentInSingle() { - const selectedOption = this.datalistOptionTargets.find(option => option.ariaSelected === "true") - - if (selectedOption) { - const text = selectedOption.dataset.text || selectedOption.innerText - this.triggerContentTarget.innerText = text - } else { - this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder - } - } - - updateTriggerContentInMultiple() { - let selectedCount = 0 - let selectedOption - - this.datalistOptionTargets.forEach((option) => { - if (option.ariaSelected === "true") { - selectedCount++ - selectedOption = option - } - }) - - if (selectedCount === 0) { - this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder - } else if (selectedCount === 1) { - const text = selectedOption.dataset.text || selectedOption.innerText - this.triggerContentTarget.innerText = text - } else { - this.triggerContentTarget.innerText = `${selectedCount} ${this.termValue}` - } - } - - filterOptions(e) { + filterItems(e) { if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) { return } @@ -183,18 +76,16 @@ export default class extends Controller { const filterTerm = this.searchInputTarget.value.toLowerCase() let resultCount = 0 - this.selectedOptionIndex = null + this.selectedItemIndex = null - this.datalistOptionTargets.forEach((option) => { - const text = option.dataset.text?.toLowerCase() || option.innerText.toLowerCase() - - option.ariaCurrent = "false" + this.inputTargets.forEach((input) => { + const text = this.inputContent(input).toLowerCase() if (text.indexOf(filterTerm) > -1) { - option.classList.remove("hidden") + input.parentElement.classList.remove("hidden") resultCount++ } else { - option.classList.add("hidden") + input.parentElement.classList.add("hidden") } }) @@ -202,48 +93,50 @@ export default class extends Controller { } keyDownPressed() { - if (this.selectedOptionIndex !== null) { - this.selectedOptionIndex++ + if (this.selectedItemIndex !== null) { + this.selectedItemIndex++ } else { - this.selectedOptionIndex = 0 + this.selectedItemIndex = 0 } - this.focusSelectedOption() + this.focusSelectedInput() } keyUpPressed() { - if (this.selectedOptionIndex !== null) { - this.selectedOptionIndex-- + if (this.selectedItemIndex !== null) { + this.selectedItemIndex-- } else { - this.selectedOptionIndex = -1 + this.selectedItemIndex = -1 } - this.focusSelectedOption() + this.focusSelectedInput() } - focusSelectedOption() { - const visibleOptions = this.datalistOptionTargets.filter(option => !option.classList.contains("hidden")) + focusSelectedInput() { + const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden")) - this.wrapSelectedOptionIndex(visibleOptions.length) + this.wrapSelectedInputIndex(visibleInputs.length) - visibleOptions.forEach((option, index) => { - option.ariaCurrent = index == this.selectedOptionIndex ? "true" : "false" - if (option.ariaCurrent === "true") { - option.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + visibleInputs.forEach((input, index) => { + if (index == this.selectedItemIndex) { + input.parentElement.ariaCurrent = "true" + input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + } else { + input.parentElement.ariaCurrent = "false" } }) } keyEnterPressed(event) { event.preventDefault() - const option = this.datalistOptionTargets.find(option => option.ariaCurrent === "true") + const option = this.itemTargets.find(item => item.ariaCurrent === "true") if (option) { option.click() } } - wrapSelectedOptionIndex(length) { - this.selectedOptionIndex = ((this.selectedOptionIndex % length) + length) % length + wrapSelectedInputIndex(length) { + this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length } } diff --git a/lib/ruby_ui/combobox/combobox_input.rb b/lib/ruby_ui/combobox/combobox_input.rb deleted file mode 100644 index 924e0136..00000000 --- a/lib/ruby_ui/combobox/combobox_input.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxInput < Base - def view_template - select(**attrs) - end - - private - - def default_attrs - { - class: "hidden", - data: { - ruby_ui__combobox_target: "select" - } - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb new file mode 100644 index 00000000..db8af32c --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_item.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxItem < Base + def view_template(&) + label(**attrs, &) + end + + private + + def default_attrs + { + class: "flex flex-row gap-2 items-center rounded-sm px-2 py-1.5 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 ", + role: "option", + data: { + ruby_ui__combobox_target: "item" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_datalist.rb b/lib/ruby_ui/combobox/combobox_list.rb similarity index 89% rename from lib/ruby_ui/combobox/combobox_datalist.rb rename to lib/ruby_ui/combobox/combobox_list.rb index 9ebe6c75..e4bce6e9 100644 --- a/lib/ruby_ui/combobox/combobox_datalist.rb +++ b/lib/ruby_ui/combobox/combobox_list.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module RubyUI - class ComboboxDatalist < Base + class ComboboxList < Base def view_template(&) div(**attrs, &) end diff --git a/lib/ruby_ui/combobox/combobox_optgroup.rb b/lib/ruby_ui/combobox/combobox_list_group.rb similarity index 74% rename from lib/ruby_ui/combobox/combobox_optgroup.rb rename to lib/ruby_ui/combobox/combobox_list_group.rb index e74fdc60..a64a02a3 100644 --- a/lib/ruby_ui/combobox/combobox_optgroup.rb +++ b/lib/ruby_ui/combobox/combobox_list_group.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module RubyUI - class ComboboxOptgroup < Base + class ComboboxListGroup < Base LABEL_CLASSES = "before:content-[attr(label)] before:px-2 before:py-1.5 before:text-xs before:font-medium before:text-muted-foreground before:not-italic" def view_template(&) @@ -12,7 +12,7 @@ def view_template(&) def default_attrs { - class: ["hidden has-[div:not(.hidden)]:flex flex-col gap-1", LABEL_CLASSES], + class: ["hidden has-[label:not(.hidden)]:flex flex-col gap-1", LABEL_CLASSES], role: "group" } end diff --git a/lib/ruby_ui/combobox/combobox_option.rb b/lib/ruby_ui/combobox/combobox_option.rb deleted file mode 100644 index 47485626..00000000 --- a/lib/ruby_ui/combobox/combobox_option.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxOption < Base - def initialize(value:, selected: false, **) - @value = value - @selected = selected - super(**) - end - - def view_template(&) - div(**attrs) do - span(class: "invisible group-aria-selected:visible") { check_icon } - span(&) - end - end - - private - - def check_icon - svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", class: "mr-2 h-4 w-4", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| - s.path( - d: "M20 6 9 17l-5-5" - ) - end - end - - def default_attrs - { - class: "group flex flex-row gap-2 items-center rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer select-none aria-selected: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 ", - role: "option", - aria: {selected: @selected.to_s}, - data: { - value: @value.to_s, - ruby_ui__combobox_target: "datalistOption", - action: "click->ruby-ui--combobox#toggleOption" - } - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_radio.rb b/lib/ruby_ui/combobox/combobox_radio.rb new file mode 100644 index 00000000..6d43b951 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_radio.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxRadio < Base + def view_template + input(type: "radio", **attrs) + end + + private + + 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", + data: { + ruby_ui__combobox_target: "input", + action: "ruby-ui--combobox#inputChanged" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_search_input.rb b/lib/ruby_ui/combobox/combobox_search_input.rb index 9aa36983..2a2d1ffb 100644 --- a/lib/ruby_ui/combobox/combobox_search_input.rb +++ b/lib/ruby_ui/combobox/combobox_search_input.rb @@ -24,7 +24,7 @@ def default_attrs placeholder: @placeholder, data: { ruby_ui__combobox_target: "searchInput", - action: "keyup->ruby-ui--combobox#filterOptions" + action: "keyup->ruby-ui--combobox#filterItems" }, autocomplete: "off", autocorrect: "off", diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index f0ca4514..577c3c24 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -3,36 +3,66 @@ require "test_helper" class RubyUI::ComboboxTest < ComponentTest - def test_render_with_all_items + def test_render_with_radio_items output = phlex do RubyUI.Combobox(multiple: true, term: "frameworks") do - RubyUI.ComboboxInput(name: "multiple") - RubyUI.ComboboxTrigger placeholder: "Select your framework" RubyUI.ComboboxDialog do RubyUI.ComboboxSearchInput(placeholder: "Type the framework name") - RubyUI.ComboboxDatalist do + RubyUI.ComboboxList do RubyUI.ComboboxEmptyState { "No results" } - RubyUI.ComboboxOptgroup label: "Ruby" do - RubyUI.ComboboxOption(value: "rails") { "Rails" } - RubyUI.ComboboxOption(value: "hanami") { "Hanami" } + RubyUI.ComboboxListGroup label: "Ruby" do + RubyUI.ComboboxItem do + RubyUI.ComboboxRadio(name: "Rails", value: "rails") + end + RubyUI.ComboboxItem do + RubyUI.ComboboxRadio(name: "Hanami", value: "hanami") + end end - RubyUI.ComboboxOptgroup label: "Crystal" do - RubyUI.ComboboxOption(value: "lucky", selected: true) { "Lucky" } - RubyUI.ComboboxOption(value: "kemal") { "Kemal" } + RubyUI.ComboboxItem do + RubyUI.ComboboxRadio(name: "Lucky", value: "lucky") + end + RubyUI.ComboboxItem do + RubyUI.ComboboxRadio(name: "Kemal", value: "kemal") end + end + end + end + end + + assert_match(/Hanami/, output) + end + + def test_render_with_checkbox_items + output = phlex do + RubyUI.Combobox(multiple: true, term: "frameworks") do + RubyUI.ComboboxTrigger placeholder: "Select your framework" + + RubyUI.ComboboxDialog do + RubyUI.ComboboxSearchInput(placeholder: "Type the framework name") + + RubyUI.ComboboxList do + RubyUI.ComboboxEmptyState { "No results" } - RubyUI.ComboboxOptgroup label: "Others" do - RubyUI.ComboboxOption(value: "django") { "Django" } - RubyUI.ComboboxOption(value: "laravel") { "Laravel" } + RubyUI.ComboboxListGroup label: "Ruby" do + RubyUI.ComboboxItem do + RubyUI.ComboboxCheckbox(name: "Rails", value: "rails") + end + RubyUI.ComboboxItem do + RubyUI.ComboboxCheckbox(name: "Hanami", value: "hanami") + end end - RubyUI.ComboboxOption(value: "spring") { "Spring" } - RubyUI.ComboboxOption(value: "vraptor") { "VRaptor" } + RubyUI.ComboboxItem do + RubyUI.ComboboxCheckbox(name: "Lucky", value: "lucky") + end + RubyUI.ComboboxItem do + RubyUI.ComboboxCheckbox(name: "Kemal", value: "kemal") + end end end end From 892dc4ff38ff414564d447f562c46b60d4629354 Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:53:48 -0300 Subject: [PATCH 4/5] Replace modal with popover --- lib/generators/ruby_ui/dependencies.yml | 4 ++ lib/ruby_ui/combobox/combobox_controller.js | 41 ++++++++++++------- lib/ruby_ui/combobox/combobox_dialog.rb | 25 ----------- lib/ruby_ui/combobox/combobox_list_group.rb | 2 +- lib/ruby_ui/combobox/combobox_popover.rb | 24 +++++++++++ lib/ruby_ui/combobox/combobox_search_input.rb | 2 +- lib/ruby_ui/combobox/combobox_trigger.rb | 5 ++- test/ruby_ui/combobox_test.rb | 4 +- 8 files changed, 61 insertions(+), 46 deletions(-) delete mode 100644 lib/ruby_ui/combobox/combobox_dialog.rb create mode 100644 lib/ruby_ui/combobox/combobox_popover.rb diff --git a/lib/generators/ruby_ui/dependencies.yml b/lib/generators/ruby_ui/dependencies.yml index fea62bb9..9db5eca0 100644 --- a/lib/generators/ruby_ui/dependencies.yml +++ b/lib/generators/ruby_ui/dependencies.yml @@ -26,6 +26,10 @@ codeblock: gems: - "rouge" +combobox: + js_packages: + - "@floating-ui/dom" + command: js_packages: - "fuse.js" diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index c14d1f6c..cd5e3358 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus"; +import { computePosition, autoUpdate, offset } from "@floating-ui/dom"; // Connects to data-controller="ruby-ui--combobox" export default class extends Controller { @@ -8,7 +9,7 @@ export default class extends Controller { static targets = [ "input", - "dialog", + "popover", "item", "emptyState", "searchInput", @@ -22,11 +23,15 @@ export default class extends Controller { this.updateTriggerContent() } + disconnect() { + this.cleanup(); + } + inputChanged(e) { this.updateTriggerContent() if (e.target.type == "radio") { - this.closeDialog() + this.closePopover() } } @@ -46,26 +51,19 @@ export default class extends Controller { } } - openDialog(event) { + openPopover(event) { event.preventDefault() - document.body.classList.add('overflow-hidden') + this.popoverTarget.style.positionAnchor this.triggerTarget.ariaExpanded = "true" - this.dialogTarget.showModal() - } - - closeDialog() { - document.body.classList.remove('overflow-hidden') - this.triggerTarget.ariaExpanded = "false" this.selectedItemIndex = null this.itemTargets.forEach(item => item.ariaCurrent = "false") - this.dialogTarget.close() + this.popoverTarget.showPopover() } - handleOutsideClick(event) { - if (event.target === this.dialogTarget) { - this.closeDialog() - } + closePopover() { + this.triggerTarget.ariaExpanded = "false" + this.popoverTarget.hidePopover() } filterItems(e) { @@ -139,4 +137,17 @@ export default class extends Controller { wrapSelectedInputIndex(length) { this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length } + + updatePopoverPosition() { + this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { + computePosition(this.triggerTarget, this.popoverTarget, { + middleware: [offset(4)], + }).then(({ x, y }) => { + Object.assign(this.popoverTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + }); + } } diff --git a/lib/ruby_ui/combobox/combobox_dialog.rb b/lib/ruby_ui/combobox/combobox_dialog.rb deleted file mode 100644 index f61f43c0..00000000 --- a/lib/ruby_ui/combobox/combobox_dialog.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class ComboboxDialog < Base - BACKDROP_CLASSES = "backdrop:bg-foreground/40" - - def view_template(&) - dialog(**attrs, &) - end - - private - - def default_attrs - { - class: ["w-11/12 md:w-full md:max-w-md border bg-background shadow-lg rounded-lg", BACKDROP_CLASSES], - role: "dialog", - autofocus: true, - data: { - ruby_ui__combobox_target: "dialog", - action: "click->ruby-ui--combobox#handleOutsideClick keydown.down->ruby-ui--combobox#keyDownPressed keydown.up->ruby-ui--combobox#keyUpPressed keydown.enter->ruby-ui--combobox#keyEnterPressed keydown.esc->ruby-ui--combobox#closeDialog:prevent" - } - } - end - end -end diff --git a/lib/ruby_ui/combobox/combobox_list_group.rb b/lib/ruby_ui/combobox/combobox_list_group.rb index a64a02a3..d1b62bb8 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 gap-1", LABEL_CLASSES], + class: ["hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1 border-b", LABEL_CLASSES], role: "group" } end diff --git a/lib/ruby_ui/combobox/combobox_popover.rb b/lib/ruby_ui/combobox/combobox_popover.rb new file mode 100644 index 00000000..3dd7edf2 --- /dev/null +++ b/lib/ruby_ui/combobox/combobox_popover.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module RubyUI + class ComboboxPopover < Base + def view_template(&) + div(**attrs, &) + end + + private + + 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: "keydown.down->ruby-ui--combobox#keyDownPressed keydown.up->ruby-ui--combobox#keyUpPressed keydown.enter->ruby-ui--combobox#keyEnterPressed keydown.esc->ruby-ui--combobox#closeDialog:prevent" + } + } + end + end +end diff --git a/lib/ruby_ui/combobox/combobox_search_input.rb b/lib/ruby_ui/combobox/combobox_search_input.rb index 2a2d1ffb..1a39df3b 100644 --- a/lib/ruby_ui/combobox/combobox_search_input.rb +++ b/lib/ruby_ui/combobox/combobox_search_input.rb @@ -24,7 +24,7 @@ def default_attrs placeholder: @placeholder, data: { ruby_ui__combobox_target: "searchInput", - action: "keyup->ruby-ui--combobox#filterItems" + action: "keyup->ruby-ui--combobox#filterItems search->ruby-ui--combobox#filterItems" }, autocomplete: "off", autocorrect: "off", diff --git a/lib/ruby_ui/combobox/combobox_trigger.rb b/lib/ruby_ui/combobox/combobox_trigger.rb index 5dd3d65e..e47da4f0 100644 --- a/lib/ruby_ui/combobox/combobox_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_trigger.rb @@ -18,14 +18,15 @@ def view_template def default_attrs { + type: "button", class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-[200px] justify-between", data: { placeholder: @placeholder, ruby_ui__combobox_target: "trigger", - action: "ruby-ui--combobox#openDialog" + action: "ruby-ui--combobox#openPopover" }, aria: { - haspopup: "dialog", + haspopup: "listbox", expanded: "false" } } diff --git a/test/ruby_ui/combobox_test.rb b/test/ruby_ui/combobox_test.rb index 577c3c24..9b3c3bdd 100644 --- a/test/ruby_ui/combobox_test.rb +++ b/test/ruby_ui/combobox_test.rb @@ -8,7 +8,7 @@ def test_render_with_radio_items RubyUI.Combobox(multiple: true, term: "frameworks") do RubyUI.ComboboxTrigger placeholder: "Select your framework" - RubyUI.ComboboxDialog do + RubyUI.ComboboxPopover do RubyUI.ComboboxSearchInput(placeholder: "Type the framework name") RubyUI.ComboboxList do @@ -42,7 +42,7 @@ def test_render_with_checkbox_items RubyUI.Combobox(multiple: true, term: "frameworks") do RubyUI.ComboboxTrigger placeholder: "Select your framework" - RubyUI.ComboboxDialog do + RubyUI.ComboboxPopover do RubyUI.ComboboxSearchInput(placeholder: "Type the framework name") RubyUI.ComboboxList do From f07ef72a284bdf5527d28f46ce4b74a97831afbd Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:42:47 -0300 Subject: [PATCH 5/5] Some improvements --- lib/ruby_ui/combobox/combobox_checkbox.rb | 6 +++++- lib/ruby_ui/combobox/combobox_controller.js | 12 +++++++++--- lib/ruby_ui/combobox/combobox_item.rb | 6 +++++- lib/ruby_ui/combobox/combobox_popover.rb | 8 +++++++- lib/ruby_ui/combobox/combobox_radio.rb | 7 ++++++- lib/ruby_ui/combobox/combobox_search_input.rb | 2 +- lib/ruby_ui/combobox/combobox_trigger.rb | 6 ++++-- 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/lib/ruby_ui/combobox/combobox_checkbox.rb b/lib/ruby_ui/combobox/combobox_checkbox.rb index c23dcee7..e3806d83 100644 --- a/lib/ruby_ui/combobox/combobox_checkbox.rb +++ b/lib/ruby_ui/combobox/combobox_checkbox.rb @@ -10,7 +10,11 @@ def view_template def default_attrs { - class: "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary", + class: [ + "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-50" + ], data: { ruby_ui__combobox_target: "input", action: "ruby-ui--combobox#inputChanged" diff --git a/lib/ruby_ui/combobox/combobox_controller.js b/lib/ruby_ui/combobox/combobox_controller.js index cd5e3358..7372ed40 100644 --- a/lib/ruby_ui/combobox/combobox_controller.js +++ b/lib/ruby_ui/combobox/combobox_controller.js @@ -1,5 +1,5 @@ import { Controller } from "@hotwired/stimulus"; -import { computePosition, autoUpdate, offset } from "@floating-ui/dom"; +import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom"; // Connects to data-controller="ruby-ui--combobox" export default class extends Controller { @@ -54,7 +54,8 @@ export default class extends Controller { openPopover(event) { event.preventDefault() - this.popoverTarget.style.positionAnchor + this.updatePopoverPosition() + this.updatePopoverWidth() this.triggerTarget.ariaExpanded = "true" this.selectedItemIndex = null this.itemTargets.forEach(item => item.ariaCurrent = "false") @@ -141,7 +142,8 @@ export default class extends Controller { updatePopoverPosition() { this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { computePosition(this.triggerTarget, this.popoverTarget, { - middleware: [offset(4)], + placement: 'bottom-start', + middleware: [offset(4), flip()], }).then(({ x, y }) => { Object.assign(this.popoverTarget.style, { left: `${x}px`, @@ -150,4 +152,8 @@ export default class extends Controller { }); }); } + + updatePopoverWidth() { + this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px` + } } diff --git a/lib/ruby_ui/combobox/combobox_item.rb b/lib/ruby_ui/combobox/combobox_item.rb index db8af32c..a9753d7d 100644 --- a/lib/ruby_ui/combobox/combobox_item.rb +++ b/lib/ruby_ui/combobox/combobox_item.rb @@ -10,7 +10,11 @@ def view_template(&) def default_attrs { - class: "flex flex-row gap-2 items-center rounded-sm px-2 py-1.5 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 ", + class: [ + "flex flex-row w-full text-wrap truncate gap-2 items-center rounded-sm px-2 py-1.5 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" + ], 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 3dd7edf2..68b937f4 100644 --- a/lib/ruby_ui/combobox/combobox_popover.rb +++ b/lib/ruby_ui/combobox/combobox_popover.rb @@ -16,7 +16,13 @@ def default_attrs popover: true, data: { ruby_ui__combobox_target: "popover", - action: "keydown.down->ruby-ui--combobox#keyDownPressed keydown.up->ruby-ui--combobox#keyUpPressed keydown.enter->ruby-ui--combobox#keyEnterPressed keydown.esc->ruby-ui--combobox#closeDialog:prevent" + action: %w[ + keydown.down->ruby-ui--combobox#keyDownPressed + keydown.up->ruby-ui--combobox#keyUpPressed + keydown.enter->ruby-ui--combobox#keyEnterPressed + keydown.esc->ruby-ui--combobox#closeDialog:prevent + resize@window->ruby-ui--combobox#updatePopoverWidth + ] } } end diff --git a/lib/ruby_ui/combobox/combobox_radio.rb b/lib/ruby_ui/combobox/combobox_radio.rb index 6d43b951..d27c7dae 100644 --- a/lib/ruby_ui/combobox/combobox_radio.rb +++ b/lib/ruby_ui/combobox/combobox_radio.rb @@ -13,7 +13,12 @@ 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", data: { ruby_ui__combobox_target: "input", - action: "ruby-ui--combobox#inputChanged" + ruby_ui__form_field_target: "input", + action: %w[ + ruby-ui--combobox#inputChanged + input->ruby-ui--form-field#onInput + invalid->ruby-ui--form-field#onInvalid + ] } } end diff --git a/lib/ruby_ui/combobox/combobox_search_input.rb b/lib/ruby_ui/combobox/combobox_search_input.rb index 1a39df3b..728ae56f 100644 --- a/lib/ruby_ui/combobox/combobox_search_input.rb +++ b/lib/ruby_ui/combobox/combobox_search_input.rb @@ -19,7 +19,7 @@ def view_template def default_attrs { type: "search", - class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", role: "searchbox", placeholder: @placeholder, data: { diff --git a/lib/ruby_ui/combobox/combobox_trigger.rb b/lib/ruby_ui/combobox/combobox_trigger.rb index e47da4f0..93fc9a98 100644 --- a/lib/ruby_ui/combobox/combobox_trigger.rb +++ b/lib/ruby_ui/combobox/combobox_trigger.rb @@ -9,7 +9,9 @@ def initialize(placeholder: "", **) def view_template button(**attrs) do - span(data: {ruby_ui__combobox_target: "triggerContent"}) { @placeholder } + span(class: "truncate", data: {ruby_ui__combobox_target: "triggerContent"}) do + @placeholder + end icon end end @@ -19,7 +21,7 @@ def view_template def default_attrs { type: "button", - class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-[200px] justify-between", + class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 justify-between", data: { placeholder: @placeholder, ruby_ui__combobox_target: "trigger",