@kubex/zinc 1.1.153 → 1.1.155

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.
@@ -8,7 +8,7 @@ layout: component
8
8
  Panels are a fundamental component in the Zinc framework, providing a structured container for displaying content with consistent styling, borders, and spacing. They support various layouts including headers with actions and footers.
9
9
 
10
10
  ```html:preview
11
- <zn-panel caption="Example Panel" description="Full example with all features" flush tabbed>
11
+ <zn-panel caption="Example Panel" flush tabbed>
12
12
  <!-- Panel actions -->
13
13
  <zn-chip slot="actions" icon="home">Awesome</zn-chip>
14
14
  <zn-chip slot="actions" icon="phone" type="info">example</zn-chip>
@@ -42,10 +42,10 @@ Panels are a fundamental component in the Zinc framework, providing a structured
42
42
 
43
43
  ### Basic Panel
44
44
 
45
- Basic panels provide a simple container with consistent borders, rounded corners, and background styling. The panel can display a caption and description in its header.
45
+ Basic panels provide a simple container with consistent borders, rounded corners, and background styling. The panel can display a caption in its header.
46
46
 
47
47
  ```html:preview
48
- <zn-panel caption="Example Panel" description="Simple panels are the best">
48
+ <zn-panel caption="Example Panel">
49
49
  <div>Panel Content</div>
50
50
  </zn-panel>
51
51
  ```
@@ -55,7 +55,7 @@ Basic panels provide a simple container with consistent borders, rounded corners
55
55
  Use the `icon` attribute to add an icon to the panel header.
56
56
 
57
57
  ```html:preview
58
- <zn-panel caption="Settings Panel" description="Configure your preferences" icon="settings">
58
+ <zn-panel caption="Settings Panel" icon="settings">
59
59
  <div style="padding: 20px;">
60
60
  <p>Panel content goes here</p>
61
61
  </div>
@@ -67,7 +67,7 @@ Use the `icon` attribute to add an icon to the panel header.
67
67
  Use the `actions` slot to add action buttons or chips to the panel header.
68
68
 
69
69
  ```html:preview
70
- <zn-panel caption="Project Details" description="Manage your project">
70
+ <zn-panel caption="Project Details">
71
71
  <zn-chip slot="actions" icon="edit" type="info">Edit</zn-chip>
72
72
  <zn-chip slot="actions" icon="delete" type="error">Delete</zn-chip>
73
73
 
@@ -99,7 +99,7 @@ Use the `footer` slot to add footer content to the panel.
99
99
  Panel headers include a border by default. Use the `header-borderless` attribute to remove the border beneath the header.
100
100
 
101
101
  ```html:preview
102
- <zn-panel caption="Borderless Header" description="Without a visible separator" header-borderless>
102
+ <zn-panel caption="Borderless Header" header-borderless>
103
103
  <div style="padding: 20px;">
104
104
  <p>Panel content with a borderless header</p>
105
105
  </div>
@@ -192,7 +192,7 @@ Use the `tabbed` attribute for panels containing tabs. This automatically remove
192
192
  Use the `transparent` attribute to remove the panel background and border, creating a borderless container.
193
193
 
194
194
  ```html:preview
195
- <zn-panel caption="Transparent Panel" description="No background or border" transparent>
195
+ <zn-panel caption="Transparent Panel" transparent>
196
196
  <div style="padding: 20px; background: rgba(var(--zn-color-info), 0.1);">
197
197
  Content in transparent panel
198
198
  </div>
@@ -204,7 +204,7 @@ Use the `transparent` attribute to remove the panel background and border, creat
204
204
  Use the `shadow` attribute to add a prominent drop shadow to the panel.
205
205
 
206
206
  ```html:preview
207
- <zn-panel caption="Shadow Panel" description="With drop shadow effect" shadow>
207
+ <zn-panel caption="Shadow Panel" shadow>
208
208
  <div style="padding: 20px;">
209
209
  Content with enhanced shadow
210
210
  </div>
