diff --git a/gem/lib/ruby_ui/combobox/combobox.rb b/gem/lib/ruby_ui/combobox/combobox.rb index fa440c49..54545976 100644 --- a/gem/lib/ruby_ui/combobox/combobox.rb +++ b/gem/lib/ruby_ui/combobox/combobox.rb @@ -2,8 +2,9 @@ module RubyUI class Combobox < Base - def initialize(term: nil, **) + def initialize(term: nil, placement: "bottom-start", **) @term = term + @placement = placement super(**) end @@ -19,6 +20,7 @@ def default_attrs data: { controller: "ruby-ui--combobox", ruby_ui__combobox_term_value: @term, + ruby_ui__combobox_placement_value: @placement, action: "turbo:morph@window->ruby-ui--combobox#updateTriggerContent" } } diff --git a/gem/lib/ruby_ui/combobox/combobox_controller.js b/gem/lib/ruby_ui/combobox/combobox_controller.js index 4bc36095..807c6f93 100644 --- a/gem/lib/ruby_ui/combobox/combobox_controller.js +++ b/gem/lib/ruby_ui/combobox/combobox_controller.js @@ -5,7 +5,8 @@ import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom"; export default class extends Controller { static values = { term: String, - minPopoverWidth: { type: Number, default: 240 } + minPopoverWidth: { type: Number, default: 240 }, + placement: { type: String, default: "bottom-start" } } static targets = [ @@ -175,7 +176,7 @@ export default class extends Controller { updatePopoverPosition() { this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { computePosition(this.triggerTarget, this.popoverTarget, { - placement: 'bottom-start', + placement: this.placementValue, middleware: [offset(4), flip()], }).then(({ x, y }) => { Object.assign(this.popoverTarget.style, { diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 49d0a049..14ae2d2e 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -923,7 +923,7 @@ "files": [ { "path": "combobox.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Combobox < Base\n def initialize(term: nil, **)\n @term = term\n super(**)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"combobox\",\n data: {\n controller: \"ruby-ui--combobox\",\n ruby_ui__combobox_term_value: @term,\n action: \"turbo:morph@window->ruby-ui--combobox#updateTriggerContent\"\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Combobox < Base\n def initialize(term: nil, placement: \"bottom-start\", **)\n @term = term\n @placement = placement\n super(**)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"combobox\",\n data: {\n controller: \"ruby-ui--combobox\",\n ruby_ui__combobox_term_value: @term,\n ruby_ui__combobox_placement_value: @placement,\n action: \"turbo:morph@window->ruby-ui--combobox#updateTriggerContent\"\n }\n }\n end\n end\nend\n" }, { "path": "combobox_badge.rb", @@ -943,7 +943,7 @@ }, { "path": "combobox_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String,\n minPopoverWidth: { type: Number, default: 240 }\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: 'bottom-start',\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)\n this.popoverTarget.style.width = `${width}px`\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--combobox\"\nexport default class extends Controller {\n static values = {\n term: String,\n minPopoverWidth: { type: Number, default: 240 },\n placement: { type: String, default: \"bottom-start\" }\n }\n\n static targets = [\n \"input\",\n \"toggleAll\",\n \"popover\",\n \"item\",\n \"emptyState\",\n \"searchInput\",\n \"trigger\",\n \"triggerContent\"\n ]\n\n selectedItemIndex = null\n\n connect() {\n this.updateTriggerContent()\n }\n\n disconnect() {\n if (this.cleanup) { this.cleanup() }\n }\n\n handlePopoverToggle(event) {\n // Keep ariaExpanded in sync with the actual popover state\n this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'\n }\n\n inputChanged(e) {\n this.updateTriggerContent()\n\n if (e.target.type == \"radio\") {\n this.closePopover()\n }\n\n if (this.hasToggleAllTarget && !e.target.checked) {\n this.toggleAllTarget.checked = false\n }\n }\n\n inputContent(input) {\n return input.dataset.text || input.parentElement.textContent\n }\n\n toggleAllItems() {\n const isChecked = this.toggleAllTarget.checked\n this.inputTargets.forEach(input => input.checked = isChecked)\n this.updateTriggerContent()\n }\n\n updateTriggerContent() {\n const checkedInputs = this.inputTargets.filter(input => input.checked)\n\n if (checkedInputs.length === 0) {\n this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder\n } else if (this.termValue && checkedInputs.length > 1) {\n this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`\n } else {\n this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(\", \")\n }\n }\n\n togglePopover(event) {\n event.preventDefault()\n\n if (this.triggerTarget.ariaExpanded === \"true\") {\n this.closePopover()\n } else {\n this.openPopover(event)\n }\n }\n\n openPopover(event) {\n if (event) event.preventDefault()\n\n this.updatePopoverPosition()\n this.updatePopoverWidth()\n this.triggerTarget.ariaExpanded = \"true\"\n this.selectedItemIndex = null\n this.itemTargets.forEach(item => item.ariaCurrent = \"false\")\n this.popoverTarget.showPopover()\n }\n\n closePopover() {\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.hidePopover()\n }\n\n filterItems(e) {\n if ([\"ArrowDown\", \"ArrowUp\", \"Tab\", \"Enter\"].includes(e.key)) {\n return\n }\n\n const filterTerm = this.searchInputTarget.value.toLowerCase()\n\n if (this.hasToggleAllTarget) {\n if (filterTerm) this.toggleAllTarget.parentElement.classList.add(\"hidden\")\n else this.toggleAllTarget.parentElement.classList.remove(\"hidden\")\n }\n\n let resultCount = 0\n\n this.selectedItemIndex = null\n\n this.inputTargets.forEach((input) => {\n const text = this.inputContent(input).toLowerCase()\n\n if (text.indexOf(filterTerm) > -1) {\n input.parentElement.classList.remove(\"hidden\")\n resultCount++\n } else {\n input.parentElement.classList.add(\"hidden\")\n }\n })\n\n this.emptyStateTarget.classList.toggle(\"hidden\", resultCount !== 0)\n }\n\n keyDownPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex++\n } else {\n this.selectedItemIndex = 0\n }\n\n this.focusSelectedInput()\n }\n\n keyUpPressed() {\n if (this.selectedItemIndex !== null) {\n this.selectedItemIndex--\n } else {\n this.selectedItemIndex = -1\n }\n\n this.focusSelectedInput()\n }\n\n focusSelectedInput() {\n const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains(\"hidden\"))\n\n this.wrapSelectedInputIndex(visibleInputs.length)\n\n visibleInputs.forEach((input, index) => {\n if (index == this.selectedItemIndex) {\n input.parentElement.ariaCurrent = \"true\"\n input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })\n } else {\n input.parentElement.ariaCurrent = \"false\"\n }\n })\n }\n\n keyEnterPressed(event) {\n event.preventDefault()\n const option = this.itemTargets.find(item => item.ariaCurrent === \"true\")\n\n if (option) {\n option.click()\n }\n }\n\n wrapSelectedInputIndex(length) {\n this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length\n }\n\n updatePopoverPosition() {\n this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {\n computePosition(this.triggerTarget, this.popoverTarget, {\n placement: this.placementValue,\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n updatePopoverWidth() {\n const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)\n this.popoverTarget.style.width = `${width}px`\n }\n}\n" }, { "path": "combobox_empty_state.rb",