Add collapsible sections support to block section fields - #69
Add collapsible sections support to block section fields#69helmutkaufmann wants to merge 1 commit into
Conversation
.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.
WalkthroughAdds collapsible-section shorthand normalization for block YAML fields, translating Estimated code review effort: 3 (Moderate) | ~25 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
CHANGELOG.mdREADME.mdformwidgets/Blocks.phpformwidgets/blocks/partials/_block.phptests/formwidgets/BlocksTest.phpupdates/version.yaml
| 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' : ''; | ||
| }); | ||
| } |
There was a problem hiding this comment.
🎯 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:
- 1: https://www.wintercms.com/docs/develop/docs/backend/forms
- 2: https://docs.octobercms.com/3.x/element/form-fields.html
- 3: https://docs.octobercms.com/2.x/backend/forms.html
- 4: https://wintercms.com/docs/develop/ui/script/input-trigger
- 5: https://wintercms.com/docs/v1.2/ui/script/input-trigger
- 6: https://octobercms.com/docs/ui/example/ui-input-trigger
- 7: https://docs.octobercms.com/4.x/element/form-fields.html
🏁 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
fiRepository: 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
fiRepository: 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.pyRepository: 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.
| // 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; | ||
| } |
There was a problem hiding this comment.
🎯 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 -SRepository: 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.
| 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'); } | ||
| }); |
There was a problem hiding this comment.
🎯 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.
Split out of #68 (closed in favor of smaller, focused PRs).
Summary
.blocktype: sectionfields support acollapsible: trueshorthand, withcollapsed: true|falsecontrolling the initial state.data-block-collapsibleattribute 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).localStorage, keyed by field name).See the README section "Collapsible Sections" for usage.
Test plan
tests/formwidgets/BlocksTest.phpcovering the collapsible markupphp artisan winter:test -p Winter.Blocks)Summary by CodeRabbit
New Features
Bug Fixes
Documentation