@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/dist/custom-elements.json +41 -3
- package/dist/vscode.html-custom-data.json +1 -1
- package/dist/web-types.json +3 -3
- package/dist/zn.d.ts +40 -6
- package/dist/zn.min.js +256 -246
- package/package.json +1 -1
- package/src/components/collapsible/collapsible.scss +3 -0
- package/src/components/expanding-action/expanding-action.component.ts +4 -2
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +4 -1
- package/src/components/page-builder/page-builder.component.ts +62 -13
- package/src/components/page-builder/page-builder.scss +1 -1
- package/src/components/page-builder/page-builder.test.ts +90 -0
- package/src/components/page-builder/page.types.ts +40 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
71
|
-
*
|
|
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
|
-
|
|
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) >
|
|
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,
|
|
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 ${
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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. */
|