@cyprnet/node-red-contrib-uibuilder-formgen 0.5.36 → 0.5.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -80,6 +80,15 @@ All notable changes to this package will be documented in this file.
80
80
 
81
81
  - Checkbox defaults: Vue 2 portals now normalize checkbox <code>defaultValue</code> (accepts <code>true</code>/<code>false</code>, <code>1</code>/<code>0</code>, and common strings). Fixes cases where a checkbox appears checked but <code>showIf</code> dependent fields do not render until toggled.
82
82
 
83
+ ## 0.5.37
84
+
85
+ - Portal UX: added a live Theme selector (Auto/Light/Dark) in the portal header. Selection applies immediately and is persisted in <code>localStorage</code> (<code>portalsmith:theme</code>) for both Vue 2 and Vue 3 portals (including legacy uib2).
86
+
87
+ ## 0.5.38
88
+
89
+ - Schema Builder: sections can be marked collapsible (and optionally collapsed by default) for the generated portal.
90
+ - Portal UX: generated portals can now collapse/expand sections when <code>section.collapsible</code> is enabled.
91
+
83
92
  ## 0.5.21
84
93
 
85
94
  - Legacy uibuilder 2.x: fixed vendor library paths (<code>../vendor/...</code>) in the uib2 portal template and uib2 Schema Builder so uibuilder comms work and <code>lookup:get</code> messages emit correctly.
@@ -608,6 +608,30 @@ return msg;</pre>
608
608
  <li><code>actions[]</code> (optional)</li>
609
609
  </ul>
610
610
 
611
+ <h3>Section properties</h3>
612
+ <p>Each section supports:</p>
613
+ <ul>
614
+ <li><code>id</code> (required)</li>
615
+ <li><code>title</code> (required)</li>
616
+ <li><code>description</code> (optional)</li>
617
+ <li><code>fields</code> (required array)</li>
618
+ <li><code>collapsible</code> (optional boolean): if true, the generated portal shows a Show/Hide toggle for that section</li>
619
+ <li><code>collapsedByDefault</code> (optional boolean): if true (and <code>collapsible</code> is true), the section starts collapsed</li>
620
+ </ul>
621
+ <pre>{
622
+ "id": "advanced",
623
+ "title": "Advanced Settings",
624
+ "description": "Optional settings most users can ignore",
625
+ "collapsible": true,
626
+ "collapsedByDefault": true,
627
+ "fields": [
628
+ { "id": "notes", "type": "textarea", "label": "Notes" }
629
+ ]
630
+ }</pre>
631
+ <div class="note">
632
+ <strong>Schema Builder:</strong> Edit a section → enable <strong>Collapsible (generated portal)</strong> and optionally <strong>Start collapsed by default</strong>.
633
+ </div>
634
+
611
635
  <h3>Supported field types</h3>
612
636
  <table>
613
637
  <thead><tr><th>Type</th><th>Description</th></tr></thead>
@@ -189,8 +189,18 @@
189
189
  <div>
190
190
  <strong>{{section.title || 'Untitled Section'}}</strong>
191
191
  <small class="text-muted ml-2">({{section.fields.length}} fields)</small>
192
+ <b-badge v-if="section.collapsible" variant="info" class="ml-2">Collapsible</b-badge>
192
193
  </div>
193
194
  <div>
195
+ <b-button
196
+ variant="outline-secondary"
197
+ size="sm"
198
+ class="mr-2"
199
+ @click="toggleBuilderSectionCollapse(section.id)"
200
+ :title="isBuilderSectionCollapsed(section.id) ? 'Expand section' : 'Collapse section'"
201
+ >
202
+ <span aria-hidden="true">{{ isBuilderSectionCollapsed(section.id) ? '▸' : '▾' }}</span>
203
+ </b-button>
194
204
  <b-button variant="outline-primary" size="sm" @click="editSection(sectionIdx)" class="mr-2">
195
205
  <i class="fa fa-edit"></i> Edit
196
206
  </b-button>
@@ -202,7 +212,7 @@
202
212
  </b-button>
203
213
  </div>
204
214
  </div>
205
- <div class="section-body">
215
+ <div class="section-body" v-show="!isBuilderSectionCollapsed(section.id)">
206
216
  <p v-if="section.description" class="text-muted mb-3">{{section.description}}</p>
