ezfw-core 1.0.30 โ†’ 1.0.32

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.
@@ -0,0 +1,105 @@
1
+ .overlay {
2
+ position: fixed;
3
+ top: 0;
4
+ left: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ background: rgba(0, 0, 0, 0.5);
8
+ z-index: 1000;
9
+ opacity: 0;
10
+ visibility: hidden;
11
+ transition: opacity 0.3s ease, visibility 0.3s ease;
12
+
13
+ &.open {
14
+ opacity: 1;
15
+ visibility: visible;
16
+ }
17
+ }
18
+
19
+ .drawer {
20
+ position: fixed;
21
+ z-index: 1001;
22
+ background: var(--ez-surface);
23
+ box-shadow: var(--ez-shadow-lg);
24
+ transition: transform 0.3s ease;
25
+ overflow: auto;
26
+
27
+ // Positions
28
+ &.left {
29
+ top: 0;
30
+ left: 0;
31
+ height: 100%;
32
+ transform: translateX(-100%);
33
+ }
34
+
35
+ &.right {
36
+ top: 0;
37
+ right: 0;
38
+ height: 100%;
39
+ transform: translateX(100%);
40
+ }
41
+
42
+ &.top {
43
+ top: 0;
44
+ left: 0;
45
+ width: 100%;
46
+ transform: translateY(-100%);
47
+ }
48
+
49
+ &.bottom {
50
+ bottom: 0;
51
+ left: 0;
52
+ width: 100%;
53
+ transform: translateY(100%);
54
+ }
55
+
56
+ // Open state
57
+ &.open {
58
+ &.left,
59
+ &.right {
60
+ transform: translateX(0);
61
+ }
62
+
63
+ &.top,
64
+ &.bottom {
65
+ transform: translateY(0);
66
+ }
67
+ }
68
+
69
+ // Sizes for left/right
70
+ &.left,
71
+ &.right {
72
+ &.sm {
73
+ width: 280px;
74
+ }
75
+
76
+ &.md {
77
+ width: 400px;
78
+ }
79
+
80
+ &.lg {
81
+ width: 600px;
82
+ }
83
+ }
84
+
85
+ // Sizes for top/bottom
86
+ &.top,
87
+ &.bottom {
88
+ &.sm {
89
+ height: 200px;
90
+ }
91
+
92
+ &.md {
93
+ height: 300px;
94
+ }
95
+
96
+ &.lg {
97
+ height: 450px;
98
+ }
99
+ }
100
+ }
101
+
102
+ .content {
103
+ height: 100%;
104
+ overflow: auto;
105
+ }
@@ -0,0 +1,92 @@
1
+ import styles from './EzDrawer.module.scss';
2
+ import { cx } from '../../utils/cssModules.js';
3
+ import { EzBaseComponent } from '../EzBaseComponent.js';
4
+
5
+ const css = cx(styles);
6
+
7
+ declare const ez: {
8
+ _createElement(config: unknown): Promise<HTMLElement>;
9
+ define(name: string, component: unknown): void;
10
+ };
11
+
12
+ export interface DrawerInstance {
13
+ close: () => void;
14
+ }
15
+
16
+ interface EzDrawerConfig {
17
+ props?: {
18
+ position?: 'left' | 'right' | 'top' | 'bottom';
19
+ size?: 'sm' | 'md' | 'lg' | string;
20
+ closeOnOverlayClick?: boolean;
21
+ onOverlayClick?: () => void;
22
+ items?: unknown[];
23
+ _drawerInstance?: DrawerInstance;
24
+ };
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export class EzDrawer extends EzBaseComponent {
29
+ static eztype = 'EzDrawer';
30
+ declare config: EzDrawerConfig;
31
+
32
+ async render(): Promise<HTMLDivElement> {
33
+ const props = this.config.props || this.config;
34
+
35
+ const {
36
+ position = 'left',
37
+ size = 'md',
38
+ closeOnOverlayClick = true,
39
+ onOverlayClick,
40
+ items = [],
41
+ _drawerInstance
42
+ } = props as EzDrawerConfig['props'] & { [key: string]: unknown };
43
+
44
+ const isPresetSize = ['sm', 'md', 'lg'].includes(size as string);
45
+
46
+ const overlay = await ez._createElement({
47
+ eztype: 'div',
48
+ cls: css('overlay'),
49
+ onClick: () => {
50
+ if (onOverlayClick) {
51
+ onOverlayClick();
52
+ }
53
+ if (closeOnOverlayClick) {
54
+ _drawerInstance?.close();
55
+ }
56
+ }
57
+ }) as HTMLDivElement;
58
+
59
+ const drawerClasses = [css('drawer'), css(position)];
60
+ if (isPresetSize) {
61
+ drawerClasses.push(css(size as string));
62
+ }
63
+
64
+ const drawerStyle: Record<string, string> = {};
65
+ if (!isPresetSize && size) {
66
+ if (position === 'left' || position === 'right') {
67
+ drawerStyle.width = size as string;
68
+ } else {
69
+ drawerStyle.height = size as string;
70
+ }
71
+ }
72
+
73
+ const drawer = await ez._createElement({
74
+ eztype: 'div',
75
+ cls: drawerClasses.join(' '),
76
+ style: Object.keys(drawerStyle).length ? drawerStyle : undefined,
77
+ onClick: (e: Event) => e.stopPropagation(),
78
+ items: [{
79
+ eztype: 'div',
80
+ cls: css('content'),
81
+ controller: this.config.controller,
82
+ items
83
+ }]
84
+ }) as HTMLDivElement;
85
+
86
+ overlay.appendChild(drawer);
87
+
88
+ return overlay;
89
+ }
90
+ }
91
+
92
+ ez.define('EzDrawer', EzDrawer);
package/core/ez.ts CHANGED
@@ -218,6 +218,7 @@ export interface EzFramework {
218
218
  dialog: unknown;
219
219
  toast: unknown;
220
220
  mask: unknown;
221
+ drawer: unknown;
221
222
  setTheme(themeName: string): void;
222
223
  getTheme(): string;
223
224
  getAvailableThemes(): string[];
@@ -583,6 +584,10 @@ const ez: EzFramework = {
583
584
  return this._services?.mask;
584
585
  },
585
586
 
587
+ get drawer(): unknown {
588
+ return this._services?.drawer;
589
+ },
590
+
586
591
  setTheme(themeName: string): void {
587
592
  if (!this._availableThemes.includes(themeName)) {
588
593
  console.warn(`[ez] Theme "${themeName}" not found. Available: ${this._availableThemes.join(', ')}`);
package/core/router.ts CHANGED
@@ -206,11 +206,21 @@ export class EzRouter {
206
206
  // If not found, try to load via the loader (dynamic import)
207
207
  if (!def) {
208
208
  const modules = this.ez._loader.getModules();
209
- const path = Object.keys(modules).find(p =>
210
- p.toLowerCase().includes(`/${viewName.toLowerCase()}/`)
211
- || p.toLowerCase().endsWith(`/${viewName.toLowerCase()}.js`)
209
+ const viewLower = viewName.toLowerCase();
210
+ const moduleKeys = Object.keys(modules);
211
+
212
+ // Priority 1: Exact file match /ViewName.js
213
+ let path = moduleKeys.find(p =>
214
+ p.toLowerCase().endsWith(`/${viewLower}.js`)
212
215
  );
213
216
 
217
+ // Priority 2: Folder structure /ViewName/ViewName.js
218
+ if (!path) {
219
+ path = moduleKeys.find(p =>
220
+ p.toLowerCase().endsWith(`/${viewLower}/${viewLower}.js`)
221
+ );
222
+ }
223
+
214
224
  if (path) {
215
225
  await this.ez._loader.loadModule(path);
216
226
  def = this.ez.get(viewName);
package/core/services.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { RouteUIService } from '../services/RouteUI.js';
2
2
  import { FetchApi } from '../services/fetchApi.js';
3
3
  import { EzDialogService } from '../services/dialog.js';
4
+ import { EzDrawerService } from '../services/drawer.js';
4
5
  import { EzToastService } from '../services/toast.js';
5
6
  import { EzMaskService } from '../services/mask.js';
6
7
 
@@ -25,6 +26,7 @@ export class EzServices {
25
26
  private _api: FetchApi | null = null;
26
27
  private _routeUI: RouteUIService | null = null;
27
28
  private _dialog: EzDialogService | null = null;
29
+ private _drawer: EzDrawerService | null = null;
28
30
  private _toast: EzToastService | null = null;
29
31
  private _mask: EzMaskService | null = null;
30
32
  private _initialized: boolean = false;
@@ -41,6 +43,7 @@ export class EzServices {
41
43
  this._initApi(),
42
44
  this._initFirebase(),
43
45
  this._initDialog(),
46
+ this._initDrawer(),
44
47
  this._initToast(),
45
48
  this._initMask()
46
49
  ]);
@@ -124,6 +127,14 @@ export class EzServices {
124
127
  return this._dialog;
125
128
  }
126
129
 
130
+ private _initDrawer(): void {
131
+ this._drawer = new EzDrawerService();
132
+ }
133
+
134
+ get drawer(): EzDrawerService | null {
135
+ return this._drawer;
136
+ }
137
+
127
138
  private _initToast(): void {
128
139
  this._toast = new EzToastService();
129
140
  }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Ez Islands - Static HTML Renderer Tests
3
+ */
4
+
5
+ import { StaticHtmlRenderer, ComponentDefinition, RenderContext } from './StaticHtmlRenderer.js';
6
+ import { ComponentAnalyzer } from './analyzer.js';
7
+
8
+ const renderer = new StaticHtmlRenderer();
9
+
10
+ // Reset island counter before each test suite
11
+ renderer.resetIslandCounter();
12
+
13
+ interface TestCase {
14
+ name: string;
15
+ component: ComponentDefinition;
16
+ props?: Record<string, unknown>;
17
+ registry?: Map<string, ComponentDefinition>;
18
+ expected: string | RegExp;
19
+ description: string;
20
+ }
21
+
22
+ const testCases: TestCase[] = [
23
+ // Basic static rendering
24
+ {
25
+ name: 'SimpleDiv',
26
+ component: {
27
+ template: () => ({
28
+ eztype: 'div',
29
+ cls: 'container',
30
+ text: 'Hello World'
31
+ })
32
+ },
33
+ expected: '<div class="container">Hello World</div>',
34
+ description: 'Simple div with class and text'
35
+ },
36
+ {
37
+ name: 'NestedElements',
38
+ component: {
39
+ template: () => ({
40
+ eztype: 'article',
41
+ cls: 'post',
42
+ items: [
43
+ { eztype: 'h1', text: 'Title' },
44
+ { eztype: 'p', text: 'Content here' }
45
+ ]
46
+ })
47
+ },
48
+ expected: '<article class="post"><h1>Title</h1><p>Content here</p></article>',
49
+ description: 'Nested elements with items'
50
+ },
51
+ {
52
+ name: 'VoidElements',
53
+ component: {
54
+ template: () => ({
55
+ eztype: 'div',
56
+ items: [
57
+ { eztype: 'img', src: '/image.jpg', alt: 'Test' },
58
+ { eztype: 'br' },
59
+ { eztype: 'input', type: 'text', placeholder: 'Enter text' }
60
+ ]
61
+ })
62
+ },
63
+ expected: '<div><img src="/image.jpg" alt="Test"><br><input type="text" placeholder="Enter text"></div>',
64
+ description: 'Void elements render without closing tag'
65
+ },
66
+ {
67
+ name: 'BooleanAttributes',
68
+ component: {
69
+ template: () => ({
70
+ eztype: 'input',
71
+ type: 'checkbox',
72
+ checked: true,
73
+ disabled: true
74
+ })
75
+ },
76
+ expected: '<input type="checkbox" checked disabled>',
77
+ description: 'Boolean attributes render correctly'
78
+ },
79
+ {
80
+ name: 'StyleObject',
81
+ component: {
82
+ template: () => ({
83
+ eztype: 'div',
84
+ style: {
85
+ backgroundColor: 'red',
86
+ fontSize: '16px',
87
+ marginTop: '10px'
88
+ }
89
+ })
90
+ },
91
+ expected: '<div style="background-color: red; font-size: 16px; margin-top: 10px"></div>',
92
+ description: 'Style object converts to CSS string'
93
+ },
94
+ {
95
+ name: 'EscapeHtml',
96
+ component: {
97
+ template: () => ({
98
+ eztype: 'div',
99
+ text: '<script>alert("xss")</script>'
100
+ })
101
+ },
102
+ expected: '<div>&lt;script&gt;alert("xss")&lt;/script&gt;</div>',
103
+ description: 'HTML in text is escaped'
104
+ },
105
+ {
106
+ name: 'EzComponentAsDiv',
107
+ component: {
108
+ template: () => ({
109
+ eztype: 'EzComponent',
110
+ cls: 'wrapper',
111
+ items: [
112
+ { eztype: 'span', text: 'Inside' }
113
+ ]
114
+ })
115
+ },
116
+ expected: '<div class="wrapper"><span>Inside</span></div>',
117
+ description: 'EzComponent renders as div'
118
+ },
119
+ {
120
+ name: 'PropsInTemplate',
121
+ component: {
122
+ template: (props) => {
123
+ const p = props as { title: string; count: number };
124
+ return {
125
+ eztype: 'div',
126
+ items: [
127
+ { eztype: 'h1', text: p.title },
128
+ { eztype: 'span', text: `Count: ${p.count}` }
129
+ ]
130
+ };
131
+ }
132
+ },
133
+ props: { title: 'My Title', count: 42 },
134
+ expected: '<div><h1>My Title</h1><span>Count: 42</span></div>',
135
+ description: 'Props are passed to template'
136
+ },
137
+ {
138
+ name: 'ArrayClasses',
139
+ component: {
140
+ template: () => ({
141
+ eztype: 'div',
142
+ cls: ['class1', 'class2', 'class3']
143
+ })
144
+ },
145
+ expected: '<div class="class1 class2 class3"></div>',
146
+ description: 'Array of classes joins correctly'
147
+ },
148
+ {
149
+ name: 'NullItemsFiltered',
150
+ component: {
151
+ template: () => ({
152
+ eztype: 'div',
153
+ items: [
154
+ { eztype: 'span', text: 'First' },
155
+ null,
156
+ { eztype: 'span', text: 'Second' },
157
+ undefined
158
+ ] as ComponentDefinition['items']
159
+ })
160
+ },
161
+ expected: '<div><span>First</span><span>Second</span></div>',
162
+ description: 'Null/undefined items are filtered out'
163
+ },
164
+
165
+ // Island rendering
166
+ {
167
+ name: 'IslandWithState',
168
+ component: {
169
+ state: { count: 0 },
170
+ template: () => ({
171
+ eztype: 'button',
172
+ text: 'Click me'
173
+ })
174
+ },
175
+ expected: /data-ez-island="IslandWithState"/,
176
+ description: 'Component with state renders as island placeholder'
177
+ },
178
+ {
179
+ name: 'IslandWithProps',
180
+ component: {
181
+ state: { loading: false },
182
+ template: (props) => ({
183
+ eztype: 'button',
184
+ text: `Add product ${(props as { productId: number }).productId}`
185
+ })
186
+ },
187
+ props: { productId: 123 },
188
+ expected: /data-ez-props="[^"]*productId[^"]*123/,
189
+ description: 'Island props are serialized in data attribute'
190
+ },
191
+ {
192
+ name: 'IslandInitialRender',
193
+ component: {
194
+ state: { count: 0 },
195
+ template: () => ({
196
+ eztype: 'div',
197
+ cls: 'counter',
198
+ text: 'Count: 0'
199
+ })
200
+ },
201
+ expected: /<div class="counter">Count: 0<\/div>/,
202
+ description: 'Island pre-renders initial content'
203
+ },
204
+ {
205
+ name: 'ManualIslandOverride',
206
+ component: {
207
+ island: true,
208
+ template: () => ({
209
+ eztype: 'div',
210
+ text: 'Forced island'
211
+ })
212
+ },
213
+ expected: /data-ez-island="ManualIslandOverride"/,
214
+ description: 'Manual island: true forces island rendering'
215
+ },
216
+ {
217
+ name: 'ManualStaticOverride',
218
+ component: {
219
+ static: true,
220
+ state: { value: '' }, // Would normally be island
221
+ template: () => ({
222
+ eztype: 'div',
223
+ text: 'Forced static'
224
+ })
225
+ },
226
+ expected: '<div>Forced static</div>',
227
+ description: 'Manual static: true forces static rendering'
228
+ }
229
+ ];
230
+
231
+ // Run tests
232
+ console.log('\n๐Ÿงช Running Ez Islands Renderer Tests\n');
233
+ console.log('='.repeat(60));
234
+
235
+ let passed = 0;
236
+ let failed = 0;
237
+
238
+ async function runTests() {
239
+ for (const test of testCases) {
240
+ try {
241
+ const registry = test.registry || new Map([[test.name, test.component]]);
242
+ const ctx: RenderContext = {
243
+ registry,
244
+ styles: new Set(),
245
+ islands: [],
246
+ props: test.props || {}
247
+ };
248
+
249
+ const result = await renderer.renderComponent(test.name, ctx, test.props || {});
250
+
251
+ let success: boolean;
252
+ if (test.expected instanceof RegExp) {
253
+ success = test.expected.test(result);
254
+ } else {
255
+ success = result === test.expected;
256
+ }
257
+
258
+ if (success) {
259
+ passed++;
260
+ console.log(`โœ… ${test.name}`);
261
+ console.log(` ${test.description}`);
262
+ } else {
263
+ failed++;
264
+ console.log(`โŒ ${test.name}`);
265
+ console.log(` ${test.description}`);
266
+ console.log(` Expected: ${test.expected}`);
267
+ console.log(` Got: ${result}`);
268
+ }
269
+ } catch (error) {
270
+ failed++;
271
+ console.log(`โŒ ${test.name}`);
272
+ console.log(` ${test.description}`);
273
+ console.log(` Error: ${error}`);
274
+ }
275
+ console.log('');
276
+ }
277
+
278
+ console.log('='.repeat(60));
279
+ console.log(`\n๐Ÿ“Š Results: ${passed} passed, ${failed} failed\n`);
280
+
281
+ // Test page rendering with hydration script
282
+ console.log('\n๐Ÿ“„ Testing full page render with islands:\n');
283
+
284
+ renderer.resetIslandCounter();
285
+
286
+ const pageRegistry = new Map<string, ComponentDefinition>();
287
+
288
+ pageRegistry.set('Header', {
289
+ template: () => ({
290
+ eztype: 'header',
291
+ items: [{ eztype: 'h1', text: 'My App' }]
292
+ })
293
+ });
294
+
295
+ pageRegistry.set('AddToCart', {
296
+ state: { quantity: 1 },
297
+ template: (props) => ({
298
+ eztype: 'button',
299
+ text: `Add to cart (${(props as { productId: number }).productId})`
300
+ })
301
+ });
302
+
303
+ pageRegistry.set('ProductPage', {
304
+ template: (props) => {
305
+ const p = props as { product: { id: number; name: string } };
306
+ return {
307
+ eztype: 'main',
308
+ items: [
309
+ { eztype: 'Header' },
310
+ { eztype: 'h2', text: p.product.name },
311
+ { eztype: 'AddToCart', props: { productId: p.product.id } }
312
+ ]
313
+ };
314
+ }
315
+ });
316
+
317
+ const ctx: RenderContext = {
318
+ registry: pageRegistry,
319
+ styles: new Set(),
320
+ islands: [],
321
+ props: {}
322
+ };
323
+
324
+ const pageHtml = await renderer.renderComponent(
325
+ 'ProductPage',
326
+ ctx,
327
+ { product: { id: 42, name: 'iPhone 15' } }
328
+ );
329
+
330
+ console.log('Generated HTML:');
331
+ console.log('-'.repeat(40));
332
+ console.log(pageHtml);
333
+ console.log('-'.repeat(40));
334
+ console.log(`\nIslands found: ${ctx.islands.length}`);
335
+ ctx.islands.forEach(island => {
336
+ console.log(` ๐Ÿ๏ธ ${island.component} (id: ${island.id})`);
337
+ });
338
+
339
+ if (failed > 0) {
340
+ process.exit(1);
341
+ }
342
+ }
343
+
344
+ runTests().catch(console.error);