@@ -284,7 +284,6 @@ All panel features can be combined to create rich, complex layouts.
284
284
  ```html:preview
285
285
  <zn-panel
286
286
  caption="Advanced Panel"
287
- description="Combining multiple features"
288
287
  icon="dashboard"
289
288
  shadow>
290
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.153",
3
+ "version": "1.1.155",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -24,8 +24,10 @@ import ZincElement from '../../internal/zinc-element';
24
24
  import ZnCollapsible from '../collapsible';
25
25
  import ZnIcon from '../icon';
26
26
  import ZnInput from '../input';
27
+ import ZnOption from '../option';
27
28
  import ZnPagePaletteItem from './modules/page-palette-item';
28
29
  import ZnPageSectionCard from './modules/page-section-card';
30
+ import ZnSelect from '../select';
29
31
 
30
32
  import styles from './page-builder.scss';
31
33
 
@@ -96,8 +98,10 @@ export default class ZnPageBuilder extends ZincElement {
96
98
  'zn-collapsible': ZnCollapsible,
97
99
  'zn-icon': ZnIcon,
98
100
  'zn-input': ZnInput,
101
+ 'zn-option': ZnOption,
99
102
  'zn-page-palette-item': ZnPagePaletteItem,
100
103
  'zn-page-section-card': ZnPageSectionCard,
104
+ 'zn-select': ZnSelect,
101
105
  };
102
106
 
103
107
  private readonly formControlController = new FormControlController(this);
@@ -115,10 +119,12 @@ export default class ZnPageBuilder extends ZincElement {
115
119
  @property({ reflect: true }) subheading = '';
116
120
 
117
121
  /**
118
- * Section type key that must lead the page. The builder hoists an existing section of
119
- * that type to the top, or inserts an empty one, and pins it there: it can't be
120
- * removed, reordered or dragged into a slot, and nothing can be dropped above it.
121
- * Its content stays fully editable in the inspector.
122
+ * Section type keys that may lead the page, comma separated. The builder hoists an
123
+ * existing section of any of them to the top, or inserts an empty one of the first,
124
+ * and pins it there: it can't be removed, reordered or dragged into a slot, and
125
+ * nothing can be dropped above it. Its content stays fully editable in the
126
+ * inspector, which offers a swap between the allowed types when there is more
127
+ * than one.
122
128
  */
123
129
  @property({ attribute: 'required-first', reflect: true }) requiredFirst = '';
124
130
 
@@ -520,27 +526,45 @@ export default class ZnPageBuilder extends ZincElement {
520
526
  */
521
527
  private get _pinnedId(): string | null {
522
528
  const first = this._state.sections[0];
523
- return this.requiredFirst && first?.type === this.requiredFirst ? first.id : null;
529
+ const types = this._requiredFirstTypes;
530
+ return first && types.includes(first.type) ? first.id : null;
531
+ }
532
+
533
+ /** The types `required-first` allows to lead the page, in the order given. */
534
+ private get _requiredFirstTypes(): string[] {
535
+ return this.requiredFirst.split(',').map(t => t.trim()).filter(Boolean);
524
536
  }
525
537
 
526
538
  private _isPinned(id: string): boolean {
527
539
  return this._pinnedId === id;
528
540
  }
529
541
 
542
+ /**
543
+ * Whether the pinned section may be removed: only while another section can take
544
+ * the lead in its place, so the page is never left without one. It stays undraggable
545
+ * either way — the replacement arrives by removing this one, not by reordering.
546
+ */
547
+ private get _canUnpin(): boolean {
548
+ const types = this._requiredFirstTypes;
549
+ return this._state.sections.slice(1).some(s => types.includes(s.type));
550
+ }
551
+
530
552
  /** Lowest top-level index a section may be added or moved to. */
531
553
  private get _firstFreeIndex(): number {
532
554
  return this._pinnedId === null ? 0 : 1;
533
555
  }
534
556
 
535
557
  /**
536
- * Sections reordered so `required-first` leads the page: an existing section of that
537
- * type is hoisted to the front, otherwise an empty one is prepended. Returns the
558
+ * Sections reordered so `required-first` leads the page: an existing section of an
559
+ * allowed type is hoisted to the front, otherwise an empty one of the first type is
560
+ * prepended. Returns the
538
561
  * argument unchanged when there is nothing to do, so callers can compare by identity.
539
562
  */
540
563
  private _requireFirst(sections: PageSection[]): PageSection[] {
541
- if (!this.requiredFirst || sections[0]?.type === this.requiredFirst) return sections;
542
- const at = sections.findIndex(s => s.type === this.requiredFirst);
543
- if (at === -1) return [{ id: generateSectionId(), type: this.requiredFirst, data: {} }, ...sections];
564
+ const types = this._requiredFirstTypes;
565
+ if (!types.length || types.includes(sections[0]?.type)) return sections;
566
+ const at = sections.findIndex(s => types.includes(s.type));
567
+ if (at === -1) return [{ id: generateSectionId(), type: types[0], data: {} }, ...sections];
544
568
  const hoisted = [...sections];
545
569
  hoisted.unshift(...hoisted.splice(at, 1));
546
570
  return hoisted;
@@ -693,7 +717,7 @@ export default class ZnPageBuilder extends ZincElement {
693
717
  }
694
718
 
695
719
  private _removeSection(id: string) {
696
- if (this._isPinned(id)) return;
720
+ if (this._isPinned(id) && !this._canUnpin) return;
697
721
  const [removed, sections] = this._extract(id);
698
722
  if (!removed) return;
699
723
  this._pushHistory();
@@ -704,7 +728,8 @@ export default class ZnPageBuilder extends ZincElement {
704
728
  if (this._selectedId === id || removed.children?.some(c => c?.id === this._selectedId)) {
705
729
  this._select(null);
706
730
  }
707
- this._commit({ sections });
731
+ // Removing the leading section hands the lead to the next one that may hold it.
732
+ this._commit({ sections: this._requireFirst(sections) });
708
733
  }
709
734
 
710
735
  private _duplicateSection(id: string) {
@@ -1139,6 +1164,7 @@ export default class ZnPageBuilder extends ZincElement {
1139
1164
  ) {
1140
1165
  const type = this.registry.get(section.type);
1141
1166
  const pinned = this._isPinned(section.id);
1167
+ const locked = pinned && !this._canUnpin;
1142
1168
  return html`