207
217
 
208
218
  <div v-if="section.fields.length === 0" class="empty-state">
@@ -328,6 +338,19 @@
328
338
  rows="2"
329
339
  />
330
340
  </b-form-group>
341
+
342
+ <b-form-group label="Collapsible (generated portal)" label-for="section-collapsible">
343
+ <b-form-checkbox id="section-collapsible" v-model="currentSection.collapsible">
344
+ Allow end users to collapse/expand this section in the generated portal
345
+ </b-form-checkbox>
346
+ <b-form-checkbox
347
+ class="mt-2"
348
+ v-model="currentSection.collapsedByDefault"
349
+ :disabled="!currentSection.collapsible"
350
+ >
351
+ Start collapsed by default
352
+ </b-form-checkbox>
353
+ </b-form-group>
331
354
  </b-modal>
332
355
 
333
356
  <!-- Field Editor Modal -->
@@ -86,8 +86,13 @@
86
86
  currentSection: {
87
87
  id: '',
88
88
  title: '',
89
- description: ''
89
+ description: '',
90
+ collapsible: false,
91
+ collapsedByDefault: false
90
92
  },
93
+
94
+ // Builder-only UI state: collapse section cards while editing (not stored in schema)
95
+ builderCollapsedSections: {},
91
96
 
92
97
  // Field editor
93
98
  editingField: null,
@@ -791,7 +796,9 @@
791
796
  this.currentSection = {
792
797
  id: '',
793
798
  title: '',
794
- description: ''
799
+ description: '',
800
+ collapsible: false,
801
+ collapsedByDefault: false
795
802
  };
796
803
  this.$bvModal.show('section-modal');
797
804
  },
@@ -799,6 +806,9 @@
799
806
  editSection(sectionIdx) {
800
807
  this.editingSection = sectionIdx;
801
808
  this.currentSection = JSON.parse(JSON.stringify(this.schema.sections[sectionIdx]));
809
+ // Defaults for older schemas
810
+ if (this.currentSection.collapsible === undefined) this.$set(this.currentSection, 'collapsible', false);
811
+ if (this.currentSection.collapsedByDefault === undefined) this.$set(this.currentSection, 'collapsedByDefault', false);
802
812
  this.$bvModal.show('section-modal');
803
813
  },
804
814
 
@@ -826,6 +836,8 @@
826
836
  id: this.currentSection.id,
827
837
  title: this.currentSection.title,
828
838
  description: this.currentSection.description || '',
839
+ collapsible: !!this.currentSection.collapsible,
840
+ collapsedByDefault: !!this.currentSection.collapsedByDefault,
829
841
  fields: []
830
842
  });
831
843
  } else {
@@ -835,6 +847,8 @@
835
847
  id: this.currentSection.id,
836
848
  title: this.currentSection.title,
837
849
  description: this.currentSection.description || '',
850
+ collapsible: !!this.currentSection.collapsible,
851
+ collapsedByDefault: !!this.currentSection.collapsedByDefault,
838
852
  fields: this.schema.sections[this.editingSection].fields
839
853
  };
840
854
 
@@ -850,7 +864,19 @@
850
864
 
851
865
  cancelEditSection() {
852
866
  this.editingSection = null;
853
- this.currentSection = { id: '', title: '', description: '' };
867
+ this.currentSection = { id: '', title: '', description: '', collapsible: false, collapsedByDefault: false };
868
+ },
869
+
870
+ toggleBuilderSectionCollapse(sectionId) {
871
+ const id = String(sectionId || '').trim();
872
+ if (!id) return;
873
+ const cur = !!this.builderCollapsedSections[id];
874
+ this.$set(this.builderCollapsedSections, id, !cur);
875
+ },
876
+
877
+ isBuilderSectionCollapsed(sectionId) {
878
+ const id = String(sectionId || '').trim();
879
+ return !!this.builderCollapsedSections[id];
854
880
  },
855
881
 
