meno-core 1.0.19 → 1.0.21
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/.claude/settings.local.json +7 -0
- package/lib/client/core/ComponentBuilder.test.ts +68 -56
- package/lib/client/core/ComponentBuilder.ts +6 -4
- package/lib/client/core/builders/embedBuilder.ts +10 -1
- package/lib/client/core/builders/index.ts +6 -2
- package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
- package/lib/client/responsiveStyleResolver.test.ts +12 -12
- package/lib/client/responsiveStyleResolver.ts +19 -7
- package/lib/client/routing/Router.tsx +35 -7
- package/lib/client/templateEngine.test.ts +126 -0
- package/lib/client/templateEngine.ts +53 -13
- package/lib/server/jsonLoader.test.ts +4 -1
- package/lib/server/jsonLoader.ts +64 -15
- package/lib/server/services/configService.ts +68 -13
- package/lib/server/ssr/attributeBuilder.ts +8 -0
- package/lib/server/ssr/index.ts +1 -1
- package/lib/server/ssr/ssrRenderer.ts +245 -111
- package/lib/server/ssrRenderer.test.ts +197 -3
- package/lib/server/validateStyleCoverage.ts +14 -17
- package/lib/shared/breakpoints.test.ts +210 -23
- package/lib/shared/breakpoints.ts +124 -17
- package/lib/shared/constants.test.ts +1 -1
- package/lib/shared/constants.ts +5 -1
- package/lib/shared/cssGeneration.test.ts +17 -0
- package/lib/shared/cssGeneration.ts +49 -12
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.test.ts +44 -2
- package/lib/shared/itemTemplateUtils.ts +15 -2
- package/lib/shared/nodeUtils.ts +23 -4
- package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
- package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
- package/lib/shared/registry/nodeTypes/index.ts +6 -5
- package/lib/shared/responsiveScaling.test.ts +87 -0
- package/lib/shared/responsiveScaling.ts +33 -29
- package/lib/shared/responsiveStyleUtils.test.ts +7 -7
- package/lib/shared/responsiveStyleUtils.ts +22 -16
- package/lib/shared/styleNodeUtils.ts +5 -5
- package/lib/shared/styleValueRegistry.ts +60 -5
- package/lib/shared/tree/PathBuilder.ts +3 -3
- package/lib/shared/treePathUtils.ts +7 -5
- package/lib/shared/types/cms.ts +4 -57
- package/lib/shared/types/components.ts +45 -4
- package/lib/shared/types/index.ts +13 -0
- package/lib/shared/utilityClassConfig.ts +14 -0
- package/lib/shared/utilityClassMapper.ts +43 -2
- package/lib/shared/validation/propValidator.ts +9 -1
- package/lib/shared/validation/schemas.ts +60 -14
- package/package.json +1 -1
- package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified List Node Type Definition
|
|
3
|
+
* Renders children for each item from either component props or CMS collections.
|
|
4
|
+
*
|
|
5
|
+
* Replaces both the original 'list' and 'cms-list' node types with a unified
|
|
6
|
+
* implementation that uses sourceType to determine data source.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { createElement as h } from 'react';
|
|
11
|
+
import { StyleValueSchema, InteractiveStylesSchema, IfConditionSchema } from '../../validation/schemas';
|
|
12
|
+
import { createNodeType } from '../createNodeType';
|
|
13
|
+
import { NODE_TYPE } from '../../constants';
|
|
14
|
+
|
|
15
|
+
// Filter condition schema (shared with CMS list queries)
|
|
16
|
+
const CMSFilterConditionSchema = z.object({
|
|
17
|
+
field: z.string(),
|
|
18
|
+
operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']).optional(),
|
|
19
|
+
value: z.unknown(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Sort configuration schema
|
|
23
|
+
const CMSSortConfigSchema = z.union([
|
|
24
|
+
z.object({
|
|
25
|
+
field: z.string(),
|
|
26
|
+
order: z.enum(['asc', 'desc']).optional(),
|
|
27
|
+
}),
|
|
28
|
+
z.array(z.object({
|
|
29
|
+
field: z.string(),
|
|
30
|
+
order: z.enum(['asc', 'desc']).optional(),
|
|
31
|
+
})),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// Schema is the SINGLE source of truth
|
|
35
|
+
const ListNodeSchemaInternal = z.object({
|
|
36
|
+
type: z.literal('list'),
|
|
37
|
+
/**
|
|
38
|
+
* Data source type:
|
|
39
|
+
* - 'prop': Read items from component props (default)
|
|
40
|
+
* - 'collection': Query items from CMS collection
|
|
41
|
+
*/
|
|
42
|
+
sourceType: z.enum(['prop', 'collection']).default('prop'),
|
|
43
|
+
/**
|
|
44
|
+
* Source identifier:
|
|
45
|
+
* - For sourceType 'prop': Prop name (e.g., "items") or template expression (e.g., "{{category.items}}")
|
|
46
|
+
* - For sourceType 'collection': Collection name (e.g., "posts", "authors")
|
|
47
|
+
*/
|
|
48
|
+
source: z.string(),
|
|
49
|
+
/**
|
|
50
|
+
* HTML element tag for the list container.
|
|
51
|
+
* Defaults to 'div'. Common alternatives: 'ul', 'ol', 'section', 'nav', 'article'.
|
|
52
|
+
*/
|
|
53
|
+
tag: z.string().optional(),
|
|
54
|
+
label: z.string().optional(), // Custom label displayed in structure tree
|
|
55
|
+
if: IfConditionSchema.optional(), // Conditional rendering - skip node when false
|
|
56
|
+
style: StyleValueSchema.optional(),
|
|
57
|
+
interactiveStyles: InteractiveStylesSchema.optional(), // Interactive CSS rules (hover, active, etc.)
|
|
58
|
+
generateElementClass: z.boolean().optional(), // Generate element class without styles (for custom CSS)
|
|
59
|
+
attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
60
|
+
/**
|
|
61
|
+
* Variable name for item in templates.
|
|
62
|
+
* - For sourceType 'prop': defaults to 'item'
|
|
63
|
+
* - For sourceType 'collection': defaults to singularized collection name
|
|
64
|
+
*/
|
|
65
|
+
itemAs: z.string().optional(),
|
|
66
|
+
|
|
67
|
+
// Collection-only options (ignored when sourceType: 'prop')
|
|
68
|
+
/** Direct item IDs or template expression for referenced items (e.g., "{{post.authorId}}") */
|
|
69
|
+
items: z.union([z.string(), z.array(z.string())]).optional(),
|
|
70
|
+
/** Filter conditions */
|
|
71
|
+
filter: z.union([
|
|
72
|
+
CMSFilterConditionSchema,
|
|
73
|
+
z.array(CMSFilterConditionSchema),
|
|
74
|
+
z.record(z.unknown()),
|
|
75
|
+
]).optional(),
|
|
76
|
+
/** Sort configuration */
|
|
77
|
+
sort: CMSSortConfigSchema.optional(),
|
|
78
|
+
/** Exclude the current CMS item from the list (useful for "related items" sections) */
|
|
79
|
+
excludeCurrentItem: z.boolean().optional(),
|
|
80
|
+
/**
|
|
81
|
+
* Emit item template for dynamic client-side rendering.
|
|
82
|
+
* When true, SSR emits a `<template data-meno-item>` element with unprocessed
|
|
83
|
+
* {{item.field}} placeholders. MenoFilter uses this to render items beyond
|
|
84
|
+
* the SSR'd limit dynamically.
|
|
85
|
+
*/
|
|
86
|
+
emitTemplate: z.boolean().optional(),
|
|
87
|
+
|
|
88
|
+
// Shared options
|
|
89
|
+
/** Maximum number of items to return */
|
|
90
|
+
limit: z.number().optional(),
|
|
91
|
+
/** Number of items to skip */
|
|
92
|
+
offset: z.number().optional(),
|
|
93
|
+
/** Children are repeated for each item */
|
|
94
|
+
children: z.array(z.unknown()).optional(),
|
|
95
|
+
}).passthrough();
|
|
96
|
+
|
|
97
|
+
// TypeScript type inferred from schema
|
|
98
|
+
export type ListNode = z.infer<typeof ListNodeSchemaInternal>;
|
|
99
|
+
|
|
100
|
+
// Export schema for validation/schemas.ts
|
|
101
|
+
export const ListNodeSchema = ListNodeSchemaInternal;
|
|
102
|
+
|
|
103
|
+
export const ListNodeType = createNodeType({
|
|
104
|
+
type: NODE_TYPE.LIST,
|
|
105
|
+
displayName: 'List',
|
|
106
|
+
category: 'special',
|
|
107
|
+
schema: ListNodeSchemaInternal,
|
|
108
|
+
|
|
109
|
+
defaultValues: {
|
|
110
|
+
sourceType: 'prop',
|
|
111
|
+
source: '',
|
|
112
|
+
children: [],
|
|
113
|
+
style: { base: {} },
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
treeDisplay: {
|
|
117
|
+
icon: 'HTML_ELEMENT',
|
|
118
|
+
getLabel: (node) => {
|
|
119
|
+
const listNode = node as ListNode;
|
|
120
|
+
const sourceType = listNode.sourceType || 'prop';
|
|
121
|
+
const prefix = sourceType === 'collection' ? 'CMS List' : 'List';
|
|
122
|
+
return listNode.source ? `${prefix}: ${listNode.source}` : prefix;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
clientRenderer: (node, context) => {
|
|
127
|
+
const listNode = node as ListNode;
|
|
128
|
+
const sourceType = listNode.sourceType || 'prop';
|
|
129
|
+
const isCollection = sourceType === 'collection';
|
|
130
|
+
|
|
131
|
+
// Different styling for collection vs prop lists
|
|
132
|
+
const bgColor = isCollection ? 'rgba(139, 92, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)';
|
|
133
|
+
const borderColor = isCollection ? 'rgba(139, 92, 246, 0.5)' : 'rgba(59, 130, 246, 0.5)';
|
|
134
|
+
const textColor = isCollection ? '#8b5cf6' : '#3b82f6';
|
|
135
|
+
const label = isCollection ? 'CMS List' : 'List';
|
|
136
|
+
|
|
137
|
+
return h('div', {
|
|
138
|
+
key: context.key,
|
|
139
|
+
'data-list': 'true',
|
|
140
|
+
'data-source-type': sourceType,
|
|
141
|
+
'data-source': listNode.source,
|
|
142
|
+
style: {
|
|
143
|
+
padding: '8px 12px',
|
|
144
|
+
background: bgColor,
|
|
145
|
+
border: `1px dashed ${borderColor}`,
|
|
146
|
+
borderRadius: '4px',
|
|
147
|
+
color: textColor,
|
|
148
|
+
fontSize: '12px',
|
|
149
|
+
},
|
|
150
|
+
}, `[${label}: ${listNode.source || 'No source'}]`);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
ssrRenderer: (_node, _context) => {
|
|
154
|
+
// Placeholder - actual SSR is handled by processList in ssrRenderer.ts
|
|
155
|
+
return '<!-- list rendered by processList -->';
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
capabilities: {
|
|
159
|
+
canHaveChildren: true,
|
|
160
|
+
requiresProps: ['source'],
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
editableFields: [
|
|
164
|
+
{
|
|
165
|
+
name: 'sourceType',
|
|
166
|
+
label: 'Source Type',
|
|
167
|
+
type: 'select',
|
|
168
|
+
required: false,
|
|
169
|
+
options: ['prop', 'collection'],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'source',
|
|
173
|
+
label: 'Source',
|
|
174
|
+
type: 'string',
|
|
175
|
+
required: true,
|
|
176
|
+
placeholder: 'e.g., items or posts'
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'itemAs',
|
|
180
|
+
label: 'Item Variable',
|
|
181
|
+
type: 'string',
|
|
182
|
+
required: false,
|
|
183
|
+
placeholder: 'default: item (prop) or singular (collection)'
|
|
184
|
+
}
|
|
185
|
+
],
|
|
186
|
+
});
|
|
@@ -7,8 +7,14 @@ import { z } from 'zod';
|
|
|
7
7
|
import { createNodeType } from '../createNodeType';
|
|
8
8
|
|
|
9
9
|
// Schema is the SINGLE source of truth
|
|
10
|
+
// Note: default uses z.any() to avoid circular reference with ComponentNodeSchema
|
|
11
|
+
// Full validation is done via the schema in validation/schemas.ts
|
|
10
12
|
const SlotMarkerSchemaInternal = z.object({
|
|
11
13
|
type: z.literal('slot'),
|
|
14
|
+
default: z.union([
|
|
15
|
+
z.array(z.any()),
|
|
16
|
+
z.string(),
|
|
17
|
+
]).optional(),
|
|
12
18
|
}).passthrough();
|
|
13
19
|
|
|
14
20
|
// TypeScript type inferred from schema
|
|
@@ -16,7 +16,8 @@ import { SlotMarkerType, SlotMarkerSchema, type SlotMarker } from './SlotMarkerT
|
|
|
16
16
|
import { EmbedNodeType, EmbedNodeSchema, type EmbedNode } from './EmbedNodeType';
|
|
17
17
|
import { LinkNodeType, LinkNodeSchema, type LinkNode } from './LinkNodeType';
|
|
18
18
|
import { LocaleListNodeType, LocaleListNodeSchema, type LocaleListNode } from './LocaleListNodeType';
|
|
19
|
-
import {
|
|
19
|
+
import { ListNodeType, ListNodeSchema, type ListNode } from './ListNodeType';
|
|
20
|
+
|
|
20
21
|
/**
|
|
21
22
|
* All built-in node type definitions
|
|
22
23
|
* Type assertion needed because TypeScript sees each as a specific generic type
|
|
@@ -28,7 +29,7 @@ export const builtInNodeTypes: NodeTypeDefinition[] = [
|
|
|
28
29
|
EmbedNodeType as NodeTypeDefinition,
|
|
29
30
|
LinkNodeType as NodeTypeDefinition,
|
|
30
31
|
LocaleListNodeType as NodeTypeDefinition,
|
|
31
|
-
|
|
32
|
+
ListNodeType as NodeTypeDefinition,
|
|
32
33
|
];
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -41,7 +42,7 @@ export const nodeSchemas = {
|
|
|
41
42
|
embed: EmbedNodeSchema,
|
|
42
43
|
link: LinkNodeSchema,
|
|
43
44
|
localeList: LocaleListNodeSchema,
|
|
44
|
-
|
|
45
|
+
list: ListNodeSchema,
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -59,7 +60,7 @@ export { SlotMarkerType, SlotMarkerSchema, type SlotMarker } from './SlotMarkerT
|
|
|
59
60
|
export { EmbedNodeType, EmbedNodeSchema, type EmbedNode } from './EmbedNodeType';
|
|
60
61
|
export { LinkNodeType, LinkNodeSchema, type LinkNode } from './LinkNodeType';
|
|
61
62
|
export { LocaleListNodeType, LocaleListNodeSchema, type LocaleListNode } from './LocaleListNodeType';
|
|
62
|
-
export {
|
|
63
|
+
export { ListNodeType, ListNodeSchema, type ListNode } from './ListNodeType';
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* Discriminated union of all component node types
|
|
@@ -72,4 +73,4 @@ export type ComponentNode =
|
|
|
72
73
|
| EmbedNode
|
|
73
74
|
| LinkNode
|
|
74
75
|
| LocaleListNode
|
|
75
|
-
|
|
|
76
|
+
| ListNode;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_RESPONSIVE_SCALES,
|
|
10
10
|
type ResponsiveScales,
|
|
11
11
|
type CSSPropertyType,
|
|
12
|
+
type BreakpointScales,
|
|
12
13
|
} from './responsiveScaling';
|
|
13
14
|
|
|
14
15
|
describe('responsiveScaling', () => {
|
|
@@ -92,6 +93,26 @@ describe('responsiveScaling', () => {
|
|
|
92
93
|
const scale = getScaleMultiplier(scales, 'fontSize', 'mobile');
|
|
93
94
|
expect(scale).toBeNull();
|
|
94
95
|
});
|
|
96
|
+
|
|
97
|
+
test('should support custom breakpoint names', () => {
|
|
98
|
+
const scales: ResponsiveScales = {
|
|
99
|
+
enabled: true,
|
|
100
|
+
baseReference: 16,
|
|
101
|
+
fontSize: { large: 0.95, tablet: 0.88, small: 0.82, mobile: 0.75 }
|
|
102
|
+
};
|
|
103
|
+
expect(getScaleMultiplier(scales, 'fontSize', 'large')).toBe(0.95);
|
|
104
|
+
expect(getScaleMultiplier(scales, 'fontSize', 'small')).toBe(0.82);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('should return null for undefined custom breakpoint', () => {
|
|
108
|
+
const scales: ResponsiveScales = {
|
|
109
|
+
enabled: true,
|
|
110
|
+
baseReference: 16,
|
|
111
|
+
fontSize: { tablet: 0.88, mobile: 0.75 }
|
|
112
|
+
};
|
|
113
|
+
expect(getScaleMultiplier(scales, 'fontSize', 'large')).toBeNull();
|
|
114
|
+
expect(getScaleMultiplier(scales, 'fontSize', 'small')).toBeNull();
|
|
115
|
+
});
|
|
95
116
|
});
|
|
96
117
|
|
|
97
118
|
describe('parseMultiValue', () => {
|
|
@@ -239,6 +260,72 @@ describe('responsiveScaling', () => {
|
|
|
239
260
|
expect(result.tablet).toBeDefined();
|
|
240
261
|
expect(result.mobile).toBeUndefined();
|
|
241
262
|
});
|
|
263
|
+
|
|
264
|
+
test('should calculate values for custom breakpoints', () => {
|
|
265
|
+
const scales: ResponsiveScales = {
|
|
266
|
+
enabled: true,
|
|
267
|
+
baseReference: 16,
|
|
268
|
+
fontSize: { large: 0.95, tablet: 0.88, small: 0.82, mobile: 0.75 }
|
|
269
|
+
};
|
|
270
|
+
const result = getResponsiveValues(
|
|
271
|
+
'67px',
|
|
272
|
+
'fontSize',
|
|
273
|
+
scales,
|
|
274
|
+
['large', 'tablet', 'small', 'mobile']
|
|
275
|
+
);
|
|
276
|
+
expect(result.base).toBe('67px');
|
|
277
|
+
expect(result.large).toBe('64px'); // 67 + (67-16) * (0.95-1) = 67 - 2.55 ≈ 64
|
|
278
|
+
expect(result.tablet).toBe('61px'); // 0.88 scale
|
|
279
|
+
expect(result.small).toBe('58px'); // 67 + (67-16) * (0.82-1) = 67 - 9.18 ≈ 58
|
|
280
|
+
expect(result.mobile).toBe('54px'); // 0.75 scale
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('should only calculate for specified breakpoints', () => {
|
|
284
|
+
const scales: ResponsiveScales = {
|
|
285
|
+
enabled: true,
|
|
286
|
+
baseReference: 16,
|
|
287
|
+
fontSize: { large: 0.95, tablet: 0.88, small: 0.82, mobile: 0.75 }
|
|
288
|
+
};
|
|
289
|
+
// Only request large and small breakpoints
|
|
290
|
+
const result = getResponsiveValues(
|
|
291
|
+
'67px',
|
|
292
|
+
'fontSize',
|
|
293
|
+
scales,
|
|
294
|
+
['large', 'small']
|
|
295
|
+
);
|
|
296
|
+
expect(result.base).toBe('67px');
|
|
297
|
+
expect(result.large).toBe('64px');
|
|
298
|
+
expect(result.small).toBe('58px');
|
|
299
|
+
expect(result.tablet).toBeUndefined();
|
|
300
|
+
expect(result.mobile).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('should gracefully handle breakpoints without scale config', () => {
|
|
304
|
+
const scales: ResponsiveScales = {
|
|
305
|
+
enabled: true,
|
|
306
|
+
baseReference: 16,
|
|
307
|
+
fontSize: { tablet: 0.88, mobile: 0.75 }
|
|
308
|
+
};
|
|
309
|
+
// Request breakpoints that don't have scales configured
|
|
310
|
+
const result = getResponsiveValues(
|
|
311
|
+
'67px',
|
|
312
|
+
'fontSize',
|
|
313
|
+
scales,
|
|
314
|
+
['large', 'tablet', 'small', 'mobile']
|
|
315
|
+
);
|
|
316
|
+
expect(result.base).toBe('67px');
|
|
317
|
+
expect(result.large).toBeUndefined(); // No scale configured
|
|
318
|
+
expect(result.tablet).toBe('61px');
|
|
319
|
+
expect(result.small).toBeUndefined(); // No scale configured
|
|
320
|
+
expect(result.mobile).toBe('54px');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('should default to tablet/mobile breakpoints for backward compatibility', () => {
|
|
324
|
+
const scales = { ...DEFAULT_RESPONSIVE_SCALES, enabled: true };
|
|
325
|
+
// No breakpointNames argument - should default to ['tablet', 'mobile']
|
|
326
|
+
const result = getResponsiveValues('67px', 'fontSize', scales);
|
|
327
|
+
expect(Object.keys(result).sort()).toEqual(['base', 'mobile', 'tablet']);
|
|
328
|
+
});
|
|
242
329
|
});
|
|
243
330
|
|
|
244
331
|
describe('DEFAULT_RESPONSIVE_SCALES', () => {
|
|
@@ -4,26 +4,21 @@
|
|
|
4
4
|
* using configured scale multipliers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Scale configuration for a CSS property category
|
|
9
|
+
* Keys are breakpoint names (e.g., 'tablet', 'mobile', 'small')
|
|
10
|
+
* Values are scale multipliers (e.g., 0.88, 0.75)
|
|
11
|
+
*/
|
|
12
|
+
export type BreakpointScales = Record<string, number>;
|
|
13
|
+
|
|
7
14
|
export interface ResponsiveScales {
|
|
8
15
|
enabled: boolean;
|
|
9
16
|
baseReference: number;
|
|
10
|
-
fontSize?:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
tablet?: number;
|
|
16
|
-
mobile?: number;
|
|
17
|
-
};
|
|
18
|
-
margin?: {
|
|
19
|
-
tablet?: number;
|
|
20
|
-
mobile?: number;
|
|
21
|
-
};
|
|
22
|
-
gap?: {
|
|
23
|
-
tablet?: number;
|
|
24
|
-
mobile?: number;
|
|
25
|
-
};
|
|
26
|
-
[key: string]: any;
|
|
17
|
+
fontSize?: BreakpointScales;
|
|
18
|
+
padding?: BreakpointScales;
|
|
19
|
+
margin?: BreakpointScales;
|
|
20
|
+
gap?: BreakpointScales;
|
|
21
|
+
[key: string]: boolean | number | BreakpointScales | undefined;
|
|
27
22
|
}
|
|
28
23
|
|
|
29
24
|
export type CSSPropertyType = 'fontSize' | 'padding' | 'margin' | 'gap' | 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft' | 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft' | 'rowGap' | 'columnGap';
|
|
@@ -73,16 +68,19 @@ export function calculateResponsiveValue(
|
|
|
73
68
|
|
|
74
69
|
/**
|
|
75
70
|
* Get the scale multiplier for a specific property and breakpoint
|
|
71
|
+
* @param scales - The responsive scales configuration
|
|
72
|
+
* @param property - The CSS property type (e.g., 'fontSize', 'padding')
|
|
73
|
+
* @param breakpoint - The breakpoint name (e.g., 'tablet', 'mobile', 'small')
|
|
76
74
|
*/
|
|
77
75
|
export function getScaleMultiplier(
|
|
78
76
|
scales: ResponsiveScales,
|
|
79
77
|
property: CSSPropertyType,
|
|
80
|
-
breakpoint:
|
|
78
|
+
breakpoint: string
|
|
81
79
|
): number | null {
|
|
82
80
|
const category = getScaleCategory(property);
|
|
83
81
|
if (!category || !scales[category]) return null;
|
|
84
82
|
|
|
85
|
-
const scaleConfig = scales[category] as
|
|
83
|
+
const scaleConfig = scales[category] as BreakpointScales | undefined;
|
|
86
84
|
return scaleConfig?.[breakpoint] ?? null;
|
|
87
85
|
}
|
|
88
86
|
|
|
@@ -143,11 +141,18 @@ export function scalePropertyValue(
|
|
|
143
141
|
/**
|
|
144
142
|
* Get responsive values for all breakpoints
|
|
145
143
|
* Returns object with calculated values for each breakpoint
|
|
144
|
+
*
|
|
145
|
+
* @param baseValue - The base CSS value (e.g., '67px')
|
|
146
|
+
* @param property - The CSS property type (e.g., 'fontSize')
|
|
147
|
+
* @param scales - The responsive scales configuration
|
|
148
|
+
* @param breakpointNames - Optional array of breakpoint names to calculate values for.
|
|
149
|
+
* If not provided, defaults to ['tablet', 'mobile'] for backward compatibility.
|
|
146
150
|
*/
|
|
147
151
|
export function getResponsiveValues(
|
|
148
152
|
baseValue: string,
|
|
149
153
|
property: CSSPropertyType,
|
|
150
|
-
scales: ResponsiveScales
|
|
154
|
+
scales: ResponsiveScales,
|
|
155
|
+
breakpointNames?: string[]
|
|
151
156
|
): Record<string, string | null> {
|
|
152
157
|
if (!scales.enabled) {
|
|
153
158
|
return { base: baseValue };
|
|
@@ -159,16 +164,15 @@ export function getResponsiveValues(
|
|
|
159
164
|
|
|
160
165
|
const baseRef = scales.baseReference || 16;
|
|
161
166
|
|
|
162
|
-
//
|
|
163
|
-
const
|
|
164
|
-
if (tabletScale !== null) {
|
|
165
|
-
result.tablet = scalePropertyValue(baseValue, baseRef, tabletScale);
|
|
166
|
-
}
|
|
167
|
+
// Use provided breakpoint names or default to tablet/mobile for backward compatibility
|
|
168
|
+
const breakpoints = breakpointNames ?? ['tablet', 'mobile'];
|
|
167
169
|
|
|
168
|
-
// Calculate
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
// Calculate value for each breakpoint
|
|
171
|
+
for (const breakpointName of breakpoints) {
|
|
172
|
+
const scale = getScaleMultiplier(scales, property, breakpointName);
|
|
173
|
+
if (scale !== null) {
|
|
174
|
+
result[breakpointName] = scalePropertyValue(baseValue, baseRef, scale);
|
|
175
|
+
}
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
return result;
|
|
@@ -11,8 +11,8 @@ import type { BreakpointConfig } from './breakpoints';
|
|
|
11
11
|
|
|
12
12
|
describe('responsiveStyleUtils', () => {
|
|
13
13
|
const breakpoints: BreakpointConfig = {
|
|
14
|
-
tablet: 1024,
|
|
15
|
-
mobile: 540,
|
|
14
|
+
tablet: { breakpoint: 1024, previewPoint: 768 },
|
|
15
|
+
mobile: { breakpoint: 540, previewPoint: 375 },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
describe('mergeResponsiveStyles', () => {
|
|
@@ -110,9 +110,9 @@ describe('responsiveStyleUtils', () => {
|
|
|
110
110
|
|
|
111
111
|
test('should handle custom breakpoints', () => {
|
|
112
112
|
const customBreakpoints: BreakpointConfig = {
|
|
113
|
-
small: 480,
|
|
114
|
-
medium: 768,
|
|
115
|
-
large: 1200,
|
|
113
|
+
small: { breakpoint: 480, previewPoint: 375 },
|
|
114
|
+
medium: { breakpoint: 768, previewPoint: 768 },
|
|
115
|
+
large: { breakpoint: 1200, previewPoint: 1200 },
|
|
116
116
|
};
|
|
117
117
|
|
|
118
118
|
const style: ResponsiveStyleObject = {
|
|
@@ -239,8 +239,8 @@ describe('responsiveStyleUtils', () => {
|
|
|
239
239
|
|
|
240
240
|
test('should work with custom breakpoints', () => {
|
|
241
241
|
const customBreakpoints: BreakpointConfig = {
|
|
242
|
-
small: 480,
|
|
243
|
-
large: 1920,
|
|
242
|
+
small: { breakpoint: 480, previewPoint: 375 },
|
|
243
|
+
large: { breakpoint: 1920, previewPoint: 1920 },
|
|
244
244
|
};
|
|
245
245
|
|
|
246
246
|
expect(breakpointToViewportWidth('small', customBreakpoints)).toBe(480);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ResponsiveStyleObject, StyleObject, StyleValue } from './types';
|
|
7
7
|
import type { BreakpointConfig, BreakpointName } from './breakpoints';
|
|
8
|
-
import { DEFAULT_BREAKPOINTS, getBreakpointName } from './breakpoints';
|
|
8
|
+
import { DEFAULT_BREAKPOINTS, getBreakpointName, getBreakpointValues } from './breakpoints';
|
|
9
9
|
import { isResponsiveStyle } from './styleUtils';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -31,13 +31,16 @@ export function mergeResponsiveStyles(
|
|
|
31
31
|
): StyleObject {
|
|
32
32
|
// Start with base styles
|
|
33
33
|
const merged: StyleObject = { ...(responsiveStyle.base || {}) };
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
// Extract breakpoint values for comparisons
|
|
36
|
+
const breakpointValues = getBreakpointValues(breakpoints);
|
|
37
|
+
|
|
35
38
|
if (strategy === 'all') {
|
|
36
39
|
// Merge all breakpoints in order: base → breakpoints sorted by value descending
|
|
37
|
-
// Get all breakpoint names and sort by value (descending)
|
|
38
|
-
const breakpointEntries = Object.entries(
|
|
40
|
+
// Get all breakpoint names and sort by breakpoint value (descending)
|
|
41
|
+
const breakpointEntries = Object.entries(breakpointValues);
|
|
39
42
|
breakpointEntries.sort((a, b) => b[1] - a[1]); // Sort descending by value
|
|
40
|
-
|
|
43
|
+
|
|
41
44
|
// Apply breakpoint styles in order (largest to smallest)
|
|
42
45
|
for (const [name] of breakpointEntries) {
|
|
43
46
|
if (responsiveStyle[name]) {
|
|
@@ -47,19 +50,19 @@ export function mergeResponsiveStyles(
|
|
|
47
50
|
} else if (strategy === 'viewport' && viewportWidth !== undefined) {
|
|
48
51
|
// Merge only up to active breakpoint based on viewport width
|
|
49
52
|
const activeBreakpoint = getBreakpointName(viewportWidth, breakpoints);
|
|
50
|
-
|
|
53
|
+
|
|
51
54
|
// If active breakpoint is 'base', only apply base styles (already done above)
|
|
52
55
|
if (activeBreakpoint === 'base') {
|
|
53
56
|
// Base styles already applied, don't apply any other breakpoints
|
|
54
57
|
return merged;
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
// Get all breakpoint names and sort by value (ascending) to find which ones to apply
|
|
58
|
-
const breakpointEntries = Object.entries(
|
|
59
|
+
|
|
60
|
+
// Get all breakpoint names and sort by breakpoint value (ascending) to find which ones to apply
|
|
61
|
+
const breakpointEntries = Object.entries(breakpointValues);
|
|
59
62
|
breakpointEntries.sort((a, b) => a[1] - b[1]); // Sort ascending by value
|
|
60
|
-
|
|
61
|
-
const activeBreakpointValue =
|
|
62
|
-
|
|
63
|
+
|
|
64
|
+
const activeBreakpointValue = breakpointValues[activeBreakpoint];
|
|
65
|
+
|
|
63
66
|
// Apply styles for breakpoints that are <= active breakpoint value
|
|
64
67
|
// This means: base (already applied) + all breakpoints up to and including the active one
|
|
65
68
|
for (const [name, value] of breakpointEntries) {
|
|
@@ -68,7 +71,7 @@ export function mergeResponsiveStyles(
|
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
|
-
|
|
74
|
+
|
|
72
75
|
return merged;
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -126,14 +129,17 @@ export function breakpointToViewportWidth(
|
|
|
126
129
|
breakpoint: BreakpointName,
|
|
127
130
|
breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS
|
|
128
131
|
): number {
|
|
132
|
+
// Extract breakpoint values for comparisons
|
|
133
|
+
const breakpointValues = getBreakpointValues(breakpoints);
|
|
134
|
+
|
|
129
135
|
if (breakpoint === 'base') {
|
|
130
136
|
// Use a large viewport width to ensure only base styles are applied
|
|
131
137
|
// Get the largest breakpoint value and add 1
|
|
132
|
-
const values = Object.values(
|
|
138
|
+
const values = Object.values(breakpointValues);
|
|
133
139
|
const maxValue = values.length > 0 ? Math.max(...values) : 1024;
|
|
134
140
|
return maxValue + 1;
|
|
135
141
|
}
|
|
136
|
-
|
|
142
|
+
|
|
137
143
|
// Return the breakpoint value, or a default if not found
|
|
138
|
-
return
|
|
144
|
+
return breakpointValues[breakpoint] ?? 1024;
|
|
139
145
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ComponentNode, HtmlNode, ComponentInstanceNode, StyleValue, ResponsiveStyleObject } from './types';
|
|
7
|
-
import { isComponentNode, isHtmlNode, isEmbedNode } from './nodeUtils';
|
|
7
|
+
import { isComponentNode, isHtmlNode, isEmbedNode, isListNode } from './nodeUtils';
|
|
8
8
|
import { isResponsiveStyle } from './styleUtils';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -26,8 +26,8 @@ export function applyStylesToNode(
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
node.props.style = { ...(node.props.style || {}), ...styles };
|
|
29
|
-
} else if (isHtmlNode(node) || isEmbedNode(node)) {
|
|
30
|
-
// HTML node and
|
|
29
|
+
} else if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node)) {
|
|
30
|
+
// HTML node, Embed node, and List node: put styles at top level
|
|
31
31
|
node.style = styles as StyleValue;
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -45,8 +45,8 @@ export function mergeNodeStyles(
|
|
|
45
45
|
): ComponentNode {
|
|
46
46
|
if (!instanceStyles) return node;
|
|
47
47
|
|
|
48
|
-
if (isHtmlNode(node) || isEmbedNode(node)) {
|
|
49
|
-
// For HTML nodes and
|
|
48
|
+
if (isHtmlNode(node) || isEmbedNode(node) || isListNode(node)) {
|
|
49
|
+
// For HTML nodes, Embed nodes, and List nodes: merge instance styles with existing top-level styles
|
|
50
50
|
const existingStyle = node.style;
|
|
51
51
|
if (existingStyle && typeof existingStyle === 'object') {
|
|
52
52
|
node.style = {
|