@kubex/zinc 1.1.92 → 1.1.95
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 +2880 -453
- package/dist/vscode.html-custom-data.json +209 -22
- package/dist/web-types.json +452 -39
- package/dist/zn.d.ts +633 -61
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +865 -560
- package/docs/pages/components/page-builder.md +143 -19
- package/docs/pages/components/schedule-builder.md +345 -0
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/alert/alert.scss +9 -13
- package/src/components/button/button.scss +5 -2
- package/src/components/chip/chip.scss +1 -1
- package/src/components/icon-picker/icon-picker.component.ts +1 -1
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/linked-select/linked-select.component.ts +22 -5
- package/src/components/page/page.scss +20 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +535 -232
- package/src/components/page-builder/page-builder.scss +230 -19
- package/src/components/page-builder/page-builder.test.ts +790 -110
- package/src/components/page-builder/page-tree.test.ts +483 -0
- package/src/components/page-builder/page-tree.ts +329 -0
- package/src/components/page-builder/page.types.ts +98 -10
- package/src/components/page-nav/page-nav.scss +9 -1
- package/src/components/panel/panel.component.ts +5 -1
- package/src/components/priority-list/priority-list.component.ts +1 -0
- package/src/components/priority-list/priority-list.scss +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
- package/src/components/schedule-builder/index.ts +12 -0
- package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
- package/src/components/schedule-builder/schedule-builder.scss +448 -0
- package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- package/src/components/toggle/toggle.component.ts +2 -1
- package/src/components/translations/translations.component.ts +5 -1
- package/src/zinc.ts +1 -0
- package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
- package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_WIDTHS,
|
|
3
|
+
defaultLayout,
|
|
4
|
+
generateSectionId,
|
|
5
|
+
isContainer,
|
|
6
|
+
MAX_COLUMNS,
|
|
7
|
+
MAX_CONTAINER_LEVELS,
|
|
8
|
+
MAX_SECTIONS,
|
|
9
|
+
type PageContainerLayout,
|
|
10
|
+
type PageSection,
|
|
11
|
+
type PageSectionType,
|
|
12
|
+
sanitiseWidths,
|
|
13
|
+
} from './page.types';
|
|
14
|
+
|
|
15
|
+
/** Clamps a requested column count into the supported range. */
|
|
16
|
+
function columnCount(columns: number): number {
|
|
17
|
+
return Math.min(Math.max(Math.floor(columns) || 1, 1), MAX_COLUMNS);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Drops trailing cells that hold nothing. Interior empties are meaningful and kept. */
|
|
21
|
+
export function trimTrailingEmptyCells(cells: PageSection[][]): PageSection[][] {
|
|
22
|
+
let end = cells.length;
|
|
23
|
+
while (end > 0 && cells[end - 1].length === 0) end--;
|
|
24
|
+
return cells.slice(0, end);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pads to a whole number of rows, always leaving at least one row to drop into. */
|
|
28
|
+
export function padCells(cells: PageSection[][], columns: number): PageSection[][] {
|
|
29
|
+
const cols = columnCount(columns);
|
|
30
|
+
const remainder = cells.length % cols;
|
|
31
|
+
const pad = cells.length === 0 ? cols : remainder === 0 ? 0 : cols - remainder;
|
|
32
|
+
return [...cells, ...Array.from({length: pad}, () => [] as PageSection[])];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The canonical cell list for a container. A growable container never keeps a
|
|
37
|
+
* trailing all-empty row (the renderer supplies the `+` row itself, so keeping
|
|
38
|
+
* one would show two); a fixed container's trailing empty row is its layout and
|
|
39
|
+
* survives untouched.
|
|
40
|
+
*/
|
|
41
|
+
export function normaliseCells(cells: PageSection[][], columns: number, grow: boolean): PageSection[][] {
|
|
42
|
+
return padCells(grow ? trimTrailingEmptyCells(cells) : cells, columns);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Re-chunks the same ordered list of stacks into a different column count. Only
|
|
47
|
+
* trailing empties are dropped, so no section can be lost by a column change.
|
|
48
|
+
*/
|
|
49
|
+
export function recolumnCells(cells: PageSection[][], columns: number): PageSection[][] {
|
|
50
|
+
return padCells(trimTrailingEmptyCells(cells), columns);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Row-major view of the flat cell list, for rendering. */
|
|
54
|
+
export function cellRows(cells: PageSection[][], columns: number): PageSection[][][] {
|
|
55
|
+
const cols = columnCount(columns);
|
|
56
|
+
const rows: PageSection[][][] = [];
|
|
57
|
+
for (let i = 0; i < cells.length; i += cols) rows.push(cells.slice(i, i + cols));
|
|
58
|
+
return rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function containerWidths(section: PageSection, type?: PageSectionType): number[] {
|
|
62
|
+
return sanitiseWidths(section.layout?.widths ?? type?.defaultWidths ?? [...DEFAULT_WIDTHS]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function containerColumns(section: PageSection, type?: PageSectionType): number {
|
|
66
|
+
return containerWidths(section, type).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function containerGrow(section: PageSection, type?: PageSectionType): boolean {
|
|
70
|
+
return section.layout?.grow ?? Boolean(type?.defaultGrow);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A container's cells, normalised. Empty for a non-container. */
|
|
74
|
+
export function containerCells(section: PageSection, type?: PageSectionType): PageSection[][] {
|
|
75
|
+
if (!isContainer(type)) return [];
|
|
76
|
+
return normaliseCells(section.cells ?? [], containerColumns(section, type), containerGrow(section, type));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Depth-first search across top-level sections and every cell stack. */
|
|
80
|
+
export function findSection(sections: PageSection[], id: string | null): PageSection | undefined {
|
|
81
|
+
if (!id) return undefined;
|
|
82
|
+
for (const section of sections) {
|
|
83
|
+
if (section.id === id) return section;
|
|
84
|
+
for (const cell of section.cells ?? []) {
|
|
85
|
+
const hit = findSection(cell, id);
|
|
86
|
+
if (hit) return hit;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** New section array with `id` replaced by `patch(section)`, wherever it lives. */
|
|
93
|
+
export function patchSection(
|
|
94
|
+
sections: PageSection[],
|
|
95
|
+
id: string,
|
|
96
|
+
patch: (section: PageSection) => PageSection
|
|
97
|
+
): PageSection[] {
|
|
98
|
+
return sections.map(section => {
|
|
99
|
+
if (section.id === id) return patch(section);
|
|
100
|
+
if (!section.cells) return section;
|
|
101
|
+
return {...section, cells: section.cells.map(cell => patchSection(cell, id, patch))};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Detaches a section from wherever it lives, returning it and the remaining tree. */
|
|
106
|
+
export function extractSection(
|
|
107
|
+
sections: PageSection[],
|
|
108
|
+
id: string
|
|
109
|
+
): [PageSection | undefined, PageSection[]] {
|
|
110
|
+
let removed: PageSection | undefined;
|
|
111
|
+
const out: PageSection[] = [];
|
|
112
|
+
for (const section of sections) {
|
|
113
|
+
if (section.id === id) {
|
|
114
|
+
removed = section;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (section.cells) {
|
|
118
|
+
const cells = section.cells.map(cell => {
|
|
119
|
+
const [hit, rest] = extractSection(cell, id);
|
|
120
|
+
if (hit) removed = hit;
|
|
121
|
+
return rest;
|
|
122
|
+
});
|
|
123
|
+
out.push({...section, cells});
|
|
124
|
+
} else {
|
|
125
|
+
out.push(section);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [removed, out];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Inserts a section into a container's cell at a stack position. */
|
|
132
|
+
export function insertIntoCell(
|
|
133
|
+
sections: PageSection[],
|
|
134
|
+
containerId: string,
|
|
135
|
+
cellIndex: number,
|
|
136
|
+
insertIndex: number,
|
|
137
|
+
section: PageSection,
|
|
138
|
+
columns: number
|
|
139
|
+
): PageSection[] {
|
|
140
|
+
return patchSection(sections, containerId, container => {
|
|
141
|
+
const cells = (container.cells ?? []).map(cell => [...cell]);
|
|
142
|
+
const target = Number.isFinite(cellIndex) ? Math.max(0, Math.floor(cellIndex)) : 0;
|
|
143
|
+
while (cells.length <= target) cells.push([]);
|
|
144
|
+
const cell = cells[target];
|
|
145
|
+
cell.splice(Math.max(0, Math.min(insertIndex, cell.length)), 0, section);
|
|
146
|
+
return {...container, cells: padCells(cells, columns)};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Nesting level of the container with this id: 1 at the top level, 2 inside a
|
|
152
|
+
* cell, 0 when not found. Only containers have cells, so every cell level is a
|
|
153
|
+
* container level.
|
|
154
|
+
*/
|
|
155
|
+
export function containerDepth(sections: PageSection[], containerId: string, depth = 1): number {
|
|
156
|
+
for (const section of sections) {
|
|
157
|
+
if (section.id === containerId) return depth;
|
|
158
|
+
for (const cell of section.cells ?? []) {
|
|
159
|
+
const hit = containerDepth(cell, containerId, depth + 1);
|
|
160
|
+
if (hit) return hit;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Deep copy with a fresh id for the section and every descendant. */
|
|
167
|
+
export function cloneWithNewIds(section: PageSection): PageSection {
|
|
168
|
+
const reid = (s: PageSection): PageSection => ({
|
|
169
|
+
...structuredClone(s),
|
|
170
|
+
id: generateSectionId(),
|
|
171
|
+
...(s.cells ? {cells: s.cells.map(cell => cell.map(reid))} : {}),
|
|
172
|
+
});
|
|
173
|
+
return reid(section);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolves a type key to its registered type — the registry's `get`. */
|
|
177
|
+
export type TypeLookup = (type: string) => PageSectionType | undefined;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Height of a container's subtree: 1 for a container with no nested containers,
|
|
181
|
+
* otherwise 1 + the tallest nested container's height. 0 for a non-container, so
|
|
182
|
+
* it composes with containerDepth to bound how deep a moved subtree would reach:
|
|
183
|
+
* dropping a section into a container at depth d puts the section's own deepest
|
|
184
|
+
* descendant at d + containerHeight(section).
|
|
185
|
+
*/
|
|
186
|
+
export function containerHeight(section: PageSection): number {
|
|
187
|
+
if (!section.cells) return 0;
|
|
188
|
+
const nested = section.cells.flat().map(containerHeight);
|
|
189
|
+
return 1 + Math.max(0, ...nested);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Re-applies each container's own grow/columns normalisation throughout the
|
|
194
|
+
* tree, so a growable container never carries a trailing all-empty row once
|
|
195
|
+
* committed — not only when read out via containerCells. A section whose
|
|
196
|
+
* current type isn't a registered container is left untouched, preserving its
|
|
197
|
+
* cells verbatim per the unknown-type contract.
|
|
198
|
+
*/
|
|
199
|
+
export function normaliseGrowth(sections: PageSection[], lookup: TypeLookup): PageSection[] {
|
|
200
|
+
return sections.map(section => {
|
|
201
|
+
if (!section.cells) return section;
|
|
202
|
+
const type = lookup(section.type);
|
|
203
|
+
if (!isContainer(type)) return section;
|
|
204
|
+
const cells = normaliseCells(section.cells, containerColumns(section, type), containerGrow(section, type))
|
|
205
|
+
.map(cell => normaliseGrowth(cell, lookup));
|
|
206
|
+
return {...section, cells};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Rewrites the pre-cells `children` shape as `cells`. The old grid was always
|
|
212
|
+
* DEFAULT_WIDTHS wide, and the slot count was the layout, so cells are padded
|
|
213
|
+
* out to the declared slot count, then rounded up to a whole number of
|
|
214
|
+
* DEFAULT_WIDTHS-wide rows; never trimmed.
|
|
215
|
+
*/
|
|
216
|
+
export function migrateSection(section: PageSection, type?: PageSectionType): PageSection {
|
|
217
|
+
if (!section.children) return section;
|
|
218
|
+
const {children, ...rest} = section;
|
|
219
|
+
const slots = type?.slots ?? children.length;
|
|
220
|
+
const cells = Array.from({length: slots}, (_, i) => {
|
|
221
|
+
const child = children[i];
|
|
222
|
+
return child && typeof child.type === 'string' ? [child] : [];
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
...rest,
|
|
226
|
+
layout: section.layout ?? {widths: [...DEFAULT_WIDTHS], grow: false},
|
|
227
|
+
cells: padCells(cells, DEFAULT_WIDTHS.length),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isSectionLike(value: unknown): value is PageSection {
|
|
232
|
+
return Boolean(value) && typeof value === 'object' && typeof (value as PageSection).type === 'string';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Normalises externally supplied sections: migrates the old shape, gives every
|
|
237
|
+
* section a unique id, drops malformed entries, sanitises layouts, caps nesting
|
|
238
|
+
* and clamps sizes. Pure — warnings are returned for the caller to log.
|
|
239
|
+
*/
|
|
240
|
+
export function normaliseSections(
|
|
241
|
+
sections: unknown,
|
|
242
|
+
lookup: TypeLookup
|
|
243
|
+
): {sections: PageSection[]; warnings: string[]} {
|
|
244
|
+
const warnings: string[] = [];
|
|
245
|
+
const seen = new Set<string>();
|
|
246
|
+
// A running admitted-section budget shared across every level, so 500 containers
|
|
247
|
+
// of 500 cells each can't slip past MAX_SECTIONS by hiding depth in nesting —
|
|
248
|
+
// only the top-level count was ever bounded before.
|
|
249
|
+
let remaining = MAX_SECTIONS;
|
|
250
|
+
let budgetExceeded = false;
|
|
251
|
+
|
|
252
|
+
const walk = (raw: PageSection, depth: number): PageSection | undefined => {
|
|
253
|
+
if (remaining <= 0) {
|
|
254
|
+
budgetExceeded = true;
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
remaining--;
|
|
258
|
+
const type = lookup(raw.type);
|
|
259
|
+
const migrated = migrateSection(raw, type);
|
|
260
|
+
const id = !migrated.id || seen.has(migrated.id) ? generateSectionId() : migrated.id;
|
|
261
|
+
seen.add(id);
|
|
262
|
+
|
|
263
|
+
const out: PageSection = {
|
|
264
|
+
id,
|
|
265
|
+
type: migrated.type,
|
|
266
|
+
label: migrated.label,
|
|
267
|
+
data: structuredClone(migrated.data ?? {}),
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (!migrated.cells) return out;
|
|
271
|
+
|
|
272
|
+
// A container past the cap keeps its identity but not its contents, so a
|
|
273
|
+
// malformed page loses as little as possible without unbounded recursion.
|
|
274
|
+
if (depth > MAX_CONTAINER_LEVELS) {
|
|
275
|
+
warnings.push(`container "${migrated.type}" exceeds ${MAX_CONTAINER_LEVELS} nesting levels; its cells were dropped`);
|
|
276
|
+
out.layout = sanitiseLayout(migrated.layout, type, warnings);
|
|
277
|
+
out.cells = [];
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const layout = sanitiseLayout(migrated.layout, type, warnings);
|
|
282
|
+
// Slice to a whole number of rows (not MAX_SECTIONS itself) so the padding
|
|
283
|
+
// below is a no-op and the clamped length never overshoots MAX_SECTIONS.
|
|
284
|
+
const cols = layout.widths.length;
|
|
285
|
+
const maxCells = Math.floor(MAX_SECTIONS / cols) * cols;
|
|
286
|
+
let rawCells = migrated.cells;
|
|
287
|
+
if (rawCells.length > maxCells) {
|
|
288
|
+
warnings.push(`container "${migrated.type}" declared ${rawCells.length} cells; keeping the first ${maxCells}`);
|
|
289
|
+
rawCells = rawCells.slice(0, maxCells);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
out.layout = layout;
|
|
293
|
+
out.cells = normaliseCells(
|
|
294
|
+
rawCells.map(cell => (Array.isArray(cell)
|
|
295
|
+
? cell.filter(isSectionLike).map(child => walk(child, depth + 1)).filter(isDefined)
|
|
296
|
+
: [])),
|
|
297
|
+
layout.widths.length,
|
|
298
|
+
layout.grow
|
|
299
|
+
);
|
|
300
|
+
return out;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const incoming = (Array.isArray(sections) ? sections : []).filter(isSectionLike);
|
|
304
|
+
const kept = incoming.map(section => walk(section, 1)).filter(isDefined);
|
|
305
|
+
if (budgetExceeded) {
|
|
306
|
+
warnings.push(`page tree has more than ${MAX_SECTIONS} sections including nested ones; the rest were dropped`);
|
|
307
|
+
}
|
|
308
|
+
return {sections: kept, warnings};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isDefined<T>(value: T | undefined): value is T {
|
|
312
|
+
return value !== undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function sanitiseLayout(
|
|
316
|
+
layout: PageContainerLayout | undefined,
|
|
317
|
+
type: PageSectionType | undefined,
|
|
318
|
+
warnings: string[]
|
|
319
|
+
): PageContainerLayout {
|
|
320
|
+
const seeded = defaultLayout(type);
|
|
321
|
+
if (!layout) return seeded;
|
|
322
|
+
const widths = sanitiseWidths(layout.widths);
|
|
323
|
+
const raw = layout.widths;
|
|
324
|
+
const unchanged = Array.isArray(raw) && raw.length === widths.length && raw.every((w, i) => w === widths[i]);
|
|
325
|
+
if (!unchanged) {
|
|
326
|
+
warnings.push(`container layout had unusable widths; corrected to ${widths.join(' ')}`);
|
|
327
|
+
}
|
|
328
|
+
return {widths, grow: Boolean(layout.grow)};
|
|
329
|
+
}
|
|
@@ -8,10 +8,25 @@ 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
|
+
/** Container instances only: the editor-chosen layout. */
|
|
12
|
+
layout?: PageContainerLayout;
|
|
13
|
+
/** Container instances only: one ordered stack per cell, row-major. */
|
|
14
|
+
cells?: PageSection[][];
|
|
15
|
+
/**
|
|
16
|
+
* @deprecated The pre-cells fixed-slot shape. Read on load and migrated to
|
|
17
|
+
* `cells`; never written back.
|
|
18
|
+
*/
|
|
12
19
|
children?: (PageSection | null)[];
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
/** A container instance's editor-chosen layout. */
|
|
23
|
+
export interface PageContainerLayout {
|
|
24
|
+
/** One weight per column; the array length IS the column count. */
|
|
25
|
+
widths: number[];
|
|
26
|
+
/** When true the builder offers a trailing empty row instead of a pinned cell count. */
|
|
27
|
+
grow: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/** The complete serialisable state of a page. Order = render order. */
|
|
16
31
|
export interface PageState {
|
|
17
32
|
sections: PageSection[];
|
|
@@ -38,18 +53,65 @@ export interface PageSectionType {
|
|
|
38
53
|
configTemplate?: HTMLTemplateElement;
|
|
39
54
|
/** Programmatic inspector body — takes precedence over `configTemplate`. */
|
|
40
55
|
renderConfig?: (section: PageSection, update: (data: Record<string, unknown>) => void) => TemplateResult;
|
|
56
|
+
/** Marks this type a container: its card renders a grid of cells on the canvas. */
|
|
57
|
+
container?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Column weights a new instance of this container starts with. Stored as parsed, not
|
|
60
|
+
* guaranteed sanitised — route through `sanitiseWidths`/`defaultLayout` before use.
|
|
61
|
+
*/
|
|
62
|
+
defaultWidths?: number[];
|
|
63
|
+
/** Whether a new instance of this container starts growable. */
|
|
64
|
+
defaultGrow?: boolean;
|
|
41
65
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
66
|
+
* @deprecated Use `container` with `columns`/`widths`. Read as a container of
|
|
67
|
+
* `DEFAULT_WIDTHS` columns with the cell count pinned to this number.
|
|
44
68
|
*/
|
|
45
69
|
slots?: number;
|
|
46
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Section type keys allowed in this container's cells. On a `container` type,
|
|
72
|
+
* omitting it allows any type, subject to the nesting cap — that is how nesting
|
|
73
|
+
* is reachable without enumerating types. The deprecated `slots=` alias keeps the
|
|
74
|
+
* old rule instead: omitting it there allows any non-container type.
|
|
75
|
+
*/
|
|
47
76
|
accepts?: string[];
|
|
48
77
|
}
|
|
49
78
|
|
|
50
|
-
/**
|
|
51
|
-
export
|
|
52
|
-
|
|
79
|
+
/** Beyond 6 columns a card is under ~170px on the 1024px canvas — unreadable. */
|
|
80
|
+
export const MAX_COLUMNS = 6;
|
|
81
|
+
/** A single weight beyond 12 makes the other columns unusably thin. */
|
|
82
|
+
export const MAX_WIDTH = 12;
|
|
83
|
+
/** Container nesting levels: a top-level container is 1, one inside a cell is 2. */
|
|
84
|
+
export const MAX_CONTAINER_LEVELS = 2;
|
|
85
|
+
/** Global section budget; also the sanity clamp on an incoming `cells` length. */
|
|
86
|
+
export const MAX_SECTIONS = 500;
|
|
87
|
+
/** What a bare `container` declaration seeds. */
|
|
88
|
+
export const DEFAULT_WIDTHS: readonly number[] = [1, 1, 1];
|
|
89
|
+
|
|
90
|
+
/** Container-ness is a property of the registered type, never of the instance. */
|
|
91
|
+
export function isContainer(type: PageSectionType | undefined): boolean {
|
|
92
|
+
return Boolean(type?.container ?? (type?.slots !== undefined && type.slots > 0));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Coerces a weights list into a usable one: each entry a whole number from 1 to
|
|
97
|
+
* MAX_WIDTH (anything unusable becomes 1), at most MAX_COLUMNS entries, falling
|
|
98
|
+
* back to DEFAULT_WIDTHS only when nothing usable remains.
|
|
99
|
+
*/
|
|
100
|
+
export function sanitiseWidths(raw: unknown): number[] {
|
|
101
|
+
const list = Array.isArray(raw) ? raw.slice(0, MAX_COLUMNS) : [];
|
|
102
|
+
const clean = list.map(entry => {
|
|
103
|
+
const n = typeof entry === 'number' && Number.isFinite(entry) ? Math.floor(entry) : 1;
|
|
104
|
+
return Math.min(Math.max(n, 1), MAX_WIDTH);
|
|
105
|
+
});
|
|
106
|
+
return clean.length ? clean : [...DEFAULT_WIDTHS];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The layout a newly placed instance of a container type starts with. */
|
|
110
|
+
export function defaultLayout(type: PageSectionType | undefined): PageContainerLayout {
|
|
111
|
+
return {
|
|
112
|
+
widths: sanitiseWidths(type?.defaultWidths ?? [...DEFAULT_WIDTHS]),
|
|
113
|
+
grow: Boolean(type?.defaultGrow),
|
|
114
|
+
};
|
|
53
115
|
}
|
|
54
116
|
|
|
55
117
|
/** Drag-and-drop MIME carrying a section type id from the palette to the canvas. */
|
|
@@ -68,8 +130,34 @@ export function generateSectionId(): string {
|
|
|
68
130
|
return `s-${Date.now().toString(36)}-${(sectionCounter++).toString(36)}`;
|
|
69
131
|
}
|
|
70
132
|
|
|
71
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* The visible text of the option a value was chosen from, so a card summarises a
|
|
135
|
+
* select by what the user picked rather than by the opaque id it stores.
|
|
136
|
+
*/
|
|
137
|
+
function optionLabel(type: PageSectionType | undefined, name: string, value: string): string {
|
|
138
|
+
const control = type?.configTemplate?.content.querySelector(`[name="${CSS.escape(name)}"]`);
|
|
139
|
+
const escaped = CSS.escape(value);
|
|
140
|
+
const option = control?.querySelector(`option[value="${escaped}"], zn-option[value="${escaped}"]`);
|
|
141
|
+
return option?.textContent?.trim() ?? '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Card summary: the label of the first value chosen from a select, else the first
|
|
146
|
+
* non-empty string value, else the type description. Options win over field order
|
|
147
|
+
* so a tile reads as its linked item however its other fields were filled in.
|
|
148
|
+
*/
|
|
72
149
|
export function sectionSummary(section: PageSection, type?: PageSectionType): string {
|
|
73
|
-
|
|
74
|
-
|
|
150
|
+
if (isContainer(type)) {
|
|
151
|
+
const columns = sanitiseWidths(section.layout?.widths ?? type?.defaultWidths).length;
|
|
152
|
+
const count = (section.cells ?? []).reduce((n, cell) => n + cell.length, 0);
|
|
153
|
+
const col = `${columns} column${columns === 1 ? '' : 's'}`;
|
|
154
|
+
return `${col} · ${count} section${count === 1 ? '' : 's'}`;
|
|
155
|
+
}
|
|
156
|
+
const strings = Object.entries(section.data)
|
|
157
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '');
|
|
158
|
+
for (const [name, value] of strings) {
|
|
159
|
+
const label = optionLabel(type, name, value);
|
|
160
|
+
if (label) return label;
|
|
161
|
+
}
|
|
162
|
+
return strings[0]?.[1] ?? type?.description ?? '';
|
|
75
163
|
}
|
|
@@ -56,16 +56,23 @@
|
|
|
56
56
|
display: flex;
|
|
57
57
|
flex-direction: column;
|
|
58
58
|
gap: var(--zn-spacing-medium);
|
|
59
|
+
|
|
60
|
+
padding-top: var(--zn-spacing-medium);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.navigation-group:first-of-type {
|
|
64
|
+
padding-top: 0;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
.navigation-group h4 {
|
|
62
68
|
margin: 0;
|
|
63
|
-
padding:
|
|
69
|
+
padding: 0;
|
|
64
70
|
color: rgb(var(--zn-text-heading));
|
|
65
71
|
font-size: var(--zn-font-size-medium);
|
|
66
72
|
font-weight: var(--zn-font-weight-semibold);
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
|
|
69
76
|
.navigation-group .navigation-item {
|
|
70
77
|
color: rgb(var(--zn-text));
|
|
71
78
|
font-size: var(--zn-font-size-medium);
|
|
@@ -73,6 +80,7 @@
|
|
|
73
80
|
display: flex;
|
|
74
81
|
align-items: center;
|
|
75
82
|
gap: var(--zn-spacing-small);
|
|
83
|
+
min-height: var(--zn-icon-size);
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
.navigation-item.active {
|
|
@@ -76,6 +76,10 @@ export default class ZnPanel extends ZincElement {
|
|
|
76
76
|
const hasActionSlot = this.hasSlotController.test('actions');
|
|
77
77
|
const hasFooterSlot = this.hasSlotController.test('footer');
|
|
78
78
|
const hasHeader = this.caption || hasActionSlot;
|
|
79
|
+
// Transparent panels with no vertical body padding have nothing to separate the header from,
|
|
80
|
+
// so the underline is just a stray rule across the page.
|
|
81
|
+
const isFlushY = this.flush || this.tabbed || this.flushY;
|
|
82
|
+
const underlineHeader = !this.headerBorderless && !(this.transparent && isFlushY);
|
|
79
83
|
|
|
80
84
|
return html`
|
|
81
85
|
<div class="${classMap({
|
|
@@ -98,7 +102,7 @@ export default class ZnPanel extends ZincElement {
|
|
|
98
102
|
${hasHeader ? html`
|
|
99
103
|
<zn-header class="${classMap({
|
|
100
104
|
"panel__header": true,
|
|
101
|
-
"panel__header--underline":
|
|
105
|
+
"panel__header--underline": underlineHeader,
|
|
102
106
|
})}"
|
|
103
107
|
icon="${this.icon}"
|
|
104
108
|
caption="${this.caption}"
|
|
@@ -51,6 +51,7 @@ export interface PriorityItem {
|
|
|
51
51
|
*
|
|
52
52
|
* @cssproperty --zn-priority-list-item-gap - The gap between list items. Defaults to `var(--zn-spacing-2x-small)`.
|
|
53
53
|
* @cssproperty --zn-priority-list-item-padding - The padding inside each item. Defaults to `var(--zn-spacing-small) var(--zn-spacing-medium)`.
|
|
54
|
+
* @cssproperty --zn-priority-list-actions-gap - The gap between an item's slotted actions. Defaults to `var(--zn-spacing-x-small)`.
|
|
54
55
|
* @cssproperty --zn-priority-list-handle-color - The color of the drag handle. Defaults to `var(--zn-color-neutral-500)`.
|
|
55
56
|
* @cssproperty --zn-priority-list-priority-color - The color of the priority number. Defaults to `var(--zn-color-neutral-600)`.
|
|
56
57
|
*/
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
--zn-priority-list-item-gap: var(--zn-spacing-2x-small);
|
|
9
9
|
--zn-priority-list-item-padding: var(--zn-spacing-small) var(--zn-spacing-medium);
|
|
10
|
+
--zn-priority-list-actions-gap: var(--zn-spacing-small);
|
|
10
11
|
--zn-priority-list-handle-color: var(--zn-color-neutral-400);
|
|
11
12
|
--zn-priority-list-priority-color: var(--zn-color-neutral-600);
|
|
12
13
|
}
|
|
@@ -134,7 +135,7 @@
|
|
|
134
135
|
.priority-list__actions {
|
|
135
136
|
display: flex;
|
|
136
137
|
align-items: center;
|
|
137
|
-
gap: var(--zn-
|
|
138
|
+
gap: var(--zn-priority-list-actions-gap);
|
|
138
139
|
flex-shrink: 0;
|
|
139
140
|
// Re-enable pointer events so action buttons are clickable
|
|
140
141
|
pointer-events: auto;
|