856
882
  deleteSection(sectionIdx) {
@@ -188,8 +188,18 @@
188
188
  <div>
189
189
  <strong>{{section.title || 'Untitled Section'}}</strong>
190
190
  <small class="text-muted ml-2">({{section.fields.length}} fields)</small>
191
+ <b-badge v-if="section.collapsible" variant="info" class="ml-2">Collapsible</b-badge>
191
192
  </div>
192
193
  <div>
194
+ <b-button
195
+ variant="outline-secondary"
196
+ size="sm"
197
+ class="mr-2"
198
+ @click="toggleBuilderSectionCollapse(section.id)"
199
+ :title="isBuilderSectionCollapsed(section.id) ? 'Expand section' : 'Collapse section'"
200
+ >
201
+ <span aria-hidden="true">{{ isBuilderSectionCollapsed(section.id) ? '▸' : '▾' }}</span>
202
+ </b-button>
193
203
  <b-button variant="outline-primary" size="sm" @click="editSection(sectionIdx)" class="mr-2">
194
204
  <i class="fa fa-edit"></i> Edit
195
205
  </b-button>
@@ -201,7 +211,7 @@
201
211
  </b-button>
202
212
  </div>
203
213
  </div>
204
- <div class="section-body">
214
+ <div class="section-body" v-show="!isBuilderSectionCollapsed(section.id)">
205
215
  <p v-if="section.description" class="text-muted mb-3">{{section.description}}</p>
206
216
 
207
217
  <div v-if="section.fields.length === 0" class="empty-state">
@@ -327,6 +337,19 @@
327
337
  rows="2"
328
338
  />
329
339
  </b-form-group>
340
+
341
+ <b-form-group label="Collapsible (generated portal)" label-for="section-collapsible">
342
+ <b-form-checkbox id="section-collapsible" v-model="currentSection.collapsible">
343
+ Allow end users to collapse/expand this section in the generated portal
344
+ </b-form-checkbox>
345
+ <b-form-checkbox
346
+ class="mt-2"
347
+ v-model="currentSection.collapsedByDefault"
348
+ :disabled="!currentSection.collapsible"
349
+ >
350
+ Start collapsed by default
351
+ </b-form-checkbox>
352
+ </b-form-group>
330
353
  </b-modal>
331
354
 
332
355
  <!-- Field Editor Modal -->
@@ -86,8 +86,13 @@
86
86
  currentSection: {
87
87
  id: '',
88
88
  title: '',
89
- description: ''
89
+ description: '',
90
+ collapsible: false,
91
+ collapsedByDefault: false
90
92
  },
93
+
94
+ // Builder-only UI state: collapse section cards while editing (not stored in schema)
95
+ builderCollapsedSections: {},
91
96
 
92
97
  // Field editor
93
98
  editingField: null,
@@ -791,7 +796,9 @@
791
796
  this.currentSection = {
792
797
  id: '',
793
798
  title: '',
794
- description: ''
799
+ description: '',
800
+ collapsible: false,
801
+ collapsedByDefault: false
795
802
  };
796
803
  this.$bvModal.show('section-modal');
797
804
  },
@@ -799,6 +806,9 @@
799
806
  editSection(sectionIdx) {
800
807
  this.editingSection = sectionIdx;
801
808
  this.currentSection = JSON.parse(JSON.stringify(this.schema.sections[sectionIdx]));
809
+ // Defaults for older schemas
810
+ if (this.currentSection.collapsible === undefined) this.$set(this.currentSection, 'collapsible', false);
811
+ if (this.currentSection.collapsedByDefault === undefined) this.$set(this.currentSection, 'collapsedByDefault', false);
802
812
  this.$bvModal.show('section-modal');
803
813
  },
804
814
 
@@ -826,6 +836,8 @@
826
836
  id: this.currentSection.id,
827
837
  title: this.currentSection.title,
828
838
  description: this.currentSection.description || '',
839
+ collapsible: !!this.currentSection.collapsible,
840
+ collapsedByDefault: !!this.currentSection.collapsedByDefault,
829
841
  fields: []
830
842
  });
831
843
  } else {
@@ -835,6 +847,8 @@
835
847
  id: this.currentSection.id,
836
848
  title: this.currentSection.title,
837
849
  description: this.currentSection.description || '',
850
+ collapsible: !!this.currentSection.collapsible,
851
+ collapsedByDefault: !!this.currentSection.collapsedByDefault,
838
852
  fields: this.schema.sections[this.editingSection].fields
839
853
  };
