@vectoriox/iox-ui 5.39.0 → 5.41.0
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/composite-core/index.d.ts +210 -12
- package/design-core/index.d.ts +694 -17
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +4 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +535 -121
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +843 -45
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +894 -21
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +3945 -3914
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +625 -5
- package/index.d.ts +22 -1
- package/package.json +1 -1
- package/src/actions-core/element-actions.ts +4 -3
- package/src/composite-core/accordion.ts +1 -0
- package/src/composite-core/composite-kinds.ts +42 -6
- package/src/composite-core/exclusive-runtime.ts +60 -10
- package/src/composite-core/exclusive.ts +64 -0
- package/src/composite-core/public-api.ts +5 -2
- package/src/composite-core/segmented.ts +349 -0
- package/src/composite-core/structure.ts +6 -1
- package/src/composite-core/tabs.ts +6 -3
- package/src/design-core/contrast-audit.ts +8 -3
- package/src/design-core/flavor-spec.generated.ts +832 -41
- package/src/generation-core/compose-site.ts +248 -12
- package/src/generation-core/nav-shape.ts +53 -3
- package/src/generation-core/node-baselines.generated.ts +49 -0
- package/src/generation-core/patterns.ts +20 -0
- package/src/generation-core/site-spec.generated.ts +595 -0
- package/styles/iox-design.css +19 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { DesignChoices, LinkType, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
1
|
+
import { COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
|
|
2
2
|
import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
|
|
3
3
|
import {
|
|
4
4
|
ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
|
|
5
5
|
buildAccordionRecipe, buildCardRecipe, buildCarouselRecipe, buildLightboxRecipe, CardLayout, CardPart, CardRole, CARD_LAYOUTS, CARD_PARTS, CompositeNode,
|
|
6
6
|
CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, pickAccordionAutoBinds, pickTabsAutoBinds, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
|
|
7
7
|
tabPanelImageOf, tabPanelTextOf, tabsListOf, tabsPanels, tabsPanelsOf, tabsTriggers, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, TABS_LABEL_BIND_PROP,
|
|
8
|
-
TABS_TEXT_BIND_PROP, TABS_WORDS,
|
|
8
|
+
TABS_TEXT_BIND_PROP, TABS_WORDS, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
|
|
9
9
|
} from '@vectoriox/iox-ui/composite-core';
|
|
10
10
|
|
|
11
11
|
import { NODE_BASELINES } from './node-baselines.generated';
|
|
@@ -41,6 +41,18 @@ export enum PlaceholderKind {
|
|
|
41
41
|
Image = 'image',
|
|
42
42
|
/** CC3: a video slot with no clip — the studio uploads its own (the poster blank meanwhile). */
|
|
43
43
|
Video = 'video',
|
|
44
|
+
/** CC4b (Q37): a price — a FACT the customer could be held to. Free text the studio types ("₪ 1,200", "From $40",
|
|
45
|
+
* "On request"); its label shows meanwhile ("Monthly price"), never a number. No currency model. */
|
|
46
|
+
Price = 'price',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The placeholders a page cannot be published with unfilled (W4's gate): a fact, a price, the studio's own video.
|
|
50
|
+
* Copy and an empty image are the author's to rewrite, never blocking. */
|
|
51
|
+
export const PUBLISH_BLOCKING_PLACEHOLDERS: ReadonlySet<PlaceholderKind> = new Set([PlaceholderKind.Fact, PlaceholderKind.Price, PlaceholderKind.Video]);
|
|
52
|
+
|
|
53
|
+
/** True when a placeholder of `kind` blocks publishing until the studio fills it. */
|
|
54
|
+
export function blocksPublish(kind: PlaceholderKind): boolean {
|
|
55
|
+
return PUBLISH_BLOCKING_PLACEHOLDERS.has(kind);
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
export interface ComposedPlaceholder {
|
|
@@ -241,6 +253,18 @@ interface Ctx {
|
|
|
241
253
|
routes: ReadonlySet<string>;
|
|
242
254
|
/** CC3: nodes whose click opens the pattern's lightbox (`opens`), wired once the pattern is stamped. */
|
|
243
255
|
openers?: string[];
|
|
256
|
+
/** CC4b: nodes shown while an option is on (`shownWhen`), wired once the pattern is stamped. */
|
|
257
|
+
shownWhen?: ShownWhen[];
|
|
258
|
+
/** A section inserted from the palette (`composeSection`): a button to a page the studio lacks keeps its words,
|
|
259
|
+
* unlinked — the author links it — instead of being removed. */
|
|
260
|
+
keepButtons?: boolean;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface ShownWhen {
|
|
264
|
+
id: string;
|
|
265
|
+
key: string;
|
|
266
|
+
/** The display the node shows with (its own layout's, else flex). */
|
|
267
|
+
display: string;
|
|
244
268
|
}
|
|
245
269
|
|
|
246
270
|
function nonEmpty(v: unknown): v is string {
|
|
@@ -273,6 +297,7 @@ function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
|
|
|
273
297
|
}
|
|
274
298
|
const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
|
|
275
299
|
if (pn.ph === PatternPlaceholder.Copy) return { value: own, placeholder: { kind: PlaceholderKind.Copy } };
|
|
300
|
+
if (pn.ph === PatternPlaceholder.Price) return { value: own, placeholder: { kind: PlaceholderKind.Price } };
|
|
276
301
|
return { value: own };
|
|
277
302
|
}
|
|
278
303
|
|
|
@@ -364,7 +389,7 @@ enum DanglingFate {
|
|
|
364
389
|
function danglingFate(pn: PatternNode, ctx: Ctx): DanglingFate | null {
|
|
365
390
|
if (!pn.href || pn.href === PatternDirective.PageRoute || ctx.routes.has(pn.href)) return null;
|
|
366
391
|
if (ctx.email) return DanglingFate.Email;
|
|
367
|
-
return UNLINKED_TYPE[pn.t] ? DanglingFate.Unlink : DanglingFate.Remove;
|
|
392
|
+
return UNLINKED_TYPE[pn.t] || (ctx.keepButtons && pn.t === BUTTON_BLOCK) ? DanglingFate.Unlink : DanglingFate.Remove;
|
|
368
393
|
}
|
|
369
394
|
|
|
370
395
|
const DANGLING_ISSUE: Readonly<Record<DanglingFate, ComposeIssueCode>> = {
|
|
@@ -378,7 +403,9 @@ function reportDangling(fate: DanglingFate, pn: PatternNode, id: string, words:
|
|
|
378
403
|
const outcome = {
|
|
379
404
|
[DanglingFate.Email]: `it now opens an email to ${ctx.email}`,
|
|
380
405
|
[DanglingFate.Remove]: 'with no email address to send to instead, it was removed',
|
|
381
|
-
[DanglingFate.Unlink]:
|
|
406
|
+
[DanglingFate.Unlink]: pn.t === BUTTON_BLOCK
|
|
407
|
+
? 'with no email address to send to instead, the button keeps its words without the link'
|
|
408
|
+
: 'with no email address to send to instead, the box keeps its contents without the link',
|
|
382
409
|
}[fate];
|
|
383
410
|
ctx.issues.push({
|
|
384
411
|
code: DANGLING_ISSUE[fate], nodeId: id, owner: ctx.owner, route: pn.href,
|
|
@@ -459,7 +486,7 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
459
486
|
reportDangling(fate, pn, id, words.value, ctx);
|
|
460
487
|
return null;
|
|
461
488
|
}
|
|
462
|
-
const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] : pn.t;
|
|
489
|
+
const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] ?? pn.t : pn.t;
|
|
463
490
|
|
|
464
491
|
const props: Record<string, unknown> = {};
|
|
465
492
|
if (pn.level) props['level'] = pn.level;
|
|
@@ -501,6 +528,11 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
501
528
|
ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: PlaceholderKind.Video, text: '' });
|
|
502
529
|
}
|
|
503
530
|
if (pn.name) setProp(node, 'ariaLabel', pn.name);
|
|
531
|
+
if (pn.icon) {
|
|
532
|
+
if (type !== ICON) throw new ComposeError(`${id}: "icon" belongs on an Icon`);
|
|
533
|
+
setProp(node, 'iconClass', pn.icon);
|
|
534
|
+
}
|
|
535
|
+
if (pn.layer) node.label = pn.layer;
|
|
504
536
|
if (pn.opens) {
|
|
505
537
|
if (pn.opens !== PatternOpens.Lightbox) throw new ComposeError(`${id}: "opens" names the pattern's lightbox`);
|
|
506
538
|
(ctx.openers ??= []).push(id);
|
|
@@ -542,11 +574,8 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
542
574
|
}
|
|
543
575
|
|
|
544
576
|
// Layout.
|
|
545
|
-
|
|
546
|
-
if (pn.
|
|
547
|
-
for (const layer of Object.values(pn.resp)) assertStyleSlots(node.type, Object.keys(layer));
|
|
548
|
-
node.responsive = Object.fromEntries(Object.entries(pn.resp).map(([bp, styleProps]) => [bp, { styleProps: { ...styleProps } }]));
|
|
549
|
-
}
|
|
577
|
+
applyLayout(node, pn);
|
|
578
|
+
if (pn.shownWhen) hideUntilOn(node, pn, ctx);
|
|
550
579
|
|
|
551
580
|
// A Repeater's rows.
|
|
552
581
|
let rowScope = repeater;
|
|
@@ -573,6 +602,44 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
573
602
|
return node;
|
|
574
603
|
}
|
|
575
604
|
|
|
605
|
+
/** A pattern node's layout: its `lay` styleProps and its per-breakpoint `resp` layers. */
|
|
606
|
+
function applyLayout(node: SerializedNode, pn: PatternNode): void {
|
|
607
|
+
if (pn.lay) setStyleProps(node, pn.lay);
|
|
608
|
+
if (pn.resp) {
|
|
609
|
+
for (const layer of Object.values(pn.resp)) assertStyleSlots(node.type, Object.keys(layer));
|
|
610
|
+
node.responsive = Object.fromEntries(Object.entries(pn.resp).map(([bp, styleProps]) => [bp, { styleProps: { ...styleProps } }]));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** CC4b (`shownWhen`, Q38): hidden at base and shown — with its own layout's display — while the pattern's Segmented
|
|
615
|
+
* option of that key is on. Wired once the pattern is stamped (the option's id is known then). */
|
|
616
|
+
function hideUntilOn(node: SerializedNode, pn: PatternNode, ctx: Ctx): void {
|
|
617
|
+
const display = String(node.styleProps['display'] ?? '') || SHOWN_DISPLAY;
|
|
618
|
+
setStyleProps(node, { display: HIDDEN_DISPLAY });
|
|
619
|
+
(ctx.shownWhen ??= []).push({ id: node.id, key: pn.shownWhen!, display });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** CC4b: every `shownWhen` node of a stamped pattern follows its option — the targeted `trigger-on` ("when Yearly is
|
|
623
|
+
* on"), compiled by both renderers to ONE `:has()` rule on the nearest common ancestor. */
|
|
624
|
+
function wireShownWhen(root: SerializedNode, entries: ReadonlyArray<ShownWhen>): void {
|
|
625
|
+
if (!entries.length) return;
|
|
626
|
+
const nodes = new Map<string, SerializedNode>();
|
|
627
|
+
const options = new Map<string, string>();
|
|
628
|
+
const visit = (n: SerializedNode) => {
|
|
629
|
+
nodes.set(n.id, n);
|
|
630
|
+
for (const option of segmentedOptions(n as unknown as CompositeNode)) if (option.itemKey && !options.has(option.itemKey)) options.set(option.itemKey, option.id!);
|
|
631
|
+
(n.children ?? []).forEach(visit);
|
|
632
|
+
};
|
|
633
|
+
visit(root);
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
const option = options.get(entry.key);
|
|
636
|
+
if (!option) throw new ComposeError(`${entry.id}: "shownWhen: ${entry.key}" names no option of a segmented in the pattern`);
|
|
637
|
+
const node = nodes.get(entry.id)!;
|
|
638
|
+
node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { display: entry.display } };
|
|
639
|
+
node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: option };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
576
643
|
/** A Card v2 `binds` key → the part it binds and that part's bound prop. */
|
|
577
644
|
const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string }>> = {
|
|
578
645
|
[CardBindTarget.Image]: { role: CardRole.Media, prop: 'src' },
|
|
@@ -591,6 +658,7 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
|
|
|
591
658
|
if (pn.recipe === PatternRecipe.Tabs) return composeTabs(pn, id, ctx);
|
|
592
659
|
if (pn.recipe === PatternRecipe.Carousel) return composeCarousel(pn, id, ctx);
|
|
593
660
|
if (pn.recipe === PatternRecipe.Lightbox) return composeLightbox(pn, id, repeater, ctx);
|
|
661
|
+
if (pn.recipe === PatternRecipe.Segmented) return composeSegmented(pn, id);
|
|
594
662
|
if (pn.recipe !== PatternRecipe.Card) throw new ComposeError(`${id}: no recipe "${pn.recipe}" in composite-core`);
|
|
595
663
|
const layout = (pn.layout ?? CardLayout.Top) as CardLayout;
|
|
596
664
|
if (!CARD_LAYOUTS.includes(layout)) throw new ComposeError(`${id}: a card has no layout "${pn.layout}"`);
|
|
@@ -599,7 +667,21 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
|
|
|
599
667
|
}
|
|
600
668
|
const factory = (spec: CompositeNodeSpec): CompositeNode =>
|
|
601
669
|
create(spec.key === CardRole.Card ? id : `${id}-${spec.key}`, spec.type, { ...(spec.props ?? {}) }, spec.elementStyle ?? null) as CompositeNode;
|
|
602
|
-
const
|
|
670
|
+
const tone = pn.badge as BadgeTone | undefined;
|
|
671
|
+
if (tone && !BADGE_TONES.includes(tone)) throw new ComposeError(`${id}: a badge has no tone "${pn.badge}"`);
|
|
672
|
+
for (const key of Object.keys(pn.words ?? {})) if (!CARD_WORD_KEYS.includes(key)) throw new ComposeError(`${id}: a card has no words "${key}"`);
|
|
673
|
+
const card = buildCardRecipe(factory, {
|
|
674
|
+
layout, parts: pn.parts as Partial<Record<CardPart, boolean>>, words: pn.words as Partial<CardWords> | undefined, badgeTone: tone,
|
|
675
|
+
}) as SerializedNode;
|
|
676
|
+
if (pn.scheme) {
|
|
677
|
+
if (!COLOR_SCHEMES.some(c => c.id === pn.scheme)) throw new ComposeError(`${id}: no colour scheme "${pn.scheme}"`);
|
|
678
|
+
setColorScheme(card, pn.scheme);
|
|
679
|
+
}
|
|
680
|
+
if (pn.ph === PatternPlaceholder.Copy) copyPlaceholders(card, ctx);
|
|
681
|
+
if (pn.c?.length) {
|
|
682
|
+
const content = findByRole(card as CompositeNode, CardRole.Content) as SerializedNode;
|
|
683
|
+
content.children = [...(content.children ?? []), ...composeChildren(pn, id, repeater, ctx)];
|
|
684
|
+
}
|
|
603
685
|
|
|
604
686
|
for (const [key, path] of Object.entries(pn.binds ?? {})) {
|
|
605
687
|
const target = CARD_BINDS[key as CardBindTarget];
|
|
@@ -628,6 +710,31 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
|
|
|
628
710
|
}
|
|
629
711
|
|
|
630
712
|
const REPEATER = 'Repeater';
|
|
713
|
+
const ICON = 'Icon';
|
|
714
|
+
const SHOWN_DISPLAY = 'flex';
|
|
715
|
+
const HIDDEN_DISPLAY = 'none';
|
|
716
|
+
/** The words a card recipe's `words` may set (composite-core `CardWords`). */
|
|
717
|
+
const CARD_WORD_KEYS: ReadonlyArray<string> = ['eyebrow', 'title', 'text', 'primary', 'secondary', 'badge'];
|
|
718
|
+
/** A card's words the studio rewrites (`ph: 'copy'`): its eyebrow, title, text and badge. */
|
|
719
|
+
const CARD_COPY_ROLES: ReadonlyArray<string> = [CardRole.Eyebrow, CardRole.Title, CardRole.Text];
|
|
720
|
+
|
|
721
|
+
/** A card's own words, as COPY placeholders (its badge's words too). */
|
|
722
|
+
function copyPlaceholders(card: SerializedNode, ctx: Ctx): void {
|
|
723
|
+
for (const role of CARD_COPY_ROLES) {
|
|
724
|
+
const part = findByRole(card as unknown as CompositeNode, role) as SerializedNode | undefined;
|
|
725
|
+
if (part) ctx.placeholders.push({ nodeId: part.id, owner: ctx.owner, kind: PlaceholderKind.Copy, text: String(part.props?.['content'] ?? '') });
|
|
726
|
+
}
|
|
727
|
+
const badge = findByRole(card as unknown as CompositeNode, CardRole.Badge) as SerializedNode | undefined;
|
|
728
|
+
const words = badge?.children?.find(c => c.type === TEXT);
|
|
729
|
+
if (words) ctx.placeholders.push({ nodeId: words.id, owner: ctx.owner, kind: PlaceholderKind.Copy, text: String(words.props?.['content'] ?? '') });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** CC4b: the Segmented control — composite-core's recipe (Monthly · Yearly), named by the pattern, laid out by it. */
|
|
733
|
+
function composeSegmented(pn: PatternNode, id: string): SerializedNode {
|
|
734
|
+
const root = buildSegmentedRecipe(recipeFactory(id, 'segmented'), pn.name !== undefined ? { label: pn.name } : {}) as SerializedNode;
|
|
735
|
+
applyLayout(root, pn);
|
|
736
|
+
return root;
|
|
737
|
+
}
|
|
631
738
|
|
|
632
739
|
/**
|
|
633
740
|
* An Accordion (CC2) — the composite-core recipe stamped with this composer's factory. Manual (no section
|
|
@@ -784,10 +891,11 @@ function wireOpeners(root: SerializedNode, openers: ReadonlyArray<string>): void
|
|
|
784
891
|
|
|
785
892
|
function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
|
|
786
893
|
if (pattern.node.each) throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
|
|
787
|
-
const own: Ctx = { ...ctx, openers: [] };
|
|
894
|
+
const own: Ctx = { ...ctx, openers: [], shownWhen: [] };
|
|
788
895
|
const node = composeNode(pattern.node, id, null, own);
|
|
789
896
|
if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional or a link`);
|
|
790
897
|
wireOpeners(node, own.openers ?? []);
|
|
898
|
+
wireShownWhen(node, own.shownWhen ?? []);
|
|
791
899
|
node.label = pattern.label;
|
|
792
900
|
return node;
|
|
793
901
|
}
|
|
@@ -886,3 +994,131 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
|
|
|
886
994
|
issues,
|
|
887
995
|
};
|
|
888
996
|
}
|
|
997
|
+
|
|
998
|
+
// ── The site header on its own (CC1.1) ──────────────────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
/** The header pattern the Studio template stamps — and the builder palette's "Navigation bar". */
|
|
1001
|
+
export const SITE_HEADER_PATTERN = 'nav.inline-cta';
|
|
1002
|
+
|
|
1003
|
+
/** A page of the studio as the builder lists it. */
|
|
1004
|
+
export interface SitePageRef {
|
|
1005
|
+
readonly name: string;
|
|
1006
|
+
readonly route: string;
|
|
1007
|
+
readonly isHome?: boolean;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** What the header is composed from when there is no Site Plan: the studio's name and its pages. */
|
|
1011
|
+
export interface NavHeaderInput {
|
|
1012
|
+
readonly business: SiteBusiness;
|
|
1013
|
+
/** Every page of the studio, in the order the navigation should list them. */
|
|
1014
|
+
readonly pages: ReadonlyArray<SitePageRef>;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export interface NavHeaderOptions {
|
|
1018
|
+
/** The header's id; its parts' ids derive from it (`<id>-0-1-…`), so a fresh id gives fresh parts. */
|
|
1019
|
+
readonly id: string;
|
|
1020
|
+
/** A phone-shape part's id (the builder passes its id generator). */
|
|
1021
|
+
readonly newId?: (suffix: string) => string;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export interface ComposedNavHeader {
|
|
1025
|
+
/** The header — a global BEFORE element — carrying the pattern's phone shape (the drawer). */
|
|
1026
|
+
header: SerializedNode;
|
|
1027
|
+
issues: ComposeIssue[];
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const ROUTE_PARAM = ':';
|
|
1031
|
+
const HOME_PATH = '/';
|
|
1032
|
+
const KEY_UNSAFE = /[^a-z0-9]+/gi;
|
|
1033
|
+
|
|
1034
|
+
/** The pages a navigation links to: every page but Home (the brand links there) and template pages
|
|
1035
|
+
* (a `/work/:slug` route has no single address), in the studio's order. */
|
|
1036
|
+
export function siteNavPages(pages: ReadonlyArray<SitePageRef>): SitePageRef[] {
|
|
1037
|
+
return pages.filter(p => !!p.route && !p.isHome && p.route !== HOME_PATH && !p.route.includes(ROUTE_PARAM));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function navPagePlans(pages: ReadonlyArray<SitePageRef>): PagePlan[] {
|
|
1041
|
+
const used = new Set<string>();
|
|
1042
|
+
return pages.map((p, i) => {
|
|
1043
|
+
let key = p.route.replace(KEY_UNSAFE, '-').replace(/^-+|-+$/g, '').toLowerCase() || `page-${i}`;
|
|
1044
|
+
for (let n = 2; used.has(key); n++) key = `${key}-${n}`;
|
|
1045
|
+
used.add(key);
|
|
1046
|
+
return { key, name: p.name, route: p.route, inNav: true, isHome: p.isHome === true, sections: [] };
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The Studio template's header, composed for a studio that has no Site Plan — the builder palette's
|
|
1052
|
+
* "Navigation bar". THE SAME pattern and the same composer as `composeSite`: the brand (the business name),
|
|
1053
|
+
* one plain Link per navigation page, the call to action (W1.1's one dangling-link rule when the studio has
|
|
1054
|
+
* no /contact page: an email to the business, else removed), and the pattern's phone shape. Pure.
|
|
1055
|
+
*/
|
|
1056
|
+
export function composeNavHeader(input: NavHeaderInput, options: NavHeaderOptions): ComposedNavHeader {
|
|
1057
|
+
if (!nonEmpty(options?.id)) throw new ComposeError('composeNavHeader needs the header id');
|
|
1058
|
+
const pattern = patternById(SITE_HEADER_PATTERN) as SitePattern;
|
|
1059
|
+
const all = navPagePlans(input.pages);
|
|
1060
|
+
const navRoutes = new Set(siteNavPages(input.pages).map(p => p.route));
|
|
1061
|
+
const pages = all.map(p => ({ ...p, inNav: navRoutes.has(p.route) }));
|
|
1062
|
+
const plan = { business: input.business, pages, dataSources: [], contentTypes: [] } as unknown as SitePlan;
|
|
1063
|
+
const email = nonEmpty(input.business.facts?.email) ? input.business.facts!.email!.trim() : null;
|
|
1064
|
+
const issues: ComposeIssue[] = [];
|
|
1065
|
+
const ctx: Ctx = {
|
|
1066
|
+
owner: GlobalSlot.Header, page: null, section: null, plan, year: 0, email, navPage: null, nextAlias: null,
|
|
1067
|
+
placeholders: [], issues, routes: new Set(pages.map(p => p.route)),
|
|
1068
|
+
};
|
|
1069
|
+
const root = composePattern(pattern, options.id, ctx);
|
|
1070
|
+
root.isGlobal = true;
|
|
1071
|
+
root.globalPosition = pattern.global;
|
|
1072
|
+
const shape = pattern.phoneMenu as NavShape | undefined;
|
|
1073
|
+
const header = shape ? planNavShape([root], shape, { newId: options.newId }, root.id).globals[0] : root;
|
|
1074
|
+
return { header, issues };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ── One pattern on its own (CC4b) ───────────────────────────────────────────────────────────────
|
|
1078
|
+
|
|
1079
|
+
/** The pricing section (CC4b, board 10): plans with a Monthly / Yearly switch — the library and the palette, never a
|
|
1080
|
+
* Studio page (Q35). */
|
|
1081
|
+
export const PRICING_PATTERN = 'pricing.tiers';
|
|
1082
|
+
|
|
1083
|
+
/** What a section is composed from when there is no Site Plan: the studio's name, its pages (the routes a link may
|
|
1084
|
+
* reach) and its facts. */
|
|
1085
|
+
export interface SectionInput {
|
|
1086
|
+
readonly business?: SiteBusiness;
|
|
1087
|
+
readonly pages?: ReadonlyArray<SitePageRef>;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export interface SectionOptions {
|
|
1091
|
+
/** The section's id; its parts' ids derive from it (`<id>-0-1-…`), so a fresh id gives fresh parts. */
|
|
1092
|
+
readonly id: string;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export interface ComposedSection {
|
|
1096
|
+
section: SerializedNode;
|
|
1097
|
+
placeholders: ComposedPlaceholder[];
|
|
1098
|
+
issues: ComposeIssue[];
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* ONE library pattern composed on its own — the builder palette's ready-made sections (CC4b: "Pricing", the
|
|
1103
|
+
* `pricing.tiers` pattern). THE SAME pattern and composer as `composeSite`: its words, its placeholders (a price shows
|
|
1104
|
+
* its label, never a number) and the one dangling-link rule — except a button to a page the studio lacks keeps its
|
|
1105
|
+
* words, unlinked, for the author to link. Pure.
|
|
1106
|
+
*/
|
|
1107
|
+
export function composeSection(patternId: string, input: SectionInput, options: SectionOptions): ComposedSection {
|
|
1108
|
+
if (!nonEmpty(options?.id)) throw new ComposeError('composeSection needs the section id');
|
|
1109
|
+
const pattern = patternById(patternId);
|
|
1110
|
+
if (!pattern) throw new ComposeError(`no pattern "${patternId}" in the library`);
|
|
1111
|
+
if (pattern.global) throw new ComposeError(`"${patternId}" is a header or footer — composeNavHeader / composeSite stamp those`);
|
|
1112
|
+
const business: SiteBusiness = input.business ?? { name: '' } as SiteBusiness;
|
|
1113
|
+
const pages = navPagePlans(input.pages ?? []);
|
|
1114
|
+
const plan = { business, pages, dataSources: [], contentTypes: [] } as unknown as SitePlan;
|
|
1115
|
+
const email = nonEmpty(business.facts?.email) ? business.facts!.email!.trim() : null;
|
|
1116
|
+
const placeholders: ComposedPlaceholder[] = [];
|
|
1117
|
+
const issues: ComposeIssue[] = [];
|
|
1118
|
+
const ctx: Ctx = {
|
|
1119
|
+
owner: options.id, page: null, section: null, plan, year: 0, email, navPage: null, nextAlias: null,
|
|
1120
|
+
placeholders, issues, routes: new Set(pages.map(p => p.route)), keepButtons: true,
|
|
1121
|
+
};
|
|
1122
|
+
const section = composePattern(pattern, options.id, ctx);
|
|
1123
|
+
return { section, placeholders, issues };
|
|
1124
|
+
}
|
|
@@ -67,6 +67,8 @@ export interface NavShapeOptions {
|
|
|
67
67
|
barItems?: number;
|
|
68
68
|
/** A new part's id (the builder passes its id generator). Default: `<nav id>-<suffix>`. */
|
|
69
69
|
newId?: (suffix: string) => string;
|
|
70
|
+
/** The studio's pages, for a header that links to none (a hand-built one): its menu lists these. */
|
|
71
|
+
pages?: ReadonlyArray<NavPage>;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export interface NavShapeResult {
|
|
@@ -199,6 +201,21 @@ export function navPages(root: SerializedNode): NavPage[] {
|
|
|
199
201
|
return source.filter(isPageLink).map(n => ({ label: textOf(n), route: String(prop(n, 'pageRoute')) }));
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
/** Where a phone menu's pages come from: the header's own page Links, else the studio's pages. */
|
|
205
|
+
export enum NavPagesSource {
|
|
206
|
+
Header = 'header',
|
|
207
|
+
Site = 'site',
|
|
208
|
+
None = 'none',
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The pages a phone menu lists: the header's (`navPages`), else `sitePages` — a hand-built header with no
|
|
212
|
+
* page links still gets a working menu. */
|
|
213
|
+
export function phoneMenuPages(root: SerializedNode, sitePages: ReadonlyArray<NavPage> = []): { pages: NavPage[]; source: NavPagesSource } {
|
|
214
|
+
const own = navPages(root);
|
|
215
|
+
if (own.length) return { pages: own, source: NavPagesSource.Header };
|
|
216
|
+
return sitePages.length ? { pages: [...sitePages], source: NavPagesSource.Site } : { pages: [], source: NavPagesSource.None };
|
|
217
|
+
}
|
|
218
|
+
|
|
202
219
|
/** A hand-built header: every page Link outside a drawer. */
|
|
203
220
|
function collectHeaderLinks(root: SerializedNode): SerializedNode[] {
|
|
204
221
|
const out: SerializedNode[] = [];
|
|
@@ -439,7 +456,7 @@ export function planNavShape(globals: ReadonlyArray<SerializedNode>, shape: NavS
|
|
|
439
456
|
const kit = makeKit(out, root.id, options.newId);
|
|
440
457
|
|
|
441
458
|
adoptRoles(root);
|
|
442
|
-
const pages =
|
|
459
|
+
const { pages } = phoneMenuPages(root, options.pages);
|
|
443
460
|
const wantDrawer = shape !== NavShape.Bar;
|
|
444
461
|
const wantBar = shape !== NavShape.Drawer;
|
|
445
462
|
const wantMenuButton = shape === NavShape.Drawer;
|
|
@@ -538,7 +555,7 @@ export interface AddBarItemResult {
|
|
|
538
555
|
* The bar's Items list "Add item" (board 2): one more item, before More — the next navigation page the
|
|
539
556
|
* bar does not link to yet (Home when every page is there). Refused at five (Q2). Input not mutated.
|
|
540
557
|
*/
|
|
541
|
-
export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick<NavShapeOptions, 'newId'> = {}): AddBarItemResult {
|
|
558
|
+
export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick<NavShapeOptions, 'newId' | 'pages'> = {}): AddBarItemResult {
|
|
542
559
|
const out = clone(globals) as SerializedNode[];
|
|
543
560
|
const root = findNavRoot(out);
|
|
544
561
|
const b = find(out, NavRole.Bar);
|
|
@@ -546,10 +563,43 @@ export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick
|
|
|
546
563
|
const kids = b.children ?? (b.children = []);
|
|
547
564
|
if (kids.length >= BAR_ITEMS_MAX) return { globals: out, added: false };
|
|
548
565
|
const linked = new Set(kids.filter(c => c.role === NavRole.BarItem).map(c => prop(c, 'pageRoute')));
|
|
549
|
-
const page = barPages(root,
|
|
566
|
+
const page = barPages(root, phoneMenuPages(root, options.pages).pages, BAR_ITEMS_MAX + 1).find(p => !linked.has(p.route))
|
|
550
567
|
?? { label: NAV_SHAPE_COPY.home, route: HOME_ROUTE };
|
|
551
568
|
const item = barItem(makeKit(out, root.id, options.newId), kids.length, page);
|
|
552
569
|
const more = kids.findIndex(c => c.role === NavRole.More);
|
|
553
570
|
if (more >= 0) kids.splice(more, 0, item); else kids.push(item);
|
|
554
571
|
return { globals: out, added: true };
|
|
555
572
|
}
|
|
573
|
+
|
|
574
|
+
export interface BarItemsEditResult {
|
|
575
|
+
globals: SerializedNode[];
|
|
576
|
+
/** False when nothing changed: no bar, an unknown item, or the bar would drop below three items (Q2). */
|
|
577
|
+
changed: boolean;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function barOf(globals: ReadonlyArray<SerializedNode>): { out: SerializedNode[]; kids: SerializedNode[] | null } {
|
|
581
|
+
const out = clone(globals) as SerializedNode[];
|
|
582
|
+
const b = find(out, NavRole.Bar);
|
|
583
|
+
return { out, kids: b ? (b.children ?? (b.children = [])) : null };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The bar's Items list "Remove" (board 2): the item goes, More included — refused below three items (Q2:
|
|
588
|
+
* 3–5), so the bar never shrinks into something a thumb cannot use. Input not mutated.
|
|
589
|
+
*/
|
|
590
|
+
export function removeBarItem(globals: ReadonlyArray<SerializedNode>, itemId: string): BarItemsEditResult {
|
|
591
|
+
const { out, kids } = barOf(globals);
|
|
592
|
+
const i = kids ? kids.findIndex(c => c.id === itemId) : -1;
|
|
593
|
+
if (!kids || i < 0 || kids.length <= BAR_ITEMS_MIN) return { globals: out, changed: false };
|
|
594
|
+
kids.splice(i, 1);
|
|
595
|
+
return { globals: out, changed: true };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The bar's Items list drag-to-reorder (board 2): item `from` moves to `to`. Input not mutated. */
|
|
599
|
+
export function moveBarItem(globals: ReadonlyArray<SerializedNode>, from: number, to: number): BarItemsEditResult {
|
|
600
|
+
const { out, kids } = barOf(globals);
|
|
601
|
+
if (!kids || from === to || from < 0 || to < 0 || from >= kids.length || to >= kids.length) return { globals: out, changed: false };
|
|
602
|
+
const [item] = kids.splice(from, 1);
|
|
603
|
+
kids.splice(to, 0, item);
|
|
604
|
+
return { globals: out, changed: true };
|
|
605
|
+
}
|