@kubex/zinc 1.1.125 → 1.1.126

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.125",
3
+ "version": "1.1.126",
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",
@@ -80,6 +80,9 @@
80
80
  // constant so the content never reflows (and re-wraps) mid-transition.
81
81
  .content {
82
82
  display: grid;
83
+ // minmax(0, 1fr), not the implicit auto track: auto floors the column at the
84
+ // content's min-content width, so nowrap children overflow the host.
85
+ grid-template-columns: minmax(0, 1fr);
83
86
  grid-template-rows: 0fr;
84
87
  opacity: 0;
85
88
  transition: grid-template-rows var(--zn-transition-medium) cubic-bezier(0.32, 0.72, 0, 1),
@@ -309,11 +309,13 @@ export default class ZnExpandingAction extends ZincElement {
309
309
  return html`
310
310
  <zn-dropdown class="expanding-action__dropdown"
311
311
  placement="bottom-end"
312
- @zn-show="${() => {
312
+ @zn-show="${(e: Event) => {
313
+ if (e.target !== e.currentTarget) return;
313
314
  this.open = true;
314
315
  this._observePlacement();
315
316
  }}"
316
- @zn-hide="${() => {
317
+ @zn-hide="${(e: Event) => {
318
+ if (e.target !== e.currentTarget) return;
317
319
  this.open = false;
318
320
  }}">
319
321
  <zn-button slot="trigger"
@@ -62,9 +62,12 @@
62
62
  }
63
63
 
64
64
  .item__description {
65
- white-space: nowrap;
65
+ display: -webkit-box;
66
+ -webkit-box-orient: vertical;
67
+ -webkit-line-clamp: 2;
66
68
  overflow: hidden;
67
69
  text-overflow: ellipsis;
68
70
  font-size: 0.75rem;
71
+ line-height: 1.3;
69
72
  color: rgb(var(--zn-color-muted-text));
70
73
  }
@@ -2,6 +2,7 @@ import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
2
2
  import {
3
3
  emptyPageState,
4
4
  generateSectionId,
5
+ MAX_SLOTS,
5
6
  PAGE_SECTION_MIME,
6
7
  PAGE_TYPE_MIME,
7
8
  type PageSection,
@@ -9,6 +10,8 @@ import {
9
10
  type PageState,
10
11
  sectionChildren,
11
12
  sectionSummary,
13
+ slotColumns,
14
+ slotCount,
12
15
  } from './page.types';
13
16
  import { FormControlController, validValidityState } from '../../internal/form';
14
17
  import { HasSlotController } from '../../internal/slot';
@@ -28,8 +31,6 @@ import styles from './page-builder.scss';
28
31
  const HISTORY_LIMIT = 50;
29
32
  /** Sections beyond this are dropped (with a warning) when external state is applied. */
30
33
  const MAX_SECTIONS = 500;
31
- /** Per-container children beyond this are dropped when external state is applied. */
32
- const MAX_CHILDREN = 24;
33
34
  /** Builder width below which the palette auto-collapses — keep in sync with the @container query in page-builder.scss. */
34
35
  const NARROW_WIDTH = 768;
35
36
 
@@ -67,8 +68,9 @@ function timeAgo(ms: number): string {
67
68
  * @slot config - `<template type="…">` declarations; never displayed. Each template's attributes
68
69
  * (type, label, icon, icon-library, color, category, description, slots, accepts) declare a
69
70
  * palette entry and its content declares the inspector form for that type. `slots` makes the
70
- * section a container with that many child slots; `accepts` is a comma-separated list of the
71
- * type keys its slots allow.
71
+ * section a container that many slots wide — rows are added as they fill — and `slots-min`/
72
+ * `slots-max` let each placed section choose its own width; `accepts` is a comma-separated
73
+ * list of the type keys its slots allow.
72
74
  * @slot header-left - Actions shown on the left of the header bar.
73
75
  * @slot header-right - Actions shown on the right of the header bar.
74
76
  *
@@ -407,7 +409,6 @@ export default class ZnPageBuilder extends ZincElement {
407
409
  private _typeFromTemplate(el: HTMLTemplateElement): PageSectionType | null {
408
410
  const type = el.getAttribute('type');
409
411
  if (!type) return null;
410
- const slots = parseInt(el.getAttribute('slots') ?? '', 10);
411
412
  return {
412
413
  type,
413
414
  label: el.getAttribute('label') ?? type,
@@ -417,11 +418,29 @@ export default class ZnPageBuilder extends ZincElement {
417
418
  category: el.getAttribute('category') ?? undefined,
418
419
  description: el.getAttribute('description') ?? undefined,
419
420
  configTemplate: el,
420
- slots: slots > 0 ? slots : undefined,
421
+ ...this._slotsFromAttributes(el),
421
422
  accepts: el.getAttribute('accepts')?.split(',').map(s => s.trim()).filter(Boolean),
422
423
  };
423
424
  }
424
425
 
426
+ /**
427
+ * `slots="N"` is a container N slots wide; adding `slots-max` lets each placed
428
+ * section choose its own width between `slots-min` (1 by default) and that. A
429
+ * width past the slot cap would leave a container with no usable row, so an
430
+ * out-of-bounds bound is ignored rather than honoured.
431
+ */
432
+ private _slotsFromAttributes(el: HTMLTemplateElement): Pick<PageSectionType, 'slots' | 'slotsMin' | 'slotsMax'> {
433
+ const bound = (name: string) => {
434
+ const value = parseInt(el.getAttribute(name) ?? '', 10);
435
+ return value > 0 && value <= MAX_SLOTS ? value : undefined;
436
+ };
437
+ const slots = bound('slots');
438
+ if (!slots) return {};
439
+ const max = bound('slots-max');
440
+ if (max === undefined || max < slots) return { slots };
441
+ return { slots, slotsMin: Math.min(bound('slots-min') ?? 1, slots), slotsMax: max };
442
+ }
443
+
425
444
  private _registerSlottedTemplates = () => {
426
445
  let added = false;
427
446
  this.querySelectorAll<HTMLTemplateElement>('template[slot="config"][type]').forEach(el => {
@@ -446,14 +465,19 @@ export default class ZnPageBuilder extends ZincElement {
446
465
  const normalise = (s: PageSection, depth: number): PageSection => {
447
466
  const id = !s.id || seen.has(s.id) ? generateSectionId() : s.id;
448
467
  seen.add(id);
449
- if (depth === 0 && (s.children?.length ?? 0) > MAX_CHILDREN) clippedChildren = true;
468
+ if (depth === 0 && (s.children?.length ?? 0) > MAX_SLOTS) clippedChildren = true;
469
+ const type = this.registry.get(s.type);
470
+ // A hand-edited config can name any width; clamp it to the declared bounds so
471
+ // the canvas lays the children out the same way the site will.
472
+ const columns = type?.slotsMax === undefined ? undefined : slotColumns(s, type);
450
473
  return {
451
474
  id,
452
475
  type: s.type,
453
476
  label: s.label,
477
+ ...(columns === undefined ? {} : { columns }),
454
478
  data: structuredClone(s.data ?? {}),
455
479
  ...(s.children && depth === 0
456
- ? { children: s.children.slice(0, MAX_CHILDREN).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null)) }
480
+ ? { children: s.children.slice(0, MAX_SLOTS).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null)) }
457
481
  : {}),
458
482
  };
459
483
  };
@@ -463,7 +487,7 @@ export default class ZnPageBuilder extends ZincElement {
463
487
  }
464
488
  const sections = incoming.slice(0, MAX_SECTIONS).map(s => normalise(s, 0));
465
489
  if (clippedChildren) {
466
- console.warn(`<zn-page-builder> some sections had more than ${MAX_CHILDREN} children; extras were dropped`);
490
+ console.warn(`<zn-page-builder> some sections had more than ${MAX_SLOTS} children; extras were dropped`);
467
491
  }
468
492
  this._history = [];
469
493
  this._redoStack = [];
@@ -615,7 +639,7 @@ export default class ZnPageBuilder extends ZincElement {
615
639
  const container = this._findSection(containerId);
616
640
  const containerType = container ? this.registry.get(container.type) : undefined;
617
641
  if (!sectionType || sectionType.slots || !container || !containerType?.slots) return null;
618
- if (slotIndex < 0 || slotIndex >= containerType.slots) return null;
642
+ if (slotIndex < 0 || slotIndex >= slotCount(container, containerType)) return null;
619
643
  if (containerType.accepts && !containerType.accepts.includes(type)) return null;
620
644
  const children = sectionChildren(container, containerType);
621
645
  if (children[slotIndex]) return null;
@@ -627,6 +651,21 @@ export default class ZnPageBuilder extends ZincElement {
627
651
  return section;
628
652
  }
629
653
 
654
+ /**
655
+ * Sets how many slots per row a container whose type allows a choice lays out,
656
+ * clamped to the declared bounds. Children keep their order and reflow into the
657
+ * new width, so nothing is lost by narrowing one.
658
+ */
659
+ setSectionColumns(id: string, columns: number) {
660
+ const section = this._findSection(id);
661
+ const type = section ? this.registry.get(section.type) : undefined;
662
+ if (!section || type?.slotsMax === undefined) return;
663
+ const next = slotColumns({ ...section, columns }, type);
664
+ if (next === slotColumns(section, type)) return;
665
+ this._pushHistory();
666
+ this._commit({ sections: this._patchSection(id, s => ({ ...s, columns: next })) });
667
+ }
668
+
630
669
  private _removeSection(id: string) {
631
670
  if (this._isPinned(id)) return;
632
671
  const [removed, sections] = this._extract(id);
@@ -706,7 +745,7 @@ export default class ZnPageBuilder extends ZincElement {
706
745
  if (this._isPinned(id)) return; // the pinned section stays at the top of the page
707
746
  if (this.registry.get(moved.type)?.slots) return; // no containers inside slots
708
747
  if (containerType.accepts && !containerType.accepts.includes(moved.type)) return;
709
- if (slotIndex < 0 || slotIndex >= containerType.slots) return;
748
+ if (slotIndex < 0 || slotIndex >= slotCount(container, containerType)) return;
710
749
 
711
750
  const target = container.children?.[slotIndex] ?? null;
712
751
  if (target?.id === id) return;
@@ -716,7 +755,7 @@ export default class ZnPageBuilder extends ZincElement {
716
755
  this._pushHistory();
717
756
  const sections = structuredClone(this._state.sections);
718
757
  const containerRef = sections.find(s => s.id === containerId)!;
719
- containerRef.children = Array.from({ length: containerType.slots }, (_, i) => containerRef.children?.[i] ?? null);
758
+ containerRef.children = sectionChildren(containerRef, containerType);
720
759
  const movedCopy = structuredClone(moved);
721
760
 
722
761
  if (fromTop) {
@@ -973,7 +1012,7 @@ export default class ZnPageBuilder extends ZincElement {
973
1012
  return html`
974
1013
  <div class="container">
975
1014
  ${card}
976
- <div class="slots">
1015
+ <div class="slots" style="--pb-slot-columns:${slotColumns(section, type)}">
977
1016
  ${sectionChildren(section, type).map((child, i) => this._renderSlot(section, child, i))}
978
1017
  </div>
979
1018
  </div>`;
@@ -1183,6 +1222,16 @@ export default class ZnPageBuilder extends ZincElement {
1183
1222
  label="Section name"
1184
1223
  .value="${section.label ?? type?.label ?? ''}"
1185
1224
  @zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
1225
+ ${type?.slotsMax === undefined ? '' : html`
1226
+ <zn-input
1227
+ class="inspector__slots"
1228
+ type="number"
1229
+ label="Slots per row"
1230
+ min="${type.slotsMin ?? 1}"
1231
+ max="${type.slotsMax}"
1232
+ help-text="A new row is added as the last one fills."
1233
+ .value="${String(slotColumns(section, type))}"
1234
+ @zn-change="${(e: Event) => this.setSectionColumns(section.id, Number((e.target as ZnInput).value))}"></zn-input>`}
1186
1235
  ${type?.renderConfig
1187
1236
  ? type.renderConfig(section, data => this._updateSectionData(section.id, data))
1188
1237
  : this._form}
@@ -510,7 +510,7 @@ zn-page-palette-item {
510
510
  display: grid;
511
511
  // minmax(0) lets cells shrink below their content's min width — otherwise a
512
512
  // child card's text forces the tracks wider than the parent tile.
513
- grid-template-columns: repeat(3, minmax(0, 1fr));
513
+ grid-template-columns: repeat(var(--pb-slot-columns, 3), minmax(0, 1fr));
514
514
  gap: 8px;
515
515
  }
516
516
 
@@ -416,6 +416,96 @@ describe('<zn-page-builder>', () => {
416
416
  expect(el.addSectionToSlot('article-tile', 'grid', 1), 'occupied slot').to.be.null;
417
417
  });
418
418
 
419
+ it('should grow a container by a row as its last row fills', async () => {
420
+ const el = await fixture<ZnPageBuilder>(html`
421
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
422
+ <template type="article-grid" slot="config" label="Article Grid" slots="3"></template>
423
+ <template type="article-tile" slot="config" label="Article"></template>
424
+ </zn-page-builder>`);
425
+ await el.updateComplete;
426
+
427
+ const slots = () => el.shadowRoot?.querySelectorAll('.slot, .slot__card').length;
428
+ expect(slots(), 'one empty row to start').to.equal(3);
429
+
430
+ el.addSectionToSlot('article-tile', 'grid', 0);
431
+ el.addSectionToSlot('article-tile', 'grid', 1);
432
+ await el.updateComplete;
433
+ expect(slots(), 'a part-filled row grows nothing').to.equal(3);
434
+
435
+ el.addSectionToSlot('article-tile', 'grid', 2);
436
+ await el.updateComplete;
437
+ expect(slots(), 'the full row adds the next one').to.equal(6);
438
+
439
+ el.addSectionToSlot('article-tile', 'grid', 3);
440
+ await el.updateComplete;
441
+ expect(slots(), 'and stays at two rows until that one fills').to.equal(6);
442
+ });
443
+
444
+ it('should lay a container out at its own width and reflow children when it changes', async () => {
445
+ const el = await fixture<ZnPageBuilder>(html`
446
+ <zn-page-builder config='{"sections":[{"id":"row","type":"category-row","data":{}}]}'>
447
+ <template type="category-row" slot="config" label="Category Row" slots="6" slots-max="6"></template>
448
+ <template type="category-tile" slot="config" label="Category"></template>
449
+ </zn-page-builder>`);
450
+ await el.updateComplete;
451
+
452
+ const grid = () => el.shadowRoot?.querySelector<HTMLElement>('.slots');
453
+ expect(grid()?.style.getPropertyValue('--pb-slot-columns'), 'declared width').to.equal('6');
454
+ expect(el.shadowRoot?.querySelectorAll('.slot--empty'), 'one row of six').to.have.length(6);
455
+
456
+ el.shadowRoot?.querySelector('.canvas zn-page-section-card')?.dispatchEvent(new Event('click'));
457
+ await el.updateComplete;
458
+
459
+ const width = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__slots');
460
+ expect(width, 'slots-per-row control').to.exist;
461
+ expect(width!.getAttribute('min')).to.equal('1');
462
+ expect(width!.getAttribute('max')).to.equal('6');
463
+ expect(width!.value).to.equal('6');
464
+
465
+ const kept = el.addSectionToSlot('category-tile', 'row', 4)!;
466
+ await el.updateComplete;
467
+
468
+ width!.value = '3';
469
+ width!.dispatchEvent(new Event('zn-change', {bubbles: true}));
470
+ await el.updateComplete;
471
+
472
+ expect(el.state.sections[0].columns).to.equal(3);
473
+ expect(grid()?.style.getPropertyValue('--pb-slot-columns')).to.equal('3');
474
+ expect(el.state.sections[0].children?.[4]?.id, 'narrowing reflows rather than drops').to.equal(kept.id);
475
+ expect(el.shadowRoot?.querySelectorAll('.slot, .slot__card'), 'six slots hold it as two rows of three')
476
+ .to.have.length(6);
477
+ });
478
+
479
+ it('should clamp a container width to the declared bounds', async () => {
480
+ const el = await fixture<ZnPageBuilder>(html`
481
+ <zn-page-builder config='{"sections":[{"id":"row","type":"category-row","data":{},"columns":99}]}'>
482
+ <template type="category-row" slot="config" label="Category Row" slots="3" slots-max="6"></template>
483
+ <template type="category-tile" slot="config" label="Category"></template>
484
+ </zn-page-builder>`);
485
+ await el.updateComplete;
486
+
487
+ expect(el.state.sections[0].columns, 'clamped to the max').to.equal(6);
488
+
489
+ el.setSectionColumns('row', 0);
490
+ await el.updateComplete;
491
+ expect(el.state.sections[0].columns, 'clamped to the min').to.equal(1);
492
+ });
493
+
494
+ it('should offer no width control for a fixed-width container', async () => {
495
+ const el = await fixture<ZnPageBuilder>(html`
496
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"columns":6}]}'>
497
+ <template type="article-grid" slot="config" label="Article Grid" slots="4"></template>
498
+ <template type="article-tile" slot="config" label="Article"></template>
499
+ </zn-page-builder>`);
500
+ await el.updateComplete;
501
+
502
+ expect(el.shadowRoot?.querySelectorAll('.slot--empty'), 'per-instance width ignored').to.have.length(4);
503
+
504
+ el.shadowRoot?.querySelector('.canvas zn-page-section-card')?.dispatchEvent(new Event('click'));
505
+ await el.updateComplete;
506
+ expect(el.shadowRoot?.querySelector('.inspector__slots')).to.not.exist;
507
+ });
508
+
419
509
  it('should swap children when dropping one filled slot onto another', async () => {
420
510
  const el = await fixture<ZnPageBuilder>(html`