1143
1169
  <zn-page-section-card
1144
1170
  class="${extraClass} ${this._draggingId === section.id ? 'dragging' : ''}"
@@ -1152,7 +1178,7 @@ export default class ZnPageBuilder extends ZincElement {
1152
1178
  color="${ifDefined(type?.color)}"
1153
1179
  ?selected="${this._selectedId === section.id}"
1154
1180
  ?unknown="${!type}"
1155
- ?locked="${pinned}"
1181
+ ?locked="${locked}"
1156
1182
  @click="${(e: Event) => {
1157
1183
  e.stopPropagation();
1158
1184
  this._select(section.id);
@@ -1340,6 +1366,21 @@ export default class ZnPageBuilder extends ZincElement {
1340
1366
  this._commit({ sections: this._patchSection(id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1341
1367
  }
1342
1368
 
1369
+ /**
1370
+ * Retypes the pinned section, keeping its id, name and data so the copy shared by
1371
+ * the allowed types survives; keys the new type has no field for stay unread. The
1372
+ * inspector form is stamped from the type's template, so it is rebuilt by hand —
1373
+ * selection has not changed.
1374
+ */
1375
+ private _swapPinnedType(type: string) {
1376
+ const id = this._pinnedId;
1377
+ const section = this._findSection(id);
1378
+ if (!id || !section || section.type === type || !this._requiredFirstTypes.includes(type)) return;
1379
+ this._pushHistory();
1380
+ this._commit({ sections: this._patchSection(id, s => ({ ...s, type })) });
1381
+ this._buildInspectorForm();
1382
+ }
1383
+
1343
1384
  private _renameSection(id: string, label: string) {
1344
1385
  this._pushHistory();
1345
1386
  this._commit({ sections: this._patchSection(id, s => ({ ...s, label: label || undefined })) });
@@ -1380,6 +1421,22 @@ export default class ZnPageBuilder extends ZincElement {
1380
1421
  this._inspectorWidth = this._clampInspector((this._inspectorWidth ?? this._inspectorRect()) + step);
1381
1422
  };
1382
1423
 
1424
+ /** Offered on the pinned section alone, and only where there is another type to take. */
1425
+ private _renderSwapControl(section: PageSection) {
1426
+ const types = this._requiredFirstTypes;
1427
+ if (types.length < 2 || !this._isPinned(section.id)) return '';
1428
+ return html`
1429
+ <zn-select
1430
+ class="inspector__swap"
1431
+ label="Section type"
1432
+ help-text="Swaps the section leading the page, keeping the settings both types share."
1433
+ .value="${section.type}"
1434
+ @zn-change="${(e: Event) => this._swapPinnedType(String((e.target as ZnSelect).value ?? ''))}">
1435
+ ${types.map(t => html`
1436
+ <zn-option value="${t}">${this.registry.get(t)?.label ?? t}</zn-option>`)}
1437
+ </zn-select>`;
1438
+ }
1439
+
1383
1440
  private _renderInspector() {
1384
1441
  const section = this._selectedSection();
1385
1442
  if (!section) return html``;
@@ -1430,6 +1487,7 @@ export default class ZnPageBuilder extends ZincElement {
1430
1487
  label="Section name"
1431
1488
  .value="${section.label ?? type?.label ?? ''}"
1432
1489
  @zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
1490
+ ${this._renderSwapControl(section)}
1433
1491
  ${type?.slotsMax === undefined ? '' : html`
1434
1492
  <zn-input
1435
1493
  class="inspector__slots"
@@ -988,6 +988,112 @@ describe('<zn-page-builder>', () => {
988
988
  expect(el.state.sections[1].children?.[0] ?? null, 'slot left empty').to.be.null;
989
989
  });
990
990
 
991
+ it('should accept any of several required-first types', async () => {
992
+ const el = await fixture<ZnPageBuilder>(html`
993
+ <zn-page-builder required-first="hero,hero-split"
994
+ config='{"sections":[{"id":"t","type":"rich-text","data":{}},{"id":"h","type":"hero-split","data":{"title":"Help"}}]}'>
995
+ <template type="hero" slot="config" label="Hero"></template>
996
+ <template type="hero-split" slot="config" label="Split Hero"></template>
997
+ <template type="rich-text" slot="config" label="Rich Text"></template>
998
+ </zn-page-builder>`);
999
+ await el.updateComplete;
1000
+
1001
+ expect(el.state.sections.map(s => s.id), 'the alternate type is hoisted too').to.deep.equal(['h', 't']);
1002
+ expect(el.shadowRoot!.querySelector('zn-page-section-card')!.hasAttribute('locked')).to.be.true;
1003
+
1004
+ const empty = await fixture<ZnPageBuilder>(html`
1005
+ <zn-page-builder required-first="hero,hero-split">
1006
+ <template type="hero" slot="config" label="Hero"></template>
1007
+ <template type="hero-split" slot="config" label="Split Hero"></template>
1008
+ </zn-page-builder>`);
1009
+ await empty.updateComplete;
1010
+ expect(empty.state.sections.map(s => s.type), 'an empty page gets the first type').to.deep.equal(['hero']);
1011
+ });
1012
+
1013
+ it('should swap the pinned section for another allowed type, keeping its content', async () => {
1014
+ const el = await fixture<ZnPageBuilder>(html`
1015
+ <zn-page-builder required-first="hero,hero-split"
1016
+ config='{"sections":[{"id":"h","type":"hero","label":"Top","data":{"title":"Help","search":true}},{"id":"t","type":"rich-text","data":{}}]}'>
1017
+ <template type="hero" slot="config" label="Hero"></template>
1018
+ <template type="hero-split" slot="config" label="Split Hero">
1019
+ <zn-input name="title"></zn-input>
1020
+ </template>
1021
+ <template type="rich-text" slot="config" label="Rich Text"></template>
1022
+ </zn-page-builder>`);
1023
+ await el.updateComplete;
1024
+
1025
+ el.shadowRoot!.querySelector('zn-page-section-card')!.dispatchEvent(new Event('click'));
1026
+ await el.updateComplete;
1027
+
1028
+ const swap = el.shadowRoot!.querySelector<HTMLElement & { value: string }>('.inspector__swap')!;
1029
+ expect(swap, 'swap control on the pinned section').to.exist;
1030
+ expect([...swap.querySelectorAll('zn-option')].map(o => o.getAttribute('value')))
1031
+ .to.deep.equal(['hero', 'hero-split']);
1032
+
1033
+ swap.value = 'hero-split';
1034
+ swap.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
1035
+ await el.updateComplete;
1036
+
1037
+ const [pinned, rest] = el.state.sections;
1038
+ expect(pinned.type, 'type swapped').to.equal('hero-split');
1039
+ expect(pinned.id, 'same section').to.equal('h');
1040
+ expect(pinned.label, 'keeps its name').to.equal('Top');
1041
+ expect(pinned.data, 'keeps its content').to.deep.equal({title: 'Help', search: true});
1042
+ expect(rest.id, 'the rest of the page is untouched').to.equal('t');
1043
+ expect(el.shadowRoot!.querySelector('zn-page-section-card')!.hasAttribute('locked'), 'still pinned').to.be.true;
1044
+
1045
+ // The inspector now stamps the new type's fields, filled from the kept data.
1046
+ const title = el.shadowRoot!.querySelector<HTMLInputElement>('.inspector__form [name="title"]');
1047
+ expect(title?.value, 'inspector rebuilt for the new type').to.equal('Help');
1048
+
1049
+ el.undo();
1050
+ await el.updateComplete;
1051
+ expect(el.state.sections[0].type, 'undo puts the original type back').to.equal('hero');
1052
+ });
1053
+
1054
+ it('should let the pinned section go once another may lead in its place', async () => {
1055
+ const el = await fixture<ZnPageBuilder>(html`
1056
+ <zn-page-builder required-first="hero,hero-split"
1057
+ config='{"sections":[{"id":"h","type":"hero","data":{}},{"id":"t","type":"rich-text","data":{}},{"id":"s","type":"hero-split","data":{}}]}'>
1058
+ <template type="hero" slot="config" label="Hero"></template>
1059
+ <template type="hero-split" slot="config" label="Split Hero"></template>
1060
+ <template type="rich-text" slot="config" label="Rich Text"></template>
1061
+ </zn-page-builder>`);
1062
+ await el.updateComplete;
1063
+
1064
+ const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!;
1065
+ expect(pinnedCard.hasAttribute('locked'), 'a replaceable hero shows no lock').to.be.false;
1066
+ expect(pinnedCard.getAttribute('draggable'), 'but still cannot be dragged').to.equal('false');
1067
+ expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'remove offered').to.exist;
1068
+
1069
+ pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
1070
+ await el.updateComplete;
1071
+
1072
+ expect(el.state.sections.map(s => s.id), 'the other hero takes the lead').to.deep.equal(['s', 't']);
1073
+ const nowPinned = el.shadowRoot!.querySelector('zn-page-section-card')!;
1074
+ expect(nowPinned.hasAttribute('locked'), 'the last hero locks again').to.be.true;
1075
+
1076
+ nowPinned.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
1077
+ await el.updateComplete;
1078
+ expect(el.state.sections.map(s => s.id), 'the last hero cannot go').to.deep.equal(['s', 't']);
1079
+
1080
+ el.undo();
1081
+ await el.updateComplete;
1082
+ expect(el.state.sections.map(s => s.id), 'undo brings the removed hero back').to.deep.equal(['h', 't', 's']);
1083
+ });
1084
+
1085
+ it('should offer no swap control when only one type may lead', async () => {
1086
+ const el = await fixture<ZnPageBuilder>(html`
1087
+ <zn-page-builder required-first="hero" config='{"sections":[{"id":"h","type":"hero","data":{}}]}'>
1088
+ <template type="hero" slot="config" label="Hero"></template>
1089
+ </zn-page-builder>`);
1090
+ await el.updateComplete;
1091
+
1092
+ el.shadowRoot!.querySelector('zn-page-section-card')!.dispatchEvent(new Event('click'));
1093
+ await el.updateComplete;
1094
+ expect(el.shadowRoot!.querySelector('.inspector__swap')).to.not.exist;
1095
+ });
1096
+
991
1097
  it('should leave the page alone without required-first', async () => {
992
1098
  const el = await fixture<ZnPageBuilder>(html`
993
1099
  <zn-page-builder config='{"sections":[{"id":"t","type":"rich-text","data":{}}]}'>