Skip to content

Add collapsible sections support to block section fields - #69

Open
helmutkaufmann wants to merge 1 commit into
wintercms:mainfrom
helmutkaufmann:feat/collapsible-sections
Open

Add collapsible sections support to block section fields#69
helmutkaufmann wants to merge 1 commit into
wintercms:mainfrom
helmutkaufmann:feat/collapsible-sections

Conversation

@helmutkaufmann

@helmutkaufmann helmutkaufmann commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Split out of #68 (closed in favor of smaller, focused PRs).

Summary

  • .block type: section fields support a collapsible: true shorthand, with collapsed: true|false controlling the initial state.
  • Handled via a data-block-collapsible attribute and an inline bootstrap in the block widget partial, independent of core's collapsible-section JS — this avoids core re-collapsing and re-binding every section on each form-widget init (which broke manually-opened sections and stalled repeater "Add item" clicks when a section had a repeater nested inside it).
  • Open/closed state persists per section across page reloads (localStorage, keyed by field name).

See the README section "Collapsible Sections" for usage.

Test plan

  • New tests in tests/formwidgets/BlocksTest.php covering the collapsible markup
  • Full plugin test suite passes (php artisan winter:test -p Winter.Blocks)

Summary by CodeRabbit

  • New Features

    • Added collapsible sections for block widgets using simple field configuration.
    • Section open/closed state now persists across page reloads.
    • Supports setting the initial expanded or collapsed state.
  • Bug Fixes

    • Improved reliability when adding nested repeater items within collapsible sections.
    • Prevented duplicate click handling and related “Add item” stalls.
  • Documentation

    • Added configuration examples and guidance for collapsible sections.