421
511
  <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
@@ -8,7 +8,9 @@ export interface PageSection {
8
8
  label?: string;
9
9
  /** Section content, keyed by field name (the inspector's `name` attributes). */
10
10
  data: Record<string, unknown>;
11
- /** Slot contents for container sections, sized to the type's `slots`. Empty slots are null. */
11
+ /** Slots per row for a container whose type allows a choice; the type's default applies otherwise. */
12
+ columns?: number;
13
+ /** Slot contents for container sections, sized to {@link slotCount}. Empty slots are null. */
12
14
  children?: (PageSection | null)[];
13
15
  }
14
16
 
@@ -39,17 +41,50 @@ export interface PageSectionType {
39
41
  /** Programmatic inspector body — takes precedence over `configTemplate`. */
40
42
  renderConfig?: (section: PageSection, update: (data: Record<string, unknown>) => void) => TemplateResult;
41
43
  /**
42
- * Number of child slots this section offers on the canvas (a container tile);
43
- * rendered as a 3-column grid. Containers cannot be placed inside other containers.
44
+ * Slots per row on the canvas, and what makes a section a container. Rows are
45
+ * added as they fill, so this is a width and not a capacity. Containers cannot
46
+ * be placed inside other containers.
44
47
  */
45
48
  slots?: number;
49
+ /** Lower bound of a per-section column count. Defaults to 1 when `slotsMax` is set. */
50
+ slotsMin?: number;
51
+ /** Upper bound of a per-section column count. Its presence is what makes the width editable. */
52
+ slotsMax?: number;
46
53
  /** Section type keys allowed in this container's slots. Omit to allow any non-container type. */
47
54
  accepts?: string[];
48
55
  }
