From ebe6e01a83f13288b0c31d225ceed1974a7122ed Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 18:51:24 +0200 Subject: [PATCH 01/55] Add collapsible sections shorthand and tabs support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeBlockFields() translates collapsible/collapsed YAML shorthands on section fields into containerAttributes so the WinterCMS form widget's bindCollapsibleSections JS picks them up: collapsible: true → data-field-collapsible attribute collapsed: true (default) → starts collapsed (core JS behaviour) collapsed: false → starts open (handled by collapsible.js) - collapsible.js re-opens sections marked data-field-collapsible-open after the core form widget collapses them all on init; also fires on ajaxSuccess for dynamically added repeater items - getGroupFormFieldConfig() override passes tabs and secondaryTabs from the block definition through to Backend\Widgets\Form, enabling native WinterCMS tab syntax in .block files Co-Authored-By: Claude Sonnet 4.6 --- assets/dist/js/collapsible.js | 30 ++++++++++++++ formwidgets/Blocks.php | 78 ++++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 assets/dist/js/collapsible.js diff --git a/assets/dist/js/collapsible.js b/assets/dist/js/collapsible.js new file mode 100644 index 0000000..fa2a82a --- /dev/null +++ b/assets/dist/js/collapsible.js @@ -0,0 +1,30 @@ +/** + * Reopens collapsible sections that carry data-field-collapsible-open. + * + * WinterCMS's core form widget collapses ALL data-field-collapsible sections on + * init. This script runs after that and re-opens the ones marked as open by default. + */ +(function () { + function openMarkedSections(root) { + (root || document).querySelectorAll( + '.section-field[data-field-collapsible][data-field-collapsible-open]' + ).forEach(function (section) { + section.classList.remove('collapsed'); + var el = section.nextElementSibling; + while (el && !el.classList.contains('section-field')) { + el.style.display = ''; + el = el.nextElementSibling; + } + }); + } + + // Run after the form widget has initialised (it uses $(document).ready internally) + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { openMarkedSections(); }); + } else { + setTimeout(openMarkedSections, 0); + } + + // Also handle dynamically added repeater items + document.addEventListener('ajaxSuccess', function () { openMarkedSections(); }); +})(); diff --git a/formwidgets/Blocks.php b/formwidgets/Blocks.php index 04b2391..4899d02 100644 --- a/formwidgets/Blocks.php +++ b/formwidgets/Blocks.php @@ -53,6 +53,7 @@ protected function loadAssets() { $this->addCss('css/blocks.css', 'Winter.Blocks'); $this->addJs('js/blocks.js', 'Winter.Blocks'); + $this->addJs('js/collapsible.js', 'Winter.Blocks'); } /** @@ -203,12 +204,14 @@ protected function processGroupMode(): void } $definitions[$code] = [ - 'code' => $code, - 'name' => array_get($config, 'name'), - 'icon' => array_get($config, 'icon', 'icon-square-o'), - 'description' => array_get($config, 'description'), - 'fields' => array_get($config, 'fields'), - 'config' => array_get($config, 'config', null), + 'code' => $code, + 'name' => array_get($config, 'name'), + 'icon' => array_get($config, 'icon', 'icon-square-o'), + 'description' => array_get($config, 'description'), + 'fields' => $this->normalizeBlockFields((array) array_get($config, 'fields', [])), + 'tabs' => array_get($config, 'tabs'), + 'secondaryTabs' => array_get($config, 'secondaryTabs'), + 'config' => array_get($config, 'config', null), ]; } @@ -219,6 +222,69 @@ protected function processGroupMode(): void $this->useGroups = true; } + /** + * Translates block YAML shorthands before the fields are handed to the form widget. + * + * Supported shorthands on `type: section` fields: + * collapsible: true — makes the section click-to-collapse + * collapsed: true|false — initial state (true = start collapsed, false = start open) + * defaults to true when collapsible is set + */ + protected function normalizeBlockFields(array $fields): array + { + foreach ($fields as &$field) { + if (($field['type'] ?? '') !== 'section') { + continue; + } + + if (!array_key_exists('collapsible', $field)) { + continue; + } + + if ($field['collapsible']) { + $startCollapsed = $field['collapsed'] ?? true; + + $field['containerAttributes'] = array_merge( + $field['containerAttributes'] ?? [], + ['data-field-collapsible' => 1] + ); + + if (!$startCollapsed) { + $field['containerAttributes']['data-field-collapsible-open'] = 1; + } + } + + unset($field['collapsible'], $field['collapsed']); + } + + return $fields; + } + + /** + * Extends the parent config to pass tabs and secondaryTabs from the block + * definition through to the Backend Form widget. + */ + protected function getGroupFormFieldConfig($code): ?array + { + $config = parent::getGroupFormFieldConfig($code); + + if ($config === null) { + return null; + } + + $def = $this->groupDefinitions[$code] ?? []; + + if (!empty($def['tabs'])) { + $config['tabs'] = $def['tabs']; + } + + if (!empty($def['secondaryTabs'])) { + $config['secondaryTabs'] = $def['secondaryTabs']; + } + + return $config; + } + /** * Determines if a block is allowed according to the widget's ignore/allow list. */ From 4b1afddffb75facab341032351b2a2a4ad23b60e Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 19:12:13 +0200 Subject: [PATCH 02/55] Add collapsible sections and tabs documentation Documents the collapsible/collapsed YAML shorthand and tabs/secondaryTabs block-level support added in this fork. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/README.md b/README.md index ba775ce..9f6c62f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Provides a "block based" content management experience in Winter CMS >**NOTE:** This plugin is still in development and is likely to undergo changes. Do not use in production environments without using a version constraint in your composer.json file and carefully monitoring for breaking changes. +> **Fork note:** This is a fork of [wintercms/wn-blocks-plugin](https://github.com/wintercms/wn-blocks-plugin) that adds **collapsible sections** and **tabs/secondaryTabs** support in block definitions. See [Collapsible Sections](#collapsible-sections) and [Tabs](#tabs) below. + ## Installation This plugin is available for installation via [Composer](http://getcomposer.org/). @@ -144,6 +146,81 @@ config: ``` +## Collapsible Sections + +Block fields that use `type: section` can be made collapsible directly in the block YAML — no `containerAttributes` required. + +```yaml +fields: + section_advanced: + label: Advanced settings + type: section + collapsible: true # makes the section click-to-collapse + collapsed: true # initial state: true = start collapsed (default), false = start open + some_field: + label: Some field + type: text +``` + +**Shorthand rules:** + +| Key | Type | Default | Description | +|---|---|---|---| +| `collapsible` | bool | — | Set to `true` to enable the collapse toggle | +| `collapsed` | bool | `true` | Initial state. `false` = section starts open | + +When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. + +> **Note:** `collapsed: false` requires the `collapsible.js` asset that is bundled with this fork. It runs after WinterCMS's core form JS (which collapses all collapsible sections) and re-opens any section marked `collapsed: false`. + +--- + +## Tabs + +Block definitions can include `tabs` and/or `secondaryTabs` at the top level. These are passed through to the WinterCMS `Backend\Widgets\Form` widget exactly as they would be in a standard `fields.yaml` file. + +```yaml +name: My Block +description: A block with tabs +icon: icon-th + +tabs: + cssClass: master-tabs + fields: + content: + label: Content + type: textarea + tab: Content + + title: + label: Title + type: text + tab: Content + + meta_title: + label: Meta title + type: text + tab: SEO + + meta_description: + label: Meta description + type: textarea + tab: SEO + +secondaryTabs: + fields: + is_active: + label: Active + type: checkbox + tab: Settings +== +
{{ content }}
+``` + +Fields declared under `tabs` / `secondaryTabs` are placed in the tabbed area of the form widget. You can combine `tabs`, `secondaryTabs`, and the top-level `fields` array in the same block. + +--- + ## Using the `blocks` FormWidget In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks. From 4d755b8d8ee2626bfd2c1e9e44a6615a3cf033a2 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 19:21:57 +0200 Subject: [PATCH 03/55] Minor README wording cleanup Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f6c62f..01f8e39 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ config: ## Collapsible Sections -Block fields that use `type: section` can be made collapsible directly in the block YAML — no `containerAttributes` required. +Block fields that use `type: section` can be made collapsible directly in the block YAML. ```yaml fields: From 648eb9ab6e54b9be5838472bb37facbbed510290 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 19:37:33 +0200 Subject: [PATCH 04/55] Fix repeater stall inside manually-uncollapsed sections WinterCMS calls bindCollapsibleSections() after every ajaxSuccess, which re-collapses all collapsible sections including ones the user manually opened. Track user-toggled open state via data-user-opened on the element and restore it after each ajaxSuccess alongside the configured-open sections. Co-Authored-By: Claude Sonnet 4.6 --- assets/dist/js/collapsible.js | 50 ++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/assets/dist/js/collapsible.js b/assets/dist/js/collapsible.js index fa2a82a..9e8e69d 100644 --- a/assets/dist/js/collapsible.js +++ b/assets/dist/js/collapsible.js @@ -1,13 +1,23 @@ /** - * Reopens collapsible sections that carry data-field-collapsible-open. + * Manages collapsible section state for block form widgets. * - * WinterCMS's core form widget collapses ALL data-field-collapsible sections on - * init. This script runs after that and re-opens the ones marked as open by default. + * WinterCMS's core form widget calls bindCollapsibleSections() after every + * ajaxSuccess event, which re-collapses all [data-field-collapsible] sections. + * This script counters that in two ways: + * + * 1. Sections configured with collapsed: false (data-field-collapsible-open) + * are always re-opened after each ajaxSuccess. + * + * 2. Sections the user has manually opened are tracked via data-user-opened + * on the element itself. That attribute survives the re-collapse (WinterCMS + * only touches the CSS class and display style, not our attribute), so we + * can restore those sections too. */ (function () { - function openMarkedSections(root) { + function openSections(root) { (root || document).querySelectorAll( - '.section-field[data-field-collapsible][data-field-collapsible-open]' + '.section-field[data-field-collapsible][data-field-collapsible-open],' + + '.section-field[data-field-collapsible][data-user-opened]' ).forEach(function (section) { section.classList.remove('collapsed'); var el = section.nextElementSibling; @@ -18,13 +28,33 @@ }); } - // Run after the form widget has initialised (it uses $(document).ready internally) + // Track which sections the user opens or closes via click. + // Uses event delegation so it catches sections inside dynamically added repeater items. + // setTimeout defers the state check until after WinterCMS has toggled the collapsed class. + document.addEventListener('click', function (e) { + var section = e.target.closest('.section-field[data-field-collapsible]'); + if (!section) return; + + setTimeout(function () { + if (section.classList.contains('collapsed')) { + section.removeAttribute('data-user-opened'); + } else { + section.setAttribute('data-user-opened', '1'); + } + }, 0); + }); + + // Initial pass after the form widget has initialised. if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { openMarkedSections(); }); + document.addEventListener('DOMContentLoaded', function () { openSections(); }); } else { - setTimeout(openMarkedSections, 0); + setTimeout(openSections, 0); } - // Also handle dynamically added repeater items - document.addEventListener('ajaxSuccess', function () { openMarkedSections(); }); + // Restore open sections after any AJAX call (repeater add/remove triggers + // bindCollapsibleSections which re-collapses everything). + // setTimeout ensures we run after WinterCMS's own ajaxSuccess handler. + document.addEventListener('ajaxSuccess', function () { + setTimeout(openSections, 0); + }); })(); From c292d6518cbe8b65005dc9206ee26167231798a1 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 19:40:13 +0200 Subject: [PATCH 05/55] Fix section re-collapse flicker on repeater AJAX using MutationObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTimeout was unreliable — the repeater's post-AJAX init could trigger another bindCollapsibleSections() call after our timeout, causing a visible "briefly shows then collapses" flicker. Switch to a MutationObserver that immediately undoes programmatic re-collapses of user-opened (data-user-opened) sections. A userTogglingSection flag suppresses the observer during genuine user click-to-collapse so the user can still close sections normally. Co-Authored-By: Claude Sonnet 4.6 --- assets/dist/js/collapsible.js | 89 +++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/assets/dist/js/collapsible.js b/assets/dist/js/collapsible.js index 9e8e69d..e843e33 100644 --- a/assets/dist/js/collapsible.js +++ b/assets/dist/js/collapsible.js @@ -1,41 +1,48 @@ /** * Manages collapsible section state for block form widgets. * - * WinterCMS's core form widget calls bindCollapsibleSections() after every - * ajaxSuccess event, which re-collapses all [data-field-collapsible] sections. - * This script counters that in two ways: + * WinterCMS calls bindCollapsibleSections() after every ajaxSuccess, which + * re-collapses all [data-field-collapsible] sections — including ones the user + * manually opened. A setTimeout race is unreliable because the repeater widget + * may trigger further post-AJAX initialization that collapses again after our + * timeout fires (visible as a "briefly shows then collapses" flicker). * - * 1. Sections configured with collapsed: false (data-field-collapsible-open) - * are always re-opened after each ajaxSuccess. - * - * 2. Sections the user has manually opened are tracked via data-user-opened - * on the element itself. That attribute survives the re-collapse (WinterCMS - * only touches the CSS class and display style, not our attribute), so we - * can restore those sections too. + * Strategy: + * - User-opened sections are tracked via data-user-opened on the element. + * - A MutationObserver watches for the collapsed class being re-added to any + * data-user-opened section and removes it immediately — no visible flicker. + * - A userTogglingSection flag suppresses the observer during genuine user + * clicks so the user can still collapse sections normally. + * - Sections configured with collapsed: false (data-field-collapsible-open) + * are handled separately via setTimeout after ajaxSuccess (existing behaviour). */ (function () { - function openSections(root) { + var userTogglingSection = false; + + function openSection(section) { + section.classList.remove('collapsed'); + var el = section.nextElementSibling; + while (el && !el.classList.contains('section-field')) { + el.style.display = ''; + el = el.nextElementSibling; + } + } + + // Re-open sections configured with collapsed: false + function openConfiguredSections(root) { (root || document).querySelectorAll( - '.section-field[data-field-collapsible][data-field-collapsible-open],' + - '.section-field[data-field-collapsible][data-user-opened]' - ).forEach(function (section) { - section.classList.remove('collapsed'); - var el = section.nextElementSibling; - while (el && !el.classList.contains('section-field')) { - el.style.display = ''; - el = el.nextElementSibling; - } - }); + '.section-field[data-field-collapsible][data-field-collapsible-open]' + ).forEach(openSection); } - // Track which sections the user opens or closes via click. - // Uses event delegation so it catches sections inside dynamically added repeater items. - // setTimeout defers the state check until after WinterCMS has toggled the collapsed class. + // Track user click intent so the MutationObserver knows not to fight it document.addEventListener('click', function (e) { var section = e.target.closest('.section-field[data-field-collapsible]'); if (!section) return; + userTogglingSection = true; setTimeout(function () { + userTogglingSection = false; if (section.classList.contains('collapsed')) { section.removeAttribute('data-user-opened'); } else { @@ -44,17 +51,37 @@ }, 0); }); - // Initial pass after the form widget has initialised. + // Immediately undo any programmatic re-collapse of a user-opened section. + // Fires synchronously (as a microtask) after the DOM change, before any + // setTimeout callbacks, so there is no visible flicker. + var observer = new MutationObserver(function (mutations) { + if (userTogglingSection) return; + mutations.forEach(function (mutation) { + var section = mutation.target; + if ( + section.hasAttribute('data-user-opened') && + section.classList.contains('collapsed') + ) { + openSection(section); + } + }); + }); + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + subtree: true, + }); + + // Initial pass after form widget init if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { openSections(); }); + document.addEventListener('DOMContentLoaded', function () { openConfiguredSections(); }); } else { - setTimeout(openSections, 0); + setTimeout(openConfiguredSections, 0); } - // Restore open sections after any AJAX call (repeater add/remove triggers - // bindCollapsibleSections which re-collapses everything). - // setTimeout ensures we run after WinterCMS's own ajaxSuccess handler. + // Restore configured-open sections after AJAX (new repeater items etc.) document.addEventListener('ajaxSuccess', function () { - setTimeout(openSections, 0); + setTimeout(openConfiguredSections, 0); }); })(); From 448537f82dcc96899e796bb92be5077145b10e31 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 19:45:33 +0200 Subject: [PATCH 06/55] Fix collapsible section re-collapse by shielding during AJAX Reactive approaches (MutationObserver, setTimeout) lose the race because the repeater's post-AJAX widget init can trigger another bindCollapsibleSections pass after our correction fires. Instead, prevent the problem: on ajaxBeforeSend, strip data-field-collapsible from user-opened sections so bindCollapsibleSections() cannot select them. Restore the attribute on ajaxComplete. Keep MutationObserver as backup. Co-Authored-By: Claude Sonnet 4.6 --- assets/dist/js/collapsible.js | 105 +++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/assets/dist/js/collapsible.js b/assets/dist/js/collapsible.js index e843e33..cbd01bd 100644 --- a/assets/dist/js/collapsible.js +++ b/assets/dist/js/collapsible.js @@ -1,20 +1,24 @@ /** * Manages collapsible section state for block form widgets. * - * WinterCMS calls bindCollapsibleSections() after every ajaxSuccess, which - * re-collapses all [data-field-collapsible] sections — including ones the user - * manually opened. A setTimeout race is unreliable because the repeater widget - * may trigger further post-AJAX initialization that collapses again after our - * timeout fires (visible as a "briefly shows then collapses" flicker). + * WinterCMS calls bindCollapsibleSections() after every AJAX call, re-collapsing + * all [data-field-collapsible] sections. Reacting after the fact (MutationObserver, + * setTimeout) is unreliable because the repeater's post-AJAX widget initialisation + * may trigger further collapse passes after our correction fires. * - * Strategy: - * - User-opened sections are tracked via data-user-opened on the element. - * - A MutationObserver watches for the collapsed class being re-added to any - * data-user-opened section and removes it immediately — no visible flicker. - * - A userTogglingSection flag suppresses the observer during genuine user - * clicks so the user can still collapse sections normally. - * - Sections configured with collapsed: false (data-field-collapsible-open) - * are handled separately via setTimeout after ajaxSuccess (existing behaviour). + * Primary strategy — prevent, not fix: + * Before each AJAX request (ajaxBeforeSend), temporarily strip data-field-collapsible + * from any section the user has manually opened. bindCollapsibleSections() cannot + * find what it cannot select. After the request completes (ajaxComplete), the + * attribute is restored. + * + * Backup — MutationObserver: + * Catches any programmatic re-collapse that slips through (e.g. from non-AJAX + * widget initialisation). A userTogglingSection flag prevents it from fighting + * genuine user click-to-collapse actions. + * + * User open/close state is stored as data-user-opened on the element itself so it + * survives across AJAX calls and DOM updates. */ (function () { var userTogglingSection = false; @@ -35,7 +39,8 @@ ).forEach(openSection); } - // Track user click intent so the MutationObserver knows not to fight it + // Track whether the current click is a user-initiated section toggle so the + // MutationObserver knows not to undo it. document.addEventListener('click', function (e) { var section = e.target.closest('.section-field[data-field-collapsible]'); if (!section) return; @@ -51,37 +56,59 @@ }, 0); }); - // Immediately undo any programmatic re-collapse of a user-opened section. - // Fires synchronously (as a microtask) after the DOM change, before any - // setTimeout callbacks, so there is no visible flicker. - var observer = new MutationObserver(function (mutations) { - if (userTogglingSection) return; - mutations.forEach(function (mutation) { - var section = mutation.target; - if ( - section.hasAttribute('data-user-opened') && - section.classList.contains('collapsed') - ) { - openSection(section); - } - }); + // --- Primary fix: shield user-opened sections from bindCollapsibleSections --- + + // Before any AJAX call, hide user-opened sections from WinterCMS by removing + // data-field-collapsible so bindCollapsibleSections() cannot select them. + document.addEventListener('ajaxBeforeSend', function () { + document.querySelectorAll('.section-field[data-field-collapsible][data-user-opened]') + .forEach(function (s) { + s.removeAttribute('data-field-collapsible'); + s.setAttribute('data-fc-suspended', '1'); + }); }); - observer.observe(document.body, { - attributes: true, - attributeFilter: ['class'], - subtree: true, + // After the AJAX call (success or failure), restore the attribute. + document.addEventListener('ajaxComplete', function () { + document.querySelectorAll('.section-field[data-fc-suspended]') + .forEach(function (s) { + s.setAttribute('data-field-collapsible', '1'); + s.removeAttribute('data-fc-suspended'); + }); + // Also re-open any collapsed: false sections in newly injected HTML + setTimeout(openConfiguredSections, 0); }); - // Initial pass after form widget init + // --- Backup: MutationObserver for non-AJAX re-collapses --- + + function setupObserver() { + var observer = new MutationObserver(function (mutations) { + if (userTogglingSection) return; + mutations.forEach(function (mutation) { + var section = mutation.target; + if ( + section.hasAttribute('data-user-opened') && + section.classList.contains('collapsed') + ) { + openSection(section); + } + }); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + subtree: true, + }); + } + + // Initial open pass + observer setup after the form widget has initialised if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { openConfiguredSections(); }); + document.addEventListener('DOMContentLoaded', function () { + openConfiguredSections(); + setupObserver(); + }); } else { setTimeout(openConfiguredSections, 0); + setupObserver(); } - - // Restore configured-open sections after AJAX (new repeater items etc.) - document.addEventListener('ajaxSuccess', function () { - setTimeout(openConfiguredSections, 0); - }); })(); From f109fe8800749f072cb78bcc406c5c1750828f04 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 19:58:09 +0200 Subject: [PATCH 07/55] Fully own collapsible sections to fix repeater stall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: core FormWidget.bindCollapsibleSections() runs on every widget init scoped to the closest form. Adding a nested repeater item inits a new FormWidget, which re-collapses all data-field-collapsible sections in the shared form and binds a SECOND click handler to each — the doubled handler made "Add item" appear to stall and snapped open sections shut. Fix: emit data-block-collapsible (core's JS ignores it) and handle collapse entirely in collapsible.js with a one-time per-section init guard and a single delegated click handler that cannot double-bind. Reuses core's is-collapsible / collapsed CSS classes so styling is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- assets/dist/js/collapsible.js | 148 ++++++++++++++-------------------- formwidgets/Blocks.php | 10 ++- 3 files changed, 68 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 01f8e39..3eccf0d 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ fields: When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. -> **Note:** `collapsed: false` requires the `collapsible.js` asset that is bundled with this fork. It runs after WinterCMS's core form JS (which collapses all collapsible sections) and re-opens any section marked `collapsed: false`. +> **Note:** Collapsible behaviour is handled entirely by the `collapsible.js` asset bundled with this fork (via the `data-block-collapsible` attribute), independent of WinterCMS's core collapsible-section JS. This is deliberate: core re-collapses and re-binds every section on each form-widget init — including when a nested repeater adds an item — which broke manually-opened sections and stalled repeater "Add item" clicks. Owning the behaviour avoids that entirely, so collapsible sections work correctly even with repeater fields nested inside them. --- diff --git a/assets/dist/js/collapsible.js b/assets/dist/js/collapsible.js index cbd01bd..d2f9820 100644 --- a/assets/dist/js/collapsible.js +++ b/assets/dist/js/collapsible.js @@ -1,114 +1,84 @@ /** - * Manages collapsible section state for block form widgets. + * Self-contained collapsible sections for block form widgets. * - * WinterCMS calls bindCollapsibleSections() after every AJAX call, re-collapsing - * all [data-field-collapsible] sections. Reacting after the fact (MutationObserver, - * setTimeout) is unreliable because the repeater's post-AJAX widget initialisation - * may trigger further collapse passes after our correction fires. + * Why this exists instead of reusing core's data-field-collapsible: * - * Primary strategy — prevent, not fix: - * Before each AJAX request (ajaxBeforeSend), temporarily strip data-field-collapsible - * from any section the user has manually opened. bindCollapsibleSections() cannot - * find what it cannot select. After the request completes (ajaxComplete), the - * attribute is restored. + * WinterCMS's FormWidget.bindCollapsibleSections() runs on EVERY FormWidget + * init(), scoped to the closest
. When a nested repeater adds an item, + * a new FormWidget initialises, finds the shared parent form, and re-collapses + * ALL data-field-collapsible sections in it while binding a SECOND click + * handler to each. The doubled handler is why adding a repeater item appeared + * to "stall" (two toggles cancel out) and why manually-opened sections snapped + * shut again. * - * Backup — MutationObserver: - * Catches any programmatic re-collapse that slips through (e.g. from non-AJAX - * widget initialisation). A userTogglingSection flag prevents it from fighting - * genuine user click-to-collapse actions. - * - * User open/close state is stored as data-user-opened on the element itself so it - * survives across AJAX calls and DOM updates. + * Solution: + * Blocks.php emits data-block-collapsible (and data-block-collapsible-open), + * which core's JS never selects, so core never collapses or binds these. + * This file owns everything: + * - one-time init per section (guarded by .block-collapsible-ready) + * - a single delegated click handler on document (cannot double-bind) + * It reuses core's .is-collapsible / .collapsed CSS classes so the visual + * styling (chevron, hover) is identical to native collapsible sections. */ (function () { - var userTogglingSection = false; + var READY_CLASS = 'block-collapsible-ready'; - function openSection(section) { - section.classList.remove('collapsed'); + // Collect the fields that follow a section header up to the next section. + function followingFields(section) { + var els = []; var el = section.nextElementSibling; while (el && !el.classList.contains('section-field')) { - el.style.display = ''; + els.push(el); el = el.nextElementSibling; } + return els; } - // Re-open sections configured with collapsed: false - function openConfiguredSections(root) { - (root || document).querySelectorAll( - '.section-field[data-field-collapsible][data-field-collapsible-open]' - ).forEach(openSection); + function setCollapsed(section, collapsed) { + section.classList.toggle('collapsed', collapsed); + followingFields(section).forEach(function (el) { + el.style.display = collapsed ? 'none' : ''; + }); } - // Track whether the current click is a user-initiated section toggle so the - // MutationObserver knows not to undo it. - document.addEventListener('click', function (e) { - var section = e.target.closest('.section-field[data-field-collapsible]'); - if (!section) return; - - userTogglingSection = true; - setTimeout(function () { - userTogglingSection = false; - if (section.classList.contains('collapsed')) { - section.removeAttribute('data-user-opened'); - } else { - section.setAttribute('data-user-opened', '1'); - } - }, 0); - }); + // One-time setup for any not-yet-initialised collapsible section. + function initSections(root) { + (root || document) + .querySelectorAll('.section-field[data-block-collapsible]:not(.' + READY_CLASS + ')') + .forEach(function (section) { + section.classList.add(READY_CLASS); - // --- Primary fix: shield user-opened sections from bindCollapsibleSections --- + // Apply core's styling hook for the chevron + pointer cursor. + var header = section.querySelector('.field-section'); + if (header) { + header.classList.add('is-collapsible'); + } - // Before any AJAX call, hide user-opened sections from WinterCMS by removing - // data-field-collapsible so bindCollapsibleSections() cannot select them. - document.addEventListener('ajaxBeforeSend', function () { - document.querySelectorAll('.section-field[data-field-collapsible][data-user-opened]') - .forEach(function (s) { - s.removeAttribute('data-field-collapsible'); - s.setAttribute('data-fc-suspended', '1'); + // Initial state: collapsed unless explicitly marked open. + var startOpen = section.hasAttribute('data-block-collapsible-open'); + setCollapsed(section, !startOpen); }); - }); + } - // After the AJAX call (success or failure), restore the attribute. - document.addEventListener('ajaxComplete', function () { - document.querySelectorAll('.section-field[data-fc-suspended]') - .forEach(function (s) { - s.setAttribute('data-field-collapsible', '1'); - s.removeAttribute('data-fc-suspended'); - }); - // Also re-open any collapsed: false sections in newly injected HTML - setTimeout(openConfiguredSections, 0); + // Single delegated click handler — never doubles regardless of widget inits. + document.addEventListener('click', function (e) { + var section = e.target.closest('.section-field[data-block-collapsible]'); + if (!section) { + return; + } + setCollapsed(section, !section.classList.contains('collapsed')); }); - // --- Backup: MutationObserver for non-AJAX re-collapses --- - - function setupObserver() { - var observer = new MutationObserver(function (mutations) { - if (userTogglingSection) return; - mutations.forEach(function (mutation) { - var section = mutation.target; - if ( - section.hasAttribute('data-user-opened') && - section.classList.contains('collapsed') - ) { - openSection(section); - } - }); - }); - observer.observe(document.body, { - attributes: true, - attributeFilter: ['class'], - subtree: true, - }); - } - - // Initial open pass + observer setup after the form widget has initialised + // Initial pass. if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { - openConfiguredSections(); - setupObserver(); - }); + document.addEventListener('DOMContentLoaded', function () { initSections(); }); } else { - setTimeout(openConfiguredSections, 0); - setupObserver(); + initSections(); } + + // Initialise sections inside any HTML injected by AJAX (new repeater items, + // newly added blocks). Already-initialised sections are skipped via the + // guard class, so existing open/closed state is preserved. + document.addEventListener('ajaxSuccess', function () { initSections(); }); + document.addEventListener('render', function () { initSections(); }); })(); diff --git a/formwidgets/Blocks.php b/formwidgets/Blocks.php index 4899d02..6f44be1 100644 --- a/formwidgets/Blocks.php +++ b/formwidgets/Blocks.php @@ -244,13 +244,19 @@ protected function normalizeBlockFields(array $fields): array if ($field['collapsible']) { $startCollapsed = $field['collapsed'] ?? true; + // Use our own data attribute (NOT data-field-collapsible) so the + // core form widget's bindCollapsibleSections() never touches these + // sections. Core re-runs that on every FormWidget init — including + // when a nested repeater adds an item — which would re-collapse and + // double-bind handlers on sections the user manually opened. We own + // the behaviour entirely in collapsible.js instead. $field['containerAttributes'] = array_merge( $field['containerAttributes'] ?? [], - ['data-field-collapsible' => 1] + ['data-block-collapsible' => 1] ); if (!$startCollapsed) { - $field['containerAttributes']['data-field-collapsible-open'] = 1; + $field['containerAttributes']['data-block-collapsible-open'] = 1; } } From b2fe297ffff76baa3ab7b57ab7639f38b381eeaf Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 20:02:47 +0200 Subject: [PATCH 08/55] Use MutationObserver for collapsible section init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOMContentLoaded / ajaxSuccess / render via native addEventListener never fired: block section fields are injected by AJAX, and the backend dispatches lifecycle events through jQuery, which native listeners don't receive. Result was that nothing collapsed at all. Detect sections with a MutationObserver instead — fires on any DOM insertion regardless of framework. Per-section guard class keeps init idempotent; childList-only observation avoids a feedback loop from our class/style writes. Co-Authored-By: Claude Sonnet 4.6 --- assets/dist/js/collapsible.js | 58 +++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/assets/dist/js/collapsible.js b/assets/dist/js/collapsible.js index d2f9820..0686caa 100644 --- a/assets/dist/js/collapsible.js +++ b/assets/dist/js/collapsible.js @@ -14,11 +14,15 @@ * Solution: * Blocks.php emits data-block-collapsible (and data-block-collapsible-open), * which core's JS never selects, so core never collapses or binds these. - * This file owns everything: - * - one-time init per section (guarded by .block-collapsible-ready) - * - a single delegated click handler on document (cannot double-bind) - * It reuses core's .is-collapsible / .collapsed CSS classes so the visual - * styling (chevron, hover) is identical to native collapsible sections. + * This file owns everything and reuses core's .is-collapsible / .collapsed CSS + * classes so the styling is identical to native collapsible sections. + * + * Init detection uses a MutationObserver rather than DOMContentLoaded / ajax + * events: the block form fields (and nested repeater items) are injected by + * AJAX, and the backend dispatches its lifecycle events through jQuery, which + * native addEventListener handlers do not reliably receive. The observer fires + * on any DOM insertion regardless of framework, so newly rendered sections are + * always picked up. A per-section guard class makes init idempotent and cheap. */ (function () { var READY_CLASS = 'block-collapsible-ready'; @@ -42,8 +46,8 @@ } // One-time setup for any not-yet-initialised collapsible section. - function initSections(root) { - (root || document) + function initSections() { + document .querySelectorAll('.section-field[data-block-collapsible]:not(.' + READY_CLASS + ')') .forEach(function (section) { section.classList.add(READY_CLASS); @@ -60,7 +64,8 @@ }); } - // Single delegated click handler — never doubles regardless of widget inits. + // Single delegated click handler — native click events bubble normally, so + // addEventListener is reliable here (unlike framework lifecycle events). document.addEventListener('click', function (e) { var section = e.target.closest('.section-field[data-block-collapsible]'); if (!section) { @@ -69,16 +74,31 @@ setCollapsed(section, !section.classList.contains('collapsed')); }); - // Initial pass. - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function () { initSections(); }); - } else { - initSections(); - } + // Run once now for anything already present. + initSections(); + + // Watch for sections injected later (block forms, repeater items). Observing + // childList only — our own class/style writes are attribute mutations and + // won't retrigger this, so there is no feedback loop. + var scheduled = false; + var observer = new MutationObserver(function () { + if (scheduled) { + return; + } + scheduled = true; + // Coalesce bursts of insertions into a single pass. + (window.requestAnimationFrame || window.setTimeout)(function () { + scheduled = false; + initSections(); + }, 0); + }); - // Initialise sections inside any HTML injected by AJAX (new repeater items, - // newly added blocks). Already-initialised sections are skipped via the - // guard class, so existing open/closed state is preserved. - document.addEventListener('ajaxSuccess', function () { initSections(); }); - document.addEventListener('render', function () { initSections(); }); + function startObserving() { + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }); + } else { + document.addEventListener('DOMContentLoaded', startObserving); + } + } + startObserving(); })(); From 0a29d9b9fc57569c28498c9add44999687186a03 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 20:09:32 +0200 Subject: [PATCH 09/55] Place collapsible.js in the widget's actual asset path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Blocks widget resolves addJs('js/collapsible.js') relative to formwidgets/blocks/assets/ (via guessViewPath), not assets/dist/. The file only existed under assets/dist/js/, so it 404'd and never loaded — which is why collapsible sections stopped working after switching to the data-block-collapsible attribute that core's JS ignores. Co-Authored-By: Claude Sonnet 4.6 --- formwidgets/blocks/assets/js/collapsible.js | 104 ++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 formwidgets/blocks/assets/js/collapsible.js diff --git a/formwidgets/blocks/assets/js/collapsible.js b/formwidgets/blocks/assets/js/collapsible.js new file mode 100644 index 0000000..0686caa --- /dev/null +++ b/formwidgets/blocks/assets/js/collapsible.js @@ -0,0 +1,104 @@ +/** + * Self-contained collapsible sections for block form widgets. + * + * Why this exists instead of reusing core's data-field-collapsible: + * + * WinterCMS's FormWidget.bindCollapsibleSections() runs on EVERY FormWidget + * init(), scoped to the closest . When a nested repeater adds an item, + * a new FormWidget initialises, finds the shared parent form, and re-collapses + * ALL data-field-collapsible sections in it while binding a SECOND click + * handler to each. The doubled handler is why adding a repeater item appeared + * to "stall" (two toggles cancel out) and why manually-opened sections snapped + * shut again. + * + * Solution: + * Blocks.php emits data-block-collapsible (and data-block-collapsible-open), + * which core's JS never selects, so core never collapses or binds these. + * This file owns everything and reuses core's .is-collapsible / .collapsed CSS + * classes so the styling is identical to native collapsible sections. + * + * Init detection uses a MutationObserver rather than DOMContentLoaded / ajax + * events: the block form fields (and nested repeater items) are injected by + * AJAX, and the backend dispatches its lifecycle events through jQuery, which + * native addEventListener handlers do not reliably receive. The observer fires + * on any DOM insertion regardless of framework, so newly rendered sections are + * always picked up. A per-section guard class makes init idempotent and cheap. + */ +(function () { + var READY_CLASS = 'block-collapsible-ready'; + + // Collect the fields that follow a section header up to the next section. + function followingFields(section) { + var els = []; + var 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' : ''; + }); + } + + // One-time setup for any not-yet-initialised collapsible section. + function initSections() { + document + .querySelectorAll('.section-field[data-block-collapsible]:not(.' + READY_CLASS + ')') + .forEach(function (section) { + section.classList.add(READY_CLASS); + + // Apply core's styling hook for the chevron + pointer cursor. + var header = section.querySelector('.field-section'); + if (header) { + header.classList.add('is-collapsible'); + } + + // Initial state: collapsed unless explicitly marked open. + var startOpen = section.hasAttribute('data-block-collapsible-open'); + setCollapsed(section, !startOpen); + }); + } + + // Single delegated click handler — native click events bubble normally, so + // addEventListener is reliable here (unlike framework lifecycle events). + document.addEventListener('click', function (e) { + var section = e.target.closest('.section-field[data-block-collapsible]'); + if (!section) { + return; + } + setCollapsed(section, !section.classList.contains('collapsed')); + }); + + // Run once now for anything already present. + initSections(); + + // Watch for sections injected later (block forms, repeater items). Observing + // childList only — our own class/style writes are attribute mutations and + // won't retrigger this, so there is no feedback loop. + var scheduled = false; + var observer = new MutationObserver(function () { + if (scheduled) { + return; + } + scheduled = true; + // Coalesce bursts of insertions into a single pass. + (window.requestAnimationFrame || window.setTimeout)(function () { + scheduled = false; + initSections(); + }, 0); + }); + + function startObserving() { + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }); + } else { + document.addEventListener('DOMContentLoaded', startObserving); + } + } + startObserving(); +})(); From 75e3ee017f1042ce6973a74118081c4daaadfa28 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 20:13:20 +0200 Subject: [PATCH 10/55] Inline collapsible-sections bootstrap in block widget partial addJs path/combiner resolution proved unreliable (the file resolved to a location where it 404'd). Embed the collapse logic inline in _block.php with a global one-time guard so it is guaranteed to load. Uses data-block-collapsible (ignored by core) plus a MutationObserver, so sections collapse correctly and nested repeaters no longer stall. Co-Authored-By: Claude Sonnet 4.6 --- formwidgets/blocks/partials/_block.php | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/formwidgets/blocks/partials/_block.php b/formwidgets/blocks/partials/_block.php index c4d2f04..4a14502 100644 --- a/formwidgets/blocks/partials/_block.php +++ b/formwidgets/blocks/partials/_block.php @@ -85,4 +85,78 @@ class="form-control blocks-group-search" + + + From 0982f316d9fab63920c212daa9c30c6077d7bde4 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 20:16:10 +0200 Subject: [PATCH 11/55] Remove unused collapsible.js; inline bootstrap is canonical The standalone collapsible.js never reliably loaded via addJs. The collapse logic now lives inline in the block widget partial, so both copies of collapsible.js and the addJs registration are dead and removed. Co-Authored-By: Claude Sonnet 4.6 --- .DS_Store | Bin 0 -> 10244 bytes README.md | 2 +- assets/.DS_Store | Bin 0 -> 8196 bytes assets/dist/.DS_Store | Bin 0 -> 6148 bytes assets/dist/js/collapsible.js | 104 -------------------- formwidgets/Blocks.php | 4 +- formwidgets/blocks/assets/js/collapsible.js | 104 -------------------- 7 files changed, 4 insertions(+), 210 deletions(-) create mode 100644 .DS_Store create mode 100644 assets/.DS_Store create mode 100644 assets/dist/.DS_Store delete mode 100644 assets/dist/js/collapsible.js delete mode 100644 formwidgets/blocks/assets/js/collapsible.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..24c74b7ab70a600fa7ead2ab0f4eaadecaa1f514 GIT binary patch literal 10244 zcmeHMU2GIp6uxKrH$#W^0!0e$goQ%*$pWQdTmH=UPbq%|wx!!rSY~$yIxss^c4l`A zZDV8N3kb$1jsHApH1c3Ti7%R{h(2mH!5ANi8ubMeeNlPv+_|%4yM-4c351!=+;i_a z_uM<@%y;(exl0Iv_N=y^kT4-c#G6qnV{?a#$=N;Q5`0cAQb2t|T;e42&PdXeES1y{`^zyb=Iu zIkLSA`&b8fI$@tieLBghr;2Ox>H&OJ@JkHf>L?Gh=}4nKo#d3O6X5Cu{GGwCP{7+A z^}^nC0#QzbHt;~;ftem)wR}&TKXJlLTlN`l2KZkEWOj*JXG?9; zoRpcwXJqrH8EZPZ4t`is7AqM0UBOssR<%4^>5mT#9+VSwZVs*L8F3BUat`Pj2Wf)V zI%s`&%C`1qbSCdl*=9CvISD!^rJAX%sk(ZbX~vAxI`V3rRK_;Vy|!bpWd`D|KI$^r z3{ia0u`_Omw^-c3_4zECb@cr@E^6fTPF&ilE3q7;i!}=tFI%~$sby=lt9$ppsaiT` z?z}o#9z-`=u5r}RGktkObB701C#74OVIAww8IF-O^vJF?NmI4;3l}{aQk1(* zjc1d_$*kd?q79$L$%>EqtVF{@zcZY|t52GkQ-R@#%ASAlG}P$Y)Ajn&H}>5WTSntr%d- zwP~7X!=n0ywMtCs6X{oCf>OiE85k4zT6t)Q4TV?J5M9l?4Ihv5j_2rxXd@pR=Hpg9tnZEzk;^pdET( zAMA%148brMa2y;Mh0`zw$bMrK;+0RfVDUT zJls!<77zCxE=n*v#UoK(T!_-rknF>N(MLrVx3r4SUMepWF<@u~AFhyBi5M!hk`E)H zwIT*56}+LmKD0r?5Ts>%7zwpVA_YF!EN_)C7-=~lDsohkDDeiNj!>6~0Y%0C3+dO% z4e|^5o!mmC*T6iehZWF>_l79qJLgH~bgG;i0TPI11eCPy)~W=NXki9s}Sj2EhfDJ=h%qmWgvYB93{ z89J7=_@{I+yNYDV2ri>?aj^(jdYG;HW*cN~ucb{xMFZ#LII==r;c+xb)@_> a|DOLDP;wIY-;djZ`ah`u5y=l={r@jL$-5{3 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 3eccf0d..436d518 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ fields: When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. -> **Note:** Collapsible behaviour is handled entirely by the `collapsible.js` asset bundled with this fork (via the `data-block-collapsible` attribute), independent of WinterCMS's core collapsible-section JS. This is deliberate: core re-collapses and re-binds every section on each form-widget init — including when a nested repeater adds an item — which broke manually-opened sections and stalled repeater "Add item" clicks. Owning the behaviour avoids that entirely, so collapsible sections work correctly even with repeater fields nested inside them. +> **Note:** Collapsible behaviour is handled by this fork via the `data-block-collapsible` attribute, bootstrapped inline in the block widget partial (`formwidgets/blocks/partials/_block.php`), independent of WinterCMS's core collapsible-section JS. This is deliberate: core re-collapses and re-binds every section on each form-widget init — including when a nested repeater adds an item — which broke manually-opened sections and stalled repeater "Add item" clicks. Owning the behaviour avoids that entirely, so collapsible sections work correctly even with repeater fields nested inside them. --- diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1370b0d4a8146c31155967ebca97cbd9651bf194 GIT binary patch literal 8196 zcmeHMTWl0n7(U;$(3#=V)7qA{6JSFllv1P=S>$5a-mvA8Vz;HGRM**^0VYgm>dx#g zNQhPAgM!AW@eRDbVxo!02tgivz!x9PO2lYPeDK8=(ML`6pEap&>lRuBq`EVlLYy*Y{{F++HRKWpWR{| zD`Es<1Y!hY1Y!hY1nva{=*$k1e1~&iw8nXiK#ahBi9qmvNYE3=R3vAG3|}3Tg;xNQ z!4)7(ROUf}WFnHONX`mLEtDW!NeWj)R}2Vu(kCLjR3vAG6z&Ys)8XAS<3K+KG*Se%Q5qzgJqaxGBs{FhFj`&3y$H1zG=}QE3%T| z%_mPLlU<#O?qu>*XX516ZYn>#DS7IYA~&{e*glduW=}ir%VG(LUIT2&yd|BUyB^ys z_KEKiRgIPPd{v#vuyhk#_Ydu=rP|kJs=gpvfJm=bl zX~)a(x}0w33byW>J+_^;o-w@%-#qRIrAg1t`+cF|ghjrAF2i}jGe=Bvu!?3sxp@uq zCJp7o#f{5XtzOr)dE3tZ!M*#-OP1DhwO(x)FIt|Jwau}TW%x%A>R!%t49ht(Nys_A zb=WfV3csL?I{LI(=1t3*TUs@3zHz0Ki9*(Tx?uSyc$+Mj`O?}YD^_ailtEM4=Ndt0 z2LD7>!j`s`nsz8?>C2mx8gX}rS?(#qbz`@xjoEtEw7H>( zagtk9ZM-O;<$EIK_8qD=k#nb~X(e7AIwN(eN7J4RM&7G?{_u?H(5$XR!w*o~q<2)$ zo5Lj{SEL;xZMb2i51a-J%Xh;@0%RX8 zg4c3eszYo~2x?oZOVkuPpjy~NbPx`*DQ2=6c7mN@XW2P+o_)$Lu`k)z>>B%?{lI=? zKeJ!ib@nH!uoz2G3yv17MmyG`1DmiDDfD1BhVeK?kj4~rsX=O#+NHHpx3on{ ziK$DKV3`#4N-p(K=|=EVutXp4q?y|VOY5%32Jekvxwbe6kGlE?8X8x$wXg3^Zcp9d zksw}$YjYVK()K2@hqMiMc^==&B9PSOmB^VNQDek+^SB(4me&aDgt|&5zVij5`k=Z- zCZ_X+qT12gDN}55DXgj+S~n^bLA**-J6bm@GKZ)k@BlbDF%&xF9@$?sB=x^+A$i&SiB#51Bh@0!M9!YG$R&2u#3}6=q zF@!xB#Tdphfyt1khj9cJ^6)~oK8smAj~DPFUd3y89dF=GoW(i3gLfm=evAwAc(}X5 zKp}B)E)R?8ykoo05zSHk$ie$D`v+n48NUDk literal 0 HcmV?d00001 diff --git a/assets/dist/.DS_Store b/assets/dist/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ba3b77f6a8c3248a9cea646d67980a85d45fcca5 GIT binary patch literal 6148 zcmeH~Jr2S!425lAKw|00n1usyg9yP1xBvnsRH*}E&(V4QVW2Rh3O!5q7rU|AzM-i_ zM0bztR-_Y=CEO?*3nNqHGr7tAdcXANVd$-pHcMIw-pT0tYadAkr~nn90#twsOh. When a nested repeater adds an item, - * a new FormWidget initialises, finds the shared parent form, and re-collapses - * ALL data-field-collapsible sections in it while binding a SECOND click - * handler to each. The doubled handler is why adding a repeater item appeared - * to "stall" (two toggles cancel out) and why manually-opened sections snapped - * shut again. - * - * Solution: - * Blocks.php emits data-block-collapsible (and data-block-collapsible-open), - * which core's JS never selects, so core never collapses or binds these. - * This file owns everything and reuses core's .is-collapsible / .collapsed CSS - * classes so the styling is identical to native collapsible sections. - * - * Init detection uses a MutationObserver rather than DOMContentLoaded / ajax - * events: the block form fields (and nested repeater items) are injected by - * AJAX, and the backend dispatches its lifecycle events through jQuery, which - * native addEventListener handlers do not reliably receive. The observer fires - * on any DOM insertion regardless of framework, so newly rendered sections are - * always picked up. A per-section guard class makes init idempotent and cheap. - */ -(function () { - var READY_CLASS = 'block-collapsible-ready'; - - // Collect the fields that follow a section header up to the next section. - function followingFields(section) { - var els = []; - var 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' : ''; - }); - } - - // One-time setup for any not-yet-initialised collapsible section. - function initSections() { - document - .querySelectorAll('.section-field[data-block-collapsible]:not(.' + READY_CLASS + ')') - .forEach(function (section) { - section.classList.add(READY_CLASS); - - // Apply core's styling hook for the chevron + pointer cursor. - var header = section.querySelector('.field-section'); - if (header) { - header.classList.add('is-collapsible'); - } - - // Initial state: collapsed unless explicitly marked open. - var startOpen = section.hasAttribute('data-block-collapsible-open'); - setCollapsed(section, !startOpen); - }); - } - - // Single delegated click handler — native click events bubble normally, so - // addEventListener is reliable here (unlike framework lifecycle events). - document.addEventListener('click', function (e) { - var section = e.target.closest('.section-field[data-block-collapsible]'); - if (!section) { - return; - } - setCollapsed(section, !section.classList.contains('collapsed')); - }); - - // Run once now for anything already present. - initSections(); - - // Watch for sections injected later (block forms, repeater items). Observing - // childList only — our own class/style writes are attribute mutations and - // won't retrigger this, so there is no feedback loop. - var scheduled = false; - var observer = new MutationObserver(function () { - if (scheduled) { - return; - } - scheduled = true; - // Coalesce bursts of insertions into a single pass. - (window.requestAnimationFrame || window.setTimeout)(function () { - scheduled = false; - initSections(); - }, 0); - }); - - function startObserving() { - if (document.body) { - observer.observe(document.body, { childList: true, subtree: true }); - } else { - document.addEventListener('DOMContentLoaded', startObserving); - } - } - startObserving(); -})(); diff --git a/formwidgets/Blocks.php b/formwidgets/Blocks.php index 6f44be1..c256269 100644 --- a/formwidgets/Blocks.php +++ b/formwidgets/Blocks.php @@ -53,7 +53,9 @@ protected function loadAssets() { $this->addCss('css/blocks.css', 'Winter.Blocks'); $this->addJs('js/blocks.js', 'Winter.Blocks'); - $this->addJs('js/collapsible.js', 'Winter.Blocks'); + // Collapsible-section behaviour is bootstrapped inline in the block + // widget partial (formwidgets/blocks/partials/_block.php) so it loads + // reliably regardless of asset-path resolution or the asset combiner. } /** diff --git a/formwidgets/blocks/assets/js/collapsible.js b/formwidgets/blocks/assets/js/collapsible.js deleted file mode 100644 index 0686caa..0000000 --- a/formwidgets/blocks/assets/js/collapsible.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Self-contained collapsible sections for block form widgets. - * - * Why this exists instead of reusing core's data-field-collapsible: - * - * WinterCMS's FormWidget.bindCollapsibleSections() runs on EVERY FormWidget - * init(), scoped to the closest . When a nested repeater adds an item, - * a new FormWidget initialises, finds the shared parent form, and re-collapses - * ALL data-field-collapsible sections in it while binding a SECOND click - * handler to each. The doubled handler is why adding a repeater item appeared - * to "stall" (two toggles cancel out) and why manually-opened sections snapped - * shut again. - * - * Solution: - * Blocks.php emits data-block-collapsible (and data-block-collapsible-open), - * which core's JS never selects, so core never collapses or binds these. - * This file owns everything and reuses core's .is-collapsible / .collapsed CSS - * classes so the styling is identical to native collapsible sections. - * - * Init detection uses a MutationObserver rather than DOMContentLoaded / ajax - * events: the block form fields (and nested repeater items) are injected by - * AJAX, and the backend dispatches its lifecycle events through jQuery, which - * native addEventListener handlers do not reliably receive. The observer fires - * on any DOM insertion regardless of framework, so newly rendered sections are - * always picked up. A per-section guard class makes init idempotent and cheap. - */ -(function () { - var READY_CLASS = 'block-collapsible-ready'; - - // Collect the fields that follow a section header up to the next section. - function followingFields(section) { - var els = []; - var 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' : ''; - }); - } - - // One-time setup for any not-yet-initialised collapsible section. - function initSections() { - document - .querySelectorAll('.section-field[data-block-collapsible]:not(.' + READY_CLASS + ')') - .forEach(function (section) { - section.classList.add(READY_CLASS); - - // Apply core's styling hook for the chevron + pointer cursor. - var header = section.querySelector('.field-section'); - if (header) { - header.classList.add('is-collapsible'); - } - - // Initial state: collapsed unless explicitly marked open. - var startOpen = section.hasAttribute('data-block-collapsible-open'); - setCollapsed(section, !startOpen); - }); - } - - // Single delegated click handler — native click events bubble normally, so - // addEventListener is reliable here (unlike framework lifecycle events). - document.addEventListener('click', function (e) { - var section = e.target.closest('.section-field[data-block-collapsible]'); - if (!section) { - return; - } - setCollapsed(section, !section.classList.contains('collapsed')); - }); - - // Run once now for anything already present. - initSections(); - - // Watch for sections injected later (block forms, repeater items). Observing - // childList only — our own class/style writes are attribute mutations and - // won't retrigger this, so there is no feedback loop. - var scheduled = false; - var observer = new MutationObserver(function () { - if (scheduled) { - return; - } - scheduled = true; - // Coalesce bursts of insertions into a single pass. - (window.requestAnimationFrame || window.setTimeout)(function () { - scheduled = false; - initSections(); - }, 0); - }); - - function startObserving() { - if (document.body) { - observer.observe(document.body, { childList: true, subtree: true }); - } else { - document.addEventListener('DOMContentLoaded', startObserving); - } - } - startObserving(); -})(); From ca958bdbfde75e1db8f1d7065aff3913a4d84985 Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 20:16:18 +0200 Subject: [PATCH 12/55] Stop tracking .DS_Store files Co-Authored-By: Claude Sonnet 4.6 --- .DS_Store | Bin 10244 -> 0 bytes .gitignore | 1 + assets/.DS_Store | Bin 8196 -> 0 bytes assets/dist/.DS_Store | Bin 6148 -> 0 bytes 4 files changed, 1 insertion(+) delete mode 100644 .DS_Store delete mode 100644 assets/.DS_Store delete mode 100644 assets/dist/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 24c74b7ab70a600fa7ead2ab0f4eaadecaa1f514..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMU2GIp6uxKrH$#W^0!0e$goQ%*$pWQdTmH=UPbq%|wx!!rSY~$yIxss^c4l`A zZDV8N3kb$1jsHApH1c3Ti7%R{h(2mH!5ANi8ubMeeNlPv+_|%4yM-4c351!=+;i_a z_uM<@%y;(exl0Iv_N=y^kT4-c#G6qnV{?a#$=N;Q5`0cAQb2t|T;e42&PdXeES1y{`^zyb=Iu zIkLSA`&b8fI$@tieLBghr;2Ox>H&OJ@JkHf>L?Gh=}4nKo#d3O6X5Cu{GGwCP{7+A z^}^nC0#QzbHt;~;ftem)wR}&TKXJlLTlN`l2KZkEWOj*JXG?9; zoRpcwXJqrH8EZPZ4t`is7AqM0UBOssR<%4^>5mT#9+VSwZVs*L8F3BUat`Pj2Wf)V zI%s`&%C`1qbSCdl*=9CvISD!^rJAX%sk(ZbX~vAxI`V3rRK_;Vy|!bpWd`D|KI$^r z3{ia0u`_Omw^-c3_4zECb@cr@E^6fTPF&ilE3q7;i!}=tFI%~$sby=lt9$ppsaiT` z?z}o#9z-`=u5r}RGktkObB701C#74OVIAww8IF-O^vJF?NmI4;3l}{aQk1(* zjc1d_$*kd?q79$L$%>EqtVF{@zcZY|t52GkQ-R@#%ASAlG}P$Y)Ajn&H}>5WTSntr%d- zwP~7X!=n0ywMtCs6X{oCf>OiE85k4zT6t)Q4TV?J5M9l?4Ihv5j_2rxXd@pR=Hpg9tnZEzk;^pdET( zAMA%148brMa2y;Mh0`zw$bMrK;+0RfVDUT zJls!<77zCxE=n*v#UoK(T!_-rknF>N(MLrVx3r4SUMepWF<@u~AFhyBi5M!hk`E)H zwIT*56}+LmKD0r?5Ts>%7zwpVA_YF!EN_)C7-=~lDsohkDDeiNj!>6~0Y%0C3+dO% z4e|^5o!mmC*T6iehZWF>_l79qJLgH~bgG;i0TPI11eCPy)~W=NXki9s}Sj2EhfDJ=h%qmWgvYB93{ z89J7=_@{I+yNYDV2ri>?aj^(jdYG;HW*cN~ucb{xMFZ#LII==r;c+xb)@_> a|DOLDP;wIY-;djZ`ah`u5y=l={r@jL$-5{3 diff --git a/.gitignore b/.gitignore index 6d42620..d7c8c38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .phpunit.result.cache mix.webpack.js +.DS_Store diff --git a/assets/.DS_Store b/assets/.DS_Store deleted file mode 100644 index 1370b0d4a8146c31155967ebca97cbd9651bf194..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMTWl0n7(U;$(3#=V)7qA{6JSFllv1P=S>$5a-mvA8Vz;HGRM**^0VYgm>dx#g zNQhPAgM!AW@eRDbVxo!02tgivz!x9PO2lYPeDK8=(ML`6pEap&>lRuBq`EVlLYy*Y{{F++HRKWpWR{| zD`Es<1Y!hY1Y!hY1nva{=*$k1e1~&iw8nXiK#ahBi9qmvNYE3=R3vAG3|}3Tg;xNQ z!4)7(ROUf}WFnHONX`mLEtDW!NeWj)R}2Vu(kCLjR3vAG6z&Ys)8XAS<3K+KG*Se%Q5qzgJqaxGBs{FhFj`&3y$H1zG=}QE3%T| z%_mPLlU<#O?qu>*XX516ZYn>#DS7IYA~&{e*glduW=}ir%VG(LUIT2&yd|BUyB^ys z_KEKiRgIPPd{v#vuyhk#_Ydu=rP|kJs=gpvfJm=bl zX~)a(x}0w33byW>J+_^;o-w@%-#qRIrAg1t`+cF|ghjrAF2i}jGe=Bvu!?3sxp@uq zCJp7o#f{5XtzOr)dE3tZ!M*#-OP1DhwO(x)FIt|Jwau}TW%x%A>R!%t49ht(Nys_A zb=WfV3csL?I{LI(=1t3*TUs@3zHz0Ki9*(Tx?uSyc$+Mj`O?}YD^_ailtEM4=Ndt0 z2LD7>!j`s`nsz8?>C2mx8gX}rS?(#qbz`@xjoEtEw7H>( zagtk9ZM-O;<$EIK_8qD=k#nb~X(e7AIwN(eN7J4RM&7G?{_u?H(5$XR!w*o~q<2)$ zo5Lj{SEL;xZMb2i51a-J%Xh;@0%RX8 zg4c3eszYo~2x?oZOVkuPpjy~NbPx`*DQ2=6c7mN@XW2P+o_)$Lu`k)z>>B%?{lI=? zKeJ!ib@nH!uoz2G3yv17MmyG`1DmiDDfD1BhVeK?kj4~rsX=O#+NHHpx3on{ ziK$DKV3`#4N-p(K=|=EVutXp4q?y|VOY5%32Jekvxwbe6kGlE?8X8x$wXg3^Zcp9d zksw}$YjYVK()K2@hqMiMc^==&B9PSOmB^VNQDek+^SB(4me&aDgt|&5zVij5`k=Z- zCZ_X+qT12gDN}55DXgj+S~n^bLA**-J6bm@GKZ)k@BlbDF%&xF9@$?sB=x^+A$i&SiB#51Bh@0!M9!YG$R&2u#3}6=q zF@!xB#Tdphfyt1khj9cJ^6)~oK8smAj~DPFUd3y89dF=GoW(i3gLfm=evAwAc(}X5 zKp}B)E)R?8ykoo05zSHk$ie$D`v+n48NUDk diff --git a/assets/dist/.DS_Store b/assets/dist/.DS_Store deleted file mode 100644 index ba3b77f6a8c3248a9cea646d67980a85d45fcca5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425lAKw|00n1usyg9yP1xBvnsRH*}E&(V4QVW2Rh3O!5q7rU|AzM-i_ zM0bztR-_Y=CEO?*3nNqHGr7tAdcXANVd$-pHcMIw-pT0tYadAkr~nn90#twsOh Date: Thu, 11 Jun 2026 20:20:10 +0200 Subject: [PATCH 13/55] Add shared field includes for block definitions A block may declare `include: path` (or a list of paths) to merge fields, tabs, secondaryTabs, and config from external plain-YAML files. Included definitions form the base; the block's own definitions override on collision. Paths resolve via File::symbolizePath() ($/, ~/, #/). Lets shared sections, tabs, and field groups be reused across blocks without copy-paste. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 66 ++++++++++++++++++++++++++++++++++++- classes/BlockManager.php | 70 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 436d518..a79f3f7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Provides a "block based" content management experience in Winter CMS >**NOTE:** This plugin is still in development and is likely to undergo changes. Do not use in production environments without using a version constraint in your composer.json file and carefully monitoring for breaking changes. -> **Fork note:** This is a fork of [wintercms/wn-blocks-plugin](https://github.com/wintercms/wn-blocks-plugin) that adds **collapsible sections** and **tabs/secondaryTabs** support in block definitions. See [Collapsible Sections](#collapsible-sections) and [Tabs](#tabs) below. +> **Fork note:** This is a fork of [wintercms/wn-blocks-plugin](https://github.com/wintercms/wn-blocks-plugin) that adds **collapsible sections**, **tabs/secondaryTabs** support, and **shared field includes** in block definitions. See [Collapsible Sections](#collapsible-sections), [Tabs](#tabs), and [Including shared field definitions](#including-shared-field-definitions) below. ## Installation @@ -221,6 +221,70 @@ Fields declared under `tabs` / `secondaryTabs` are placed in the tabbed area of --- +## Including shared field definitions + +To avoid repeating the same fields (or sections/tabs) across many blocks, a block can pull them in from one or more external YAML files via the top-level `include` key. + +```yaml +name: Article +description: An article block +icon: icon-newspaper + +include: $/myauthor/myplugin/blocks/_seo.yaml + +fields: + title: + label: Title + type: text +== +
{{ title }}
+``` + +`_seo.yaml` is a plain YAML file (no `==` markup, no block metadata) containing any of `fields`, `tabs`, `secondaryTabs`, or `config`: + +```yaml +# blocks/_seo.yaml +tabs: + fields: + meta_title: + label: Meta title + type: text + tab: SEO + meta_description: + label: Meta description + type: textarea + tab: SEO +``` + +**Multiple includes** — pass a list; they are merged in order: + +```yaml +include: + - $/myauthor/myplugin/blocks/_seo.yaml + - ~/app/blocks/_tracking.yaml +``` + +**Merge rules:** + +| | | +|---|---| +| Merged keys | `fields`, `tabs`, `secondaryTabs`, `config` | +| Precedence | Included files form the base; the block's own definitions **override** on key collision | +| Order | Multiple includes merge top-to-bottom (later files override earlier ones, the block still wins overall) | +| Missing files | Silently skipped | + +**Path resolution** uses the standard Winter path symbols via `File::symbolizePath()`: + +| Symbol | Resolves to | +|---|---| +| `$/author/plugin/...` | `plugins/author/plugin/...` | +| `~/...` | application root | +| `#/...` | `storage/app/...` | + +This works for `collapsible` sections and tabs too — an included file can define a complete collapsible section (or a whole tab group) that every block reuses without copy-paste. + +--- + ## Using the `blocks` FormWidget In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks. diff --git a/classes/BlockManager.php b/classes/BlockManager.php index e3d2f07..38807a9 100644 --- a/classes/BlockManager.php +++ b/classes/BlockManager.php @@ -7,6 +7,7 @@ use Cms\Classes\Theme; use Event; use File; +use Yaml; use System\Classes\PluginManager; use Winter\Storm\Support\Traits\Singleton; use Winter\Storm\Support\Str; @@ -98,7 +99,7 @@ public function getConfigs(string|array|null $tags = null): array } } - $configs[pathinfo($block['fileName'])['filename']] = array_except( + $config = array_except( $block->getAttributes(), [ 'fileName', @@ -108,11 +109,78 @@ public function getConfigs(string|array|null $tags = null): array 'code', ] ); + + $config = $this->resolveIncludes($config); + + $configs[pathinfo($block['fileName'])['filename']] = $config; } return $configs; } + /** + * Resolves an `include` directive in a block definition by merging field + * definitions from one or more external YAML files. + * + * A block may declare: + * + * include: $/author/plugin/blocks/_shared.yaml + * # or + * include: + * - $/author/plugin/blocks/_seo.yaml + * - ~/app/blocks/_tracking.yaml + * + * Each included file is a plain YAML file that may contain any of the keys + * `fields`, `tabs`, `secondaryTabs` and `config`. Included definitions are + * merged in order and act as a base; the block's own definitions take + * precedence on key collisions. + * + * Paths are resolved with File::symbolizePath(), so the usual Winter symbols + * are supported ($ = plugins, ~ = app, # = app/storage/...). + */ + protected function resolveIncludes(array $config): array + { + if (empty($config['include'])) { + unset($config['include']); + return $config; + } + + $paths = (array) $config['include']; + unset($config['include']); + + $mergeKeys = ['fields', 'tabs', 'secondaryTabs', 'config']; + + foreach ($paths as $path) { + if (!is_string($path) || $path === '') { + continue; + } + + $realPath = File::symbolizePath($path); + if (!$realPath || !File::exists($realPath)) { + continue; + } + + $included = Yaml::parse(File::get($realPath)); + if (!is_array($included)) { + continue; + } + + foreach ($mergeKeys as $key) { + if (!isset($included[$key]) || !is_array($included[$key])) { + continue; + } + + // Included definitions form the base; the block's own win on collision. + $config[$key] = array_replace_recursive( + $included[$key], + (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [] + ); + } + } + + return $config; + } + /** * Get the configuration of the provided block type */ From 03871e7da516b139b122bf26feaa3c256d95ab3f Mon Sep 17 00:00:00 2001 From: Helmut Kaufmann Date: Thu, 11 Jun 2026 20:30:45 +0200 Subject: [PATCH 14/55] Add nested includes, schema guard, collapse persistence, recent blocks - BlockManager: resolve nested includes recursively with a circular-reference guard; warn when an include redefines a field with a different type; log missing include files instead of skipping silently. - Block widget partial: persist each collapsible section's open/closed state in localStorage; pin recently used blocks to the top of the add-block palette. - winter.mix.js: document the two distinct blocks.js files (frontend Snowboard build vs backend FormWidget script) to prevent accidental merging. - Add CHANGELOG.md summarising fork additions; update README. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 42 +++++++++++ README.md | 14 +++- classes/BlockManager.php | 61 ++++++++++++++-- formwidgets/blocks/partials/_block.php | 97 +++++++++++++++++++++++--- winter.mix.js | 13 ++++ 5 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..377b9e8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +This is a fork of [wintercms/wn-blocks-plugin](https://github.com/wintercms/wn-blocks-plugin). +Entries below describe changes that diverge from upstream. + +## Fork additions + +### Collapsible sections +- `.block` `type: section` fields support a `collapsible: true` shorthand, with + `collapsed: true|false` controlling the initial state. +- Handled via the `data-block-collapsible` attribute and an inline bootstrap in + the block widget partial, independent of core's collapsible-section JS. This + fixes the core behaviour where adding an item to a repeater nested inside a + section re-collapsed it and double-bound its click handler (causing the + "Add item" stall). +- **Open/closed state now persists** per section across page reloads + (`localStorage`, keyed by field name). + +### Tabs +- `.block` definitions may declare top-level `tabs` and `secondaryTabs`, passed + through to the backend `Form` widget like a standard `fields.yaml`. + +### Shared field includes +- `.block` definitions may declare a top-level `include:` (string or list) to + merge `fields`, `tabs`, `secondaryTabs`, and `config` from external plain-YAML + files. +- Included definitions form the base; the block's own definitions override on + collision. Paths resolve via `File::symbolizePath()` (`$/`, `~/`, `#/`). +- **Nested includes** are resolved recursively, guarded against circular + references. +- A **schema guard** logs a warning when an include would redefine a field with + a different `type`. +- Missing include files are skipped and logged as a warning. + +### Editor UX +- **Recently used blocks** are pinned to the top of the "add block" palette + (tracked in `localStorage`, most-recent first). + +### Housekeeping +- Documented the two distinct `blocks.js` files (frontend Snowboard build vs. + backend FormWidget script) in `winter.mix.js` to prevent accidental merging. +- Stopped tracking `.DS_Store` files. diff --git a/README.md b/README.md index a79f3f7..f659d16 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Provides a "block based" content management experience in Winter CMS >**NOTE:** This plugin is still in development and is likely to undergo changes. Do not use in production environments without using a version constraint in your composer.json file and carefully monitoring for breaking changes. -> **Fork note:** This is a fork of [wintercms/wn-blocks-plugin](https://github.com/wintercms/wn-blocks-plugin) that adds **collapsible sections**, **tabs/secondaryTabs** support, and **shared field includes** in block definitions. See [Collapsible Sections](#collapsible-sections), [Tabs](#tabs), and [Including shared field definitions](#including-shared-field-definitions) below. +> **Fork note:** This is a fork of [wintercms/wn-blocks-plugin](https://github.com/wintercms/wn-blocks-plugin) that adds **collapsible sections** (with persisted state), **tabs/secondaryTabs** support, **shared field includes** (with nested includes), and **recently used blocks** in the palette. See [Collapsible Sections](#collapsible-sections), [Tabs](#tabs), [Including shared field definitions](#including-shared-field-definitions), and [Recently used blocks](#recently-used-blocks) below. Full list in [CHANGELOG.md](CHANGELOG.md). ## Installation @@ -169,7 +169,7 @@ fields: | `collapsible` | bool | — | Set to `true` to enable the collapse toggle | | `collapsed` | bool | `true` | Initial state. `false` = section starts open | -When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. +When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. Each section's open/closed state is **remembered across page reloads** (stored in `localStorage`, keyed by field name), so the editor returns to the state you left it in. > **Note:** Collapsible behaviour is handled by this fork via the `data-block-collapsible` attribute, bootstrapped inline in the block widget partial (`formwidgets/blocks/partials/_block.php`), independent of WinterCMS's core collapsible-section JS. This is deliberate: core re-collapses and re-binds every section on each form-widget init — including when a nested repeater adds an item — which broke manually-opened sections and stalled repeater "Add item" clicks. Owning the behaviour avoids that entirely, so collapsible sections work correctly even with repeater fields nested inside them. @@ -271,7 +271,9 @@ include: | Merged keys | `fields`, `tabs`, `secondaryTabs`, `config` | | Precedence | Included files form the base; the block's own definitions **override** on key collision | | Order | Multiple includes merge top-to-bottom (later files override earlier ones, the block still wins overall) | -| Missing files | Silently skipped | +| Nested includes | An included file may itself declare `include:` — resolved recursively, with a circular-reference guard | +| Type guard | Redefining a field with a different `type` than the include logs a warning | +| Missing files | Skipped, and logged as a warning | **Path resolution** uses the standard Winter path symbols via `File::symbolizePath()`: @@ -285,6 +287,12 @@ This works for `collapsible` sections and tabs too — an included file can defi --- +## Recently used blocks + +When adding a block from the palette, the blocks you use most recently are pinned to the top of the list (tracked per browser in `localStorage`, most-recent first). This speeds up repetitive content building where the same few block types are added over and over. No configuration is required. + +--- + ## Using the `blocks` FormWidget In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks. diff --git a/classes/BlockManager.php b/classes/BlockManager.php index 38807a9..3b986d1 100644 --- a/classes/BlockManager.php +++ b/classes/BlockManager.php @@ -7,6 +7,7 @@ use Cms\Classes\Theme; use Event; use File; +use Log; use Yaml; use System\Classes\PluginManager; use Winter\Storm\Support\Traits\Singleton; @@ -135,10 +136,15 @@ public function getConfigs(string|array|null $tags = null): array * merged in order and act as a base; the block's own definitions take * precedence on key collisions. * + * Included files may themselves declare an `include` key — nested includes + * are resolved recursively, guarded against circular references. + * * Paths are resolved with File::symbolizePath(), so the usual Winter symbols * are supported ($ = plugins, ~ = app, # = app/storage/...). + * + * @param string[] $visited Canonical paths already being resolved (cycle guard). */ - protected function resolveIncludes(array $config): array + protected function resolveIncludes(array $config, array $visited = []): array { if (empty($config['include'])) { unset($config['include']); @@ -157,6 +163,13 @@ protected function resolveIncludes(array $config): array $realPath = File::symbolizePath($path); if (!$realPath || !File::exists($realPath)) { + Log::warning("Winter.Blocks: included file not found: {$path}"); + continue; + } + + $canonical = PathResolver::standardize($realPath); + if (in_array($canonical, $visited, true)) { + Log::warning("Winter.Blocks: circular include detected, skipping: {$path}"); continue; } @@ -165,22 +178,60 @@ protected function resolveIncludes(array $config): array continue; } + // Resolve nested includes first so they form the deepest base layer. + $included = $this->resolveIncludes($included, array_merge($visited, [$canonical])); + foreach ($mergeKeys as $key) { if (!isset($included[$key]) || !is_array($included[$key])) { continue; } + $own = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : []; + + // Warn when a field is redefined with a different type. + $this->warnOnTypeCollisions($key, $included[$key], $own); + // Included definitions form the base; the block's own win on collision. - $config[$key] = array_replace_recursive( - $included[$key], - (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [] - ); + $config[$key] = array_replace_recursive($included[$key], $own); } } return $config; } + /** + * Logs a warning when merging an include would redefine a field with a + * different `type`, which is almost always a mistake. Field definitions live + * directly under `fields`/`config`, and under a `fields` sub-key for + * `tabs`/`secondaryTabs`. + */ + protected function warnOnTypeCollisions(string $key, array $included, array $own): void + { + $nested = in_array($key, ['tabs', 'secondaryTabs'], true); + $includedFields = $nested ? ($included['fields'] ?? []) : $included; + $ownFields = $nested ? ($own['fields'] ?? []) : $own; + + if (!is_array($includedFields) || !is_array($ownFields)) { + return; + } + + foreach ($includedFields as $name => $def) { + if (!isset($ownFields[$name]) || !is_array($def) || !is_array($ownFields[$name])) { + continue; + } + + $includedType = $def['type'] ?? null; + $ownType = $ownFields[$name]['type'] ?? null; + + if ($includedType && $ownType && $includedType !== $ownType) { + Log::warning( + "Winter.Blocks: field '{$name}' redefined with a different type " . + "('{$ownType}' overrides included '{$includedType}') in '{$key}'." + ); + } + } + } + /** * Get the configuration of the provided block type */ diff --git a/formwidgets/blocks/partials/_block.php b/formwidgets/blocks/partials/_block.php index 4a14502..00ac933 100644 --- a/formwidgets/blocks/partials/_block.php +++ b/formwidgets/blocks/partials/_block.php @@ -69,6 +69,7 @@ class="form-control blocks-group-search" @@ -88,25 +89,39 @@ class="form-control blocks-group-search"