.block type: section fields support collapsible: true / collapsed:,
bootstrapped inline in the block widget partial (independent of
core's collapsible-section JS, which double-binds and re-collapses
sections when a nested repeater adds an item). Open/closed state
persists per section across reloads via localStorage.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds collapsible-section shorthand normalization for block YAML fields, translating collapsible and collapsed into container data attributes. The block widget partial initializes collapsible sections, restores and persists state per widget and field through localStorage, and observes sections added by nested repeaters. Tests cover shorthand translation and unaffected fields. Documentation and changelog entries describe the feature.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding collapsible section support for block section fields.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@formwidgets/blocks/partials/_block.php`:
- Around line 136-144: Update storageKey() to include a stable repeater
item-level scope, such as block_item.php’s indexValue or an equivalent item
identifier, in addition to the existing widget id before appending
data-field-name. Ensure repeated items with the same section name produce
distinct localStorage keys while preserving the current behavior for widget and
field-name scoping.
- Around line 120-134: Update setCollapsed so expanding a section does not
blindly clear inline display styles on every element returned by
followingFields. Limit the visibility reset to elements owned by the collapsible
section, or track and restore only styles applied by the collapse behavior,
preserving WinterCMS input-trigger visibility for unrelated fields.
- Around line 146-171: Update initSections and the delegated collapse handling
to make each .field-section keyboard-accessible: expose it as a focusable
button-like control with appropriate aria-expanded state, and handle Enter and
Space keydown events by toggling the associated section through the existing
setCollapsed and persistence flow. Keep mouse click behavior and synchronize
aria-expanded whenever the collapsed state changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 831db646-0a99-4afd-9a9b-5e0620c35488

📥 Commits

Reviewing files that changed from the base of the PR and between 394377b and b0cc189.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • README.md
  • formwidgets/Blocks.php
  • formwidgets/blocks/partials/_block.php
  • tests/formwidgets/BlocksTest.php
  • updates/version.yaml

Comment on lines +120 to +134
function followingFields(section) {
var els = [], el = section.nextElementSibling;
while (el && !el.classList.contains('section-field')) {
els.push(el);
el = el.nextElementSibling;
}
return els;
}

function setCollapsed(section, collapsed) {
section.classList.toggle('collapsed', collapsed);
followingFields(section).forEach(function (el) {
el.style.display = collapsed ? 'none' : '';
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

How does WinterCMS/OctoberCMS implement the "trigger" form field option for conditionally showing/hiding fields — does it toggle a CSS class or set inline display style?

💡 Result:

WinterCMS and OctoberCMS implement conditional field visibility via the trigger form field option using a JavaScript-based Input Trigger API [1][2][3]. This mechanism operates by manipulating the DOM directly rather than solely toggling CSS classes [4][5][6]. When the specified trigger condition is met (e.g., a checkbox is checked or a value matches), the system applies the defined action—such as 'show' or 'hide'—to the target field [1][2][7]. While the system allows for the assignment of custom CSS classes to field containers via the 'cssClass' property [7], the trigger mechanism itself handles visibility by modifying the element's style or display status to show or hide the field, typically avoiding a simple CSS-only toggle [4][5][6]. The API uses data attributes (such as data-trigger-action and data-trigger-condition) on the target HTML elements to manage these state-based visibility changes [4][5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate file context =="
if [ -f formwidgets/blocks/partials/_block.php ]; then
  nl -ba formwidgets/blocks/partials/_block.php | sed -n '1,220p'
else
  echo "formwidgets/blocks/partials/_block.php not found"
  git ls-files | rg '(^|/)partial_?_block|block.*php|trigger|input-trigger' | head -80 || true
fi

echo
echo "== occurrences of followingFields/setCollapsed in tracked files =="
rg -n "function followingFields|function setCollapsed|setCollapsed\\(" . || true

echo
echo "== trigger/display related occurrences in formwidgets/blocks if present =="
if [ -d formwidgets/blocks ]; then
  rg -n "data-trigger-action|data-trigger-condition|trigger-action|trigger-condition|style\\.display|display\\[" formwidgets/blocks || true
fi

Repository: wintercms/wn-blocks-plugin

Length of output: 233


🏁 Script executed:

#!/bin/sh
set -euo pipefail

echo "== candidate file context =="
if [ -f formwidgets/blocks/partials/_block.php ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' formwidgets/blocks/partials/_block.php | sed -n '1,220p'
else
  echo "formwidgets/blocks/partials/_block.php not found"
  git ls-files | rg '(^|/)partial_?_block|block.*php|trigger|input-trigger' | head -80 || true
fi

echo
echo "== occurrences of followingFields/setCollapsed in tracked files =="
grep -RIn "function followingFields\|function setCollapsed\|setCollapsed\(" . || true

echo
echo "== trigger/display related occurrences in formwidgets/blocks if present =="
if [ -d formwidgets/blocks ]; then
  grep -RIn "data-trigger-action\|data-trigger-condition\|trigger-action\|trigger-condition\|style\.display\|display\[" formwidgets/blocks || true
fi

Repository: wintercms/wn-blocks-plugin

Length of output: 9953


🏁 Script executed:

#!/bin/sh
set -euo pipefail

echo "== occurrences of followingFields/setCollapsed in tracked files =="
grep -RIn 'function followingFields\|function setCollapsed\|setCollapsed(' . || true

cat > /tmp/check_collapsed_trigger_compat.py <<'PY'
class El:
    def __init__(self, cls, trigger=None):
        self.cls = cls
        self.trigger = trigger
        self.style = type('S', (), {'display': ''})()
        self.nextElementSibling = None
        self.tagName = 'div'

def followingFields(section):
    els, el = [], section.nextElementSibling
    while el and 'section-field' not in el.cls:
        els.append(el)
        el = el.nextElementSibling
    return els

def setCollapsed(section, collapsed):
    if collapsed:
        section.cls.add('collapsed')
    else:
        section.cls.discard('collapsed')
    for el in followingFields(section):
        el.style.display = 'none' if collapsed else ''

# Build a short chain that matches the wrapper search: collapsible section
# surrounded by section fields, with a trigger target in between.
trigger_target = El({'section-subcontent', 'my-trigger-target'}, 'trigger')
bottom = El({'section-field'})
top = El({'section-field', 'collapsed', 'block-collapsible'})
top.nextElementSibling = trigger_target
trigger_target.nextElementSibling = bottom

before = {'collapsed' in top.cls, trigger_target.style.display}
assert before == (True, '')

setCollapsed(top, False)  # expand section
after = {'collapsed' in top.cls, trigger_target.style.display}
print(after)

if after == {True, ''}:
    print("EXPANDING section overwrites empty inline display on follow-on trigger targets")
elif after == {True, 'none'}:
    print("EXPANDING preserves existing inline none for trigger targets")
else:
    print("unexpected after")
PY
python3 /tmp/check_collapsed_trigger_compat.py
rm /tmp/check_collapsed_trigger_compat.py

Repository: wintercms/wn-blocks-plugin

Length of output: 755


Preserve conditional visibility when expanding collapsible block sections.

setCollapsed resets every following field’s inline display to '' on expand, which can override WinterCMS input-trigger visibility that also uses inline display toggles. Scope the reset to fields that are truly section wrapper content, or avoid overwriting existing inline display values unless the field is part of the block’s collapsed state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@formwidgets/blocks/partials/_block.php` around lines 120 - 134, Update
setCollapsed so expanding a section does not blindly clear inline display styles
on every element returned by followingFields. Limit the visibility reset to
elements owned by the collapsible section, or track and restore only styles
applied by the collapse behavior, preserving WinterCMS input-trigger visibility
for unrelated fields.

