diff --git a/gem/lib/ruby_ui/combobox/combobox.rb b/gem/lib/ruby_ui/combobox/combobox.rb index 54545976..c5faf66d 100644 --- a/gem/lib/ruby_ui/combobox/combobox.rb +++ b/gem/lib/ruby_ui/combobox/combobox.rb @@ -21,7 +21,11 @@ def default_attrs controller: "ruby-ui--combobox", ruby_ui__combobox_term_value: @term, ruby_ui__combobox_placement_value: @placement, - action: "turbo:morph@window->ruby-ui--combobox#updateTriggerContent" + action: %w[ + turbo:morph@window->ruby-ui--combobox#updateTriggerContent + click@window->ruby-ui--combobox#handleOutsideClick + keydown.esc@window->ruby-ui--combobox#handleEscape + ] } } end diff --git a/gem/lib/ruby_ui/combobox/combobox_controller.js b/gem/lib/ruby_ui/combobox/combobox_controller.js index 807c6f93..ed88d8b7 100644 --- a/gem/lib/ruby_ui/combobox/combobox_controller.js +++ b/gem/lib/ruby_ui/combobox/combobox_controller.js @@ -27,7 +27,9 @@ export default class extends Controller { } disconnect() { - if (this.cleanup) { this.cleanup() } + this.stopAutoUpdate() + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasPopoverTarget) this.settleExit(this.popoverTarget) } handlePopoverToggle(event) { @@ -35,6 +37,26 @@ export default class extends Controller { this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false' } + // Still true while the exit animation runs; the window handlers also reach a combobox rendered without a popover. + get popoverShowing() { + return this.hasPopoverTarget && this.popoverTarget.matches(":popover-open") + } + + handleOutsideClick(event) { + if (!this.popoverShowing) return + if (this.popoverTarget.contains(event.target)) return + if (this.triggerTarget.contains(event.target)) return + + this.closePopover() + } + + handleEscape(event) { + if (!this.popoverShowing) return + + event.preventDefault() + this.closePopover() + } + inputChanged(e) { this.updateTriggerContent() @@ -87,12 +109,60 @@ export default class extends Controller { this.triggerTarget.ariaExpanded = "true" this.selectedItemIndex = null this.itemTargets.forEach(item => item.ariaCurrent = "false") - this.popoverTarget.showPopover() + this.popoverTarget.dataset.state = "open" + // Reopened mid-exit: it is still showing, and the state flip alone brings it back in. + if (!this.popoverShowing) this.popoverTarget.showPopover() } closePopover() { + if (this.popoverTarget.dataset.state === "closed") return + this.triggerTarget.ariaExpanded = "false" - this.popoverTarget.hidePopover() + this.popoverTarget.dataset.state = "closed" + this.hideAfterExitAnimation(this.popoverTarget) + } + + // Positioning keeps running until the popover is hidden, so it does not drift while it fades. + afterExit(popover) { + popover.hidePopover() + this.stopAutoUpdate() + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } filterItems(e) { @@ -174,19 +244,30 @@ export default class extends Controller { } updatePopoverPosition() { + this.stopAutoUpdate() + this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => { computePosition(this.triggerTarget, this.popoverTarget, { placement: this.placementValue, middleware: [offset(4), flip()], - }).then(({ x, y }) => { + }).then(({ x, y, placement }) => { Object.assign(this.popoverTarget.style, { left: `${x}px`, top: `${y}px`, }); + // flip() can resolve to the opposite side, so the slide-in direction follows the resolved value. + this.popoverTarget.dataset.side = placement.split("-")[0] }); }); } + stopAutoUpdate() { + if (!this.cleanup) return + + this.cleanup() + this.cleanup = null + } + updatePopoverWidth() { const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue) this.popoverTarget.style.width = `${width}px` diff --git a/gem/lib/ruby_ui/combobox/combobox_popover.rb b/gem/lib/ruby_ui/combobox/combobox_popover.rb index 4d439e2f..116cc981 100644 --- a/gem/lib/ruby_ui/combobox/combobox_popover.rb +++ b/gem/lib/ruby_ui/combobox/combobox_popover.rb @@ -10,10 +10,16 @@ def view_template(&) def default_attrs { - class: "inset-auto m-0 absolute border bg-background shadow-lg rounded-lg", - role: "popover", + class: [ + "inset-auto m-0 absolute border bg-background shadow-lg rounded-lg", + "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", + "data-[state=closed]:fill-mode-forwards duration-100", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2" + ], autofocus: true, - popover: true, + popover: "manual", data: { ruby_ui__combobox_target: "popover", action: %w[ @@ -21,7 +27,6 @@ def default_attrs 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/gem/test/ruby_ui/combobox_test.rb b/gem/test/ruby_ui/combobox_test.rb index 800ce6c0..94097d50 100644 --- a/gem/test/ruby_ui/combobox_test.rb +++ b/gem/test/ruby_ui/combobox_test.rb @@ -227,7 +227,6 @@ def test_combobox_keyboard_actions_on_controller assert_match(/keydown\.down/, output) assert_match(/keydown\.up/, output) assert_match(/keydown\.enter/, output) - assert_match(/keydown\.esc/, output) end def test_combobox_input_trigger_focusin_action @@ -249,4 +248,61 @@ def test_combobox_popover_has_autofocus output = phlex { RubyUI.ComboboxPopover { "" } } assert_match(/autofocus/, output) end + + # The browser's light dismiss hides an auto popover before any exit animation can run. + def test_combobox_popover_is_a_manual_popover + output = phlex { RubyUI.ComboboxPopover { "options" } } + + assert_match(/popover="manual"/, output) + refute_match(/role="popover"/, output) + end + + # data-state is set by the controller on open; rendering it closed would start an exit run. + def test_combobox_popover_renders_without_state + output = phlex { RubyUI.ComboboxPopover { "options" } } + + refute_match(/data-state/, output) + refute_match(/data-side/, output) + end + + def test_combobox_popover_animates_open_and_closed + output = phlex { RubyUI.ComboboxPopover { "options" } } + + assert_match(/data-\[state=open\]:animate-in/, output) + assert_match(/data-\[state=open\]:fade-in-0/, output) + assert_match(/data-\[state=open\]:zoom-in-95/, output) + assert_match(/data-\[state=closed\]:animate-out/, output) + assert_match(/data-\[state=closed\]:fade-out-0/, output) + assert_match(/data-\[state=closed\]:zoom-out-95/, output) + assert_match(/\bduration-100\b/, output) + end + + # hidePopover() lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_combobox_popover_holds_the_last_frame_of_the_exit_animation + output = phlex { RubyUI.ComboboxPopover { "options" } } + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end + + def test_combobox_popover_slides_in_from_the_trigger_side + output = phlex { RubyUI.ComboboxPopover { "options" } } + + assert_match(/data-\[side=bottom\]:slide-in-from-top-2/, output) + assert_match(/data-\[side=top\]:slide-in-from-bottom-2/, output) + assert_match(/data-\[side=left\]:slide-in-from-right-2/, output) + assert_match(/data-\[side=right\]:slide-in-from-left-2/, output) + end + + def test_combobox_popover_keeps_positioning_classes + output = phlex { RubyUI.ComboboxPopover { "options" } } + + assert_match(/inset-auto m-0 absolute/, output) + end + + def test_combobox_owns_outside_click_and_escape + output = phlex { RubyUI.Combobox { "" } } + + assert_match(/click@window->ruby-ui--combobox#handleOutsideClick/, output) + assert_match(/keydown\.esc@window->ruby-ui--combobox#handleEscape/, output) + end end diff --git a/mcp/data/registry.json b/mcp/data/registry.json index ed4cd153..a6a45379 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, 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" + "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: %w[\n turbo:morph@window->ruby-ui--combobox#updateTriggerContent\n click@window->ruby-ui--combobox#handleOutsideClick\n keydown.esc@window->ruby-ui--combobox#handleEscape\n ]\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 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" + "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 this.stopAutoUpdate()\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasPopoverTarget) this.settleExit(this.popoverTarget)\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 // Still true while the exit animation runs; the window handlers also reach a combobox rendered without a popover.\n get popoverShowing() {\n return this.hasPopoverTarget && this.popoverTarget.matches(\":popover-open\")\n }\n\n handleOutsideClick(event) {\n if (!this.popoverShowing) return\n if (this.popoverTarget.contains(event.target)) return\n if (this.triggerTarget.contains(event.target)) return\n\n this.closePopover()\n }\n\n handleEscape(event) {\n if (!this.popoverShowing) return\n\n event.preventDefault()\n this.closePopover()\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.dataset.state = \"open\"\n // Reopened mid-exit: it is still showing, and the state flip alone brings it back in.\n if (!this.popoverShowing) this.popoverTarget.showPopover()\n }\n\n closePopover() {\n if (this.popoverTarget.dataset.state === \"closed\") return\n\n this.triggerTarget.ariaExpanded = \"false\"\n this.popoverTarget.dataset.state = \"closed\"\n this.hideAfterExitAnimation(this.popoverTarget)\n }\n\n // Positioning keeps running until the popover is hidden, so it does not drift while it fades.\n afterExit(popover) {\n popover.hidePopover()\n this.stopAutoUpdate()\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\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.stopAutoUpdate()\n\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, placement }) => {\n Object.assign(this.popoverTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n // flip() can resolve to the opposite side, so the slide-in direction follows the resolved value.\n this.popoverTarget.dataset.side = placement.split(\"-\")[0]\n });\n });\n }\n\n stopAutoUpdate() {\n if (!this.cleanup) return\n\n this.cleanup()\n this.cleanup = null\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", @@ -971,7 +971,7 @@ }, { "path": "combobox_popover.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxPopover < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"inset-auto m-0 absolute border bg-background shadow-lg rounded-lg\",\n role: \"popover\",\n autofocus: true,\n popover: true,\n data: {\n ruby_ui__combobox_target: \"popover\",\n action: %w[\n toggle->ruby-ui--combobox#handlePopoverToggle\n keydown.down->ruby-ui--combobox#keyDownPressed\n keydown.up->ruby-ui--combobox#keyUpPressed\n keydown.enter->ruby-ui--combobox#keyEnterPressed\n keydown.esc->ruby-ui--combobox#closePopover:prevent\n resize@window->ruby-ui--combobox#updatePopoverWidth\n ]\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ComboboxPopover < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: [\n \"inset-auto m-0 absolute border bg-background shadow-lg rounded-lg\",\n \"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n \"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n \"data-[state=closed]:fill-mode-forwards duration-100\",\n \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2\",\n \"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\"\n ],\n autofocus: true,\n popover: \"manual\",\n data: {\n ruby_ui__combobox_target: \"popover\",\n action: %w[\n toggle->ruby-ui--combobox#handlePopoverToggle\n keydown.down->ruby-ui--combobox#keyDownPressed\n keydown.up->ruby-ui--combobox#keyUpPressed\n keydown.enter->ruby-ui--combobox#keyEnterPressed\n resize@window->ruby-ui--combobox#updatePopoverWidth\n ]\n }\n }\n end\n end\nend\n" }, { "path": "combobox_radio.rb",