From f374c23589df14f413c8ff5e17ffc8fedeef3a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Wed, 5 Aug 2026 11:24:23 -0300 Subject: [PATCH] Fix accordion clipping content that grows after opening revealContent froze the panel at the height measured on open, so content that appears after opening (lazy-loaded iframes/images, expanding rows) was clipped. Release the height to `auto` once the open animation finishes, and collapse from the current rendered height since Motion can't interpolate from `auto`. Co-Authored-By: Claude Opus 4.8 --- .../ruby_ui/accordion_controller.js | 25 ++++++++++++------- .../ruby_ui/accordion/accordion_controller.js | 25 ++++++++++++------- mcp/data/registry.json | 2 +- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/accordion_controller.js b/docs/app/javascript/controllers/ruby_ui/accordion_controller.js index 3ed2952c2..d2ed8d2f4 100644 --- a/docs/app/javascript/controllers/ruby_ui/accordion_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/accordion_controller.js @@ -79,7 +79,11 @@ export default class extends Controller { duration: this.animationDurationValue, easing: this.animationEasingValue, }, - ); + ) + .finished.then(() => { + if (content.dataset.state === "open") content.style.height = "auto"; + }) + .catch(() => {}); } // Hide the accordion content with animation @@ -87,20 +91,23 @@ export default class extends Controller { const content = this.contentTarget; content.dataset.state = "closed"; + const currentHeight = content.getBoundingClientRect().height; animate( content, - { height: 0 }, + { height: [`${currentHeight}px`, "0px"] }, { duration: this.animationDurationValue, easing: this.animationEasingValue, }, - ).finished.then(() => { - // After animation completes, truly hide the element so it is removed - // from layout and form focus — prevents trapped validation errors - if (content.dataset.state === "closed") { - content.setAttribute("hidden", ""); - } - }); + ) + .finished.then(() => { + // After animation completes, truly hide the element so it is removed + // from layout and form focus — prevents trapped validation errors + if (content.dataset.state === "closed") { + content.setAttribute("hidden", ""); + } + }) + .catch(() => {}); } // Rotate the accordion icon 180deg using animate function diff --git a/gem/lib/ruby_ui/accordion/accordion_controller.js b/gem/lib/ruby_ui/accordion/accordion_controller.js index 3ed2952c2..d2ed8d2f4 100644 --- a/gem/lib/ruby_ui/accordion/accordion_controller.js +++ b/gem/lib/ruby_ui/accordion/accordion_controller.js @@ -79,7 +79,11 @@ export default class extends Controller { duration: this.animationDurationValue, easing: this.animationEasingValue, }, - ); + ) + .finished.then(() => { + if (content.dataset.state === "open") content.style.height = "auto"; + }) + .catch(() => {}); } // Hide the accordion content with animation @@ -87,20 +91,23 @@ export default class extends Controller { const content = this.contentTarget; content.dataset.state = "closed"; + const currentHeight = content.getBoundingClientRect().height; animate( content, - { height: 0 }, + { height: [`${currentHeight}px`, "0px"] }, { duration: this.animationDurationValue, easing: this.animationEasingValue, }, - ).finished.then(() => { - // After animation completes, truly hide the element so it is removed - // from layout and form focus — prevents trapped validation errors - if (content.dataset.state === "closed") { - content.setAttribute("hidden", ""); - } - }); + ) + .finished.then(() => { + // After animation completes, truly hide the element so it is removed + // from layout and form focus — prevents trapped validation errors + if (content.dataset.state === "closed") { + content.setAttribute("hidden", ""); + } + }) + .catch(() => {}); } // Rotate the accordion icon 180deg using animate function diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 4ad7014a6..f1699f58d 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -15,7 +15,7 @@ }, { "path": "accordion_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { animate } from \"motion\";\n\n// Connects to data-controller=\"ruby-ui--accordion\"\nexport default class extends Controller {\n static targets = [\"icon\", \"content\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n animationDuration: {\n type: Number,\n default: 0.15, // Default animation duration (in seconds)\n },\n animationEasing: {\n type: String,\n default: \"ease-in-out\", // Default animation easing\n },\n rotateIcon: {\n type: Number,\n default: 180, // Default icon rotation (in degrees)\n },\n };\n\n connect() {\n // Set the initial state of the accordion\n let originalAnimationDuration = this.animationDurationValue;\n this.animationDurationValue = 0;\n this.openValue ? this.open() : this.close();\n this.animationDurationValue = originalAnimationDuration;\n }\n\n // Toggle the 'open' value\n toggle() {\n this.openValue = !this.openValue;\n }\n\n // Handle changes in the 'open' value\n openValueChanged(isOpen, wasOpen) {\n if (isOpen) {\n this.open();\n } else {\n this.close();\n }\n }\n\n // Open the accordion content\n open() {\n if (this.hasContentTarget) {\n this.revealContent();\n this.hasIconTarget && this.rotateIcon();\n this.openValue = true;\n }\n }\n\n // Close the accordion content\n close() {\n if (this.hasContentTarget) {\n this.hideContent();\n this.hasIconTarget && this.rotateIcon();\n this.openValue = false;\n }\n }\n\n // Reveal the accordion content with animation\n revealContent() {\n const content = this.contentTarget;\n\n // Remove hidden so the element participates in layout before measuring\n content.removeAttribute(\"hidden\");\n content.dataset.state = \"open\";\n\n const contentHeight = content.scrollHeight;\n animate(\n content,\n { height: `${contentHeight}px` },\n {\n duration: this.animationDurationValue,\n easing: this.animationEasingValue,\n },\n );\n }\n\n // Hide the accordion content with animation\n hideContent() {\n const content = this.contentTarget;\n content.dataset.state = \"closed\";\n\n animate(\n content,\n { height: 0 },\n {\n duration: this.animationDurationValue,\n easing: this.animationEasingValue,\n },\n ).finished.then(() => {\n // After animation completes, truly hide the element so it is removed\n // from layout and form focus — prevents trapped validation errors\n if (content.dataset.state === \"closed\") {\n content.setAttribute(\"hidden\", \"\");\n }\n });\n }\n\n // Rotate the accordion icon 180deg using animate function\n rotateIcon() {\n animate(this.iconTarget, {\n rotate: `${this.openValue ? this.rotateIconValue : 0}deg`,\n });\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { animate } from \"motion\";\n\n// Connects to data-controller=\"ruby-ui--accordion\"\nexport default class extends Controller {\n static targets = [\"icon\", \"content\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n animationDuration: {\n type: Number,\n default: 0.15, // Default animation duration (in seconds)\n },\n animationEasing: {\n type: String,\n default: \"ease-in-out\", // Default animation easing\n },\n rotateIcon: {\n type: Number,\n default: 180, // Default icon rotation (in degrees)\n },\n };\n\n connect() {\n // Set the initial state of the accordion\n let originalAnimationDuration = this.animationDurationValue;\n this.animationDurationValue = 0;\n this.openValue ? this.open() : this.close();\n this.animationDurationValue = originalAnimationDuration;\n }\n\n // Toggle the 'open' value\n toggle() {\n this.openValue = !this.openValue;\n }\n\n // Handle changes in the 'open' value\n openValueChanged(isOpen, wasOpen) {\n if (isOpen) {\n this.open();\n } else {\n this.close();\n }\n }\n\n // Open the accordion content\n open() {\n if (this.hasContentTarget) {\n this.revealContent();\n this.hasIconTarget && this.rotateIcon();\n this.openValue = true;\n }\n }\n\n // Close the accordion content\n close() {\n if (this.hasContentTarget) {\n this.hideContent();\n this.hasIconTarget && this.rotateIcon();\n this.openValue = false;\n }\n }\n\n // Reveal the accordion content with animation\n revealContent() {\n const content = this.contentTarget;\n\n // Remove hidden so the element participates in layout before measuring\n content.removeAttribute(\"hidden\");\n content.dataset.state = \"open\";\n\n const contentHeight = content.scrollHeight;\n animate(\n content,\n { height: `${contentHeight}px` },\n {\n duration: this.animationDurationValue,\n easing: this.animationEasingValue,\n },\n )\n .finished.then(() => {\n if (content.dataset.state === \"open\") content.style.height = \"auto\";\n })\n .catch(() => {});\n }\n\n // Hide the accordion content with animation\n hideContent() {\n const content = this.contentTarget;\n content.dataset.state = \"closed\";\n\n const currentHeight = content.getBoundingClientRect().height;\n animate(\n content,\n { height: [`${currentHeight}px`, \"0px\"] },\n {\n duration: this.animationDurationValue,\n easing: this.animationEasingValue,\n },\n )\n .finished.then(() => {\n // After animation completes, truly hide the element so it is removed\n // from layout and form focus — prevents trapped validation errors\n if (content.dataset.state === \"closed\") {\n content.setAttribute(\"hidden\", \"\");\n }\n })\n .catch(() => {});\n }\n\n // Rotate the accordion icon 180deg using animate function\n rotateIcon() {\n animate(this.iconTarget, {\n rotate: `${this.openValue ? this.rotateIconValue : 0}deg`,\n });\n }\n}\n" }, { "path": "accordion_default_content.rb",