Comment on lines +136 to +144
// Scoped to data-widget-id so sections with the same field name in
// different block widgets on the same page don't share state.
function storageKey(section) {
var name = section.getAttribute('data-field-name') || '';
if (!name) { return null; }
var fieldBlocks = section.closest('.field-blocks');
var widgetId = fieldBlocks ? (fieldBlocks.getAttribute('data-widget-id') || '') : '';
return COLLAPSE_PREFIX + (widgetId ? widgetId + ':' : '') + name;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the block item partial for a stable per-item identifier usable for scoping localStorage keys.
fd -e php _block_item -x cat -n {}

Repository: wintercms/wn-blocks-plugin

Length of output: 3762


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant files:\n'
fd -e php '_block(\.(php|js))?$|blocks' formwidgets | sed -n '1,120p'

printf '\nOutline _block.php:\n'
ast-grep outline formwidgets/blocks/partials/_block.php --view expanded || true

printf '\nRelevant _block.php lines 1-190:\n'
sed -n '1,190p' formwidgets/blocks/partials/_block.php | cat -n

printf '\nSearch storageKey and related collapse storage:\n'
rg -n "storageKey|COLLAPSE_PREFIX|getItem|setItem|removeItem|data-field-name|field-blocks|data-widget-id" formwidgets/blocks/partials/_block.php formwidgets/blocks -S

Repository: wintercms/wn-blocks-plugin

Length of output: 27761


Prefix block collapse keys with a per-item repeater scope.

storageKey() only uses data-field-name + widget id, while every block item in a single .field-blocks renders the same field names under that one widget id. Adding or toggling collapse state for the same section type in multiple repeated items therefore writes to the same localStorage key; on reload, the last-written state is applied to all matching items, including newly added blocks. Add a stable item-level scope to each key, e.g. using block_item.php’s indexValue or a comparable repeater item id, before combining it with the field name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@formwidgets/blocks/partials/_block.php` around lines 136 - 144, Update
storageKey() to include a stable repeater item-level scope, such as
block_item.php’s indexValue or an equivalent item identifier, in addition to the
existing widget id before appending data-field-name. Ensure repeated items with
the same section name produce distinct localStorage keys while preserving the
current behavior for widget and field-name scoping.

Comment on lines +146 to +171
function initSections() {
document.querySelectorAll(
'.section-field[data-block-collapsible]:not(.' + READY + ')'
).forEach(function (section) {
section.classList.add(READY);
var header = section.querySelector('.field-section');
if (header) { header.classList.add('is-collapsible'); }

// Restore persisted state if present, else fall back to config default.
var key = storageKey(section);
var stored = key ? lsGet(key) : null;
var collapsed = stored !== null
? stored === '1'
: !section.hasAttribute('data-block-collapsible-open');
setCollapsed(section, collapsed);
});
}

document.addEventListener('click', function (e) {
var section = e.target.closest('.section-field[data-block-collapsible]');
if (!section) { return; }
var collapsed = !section.classList.contains('collapsed');
setCollapsed(section, collapsed);
var key = storageKey(section);
if (key) { lsSet(key, collapsed ? '1' : '0'); }
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Collapse toggle is mouse-only; no keyboard access.

The header only gets a click listener (document.addEventListener('click', ...)); initSections() never adds tabindex, role="button", or aria-expanded, and there's no keydown handler for Enter/Space. A keyboard-only or screen-reader user has no way to expand a collapsed section to reach the fields inside it.

♿️ Proposed fix
                 section.classList.add(READY);
                 var header = section.querySelector('.field-section');
-                if (header) { header.classList.add('is-collapsible'); }
+                if (header) {
+                    header.classList.add('is-collapsible');
+                    header.setAttribute('tabindex', '0');
+                    header.setAttribute('role', 'button');
+                }
         document.addEventListener('click', function (e) {
             var section = e.target.closest('.section-field[data-block-collapsible]');
             if (!section) { return; }
-            var collapsed = !section.classList.contains('collapsed');
-            setCollapsed(section, collapsed);
-            var key = storageKey(section);
-            if (key) { lsSet(key, collapsed ? '1' : '0'); }
+            toggleSection(section);
         });
+
+        document.addEventListener('keydown', function (e) {
+            if (e.key !== 'Enter' && e.key !== ' ') { return; }
+            var section = e.target.closest('.section-field[data-block-collapsible]');
+            if (!section) { return; }
+            e.preventDefault();
+            toggleSection(section);
+        });
+
+        function toggleSection(section) {
+            var collapsed = !section.classList.contains('collapsed');
+            setCollapsed(section, collapsed);
+            var key = storageKey(section);
+            if (key) { lsSet(key, collapsed ? '1' : '0'); }
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@formwidgets/blocks/partials/_block.php` around lines 146 - 171, Update
initSections and the delegated collapse handling to make each .field-section
keyboard-accessible: expose it as a focusable button-like control with
appropriate aria-expanded state, and handle Enter and Space keydown events by
toggling the associated section through the existing setCollapsed and
persistence flow. Keep mouse click behavior and synchronize aria-expanded
whenever the collapsed state changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant