@vectoriox/iox-ui 5.31.1 → 5.32.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/actions-core/index.d.ts +52 -2
- package/design-core/index.d.ts +650 -6
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +69 -1
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +791 -20
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +564 -22
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +365 -24
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +126 -6
- package/index.d.ts +245 -2
- package/package.json +1 -1
- package/src/actions-core/dialog-opener.ts +89 -0
- package/src/actions-core/public-api.ts +1 -0
- package/src/design-core/contrast-audit.ts +11 -2
- package/src/design-core/element-styles.ts +6 -3
- package/src/design-core/flavor-spec.generated.ts +737 -11
- package/src/design-core/node-classes.ts +39 -4
- package/src/generation-core/compose-site.ts +9 -1
- package/src/generation-core/nav-shape.ts +553 -0
- package/src/generation-core/node-baselines.generated.ts +23 -8
- package/src/generation-core/patterns.ts +9 -3
- package/src/generation-core/public-api.ts +1 -0
- package/src/generation-core/serialized-node.ts +2 -1
- package/src/generation-core/site-spec.generated.ts +8 -2
- package/styles/iox-design.css +101 -1
|
@@ -52,12 +52,47 @@ export function roleClass(role: unknown): string | null {
|
|
|
52
52
|
return typeof role === 'string' && ROLE_SHAPE.test(role) ? `${ROLE_PREFIX}${role.replace(/\./g, '-')}` : null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** The node shapes this module reads: a serialized node (`props`) or a builder node (`traits[]`). */
|
|
55
|
+
/** The node shapes this module reads: a serialized node (`props`, `styleProps`) or a builder node (`traits[]`, `styleTraits`). */
|
|
56
56
|
export interface DesignClassSource {
|
|
57
57
|
elementStyle?: string | null;
|
|
58
58
|
role?: string | null;
|
|
59
59
|
props?: Readonly<Record<string, unknown>> | null;
|
|
60
60
|
traits?: ReadonlyArray<{ name?: string; default?: unknown }> | null;
|
|
61
|
+
styleProps?: Readonly<Record<string, unknown>> | null;
|
|
62
|
+
styleTraits?: ReadonlyArray<{ traits?: ReadonlyArray<{ name?: string; default?: unknown }> }> | null;
|
|
63
|
+
responsive?: Readonly<Record<string, { styleProps?: Readonly<Record<string, unknown>> } | undefined>> | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The element style whose wearer is the phone's bottom navigation bar (CC1, R1). */
|
|
67
|
+
export const BOTTOM_BAR_STYLE = 'bottom-bar';
|
|
68
|
+
/** The page reserves the bar's height wherever this class is on the page… */
|
|
69
|
+
export const BAR_RESERVE_CLASS = 'iox-bar-reserve';
|
|
70
|
+
/** …the responsive Breakpoint ids that can hold an override, widest first (a spec pins it to BREAKPOINTS). */
|
|
71
|
+
export const BAR_RESERVE_BREAKPOINTS: ReadonlyArray<string> = ['laptop', 'tablet', 'mobile', 'small'];
|
|
72
|
+
|
|
73
|
+
function baseDisplayOf(node: DesignClassSource): unknown {
|
|
74
|
+
if (node.styleProps && 'display' in node.styleProps) return node.styleProps['display'];
|
|
75
|
+
for (const group of node.styleTraits ?? []) {
|
|
76
|
+
const hit = group.traits?.find(t => t?.name === 'display');
|
|
77
|
+
if (hit) return hit.default;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The class that makes the page reserve a bottom bar's height (iox-design.css: `padding-bottom` and
|
|
84
|
+
* `scroll-padding-bottom`, WCAG 2.4.11), only at the widths where the bar SHOWS: `iox-bar-reserve` when
|
|
85
|
+
* it shows at every width, `iox-bar-reserve-<bp>` when it is hidden at base and shown from that
|
|
86
|
+
* breakpoint down (the phone shape's own rule), null when it never shows or is not a bar.
|
|
87
|
+
*/
|
|
88
|
+
export function barReserveClass(node: DesignClassSource | null | undefined): string | null {
|
|
89
|
+
if (!node || node.elementStyle !== BOTTOM_BAR_STYLE) return null;
|
|
90
|
+
if (baseDisplayOf(node) !== 'none') return BAR_RESERVE_CLASS;
|
|
91
|
+
for (const bp of BAR_RESERVE_BREAKPOINTS) {
|
|
92
|
+
const display = node.responsive?.[bp]?.styleProps?.['display'];
|
|
93
|
+
if (display !== undefined && display !== 'none') return `${BAR_RESERVE_CLASS}-${bp}`;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
/** A node's `colorScheme`, from `props` (serialized) or its trait (builder). */
|
|
@@ -74,15 +109,15 @@ export function hasElementStyleMarker(node: { elementStyle?: string | null } | n
|
|
|
74
109
|
return elementStyleClass(node?.elementStyle) !== null;
|
|
75
110
|
}
|
|
76
111
|
|
|
77
|
-
/** The classes a node renders: `'iox-es iox-es-card iox-scheme-inverse'
|
|
112
|
+
/** The classes a node renders: `'iox-es iox-es-card iox-scheme-inverse'` (+ a bottom bar's reserve class), or `''` for none. */
|
|
78
113
|
export function nodeDesignClasses(node: DesignClassSource | null | undefined): string {
|
|
79
114
|
const marker = elementStyleClass(node?.elementStyle);
|
|
80
|
-
return [marker ? `${ELEMENT_STYLE_MARKER_CLASS} ${marker}` : '', colorSchemeClass(colorSchemeOf(node)) ?? '', roleClass(node?.role) ?? '']
|
|
115
|
+
return [marker ? `${ELEMENT_STYLE_MARKER_CLASS} ${marker}` : '', colorSchemeClass(colorSchemeOf(node)) ?? '', roleClass(node?.role) ?? '', barReserveClass(node) ?? '']
|
|
81
116
|
.filter(Boolean).join(' ');
|
|
82
117
|
}
|
|
83
118
|
|
|
84
119
|
const isDesignClass = (c: string) =>
|
|
85
|
-
c === ELEMENT_STYLE_MARKER_CLASS || c.startsWith(MARKER_PREFIX) || c.startsWith(SCHEME_PREFIX) || c.startsWith(ROLE_PREFIX);
|
|
120
|
+
c === ELEMENT_STYLE_MARKER_CLASS || c.startsWith(MARKER_PREFIX) || c.startsWith(SCHEME_PREFIX) || c.startsWith(ROLE_PREFIX) || c.startsWith(BAR_RESERVE_CLASS);
|
|
86
121
|
|
|
87
122
|
/** Make an element's design classes match the node — the live switch on the canvas and the
|
|
88
123
|
* component path's one-time stamp. Removes stale ones; touches no other class. */
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
RelativeOp, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
|
|
11
11
|
} from './site-plan.model';
|
|
12
12
|
import { assertValidSitePlan, nextSourceOf, routeParams } from './validate-site-plan';
|
|
13
|
+
import { NavShape, planNavShape } from './nav-shape';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* THE composer — a Site Plan in, ordinary IOX data out (design-system plan §6b, W1). Deterministic and
|
|
@@ -456,6 +457,7 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
|
|
|
456
457
|
assertTraits(type, Object.keys(props));
|
|
457
458
|
// Props first: a Heading with no `es` wears its LEVEL's default style.
|
|
458
459
|
const node = create(id, type, props, pn.es ?? null);
|
|
460
|
+
if (pn.role) node.role = pn.role;
|
|
459
461
|
|
|
460
462
|
// Words.
|
|
461
463
|
if (words.placeholder) {
|
|
@@ -665,13 +667,19 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
|
|
|
665
667
|
};
|
|
666
668
|
});
|
|
667
669
|
|
|
668
|
-
|
|
670
|
+
let globals = Object.values(GlobalSlot).map(slot => {
|
|
669
671
|
const pattern = patternById(plan.globals[slot]) as SitePattern;
|
|
670
672
|
const node = composePattern(pattern, slot, { ...base, owner: slot, page: null, section: null, nextAlias: null });
|
|
671
673
|
node.isGlobal = true;
|
|
672
674
|
node.globalPosition = pattern.global;
|
|
673
675
|
return node;
|
|
674
676
|
});
|
|
677
|
+
// CC1: a header pattern that names its phone shape gets it — the same edit the author's "Phone menu"
|
|
678
|
+
// section makes, so the composed header and a hand-switched one are one structure.
|
|
679
|
+
for (const slot of Object.values(GlobalSlot)) {
|
|
680
|
+
const shape = patternById(plan.globals[slot])?.phoneMenu;
|
|
681
|
+
if (shape) globals = planNavShape(globals, shape as NavShape, {}, slot).globals;
|
|
682
|
+
}
|
|
675
683
|
|
|
676
684
|
return {
|
|
677
685
|
source: clone(plan.source),
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { LinkType } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { PatternGlobalPosition } from './patterns';
|
|
4
|
+
import { createSerializedNode, SerializedNode, walkSerialized } from './serialized-node';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone shapes of a navigation bar (complex-components plan CC1, §7a + R1; boards 1–3). ONE pure tree
|
|
8
|
+
* edit, `planNavShape`, turns a site's header into the shape the author (the Navigation bar's "Phone
|
|
9
|
+
* menu" section) or the composer (`nav.inline-cta`'s `phoneMenu`) asks for:
|
|
10
|
+
*
|
|
11
|
+
* - **Drawer** — a menu button in the header opens a slide-in Dialog at the end edge that lists every
|
|
12
|
+
* page (board 1). The drawer is a child of the Navigation bar.
|
|
13
|
+
* - **Bottom bar** — an app-style bar fixed to the bottom of the screen with up to five pages, no drawer
|
|
14
|
+
* (board 2). The phone header keeps only the brand (Q4).
|
|
15
|
+
* - **Both** — the bar with four pages and More, which opens the drawer; the drawer is then its own
|
|
16
|
+
* global, beside the Navigation bar (board 2's layer tree).
|
|
17
|
+
*
|
|
18
|
+
* Every part is a real node with a `role` (R2): editable, selectable, styled by its element style. Parts
|
|
19
|
+
* a shape keeps are passed through untouched, so an author's edits survive a change of shape; the parts
|
|
20
|
+
* it no longer has are removed (undo brings them back — the caller records ONE history step). Nothing
|
|
21
|
+
* here reads a clock or a random source: ids come from `newId` (deterministic by default, so the
|
|
22
|
+
* composer's output is byte-stable).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export enum NavShape {
|
|
26
|
+
Drawer = 'drawer',
|
|
27
|
+
Bar = 'bar',
|
|
28
|
+
Both = 'both',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The roles of a navigation's parts (the composite plan's `role` field, here for the nav). */
|
|
32
|
+
export enum NavRole {
|
|
33
|
+
Root = 'nav',
|
|
34
|
+
Brand = 'nav.brand',
|
|
35
|
+
Links = 'nav.links',
|
|
36
|
+
Link = 'nav.link',
|
|
37
|
+
Cta = 'nav.cta',
|
|
38
|
+
MenuButton = 'nav.menu-button',
|
|
39
|
+
Drawer = 'nav.drawer',
|
|
40
|
+
DrawerHead = 'nav.drawer-head',
|
|
41
|
+
DrawerClose = 'nav.drawer-close',
|
|
42
|
+
DrawerLinks = 'nav.drawer-links',
|
|
43
|
+
DrawerLink = 'nav.drawer-link',
|
|
44
|
+
DrawerCta = 'nav.drawer-cta',
|
|
45
|
+
Bar = 'nav.bar',
|
|
46
|
+
BarItem = 'nav.bar-item',
|
|
47
|
+
More = 'nav.more',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The breakpoints a phone menu may switch at (the responsive Breakpoint ids; Q5: Mobile landscape). */
|
|
51
|
+
export enum PhoneMenuBreakpoint {
|
|
52
|
+
Tablet = 'tablet',
|
|
53
|
+
Mobile = 'mobile',
|
|
54
|
+
Small = 'small',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_PHONE_MENU_BREAKPOINT = PhoneMenuBreakpoint.Mobile;
|
|
58
|
+
|
|
59
|
+
/** The bar's item count, More included (Q2: 3–5; iOS and Material both cap at five). */
|
|
60
|
+
export const BAR_ITEMS_MIN = 3;
|
|
61
|
+
export const BAR_ITEMS_MAX = 5;
|
|
62
|
+
|
|
63
|
+
export interface NavShapeOptions {
|
|
64
|
+
/** Where the phone shape takes over (≤ this breakpoint). Default Mobile landscape (≤767). */
|
|
65
|
+
at?: PhoneMenuBreakpoint | `${PhoneMenuBreakpoint}`;
|
|
66
|
+
/** Items in the bar, More included (3–5). Default: 5 for Bottom bar, 4 + More for Both. */
|
|
67
|
+
barItems?: number;
|
|
68
|
+
/** A new part's id (the builder passes its id generator). Default: `<nav id>-<suffix>`. */
|
|
69
|
+
newId?: (suffix: string) => string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface NavShapeResult {
|
|
73
|
+
/** The globals after the edit (the Navigation bar edited in place, the drawer/bar added or removed). */
|
|
74
|
+
globals: SerializedNode[];
|
|
75
|
+
shape: NavShape;
|
|
76
|
+
at: PhoneMenuBreakpoint;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class NavShapeError extends Error {}
|
|
80
|
+
|
|
81
|
+
const CONTAINER = 'Container';
|
|
82
|
+
const DIALOG = 'Dialog';
|
|
83
|
+
const LINK = 'Link';
|
|
84
|
+
const LINKED = 'LinkedContainer';
|
|
85
|
+
const BUTTON = 'ButtonBlock';
|
|
86
|
+
const ICON = 'Icon';
|
|
87
|
+
const TEXT = 'Text';
|
|
88
|
+
const HEADING = 'Heading';
|
|
89
|
+
const NAV_STYLE = 'nav';
|
|
90
|
+
const DISPLAY = 'display';
|
|
91
|
+
const NONE = 'none';
|
|
92
|
+
const CLICK = 'click';
|
|
93
|
+
const HOME_ROUTE = '/';
|
|
94
|
+
const COMPOSED = { releaseLayoutDefaults: true };
|
|
95
|
+
|
|
96
|
+
/** The words the parts carry — site copy, editable like any other (the composer writes English copy). */
|
|
97
|
+
export const NAV_SHAPE_COPY = {
|
|
98
|
+
menu: 'Menu',
|
|
99
|
+
closeMenu: 'Close menu',
|
|
100
|
+
more: 'More',
|
|
101
|
+
home: 'Home',
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
/** The layer names of the parts (R2: the tree, the breadcrumb and the toolbar read them). */
|
|
105
|
+
export const NAV_PART_LABELS: Readonly<Record<string, string>> = {
|
|
106
|
+
[NavRole.Root]: 'Navigation bar',
|
|
107
|
+
[NavRole.Brand]: 'Brand',
|
|
108
|
+
[NavRole.Links]: 'Links',
|
|
109
|
+
[NavRole.MenuButton]: 'Menu button',
|
|
110
|
+
[NavRole.Drawer]: 'Menu drawer',
|
|
111
|
+
[NavRole.DrawerHead]: 'Drawer header',
|
|
112
|
+
[NavRole.DrawerClose]: 'Close',
|
|
113
|
+
[NavRole.DrawerLinks]: 'Drawer links',
|
|
114
|
+
[NavRole.Bar]: 'Bottom bar',
|
|
115
|
+
[NavRole.More]: 'More',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Phosphor icons for the bar's items by route — there is no page icon field (Q6), so the bar starts here and
|
|
119
|
+
* the author picks (the icons are ordinary Icon nodes). */
|
|
120
|
+
const ROUTE_ICONS: ReadonlyArray<[RegExp, string]> = [
|
|
121
|
+
[/^\/?$/, 'ph-thin ph-house'],
|
|
122
|
+
[/work|project|portfolio/i, 'ph-thin ph-squares-four'],
|
|
123
|
+
[/about|studio|team/i, 'ph-thin ph-user'],
|
|
124
|
+
[/journal|blog|news/i, 'ph-thin ph-book-open'],
|
|
125
|
+
[/contact/i, 'ph-thin ph-chat-circle'],
|
|
126
|
+
[/shop|store/i, 'ph-thin ph-bag'],
|
|
127
|
+
];
|
|
128
|
+
const FALLBACK_ICON = 'ph-thin ph-file';
|
|
129
|
+
const MENU_ICON = 'ph-thin ph-list';
|
|
130
|
+
const CLOSE_ICON = 'ph-thin ph-x';
|
|
131
|
+
const MORE_ICON = 'ph-thin ph-dots-three';
|
|
132
|
+
|
|
133
|
+
export function barIconForRoute(route: string): string {
|
|
134
|
+
return ROUTE_ICONS.find(([re]) => re.test(route))?.[1] ?? FALLBACK_ICON;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One page the navigation links to, read off its links. */
|
|
138
|
+
export interface NavPage {
|
|
139
|
+
label: string;
|
|
140
|
+
route: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const roleOf = (n: SerializedNode | null | undefined) => n?.role;
|
|
146
|
+
|
|
147
|
+
function find(nodes: ReadonlyArray<SerializedNode>, role: NavRole): SerializedNode | null {
|
|
148
|
+
let hit: SerializedNode | null = null;
|
|
149
|
+
walkSerialized(nodes, n => { if (!hit && n.role === role) hit = n; });
|
|
150
|
+
return hit;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function findAll(nodes: ReadonlyArray<SerializedNode>, role: NavRole): SerializedNode[] {
|
|
154
|
+
const out: SerializedNode[] = [];
|
|
155
|
+
walkSerialized(nodes, n => { if (n.role === role) out.push(n); });
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The Navigation bar: the global with the root role, else the first global wearing the `nav` style. */
|
|
160
|
+
export function findNavRoot(globals: ReadonlyArray<SerializedNode>, navId?: string | null): SerializedNode | null {
|
|
161
|
+
if (navId) return globals.find(g => g.id === navId) ?? null;
|
|
162
|
+
return globals.find(g => g.role === NavRole.Root) ?? globals.find(g => g.elementStyle === NAV_STYLE && g.type === CONTAINER) ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The shape a site's globals are in now, or null when there is no Navigation bar. */
|
|
166
|
+
export function navShapeOf(globals: ReadonlyArray<SerializedNode>): NavShape | null {
|
|
167
|
+
if (!findNavRoot(globals)) return null;
|
|
168
|
+
const bar = !!find(globals, NavRole.Bar);
|
|
169
|
+
const drawer = !!find(globals, NavRole.Drawer);
|
|
170
|
+
if (bar && drawer) return NavShape.Both;
|
|
171
|
+
if (bar) return NavShape.Bar;
|
|
172
|
+
return drawer ? NavShape.Drawer : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const hiddenAt = (n: SerializedNode): string | null =>
|
|
176
|
+
Object.entries(n.responsive ?? {}).find(([, layer]) => layer?.styleProps?.[DISPLAY] === NONE)?.[0] ?? null;
|
|
177
|
+
|
|
178
|
+
/** The breakpoint the phone shape takes over at, read off the header's links row (default Mobile). */
|
|
179
|
+
export function navShapeBreakpoint(globals: ReadonlyArray<SerializedNode>): PhoneMenuBreakpoint {
|
|
180
|
+
const links = find(globals, NavRole.Links);
|
|
181
|
+
const bp = links ? hiddenAt(links) : null;
|
|
182
|
+
return (Object.values(PhoneMenuBreakpoint) as string[]).includes(bp ?? '') ? bp as PhoneMenuBreakpoint : DEFAULT_PHONE_MENU_BREAKPOINT;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const prop = (n: SerializedNode, name: string) => n.props?.[name];
|
|
186
|
+
const isPageLink = (n: SerializedNode) => prop(n, 'linkType') === LinkType.Page && typeof prop(n, 'pageRoute') === 'string';
|
|
187
|
+
|
|
188
|
+
function textOf(n: SerializedNode): string {
|
|
189
|
+
if (n.type === LINK) return String(prop(n, 'label') ?? '');
|
|
190
|
+
let words = '';
|
|
191
|
+
walkSerialized(n.children ?? [], c => { if (!words && (c.type === TEXT || c.type === HEADING)) words = String(prop(c, 'content') ?? ''); });
|
|
192
|
+
return words;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The pages the header links to, in order: its Links (W1.1 — one plain Link per page in the navigation). */
|
|
196
|
+
export function navPages(root: SerializedNode): NavPage[] {
|
|
197
|
+
const links = findAll([root], NavRole.Link);
|
|
198
|
+
const source = links.length ? links : collectHeaderLinks(root);
|
|
199
|
+
return source.filter(isPageLink).map(n => ({ label: textOf(n), route: String(prop(n, 'pageRoute')) }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A hand-built header: every page Link outside a drawer. */
|
|
203
|
+
function collectHeaderLinks(root: SerializedNode): SerializedNode[] {
|
|
204
|
+
const out: SerializedNode[] = [];
|
|
205
|
+
const visit = (n: SerializedNode) => {
|
|
206
|
+
if (n.type === DIALOG) return;
|
|
207
|
+
if (n.type === LINK) out.push(n);
|
|
208
|
+
(n.children ?? []).forEach(visit);
|
|
209
|
+
};
|
|
210
|
+
(root.children ?? []).forEach(visit);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── building ────────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
interface Kit {
|
|
217
|
+
id: (suffix: string) => string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** New ids: the caller's generator, else `<nav id>-<suffix>` made unique against every id already there. */
|
|
221
|
+
function makeKit(globals: ReadonlyArray<SerializedNode>, rootId: string, newId?: (suffix: string) => string): Kit {
|
|
222
|
+
if (newId) return { id: newId };
|
|
223
|
+
const used = new Set<string>();
|
|
224
|
+
walkSerialized(globals, n => used.add(n.id));
|
|
225
|
+
return {
|
|
226
|
+
id: (suffix: string) => {
|
|
227
|
+
const base = `${rootId}-${suffix}`;
|
|
228
|
+
let id = base;
|
|
229
|
+
for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
|
|
230
|
+
used.add(id);
|
|
231
|
+
return id;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Point an opener's dialog actions at `dialogId` (a drawer rebuilt under a new id keeps its openers working). */
|
|
237
|
+
function retarget(node: SerializedNode, dialogId: string): SerializedNode {
|
|
238
|
+
for (const ix of node.interactions as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
|
|
239
|
+
for (const a of ix.actions ?? []) if (a.type === 'openDialog' || a.type === 'toggleDialog') a.target = dialogId;
|
|
240
|
+
}
|
|
241
|
+
return node;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function part(kit: Kit, suffix: string, type: string, props: Record<string, unknown>, style: string | null, role?: NavRole | null, lay: Record<string, unknown> = {}): SerializedNode {
|
|
245
|
+
const node = createSerializedNode(kit.id(suffix), type, props, style, COMPOSED);
|
|
246
|
+
if (role) node.role = role;
|
|
247
|
+
const label = role ? NAV_PART_LABELS[role] : undefined;
|
|
248
|
+
if (label) node.label = label;
|
|
249
|
+
Object.assign(node.styleProps, lay);
|
|
250
|
+
return node;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function clickTo(kit: Kit, suffix: string, type: 'openDialog' | 'closeDialog', target: string) {
|
|
254
|
+
return { id: kit.id(`${suffix}-click`), trigger: CLICK, actions: [{ type, target, duration: 0, delay: 0, easing: 'ease' }] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Show at the phone width, hidden above it (base `display: none`, the breakpoint's own `display`). */
|
|
258
|
+
function phoneOnly(node: SerializedNode, at: string, display: string): void {
|
|
259
|
+
node.styleProps[DISPLAY] = NONE;
|
|
260
|
+
node.responsive = { ...(node.responsive ?? {}), [at]: { ...(node.responsive?.[at] ?? {}), styleProps: { ...(node.responsive?.[at]?.styleProps ?? {}), [DISPLAY]: display } } };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Hidden at the phone width: the header's links row. */
|
|
264
|
+
function hideAt(node: SerializedNode, at: string): void {
|
|
265
|
+
node.responsive = { ...(node.responsive ?? {}), [at]: { ...(node.responsive?.[at] ?? {}), styleProps: { ...(node.responsive?.[at]?.styleProps ?? {}), [DISPLAY]: NONE } } };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Move every `display` override the shape owns to the new breakpoint. */
|
|
269
|
+
function moveDisplayOverride(node: SerializedNode, to: string): void {
|
|
270
|
+
for (const [bp, layer] of Object.entries(node.responsive ?? {})) {
|
|
271
|
+
if (bp === to || !layer?.styleProps || !(DISPLAY in layer.styleProps)) continue;
|
|
272
|
+
const value = layer.styleProps[DISPLAY];
|
|
273
|
+
const { [DISPLAY]: _drop, ...rest } = layer.styleProps;
|
|
274
|
+
node.responsive![bp] = { ...layer, styleProps: rest };
|
|
275
|
+
if (!Object.keys(rest).length && Object.keys(layer).length === 1) delete node.responsive![bp];
|
|
276
|
+
node.responsive![to] = { ...(node.responsive![to] ?? {}), styleProps: { ...(node.responsive![to]?.styleProps ?? {}), [DISPLAY]: value } };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function iconNode(kit: Kit, suffix: string, iconClass: string): SerializedNode {
|
|
281
|
+
return part(kit, suffix, ICON, { iconClass }, null);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function labelText(kit: Kit, suffix: string, words: string): SerializedNode {
|
|
285
|
+
return part(kit, suffix, TEXT, { content: words, tag: 'span' }, 'button-label');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function menuButton(kit: Kit, drawerId: string, at: string): SerializedNode {
|
|
289
|
+
const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton,
|
|
290
|
+
{ width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' });
|
|
291
|
+
btn.children = [iconNode(kit, 'menu-icon', MENU_ICON)];
|
|
292
|
+
btn.interactions = [clickTo(kit, 'menu', 'openDialog', drawerId)];
|
|
293
|
+
phoneOnly(btn, at, 'inline-flex');
|
|
294
|
+
return btn;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function pageLink(kit: Kit, suffix: string, page: NavPage, role: NavRole, lay: Record<string, unknown> = {}): SerializedNode {
|
|
298
|
+
return part(kit, suffix, LINK, { label: page.label, linkType: LinkType.Page, pageRoute: page.route }, 'link', role, lay);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function drawer(kit: Kit, root: SerializedNode, pages: NavPage[], at: string): SerializedNode {
|
|
302
|
+
const dr = part(kit, 'drawer', DIALOG, {
|
|
303
|
+
placement: 'end', closeOnBackdrop: true, showCloseButton: false, ariaLabel: NAV_SHAPE_COPY.menu, closeAbove: at,
|
|
304
|
+
}, 'dialog', NavRole.Drawer, { width: '86vw', maxWidth: '360px', display: 'flex', flexDirection: 'column', gap: '20px' });
|
|
305
|
+
const brandWords = textOf(find([root], NavRole.Brand) ?? root) || NAV_PART_LABELS[NavRole.Root];
|
|
306
|
+
const head = part(kit, 'drawer-head', CONTAINER, {}, 'box', NavRole.DrawerHead,
|
|
307
|
+
{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px' });
|
|
308
|
+
const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose,
|
|
309
|
+
{ width: '44px', height: '44px', alignItems: 'center', justifyContent: 'center' });
|
|
310
|
+
close.children = [iconNode(kit, 'drawer-close-icon', CLOSE_ICON)];
|
|
311
|
+
close.interactions = [clickTo(kit, 'drawer-close', 'closeDialog', dr.id)];
|
|
312
|
+
head.children = [part(kit, 'drawer-brand', HEADING, { content: brandWords, level: 'h3' }, 'h3'), close];
|
|
313
|
+
const list = part(kit, 'drawer-links', CONTAINER, {}, 'box', NavRole.DrawerLinks,
|
|
314
|
+
{ display: 'flex', flexDirection: 'column', gap: '4px' });
|
|
315
|
+
// Board 1: every page, Home first, at lead size (on each Link: a Link's own baseline font size would
|
|
316
|
+
// beat one inherited from the list). Only the CURRENT page is underlined — the shared CSS keys that on
|
|
317
|
+
// the list's role (iox-design.css), so no Link holds a literal over its element style.
|
|
318
|
+
list.children = withHome(root, pages).map((p, i) => pageLink(kit, `drawer-link-${i}`, p, NavRole.DrawerLink,
|
|
319
|
+
{ display: 'flex', alignItems: 'center', minHeight: '48px', fontSize: 'var(--iox-size-lead)' }));
|
|
320
|
+
dr.children = [head, list];
|
|
321
|
+
const cta = find([root], NavRole.Cta);
|
|
322
|
+
if (cta) {
|
|
323
|
+
const copy = JSON.parse(JSON.stringify(cta)) as SerializedNode;
|
|
324
|
+
const reid = (n: SerializedNode, path: string) => { n.id = kit.id(path); (n.children ?? []).forEach((c, i) => reid(c, `${path}-${i}`)); };
|
|
325
|
+
reid(copy, 'drawer-cta');
|
|
326
|
+
copy.role = NavRole.DrawerCta;
|
|
327
|
+
copy.styleProps = { ...copy.styleProps, alignSelf: 'stretch' };
|
|
328
|
+
dr.children.push(copy);
|
|
329
|
+
}
|
|
330
|
+
return dr;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function barItem(kit: Kit, i: number, page: NavPage): SerializedNode {
|
|
334
|
+
const item = part(kit, `bar-item-${i}`, LINKED, { linkType: LinkType.Page, pageRoute: page.route, target: '_self' }, 'bottom-bar-item', NavRole.BarItem, BAR_ITEM_LAY);
|
|
335
|
+
item.label = page.label;
|
|
336
|
+
item.children = [iconNode(kit, `bar-item-${i}-icon`, barIconForRoute(page.route)), labelText(kit, `bar-item-${i}-label`, page.label)];
|
|
337
|
+
return item;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const BAR_ITEM_LAY = {
|
|
341
|
+
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '3px',
|
|
342
|
+
flexGrow: '1', flexShrink: '1', flexBasis: '0px', minWidth: '0px', minHeight: '52px', padding: '4px 2px', textDecoration: 'none',
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function moreItem(kit: Kit, drawerId: string): SerializedNode {
|
|
346
|
+
const more = part(kit, 'bar-more', BUTTON, { tag: 'button' }, 'bottom-bar-item', NavRole.More, BAR_ITEM_LAY);
|
|
347
|
+
more.children = [iconNode(kit, 'bar-more-icon', MORE_ICON), labelText(kit, 'bar-more-label', NAV_SHAPE_COPY.more)];
|
|
348
|
+
more.interactions = [clickTo(kit, 'bar-more', 'openDialog', drawerId)];
|
|
349
|
+
return more;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Home first, then the header's pages: the header reaches Home through its brand, a phone menu names it. */
|
|
353
|
+
function withHome(root: SerializedNode, pages: NavPage[]): NavPage[] {
|
|
354
|
+
const brand = find([root], NavRole.Brand);
|
|
355
|
+
const home = brand && isPageLink(brand) && prop(brand, 'pageRoute') === HOME_ROUTE && !pages.some(p => p.route === HOME_ROUTE)
|
|
356
|
+
? [{ label: NAV_SHAPE_COPY.home, route: HOME_ROUTE }] : [];
|
|
357
|
+
return [...home, ...pages];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The bar's pages: Home first (a bottom bar's customary first stop), capped. */
|
|
361
|
+
function barPages(root: SerializedNode, pages: NavPage[], count: number): NavPage[] {
|
|
362
|
+
return withHome(root, pages).slice(0, count);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function bar(kit: Kit, root: SerializedNode, pages: NavPage[], shape: NavShape, count: number, drawerId: string | null, at: string): SerializedNode {
|
|
366
|
+
// The node IS fixed (not only its element style): the builder canvas forces a node's own `position`
|
|
367
|
+
// with !important, so a stored `static` default would pin the bar into the flow there.
|
|
368
|
+
const b = part(kit, 'bar', CONTAINER, { htmlTag: 'nav' }, 'bottom-bar', NavRole.Bar,
|
|
369
|
+
{ position: 'fixed', justifyContent: 'space-around', alignItems: 'stretch', gap: '4px', padding: '6px 8px', width: 'auto' });
|
|
370
|
+
phoneOnly(b, at, 'flex');
|
|
371
|
+
const itemCount = shape === NavShape.Both ? count - 1 : count;
|
|
372
|
+
b.children = barPages(root, pages, itemCount).map((p, i) => barItem(kit, i, p));
|
|
373
|
+
if (shape === NavShape.Both && drawerId) b.children.push(moreItem(kit, drawerId));
|
|
374
|
+
b.isGlobal = true;
|
|
375
|
+
b.globalPosition = PatternGlobalPosition.After;
|
|
376
|
+
return b;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── the edit ────────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
382
|
+
|
|
383
|
+
function removeRole(node: SerializedNode, role: NavRole): SerializedNode | null {
|
|
384
|
+
let removed: SerializedNode | null = null;
|
|
385
|
+
const visit = (n: SerializedNode) => {
|
|
386
|
+
if (!n.children) return;
|
|
387
|
+
const i = n.children.findIndex(c => c.role === role);
|
|
388
|
+
if (i >= 0) { removed = n.children[i]; n.children.splice(i, 1); return; }
|
|
389
|
+
n.children.forEach(visit);
|
|
390
|
+
};
|
|
391
|
+
visit(node);
|
|
392
|
+
return removed;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The root's own parts that hide at the phone width: everything but the brand, the menu button and the drawer. */
|
|
396
|
+
function phoneHiddenParts(root: SerializedNode): SerializedNode[] {
|
|
397
|
+
const keep = new Set<string | undefined>([NavRole.Brand, NavRole.MenuButton, NavRole.Drawer]);
|
|
398
|
+
return (root.children ?? []).filter((c, i) => !keep.has(roleOf(c)) && !(i === 0 && !c.role && c.type === LINKED));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Board 2 / board 8: five items either way — five pages (Bottom bar) or four pages + More (Both). */
|
|
402
|
+
function defaultBarCount(_shape: NavShape): number {
|
|
403
|
+
return BAR_ITEMS_MAX;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const JUSTIFY = 'justifyContent';
|
|
407
|
+
const CENTER = 'center';
|
|
408
|
+
|
|
409
|
+
/** Board 2: with a bar, the phone header holds only the brand, centred. The override lives in the phone
|
|
410
|
+
* breakpoint's layer only; switching back to the drawer (or moving the breakpoint) removes it. */
|
|
411
|
+
function centerBrandAt(root: SerializedNode, at: string, on: boolean): void {
|
|
412
|
+
for (const [bp, layer] of Object.entries(root.responsive ?? {})) {
|
|
413
|
+
if (layer?.styleProps?.[JUSTIFY] !== CENTER) continue;
|
|
414
|
+
const { [JUSTIFY]: _drop, ...rest } = layer.styleProps;
|
|
415
|
+
root.responsive![bp] = { ...layer, styleProps: rest };
|
|
416
|
+
}
|
|
417
|
+
if (on) root.responsive = { ...(root.responsive ?? {}), [at]: { ...(root.responsive?.[at] ?? {}), styleProps: { ...(root.responsive?.[at]?.styleProps ?? {}), [JUSTIFY]: CENTER } } };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function clampBarCount(n: number | undefined, shape: NavShape): number {
|
|
421
|
+
const v = Number.isFinite(n) ? Math.round(n as number) : defaultBarCount(shape);
|
|
422
|
+
return Math.min(BAR_ITEMS_MAX, Math.max(BAR_ITEMS_MIN, v));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Put a site's header into `shape`. The Navigation bar is `navId` or the global carrying the root role
|
|
427
|
+
* (else the first Container wearing `nav`, which this then marks with roles). Returns new globals;
|
|
428
|
+
* the input is not mutated.
|
|
429
|
+
*/
|
|
430
|
+
export function planNavShape(globals: ReadonlyArray<SerializedNode>, shape: NavShape, options: NavShapeOptions = {}, navId?: string | null): NavShapeResult {
|
|
431
|
+
if (!(Object.values(NavShape) as string[]).includes(shape)) throw new NavShapeError(`unknown phone menu shape "${shape}"`);
|
|
432
|
+
const out = clone(globals) as SerializedNode[];
|
|
433
|
+
const root = findNavRoot(out, navId);
|
|
434
|
+
if (!root) throw new NavShapeError('no Navigation bar among the globals');
|
|
435
|
+
const at = (options.at ?? navShapeBreakpoint(out)) as PhoneMenuBreakpoint;
|
|
436
|
+
if (!(Object.values(PhoneMenuBreakpoint) as string[]).includes(at)) throw new NavShapeError(`a phone menu cannot switch at "${at}"`);
|
|
437
|
+
const kit = makeKit(out, root.id, options.newId);
|
|
438
|
+
|
|
439
|
+
adoptRoles(root);
|
|
440
|
+
const pages = navPages(root);
|
|
441
|
+
const wantDrawer = shape !== NavShape.Bar;
|
|
442
|
+
const wantBar = shape !== NavShape.Drawer;
|
|
443
|
+
const wantMenuButton = shape === NavShape.Drawer;
|
|
444
|
+
|
|
445
|
+
// The drawer: kept (moved to where the shape puts it) or built; removed for Bar.
|
|
446
|
+
let dr = removeRole(root, NavRole.Drawer);
|
|
447
|
+
const topIndex = out.findIndex(g => g.role === NavRole.Drawer);
|
|
448
|
+
if (!dr && topIndex >= 0) dr = out.splice(topIndex, 1)[0];
|
|
449
|
+
if (dr) { delete dr.isGlobal; delete dr.globalPosition; }
|
|
450
|
+
if (wantDrawer && !dr) dr = drawer(kit, root, pages, at);
|
|
451
|
+
if (!wantDrawer) dr = null;
|
|
452
|
+
if (dr) dr.props = { ...dr.props, closeAbove: at };
|
|
453
|
+
|
|
454
|
+
// The menu button: Drawer only (Q4: with a bar, the phone header keeps only the brand).
|
|
455
|
+
const existingMenu = removeRole(root, NavRole.MenuButton);
|
|
456
|
+
const menu = wantMenuButton ? (existingMenu ?? menuButton(kit, dr!.id, at)) : null;
|
|
457
|
+
|
|
458
|
+
for (const p of phoneHiddenParts(root)) { moveDisplayOverride(p, at); hideAt(p, at); }
|
|
459
|
+
centerBrandAt(root, at, wantBar);
|
|
460
|
+
|
|
461
|
+
const kids = root.children ?? (root.children = []);
|
|
462
|
+
if (menu) { moveDisplayOverride(menu, at); kids.push(menu); }
|
|
463
|
+
if (dr && shape === NavShape.Drawer) kids.push(dr);
|
|
464
|
+
if (dr && shape === NavShape.Both) {
|
|
465
|
+
dr.isGlobal = true;
|
|
466
|
+
dr.globalPosition = PatternGlobalPosition.Before;
|
|
467
|
+
out.splice(out.indexOf(root) + 1, 0, dr);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The bar: kept (its items are the author's; More follows the shape) or built; removed for Drawer.
|
|
471
|
+
const barIndex = out.findIndex(g => g.role === NavRole.Bar);
|
|
472
|
+
let b = barIndex >= 0 ? out.splice(barIndex, 1)[0] : null;
|
|
473
|
+
let more: SerializedNode | null = null;
|
|
474
|
+
if (wantBar) {
|
|
475
|
+
const count = clampBarCount(options.barItems, shape);
|
|
476
|
+
if (!b) b = bar(kit, root, pages, shape, count, dr?.id ?? null, at);
|
|
477
|
+
else {
|
|
478
|
+
moveDisplayOverride(b, at);
|
|
479
|
+
more = (b.children ?? []).find(c => c.role === NavRole.More) ?? null;
|
|
480
|
+
b.children = (b.children ?? []).filter(c => c.role !== NavRole.More);
|
|
481
|
+
if (options.barItems !== undefined) {
|
|
482
|
+
const items = b.children;
|
|
483
|
+
const want = shape === NavShape.Both ? count - 1 : count;
|
|
484
|
+
const extra = barPages(root, pages, BAR_ITEMS_MAX).filter(p => !items.some(c => prop(c, 'pageRoute') === p.route));
|
|
485
|
+
while (items.length > want) items.pop();
|
|
486
|
+
while (items.length < want && extra.length) items.push(barItem(kit, items.length, extra.shift()!));
|
|
487
|
+
}
|
|
488
|
+
if (shape === NavShape.Both && dr) b.children.push(retarget(more ?? moreItem(kit, dr.id), dr.id));
|
|
489
|
+
}
|
|
490
|
+
out.push(b);
|
|
491
|
+
}
|
|
492
|
+
return { globals: out, shape, at };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Mark a hand-built header's parts with their roles, so every later edit can find them. */
|
|
496
|
+
function adoptRoles(root: SerializedNode): void {
|
|
497
|
+
root.role = NavRole.Root;
|
|
498
|
+
const kids = root.children ?? [];
|
|
499
|
+
const brand = kids.find(c => c.role === NavRole.Brand) ?? (kids[0]?.type === LINKED ? kids[0] : null);
|
|
500
|
+
if (brand) brand.role = NavRole.Brand;
|
|
501
|
+
for (const c of kids) {
|
|
502
|
+
if (c === brand || c.role) continue;
|
|
503
|
+
if (c.type === CONTAINER && (c.children ?? []).some(k => k.type === LINK)) {
|
|
504
|
+
c.role = NavRole.Links;
|
|
505
|
+
for (const k of c.children ?? []) {
|
|
506
|
+
if (k.role) continue;
|
|
507
|
+
if (k.type === LINK) k.role = NavRole.Link;
|
|
508
|
+
else if (k.type === BUTTON) k.role = NavRole.Cta;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
nameParts(root);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Board 3's layer names for the header's own parts, where the author has not named them: Brand, Links,
|
|
516
|
+
* and the call to action by its words ("Get in touch"). */
|
|
517
|
+
function nameParts(root: SerializedNode): void {
|
|
518
|
+
for (const role of [NavRole.Brand, NavRole.Links]) {
|
|
519
|
+
const part = find([root], role);
|
|
520
|
+
if (part && !part.label) part.label = NAV_PART_LABELS[role];
|
|
521
|
+
}
|
|
522
|
+
const cta = find([root], NavRole.Cta);
|
|
523
|
+
if (cta && !cta.label) {
|
|
524
|
+
const words = textOf(cta);
|
|
525
|
+
if (words) cta.label = words;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface AddBarItemResult {
|
|
530
|
+
globals: SerializedNode[];
|
|
531
|
+
/** False when the bar is full (5 items, More included — Q2) or there is no bar. */
|
|
532
|
+
added: boolean;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The bar's Items list "Add item" (board 2): one more item, before More — the next navigation page the
|
|
537
|
+
* bar does not link to yet (Home when every page is there). Refused at five (Q2). Input not mutated.
|
|
538
|
+
*/
|
|
539
|
+
export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick<NavShapeOptions, 'newId'> = {}): AddBarItemResult {
|
|
540
|
+
const out = clone(globals) as SerializedNode[];
|
|
541
|
+
const root = findNavRoot(out);
|
|
542
|
+
const b = find(out, NavRole.Bar);
|
|
543
|
+
if (!root || !b) return { globals: out, added: false };
|
|
544
|
+
const kids = b.children ?? (b.children = []);
|
|
545
|
+
if (kids.length >= BAR_ITEMS_MAX) return { globals: out, added: false };
|
|
546
|
+
const linked = new Set(kids.filter(c => c.role === NavRole.BarItem).map(c => prop(c, 'pageRoute')));
|
|
547
|
+
const page = barPages(root, navPages(root), BAR_ITEMS_MAX + 1).find(p => !linked.has(p.route))
|
|
548
|
+
?? { label: NAV_SHAPE_COPY.home, route: HOME_ROUTE };
|
|
549
|
+
const item = barItem(makeKit(out, root.id, options.newId), kids.length, page);
|
|
550
|
+
const more = kids.findIndex(c => c.role === NavRole.More);
|
|
551
|
+
if (more >= 0) kids.splice(more, 0, item); else kids.push(item);
|
|
552
|
+
return { globals: out, added: true };
|
|
553
|
+
}
|