49
56
 
50
- /** A container section's slot contents, padded/truncated to the type's slot count. */
57
+ /** Per-container children beyond this are dropped when external state is applied. */
58
+ export const MAX_SLOTS = 24;
59
+
60
+ /**
61
+ * Slots per row for a placed container: its own choice when the type allows one,
62
+ * clamped to the declared bounds, else the type's fixed width.
63
+ */
64
+ export function slotColumns(section: PageSection, type: PageSectionType): number {
65
+ const columns = type.slots ?? 0;
66
+ if (type.slotsMax === undefined) return columns;
67
+ return Math.min(Math.max(section.columns ?? columns, type.slotsMin ?? 1), type.slotsMax);
68
+ }
69
+
70
+ /**
71
+ * How many slots a placed container shows: enough rows to hold every child it
72
+ * already has, plus a fresh row once the last one fills. Empty rows are never
73
+ * offered ahead of being needed, and the total stays within {@link MAX_SLOTS}.
74
+ */
75
+ export function slotCount(section: PageSection, type: PageSectionType): number {
76
+ const columns = slotColumns(section, type);
77
+ if (columns < 1) return 0;
78
+ const placed = section.children?.reduce((last, c, i) => (c ? i + 1 : last), 0) ?? 0;
79
+ const rows = Math.ceil(placed / columns) || 1;
80
+ const full = section.children?.slice((rows - 1) * columns, rows * columns)
81
+ .filter(Boolean).length === columns;
82
+ return Math.min(columns * (full ? rows + 1 : rows), Math.floor(MAX_SLOTS / columns) * columns);
83
+ }
84
+
85
+ /** A container section's slot contents, padded/truncated to its slot count. */
51
86
  export function sectionChildren(section: PageSection, type: PageSectionType): (PageSection | null)[] {
52
- return Array.from({length: type.slots ?? 0}, (_, i) => section.children?.[i] ?? null);
87
+ return Array.from({length: slotCount(section, type)}, (_, i) => section.children?.[i] ?? null);
53
88
  }
54
89
 
55
90
  /** Drag-and-drop MIME carrying a section type id from the palette to the canvas. */