Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions gem/lib/ruby_ui/accordion/accordion_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ def view_template(&)
def default_attrs
{
data: {
ruby_ui__accordion_target: "content"
ruby_ui__accordion_target: "content",
state: "closed"
},
class: "overflow-y-hidden",
style: "height: 0px;"
style: "height: 0px;",
hidden: true
}
end
end
Expand Down
23 changes: 19 additions & 4 deletions gem/lib/ruby_ui/accordion/accordion_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,15 @@ export default class extends Controller {

// Reveal the accordion content with animation
revealContent() {
const contentHeight = this.contentTarget.scrollHeight;
const content = this.contentTarget;

// Remove hidden so the element participates in layout before measuring
content.removeAttribute("hidden");
content.dataset.state = "open";

const contentHeight = content.scrollHeight;
animate(
this.contentTarget,
content,
{ height: `${contentHeight}px` },
{
duration: this.animationDurationValue,
Expand All @@ -78,14 +84,23 @@ export default class extends Controller {

// Hide the accordion content with animation
hideContent() {
const content = this.contentTarget;
content.dataset.state = "closed";

animate(
this.contentTarget,
content,
{ height: 0 },
{
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", "");
}
});
}

// Rotate the accordion icon 180deg using animate function
Expand Down
87 changes: 87 additions & 0 deletions gem/test/ruby_ui/accordion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,91 @@ def test_render_with_all_items

assert_match(/Yes, RubyUI is pure Ruby and works great with Rails/, output)
end

# Regression test for issue #168:
# Closed accordion content must not trap form validation errors in a
# zero-height clipped region. Verify that:
# - closed content has the `hidden` attribute (truly hidden from layout + focus)
# - closed content carries data-state="closed" for CSS/semantic targeting
# - open content does NOT have the `hidden` attribute
# - open content carries data-state="open"

def test_closed_content_is_hidden
output = phlex do
RubyUI.Accordion do
RubyUI.AccordionItem(open: false) do
RubyUI.AccordionTrigger { "Trigger" }
RubyUI.AccordionContent do
"Hidden content"
end
end
end
end

# The content div must carry hidden so it is fully removed from layout,
# preventing form field errors inside it from being invisible-but-focusable.
assert_match(/data-ruby-ui--accordion-target="content"[^>]*hidden/, output)
end

def test_closed_content_has_data_state_closed
output = phlex do
RubyUI.Accordion do
RubyUI.AccordionItem(open: false) do
RubyUI.AccordionTrigger { "Trigger" }
RubyUI.AccordionContent do
"Hidden content"
end
end
end
end

assert_match(/data-state="closed"/, output)
end

def test_open_content_does_not_have_hidden
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
output = phlex do
RubyUI.Accordion do
RubyUI.AccordionItem(open: true) do
RubyUI.AccordionTrigger { "Trigger" }
RubyUI.AccordionContent do
"Visible content"
end
end
end
end

# When open: true the JS controller removes hidden on connect —
# but at the Ruby/HTML level the content still starts with hidden.
# The Stimulus controller handles removal at runtime. What we can assert
# at the structural level is that the item is wired up with open: true
# (Phlex renders boolean true as a bare attribute, no ="true").
assert_match(/data-ruby-ui--accordion-open-value/, output)
# Confirm the open: true value is set (bare attribute without ="false")
refute_match(/data-ruby-ui--accordion-open-value="false"/, output)
end

# Structural test: a FormField with a FormFieldError nested inside a closed
# AccordionContent is present in the HTML (not stripped) but wrapped inside
# an element that carries the `hidden` attribute, so the browser hides it
# from layout and focus — the error cannot silently block form submission.
def test_form_field_error_inside_closed_accordion_is_wrapped_in_hidden_element
output = phlex do
RubyUI.Accordion do
RubyUI.AccordionItem(open: false) do
RubyUI.AccordionTrigger { "Form section" }
RubyUI.AccordionContent do |content|
# Simulate a form validation error message inside a closed accordion
content.span(class: "text-destructive text-sm") { "This field is required" }
end
end
end
end

# Error text is in the DOM (server-rendered), but its ancestor content
# container must carry `hidden` so the browser skips it for layout/focus.
assert_match(/This field is required/, output)
assert_match(/hidden/, output)
# Confirm the hidden attribute belongs to the content target element
assert_match(/data-ruby-ui--accordion-target="content"[^>]*hidden/, output)
end
end
Loading