840
854
 
@@ -850,7 +864,19 @@
850
864
 
851
865
  cancelEditSection() {
852
866
  this.editingSection = null;
853
- this.currentSection = { id: '', title: '', description: '' };
867
+ this.currentSection = { id: '', title: '', description: '', collapsible: false, collapsedByDefault: false };
868
+ },
869
+
870
+ toggleBuilderSectionCollapse(sectionId) {
871
+ const id = String(sectionId || '').trim();
872
+ if (!id) return;
873
+ const cur = !!this.builderCollapsedSections[id];
874
+ this.$set(this.builderCollapsedSections, id, !cur);
875
+ },
876
+
877
+ isBuilderSectionCollapsed(sectionId) {
878
+ const id = String(sectionId || '').trim();
879
+ return !!this.builderCollapsedSections[id];
854
880
  },
855
881
 
856
882
  deleteSection(sectionIdx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyprnet/node-red-contrib-uibuilder-formgen",
3
- "version": "0.5.36",
3
+ "version": "0.5.38",
4
4
  "description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
5
5
  "keywords": [
6
6
  "node-red",
@@ -148,6 +148,22 @@
148
148
  gap: 1rem;
149
149
  margin-bottom: 0.5rem;
150
150
  }
151
+ .ps-header-right {
152
+ display: flex;
153
+ align-items: center;
154
+ justify-content: flex-end;
155
+ gap: 0.75rem;
156
+ flex-wrap: wrap;
157
+ }
158
+ .ps-theme-label {
159
+ font-size: 12px;
160
+ color: var(--ps-muted);
161
+ margin: 0;
162
+ }
163
+ .ps-theme-select {
164
+ width: auto;
165
+ min-width: 120px;
166
+ }
151
167
  .ps-logo {
152
168
  max-height: 48px;
153
169
  max-width: 220px;
@@ -210,6 +226,15 @@
210
226
  margin-bottom: 1.5rem;
211
227
  margin-top: 2rem;
212
228
  }
229
+ .ps-section-head {
230
+ display: flex;
231
+ align-items: flex-end;
232
+ justify-content: space-between;
233
+ gap: 0.75rem;
234
+ }
235
+ .ps-section-toggle {
236
+ white-space: nowrap;
237
+ }
213
238
  .section-header:first-child {
214
239
  margin-top: 0;
215
240
  }
@@ -229,18 +254,29 @@
229
254
  <div class="container form-container">
230
255
  <div class="ps-header" style="--ps-logo-max-height: [[logoMaxHeightPx]]px; --ps-logo-max-width: [[logoMaxWidthPx]]px;">
231
256
  <h1 class="mb-0">[[title]]</h1>
232
- [[#logoUrl]]
233
- <img class="ps-custom-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
234
- [[/logoUrl]]
235
- [[^logoUrl]]
236
- [[^licensed]]
237
- <svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
238
- <rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
239
- <text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
240
- <text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
241
- </svg>
242
- [[/licensed]]
243
- [[/logoUrl]]
257
+ <div class="ps-header-right">
258
+ <div>
259
+ <div class="ps-theme-label">Theme</div>
260
+ <select class="custom-select custom-select-sm ps-theme-select" v-model="themeMode" @change="setTheme(themeMode)">
261
+ <option value="auto">Auto</option>
262
+ <option value="light">Light</option>
263
+ <option value="dark">Dark</option>
264
+ </select>
265
+ </div>
266
+
267
+ [[#logoUrl]]
268
+ <img class="ps-custom-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
269
+ [[/logoUrl]]
270
+ [[^logoUrl]]
271
+ [[^licensed]]
272
+ <svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
273
+ <rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
274
+ <text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
275
+ <text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
276
+ </svg>
277
+ [[/licensed]]
278
+ [[/logoUrl]]
279
+ </div>
244
280
  </div>
245
281
  <p v-if="description" class="text-muted mb-4">[[description]]</p>
246
282
 
@@ -430,8 +466,22 @@
430
466
  <form @submit.prevent="handleSubmit">
431
467
  <!-- Dynamic Sections -->
432
468
  <div v-for="section in schema.sections" :key="section.id" class="form-section">
433
- <h3 v-if="section.title" class="section-header">{{section.title}}</h3>
469
+ <div class="ps-section-head section-header" v-if="section.title || section.collapsible">
470
+ <h3 v-if="section.title" class="mb-0">{{section.title}}</h3>
471
+ <button
472
+ v-if="section.collapsible"
473
+ type="button"
474
+ class="btn btn-sm btn-outline-secondary ps-section-toggle"
475
+ @click="toggleSection(section.id)"
476
+ :aria-expanded="(!isSectionCollapsed(section.id)).toString()"
477
+ :aria-controls="'ps-sec-' + section.id"
478
+ >
479
+ {{ isSectionCollapsed(section.id) ? 'Show' : 'Hide' }}
480
+ </button>
481
+ </div>
434
482
  <p v-if="section.description" class="text-muted mb-3">{{section.description}}</p>
483
+
484
+ <div :id="'ps-sec-' + section.id" v-show="!section.collapsible || !isSectionCollapsed(section.id)">
435
485
 
436
486
  <!-- Dynamic Fields -->
437
487
  <div v-for="field in visibleFieldsBySection[section.id]" :key="field.id" class="form-group">
@@ -593,6 +643,7 @@
593
643
  {{field.help}}
594
644
  </small>
595
645
  </div>
646
+ </div>
596
647
  </div>
597
648
 
598
649
  <!-- Action Buttons -->
@@ -495,18 +495,31 @@
495
495
  .replace(/'/g, '&#039;');
496
496
  }
497
497
 
498
- function applyTheme() {
498
+ function normalizeThemeMode(mode, fallback) {
499
+ const m = String(mode || '').trim().toLowerCase();
500
+ if (m === 'light' || m === 'dark' || m === 'auto') return m;
501
+ const fb = String(fallback || '').trim().toLowerCase();
502
+ return (fb === 'light' || fb === 'dark' || fb === 'auto') ? fb : 'auto';
503
+ }
504
+
505
+ function getThemeMode() {
499
506
  try {
500
- // Optional override via localStorage (lets you tweak without regenerating)
501
507
  const stored = localStorage.getItem('portalsmith:theme');
502
- const desired = (stored && stored.trim()) ? stored.trim().toLowerCase() : (CONFIG.themeMode || 'auto');
508
+ if (stored && stored.trim()) return normalizeThemeMode(stored, CONFIG.themeMode);
509
+ } catch (e) { /* ignore */ }
510
+ return normalizeThemeMode(CONFIG.themeMode, 'auto');
511
+ }
503
512
 
504
- if (desired === 'light' || desired === 'dark' || desired === 'auto') {
505
- document.documentElement.setAttribute('data-theme', desired);
506
- }
507
- } catch (e) {
508
- // ignore (storage blocked, etc.)
509
- }
513
+ function applyTheme(mode) {
514
+ const desired = normalizeThemeMode(mode, getThemeMode());
515
+ document.documentElement.setAttribute('data-theme', desired);
516
+ return desired;
517
+ }
518
+
519
+ function setThemeMode(mode) {
520
+ const desired = normalizeThemeMode(mode, getThemeMode());
521
+ try { localStorage.setItem('portalsmith:theme', desired); } catch (e) { /* ignore */ }
522
+ return applyTheme(desired);
510
523
  }
511
524
 
512
525
  // Vue app instance
@@ -778,7 +791,20 @@
778
791
  resultSortKey: '',
779
792
  resultSortDir: 'asc',
780
793
  copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
781
- exportFormats: CONFIG.exportFormats
794
+ exportFormats: CONFIG.exportFormats,
795
+ themeMode: getThemeMode(),
796
+
797
+ // Section collapse state (keyed by section.id)
798
+ collapsedSections: (function initCollapsed() {
799
+ const m = {};
800
+ try {
801
+ (schema.sections || []).forEach(s => {
802
+ if (!s || !s.id) return;
803
+ if (s.collapsible) m[s.id] = !!s.collapsedByDefault;
804
+ });
805
+ } catch (e) { /* ignore */ }
806
+ return m;
807
+ })(),
782
808
  },
783
809
  computed: {
784
810
  description() {
@@ -965,6 +991,19 @@
965
991
  }
966
992
  },
967
993
  methods: {
994
+ toggleSection(sectionId) {
995
+ const id = String(sectionId || '').trim();
996
+ if (!id) return;
997
+ const cur = !!this.collapsedSections[id];
998
+ this.$set(this.collapsedSections, id, !cur);
999
+ },
1000
+ isSectionCollapsed(sectionId) {
1001
+ const id = String(sectionId || '').trim();
1002
+ return !!this.collapsedSections[id];
1003
+ },
1004
+ setTheme(mode) {
1005
+ this.themeMode = setThemeMode(mode);
1006
+ },
968
1007
  toggleResultSort(key) {
969
1008
  const k = String(key || '').trim();
970
1009
  if (!k) return;
@@ -146,6 +146,22 @@
146
146
  gap: 1rem;
147
147
  margin-bottom: 0.5rem;
148
148
  }
149
+ .ps-header-right {
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: flex-end;
153
+ gap: 0.75rem;
154
+ flex-wrap: wrap;
155
+ }
156
+ .ps-theme-label {
157
+ font-size: 12px;
158
+ color: var(--ps-muted);
159
+ margin: 0;
160
+ }
161
+ .ps-theme-select {
162
+ width: auto;
163
+ min-width: 120px;
164
+ }
149
165
  .ps-logo {
150
166
  max-height: 48px;
151
167
  max-width: 220px;
@@ -208,6 +224,15 @@
208
224
  margin-bottom: 1.5rem;
209
225
  margin-top: 2rem;
210
226
  }
227
+ .ps-section-head {
228
+ display: flex;
229
+ align-items: flex-end;
230
+ justify-content: space-between;
231
+ gap: 0.75rem;
232
+ }
233
+ .ps-section-toggle {
234
+ white-space: nowrap;
235
+ }
211
236
  .section-header:first-child {
212
237
  margin-top: 0;
213
238
  }
@@ -227,18 +252,29 @@
227
252
  <div class="container form-container">
228
253
  <div class="ps-header" style="--ps-logo-max-height: [[logoMaxHeightPx]]px; --ps-logo-max-width: [[logoMaxWidthPx]]px;">
229
254
  <h1 class="mb-0">[[title]]</h1>
230
- [[#logoUrl]]
231
- <img class="ps-custom-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
232
- [[/logoUrl]]
233
- [[^logoUrl]]
234
- [[^licensed]]
235
- <svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
236
- <rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
237
- <text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
238
- <text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
239
- </svg>
240
- [[/licensed]]
241
- [[/logoUrl]]
255
+ <div class="ps-header-right">
256
+ <div>
257
+ <div class="ps-theme-label">Theme</div>
258
+ <select class="custom-select custom-select-sm ps-theme-select" v-model="themeMode" @change="setTheme(themeMode)">
259
+ <option value="auto">Auto</option>
260
+ <option value="light">Light</option>
261
+ <option value="dark">Dark</option>
262
+ </select>
263
+ </div>
264
+
265
+ [[#logoUrl]]
266
+ <img class="ps-custom-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
267
+ [[/logoUrl]]
268
+ [[^logoUrl]]
269
+ [[^licensed]]
270
+ <svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
271
+ <rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
272
+ <text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
273
+ <text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
274
+ </svg>
275
+ [[/licensed]]
276
+ [[/logoUrl]]
277
+ </div>
242
278
  </div>
243
279
  <p v-if="description" class="text-muted mb-4">[[description]]</p>
244
280
 
@@ -428,8 +464,22 @@
428
464
  <form @submit.prevent="handleSubmit">
429
465
  <!-- Dynamic Sections -->
430
466
  <div v-for="section in schema.sections" :key="section.id" class="form-section">
431
- <h3 v-if="section.title" class="section-header">{{section.title}}</h3>
467
+ <div class="ps-section-head section-header" v-if="section.title || section.collapsible">
468
+ <h3 v-if="section.title" class="mb-0">{{section.title}}</h3>
469
+ <button
470
+ v-if="section.collapsible"
471
+ type="button"
472
+ class="btn btn-sm btn-outline-secondary ps-section-toggle"
473
+ @click="toggleSection(section.id)"
474
+ :aria-expanded="(!isSectionCollapsed(section.id)).toString()"
475
+ :aria-controls="'ps-sec-' + section.id"
476
+ >
477
+ {{ isSectionCollapsed(section.id) ? 'Show' : 'Hide' }}
478
+ </button>
479
+ </div>
432
480
  <p v-if="section.description" class="text-muted mb-3">{{section.description}}</p>
481
+
482
+ <div :id="'ps-sec-' + section.id" v-show="!section.collapsible || !isSectionCollapsed(section.id)">
433
483
 
434
484
  <!-- Dynamic Fields -->
435
485
  <div v-for="field in visibleFieldsBySection[section.id]" :key="field.id" class="form-group">
@@ -591,6 +641,7 @@
591
641
  {{field.help}}
592
642
  </small>
593
643
  </div>
644
+ </div>
594
645
  </div>
595
646
 
596
647
  <!-- Action Buttons -->
@@ -131,6 +131,22 @@
131
131
  gap: 1rem;
132
132
  margin-bottom: 0.5rem;
133
133
  }
134
+ .ps-header-right {
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: flex-end;
138
+ gap: 0.75rem;
139
+ flex-wrap: wrap;
140
+ }
141
+ .ps-theme-label {
142
+ font-size: 12px;
143
+ color: var(--ps-muted);
144
+ margin: 0;
145
+ }
146
+ .ps-theme-select {
147
+ width: auto;
148
+ min-width: 120px;
149
+ }
134
150
  .ps-logo {
135
151
  max-height: 48px;
136
152
  max-width: 220px;
@@ -193,6 +209,15 @@
193
209
  margin-bottom: 1.5rem;
194
210
  margin-top: 2rem;
195
211
  }
212
+ .ps-section-head {
213
+ display: flex;
214
+ align-items: flex-end;
215
+ justify-content: space-between;
216
+ gap: 0.75rem;
217
+ }
218
+ .ps-section-toggle {
219
+ white-space: nowrap;
220
+ }
196
221
  .section-header:first-child {
197
222
  margin-top: 0;
198
223
  }
@@ -212,18 +237,29 @@
212
237
  <div class="container form-container">
213
238
  <div class="ps-header" style="--ps-logo-max-height: [[logoMaxHeightPx]]px; --ps-logo-max-width: [[logoMaxWidthPx]]px;">
214
239
  <h1 class="mb-0">[[title]]</h1>
215
- [[#logoUrl]]
216
- <img class="ps-custom-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
217
- [[/logoUrl]]
218
- [[^logoUrl]]
219
- [[^licensed]]
220
- <svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
221
- <rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
222
- <text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
223
- <text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
224
- </svg>
225
- [[/licensed]]
226
- [[/logoUrl]]
240
+ <div class="ps-header-right">
241
+ <div>
242
+ <div class="ps-theme-label">Theme</div>
243
+ <select class="form-select form-select-sm ps-theme-select" v-model="themeMode" @change="setTheme(themeMode)">
244
+ <option value="auto">Auto</option>
245
+ <option value="light">Light</option>
246
+ <option value="dark">Dark</option>
247
+ </select>
248
+ </div>
249
+
250
+ [[#logoUrl]]
251
+ <img class="ps-custom-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
252
+ [[/logoUrl]]
253
+ [[^logoUrl]]
254
+ [[^licensed]]
255
+ <svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
256
+ <rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
257
+ <text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
258
+ <text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
259
+ </svg>
260
+ [[/licensed]]
261
+ [[/logoUrl]]
262
+ </div>
227
263
  </div>
228
264
  <p v-if="description" class="text-muted mb-4">[[description]]</p>
229
265
 
@@ -383,10 +419,24 @@
383
419
 
384
420
  <!-- Sections -->
385
421
  <div v-for="section in schema.sections" :key="section.id" class="mb-4">
386
- <div class="section-header">
387
- <h2 class="h4 mb-1">{{section.title}}</h2>
388
- <p v-if="section.description" class="text-muted mb-0">{{section.description}}</p>
422
+ <div class="section-header ps-section-head">
423
+ <div>
424
+ <h2 class="h4 mb-1">{{section.title}}</h2>
425
+ <p v-if="section.description" class="text-muted mb-0">{{section.description}}</p>
426
+ </div>
427
+ <button
428
+ v-if="section.collapsible"
429
+ type="button"
430
+ class="btn btn-sm btn-outline-secondary ps-section-toggle"
431
+ @click="toggleSection(section.id)"
432
+ :aria-expanded="(!isSectionCollapsed(section.id)).toString()"
433
+ :aria-controls="'ps-sec-' + section.id"
434
+ >
435
+ {{ isSectionCollapsed(section.id) ? 'Show' : 'Hide' }}
436
+ </button>
389
437
  </div>
438
+
439
+ <div :id="'ps-sec-' + section.id" v-show="!section.collapsible || !isSectionCollapsed(section.id)">
390
440
 
391
441
  <!-- Fields -->
392
442
  <div v-for="field in visibleFieldsBySection[section.id]" :key="field.id" class="mb-3">
@@ -552,6 +602,7 @@
552
602
  {{fieldErrors[field.id]}}
553
603
  </div>
554
604
  </div>
605
+ </div>
555
606
  </div>
556
607
 
557
608
  <!-- Actions -->
@@ -352,9 +352,31 @@
352
352
  });
353
353
  }
354
354
 
355
- function applyTheme() {
356
- const m = String(CONFIG.themeMode || 'auto').toLowerCase();
357
- document.documentElement.setAttribute('data-theme', m);
355
+ function normalizeThemeMode(mode, fallback) {
356
+ const m = String(mode || '').trim().toLowerCase();
357
+ if (m === 'light' || m === 'dark' || m === 'auto') return m;
358
+ const fb = String(fallback || '').trim().toLowerCase();
359
+ return (fb === 'light' || fb === 'dark' || fb === 'auto') ? fb : 'auto';
360
+ }
361
+
362
+ function getThemeMode() {
363
+ try {
364
+ const stored = localStorage.getItem('portalsmith:theme');
365
+ if (stored && stored.trim()) return normalizeThemeMode(stored, CONFIG.themeMode);
366
+ } catch (e) { /* ignore */ }
367
+ return normalizeThemeMode(CONFIG.themeMode, 'auto');
368
+ }
369
+
370
+ function applyTheme(mode) {
371
+ const desired = normalizeThemeMode(mode, getThemeMode());
372
+ document.documentElement.setAttribute('data-theme', desired);
373
+ return desired;
374
+ }
375
+
376
+ function setThemeMode(mode) {
377
+ const desired = normalizeThemeMode(mode, getThemeMode());
378
+ try { localStorage.setItem('portalsmith:theme', desired); } catch (e) { /* ignore */ }
379
+ return applyTheme(desired);
358
380
  }
359
381
 
360
382
  function tryParseHeadersJson(s) {
@@ -516,7 +538,20 @@
516
538
  resultSortKey: '',
517
539
  resultSortDir: 'asc',
518
540
  copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
519
- exportFormats: CONFIG.exportFormats
541
+ exportFormats: CONFIG.exportFormats,
542
+ themeMode: getThemeMode(),
543
+
544
+ // Section collapse state (keyed by section.id)
545
+ collapsedSections: (function initCollapsed() {
546
+ const m = {};
547
+ try {
548
+ (schema.sections || []).forEach(s => {
549
+ if (!s || !s.id) return;
550
+ if (s.collapsible) m[s.id] = !!s.collapsedByDefault;
551
+ });
552
+ } catch (e) { /* ignore */ }
553
+ return m;
554
+ })(),
520
555
  };
521
556
  },
522
557
  computed: {
@@ -657,6 +692,19 @@
657
692
  }
658
693
  },
659
694
  methods: {
695
+ setTheme(mode) {
696
+ this.themeMode = setThemeMode(mode);
697
+ },
698
+ toggleSection(sectionId) {
699
+ const id = String(sectionId || '').trim();
700
+ if (!id) return;
701
+ const cur = !!this.collapsedSections[id];
702
+ this.collapsedSections[id] = !cur;
703
+ },
704
+ isSectionCollapsed(sectionId) {
705
+ const id = String(sectionId || '').trim();
706
+ return !!this.collapsedSections[id];
707
+ },
660
708
  toggleResultSort(key) {
661
709
  const k = String(key || '').trim();
662
710
  if (!k) return;