metaowl 0.1.2 → 0.2.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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * @module Layouts
3
+ *
4
+ * Layout system for OWL applications, enabling shared page structures.
5
+ *
6
+ * Features:
7
+ * - Named layouts with automatic resolution
8
+ * - Fallback to default layout
9
+ * - Layout-specific state and lifecycle
10
+ * - Nested layouts support
11
+ * - Layout transitions
12
+ *
13
+ * Directory Convention:
14
+ * src/
15
+ * layouts/
16
+ * default/
17
+ * DefaultLayout.js # Default layout for all pages
18
+ * DefaultLayout.xml
19
+ * DefaultLayout.css
20
+ * auth/
21
+ * AuthLayout.js # Layout for auth pages (login, register)
22
+ * admin/
23
+ * AdminLayout.js # Layout with sidebar for admin pages
24
+ *
25
+ * Usage in Pages:
26
+ * export class MyPage extends Component {
27
+ * static layout = 'admin' // Use admin layout
28
+ * }
29
+ *
30
+ * Layout Template Convention:
31
+ * <templates>
32
+ * <t t-name="DefaultLayout">
33
+ * <div class="layout-default">
34
+ * <header t-name="header"/>
35
+ * <main>
36
+ * <t t-slot="default"/> <!-- Page content renders here -->
37
+ * </main>
38
+ * <footer t-name="footer"/>
39
+ * </div>
40
+ * </t>
41
+ * </templates>
42
+ *
43
+ * @example
44
+ * // layouts/default/DefaultLayout.js
45
+ * import { Component } from '@odoo/owl'
46
+ *
47
+ * export class DefaultLayout extends Component {
48
+ * static template = 'DefaultLayout'
49
+ * }
50
+ *
51
+ * // pages/index/Index.js
52
+ * import { Component } from '@odoo/owl'
53
+ *
54
+ * export class IndexPage extends Component {
55
+ * static template = 'IndexPage'
56
+ * static layout = 'default' // Optional, 'default' is implicit
57
+ * }
58
+ */
59
+
60
+ import { Component, xml } from '@odoo/owl'
61
+
62
+ /**
63
+ * Registry of layout components.
64
+ * @type {Map<string, typeof Component>}
65
+ */
66
+ const _layouts = new Map()
67
+
68
+ /**
69
+ * Default layout name used when none specified.
70
+ * @type {string}
71
+ */
72
+ let _defaultLayout = 'default'
73
+
74
+ /**
75
+ * Current active layout instance.
76
+ * @type {Component|null}
77
+ */
78
+ let _currentLayout = null
79
+
80
+ /**
81
+ * Layout change listeners.
82
+ * @type {Function[]}
83
+ */
84
+ const _listeners = []
85
+
86
+ /**
87
+ * Layout configuration per route.
88
+ * @type {Map<string, string>}
89
+ */
90
+ const _routeLayouts = new Map()
91
+
92
+ /**
93
+ * Register a layout component.
94
+ *
95
+ * @param {string} name - Layout identifier
96
+ * @param {typeof Component} layoutComponent - OWL component class
97
+ * @param {object} options - Layout options
98
+ * @param {boolean} [options.default=false] - Set as default layout
99
+ *
100
+ * @example
101
+ * import { DefaultLayout } from './layouts/default/DefaultLayout.js'
102
+ * import { AdminLayout } from './layouts/admin/AdminLayout.js'
103
+ *
104
+ * registerLayout('default', DefaultLayout, { default: true })
105
+ * registerLayout('admin', AdminLayout)
106
+ */
107
+ export function registerLayout(name, layoutComponent, options = {}) {
108
+ _layouts.set(name, layoutComponent)
109
+
110
+ if (options.default) {
111
+ _defaultLayout = name
112
+ }
113
+
114
+ // Notify listeners
115
+ for (const listener of _listeners) {
116
+ listener({ type: 'register', name, layout: layoutComponent })
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Unregister a layout.
122
+ *
123
+ * @param {string} name - Layout identifier
124
+ * @returns {boolean} True if layout was removed
125
+ */
126
+ export function unregisterLayout(name) {
127
+ const removed = _layouts.delete(name)
128
+
129
+ if (removed) {
130
+ for (const listener of _listeners) {
131
+ listener({ type: 'unregister', name })
132
+ }
133
+ }
134
+
135
+ return removed
136
+ }
137
+
138
+ /**
139
+ * Get a registered layout by name.
140
+ *
141
+ * @param {string} name - Layout identifier
142
+ * @returns {typeof Component|undefined}
143
+ */
144
+ export function getLayout(name) {
145
+ return _layouts.get(name)
146
+ }
147
+
148
+ /**
149
+ * Check if a layout is registered.
150
+ *
151
+ * @param {string} name - Layout identifier
152
+ * @returns {boolean}
153
+ */
154
+ export function hasLayout(name) {
155
+ return _layouts.has(name)
156
+ }
157
+
158
+ /**
159
+ * Get all registered layout names.
160
+ *
161
+ * @returns {string[]}
162
+ */
163
+ export function getLayoutNames() {
164
+ return Array.from(_layouts.keys())
165
+ }
166
+
167
+ /**
168
+ * Set the default layout name.
169
+ *
170
+ * @param {string} name - Layout identifier
171
+ */
172
+ export function setDefaultLayout(name) {
173
+ if (!_layouts.has(name)) {
174
+ console.warn(`[metaowl] Layout "${name}" is not registered yet`)
175
+ }
176
+ _defaultLayout = name
177
+ }
178
+
179
+ /**
180
+ * Get the current default layout name.
181
+ *
182
+ * @returns {string}
183
+ */
184
+ export function getDefaultLayout() {
185
+ return _defaultLayout
186
+ }
187
+
188
+ /**
189
+ * Resolve layout name for a given component or route.
190
+ *
191
+ * @param {typeof Component} component - Page component
192
+ * @param {string} [routePath] - Optional route path for route-specific layout
193
+ * @returns {string} Layout name
194
+ */
195
+ export function resolveLayout(component, routePath) {
196
+ // Check route-specific layout first
197
+ if (routePath && _routeLayouts.has(routePath)) {
198
+ return _routeLayouts.get(routePath)
199
+ }
200
+
201
+ // Check component static property
202
+ if (component.layout) {
203
+ return component.layout
204
+ }
205
+
206
+ // Check for layout defined via decorator/metadata
207
+ if (component._layout) {
208
+ return component._layout
209
+ }
210
+
211
+ // Fall back to default
212
+ return _defaultLayout
213
+ }
214
+
215
+ /**
216
+ * Assign a layout to a specific route.
217
+ *
218
+ * @param {string} routePath - Route path pattern
219
+ * @param {string} layoutName - Layout identifier
220
+ */
221
+ export function setRouteLayout(routePath, layoutName) {
222
+ _routeLayouts.set(routePath, layoutName)
223
+ }
224
+
225
+ /**
226
+ * Get layout assigned to a route.
227
+ *
228
+ * @param {string} routePath - Route path
229
+ * @returns {string|undefined}
230
+ */
231
+ export function getRouteLayout(routePath) {
232
+ return _routeLayouts.get(routePath)
233
+ }
234
+
235
+ /**
236
+ * Create a layout wrapper component that renders the page inside the layout.
237
+ *
238
+ * @param {typeof Component} layoutComponent - Layout component class
239
+ * @param {typeof Component} pageComponent - Page component class
240
+ * @param {object} [props] - Props to pass to page component
241
+ * @returns {typeof Component} Wrapper component
242
+ */
243
+ export function createLayoutWrapper(layoutComponent, pageComponent, props = {}) {
244
+ const LayoutClass = layoutComponent
245
+ const PageClass = pageComponent
246
+
247
+ return class LayoutWrapper extends Component {
248
+ static template = xml`
249
+ <t t-component="layout" t-props="layoutProps">
250
+ <t t-component="page" t-props="pageProps"/>
251
+ </t>
252
+ `
253
+
254
+ setup() {
255
+ this.layout = LayoutClass
256
+ this.page = PageClass
257
+ this.layoutProps = {}
258
+ this.pageProps = props
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Mount a page component within its resolved layout.
265
+ *
266
+ * @param {typeof Component} pageComponent - Page component to mount
267
+ * @param {HTMLElement} target - Mount target element
268
+ * @param {object} [options] - Mount options
269
+ * @param {string} [options.routePath] - Current route path
270
+ * @param {object} [options.props] - Props for page component
271
+ * @param {object} [config] - OWL mount configuration
272
+ * @returns {Promise<Component>} Mounted component instance
273
+ */
274
+ export async function mountWithLayout(pageComponent, target, options = {}, config = {}) {
275
+ const { routePath, props = {} } = options
276
+
277
+ const layoutName = resolveLayout(pageComponent, routePath)
278
+ const LayoutClass = getLayout(layoutName)
279
+
280
+ if (!LayoutClass) {
281
+ console.warn(`[metaowl] Layout "${layoutName}" not found, mounting page without layout`)
282
+ const { mount } = await import('@odoo/owl')
283
+ return mount(pageComponent, target, { ...config, props })
284
+ }
285
+
286
+ // Create wrapper that combines layout and page
287
+ const WrapperClass = createLayoutWrapper(LayoutClass, pageComponent, props)
288
+
289
+ const { mount } = await import('@odoo/owl')
290
+ const instance = await mount(WrapperClass, target, config)
291
+
292
+ _currentLayout = instance
293
+
294
+ // Notify listeners
295
+ for (const listener of _listeners) {
296
+ listener({ type: 'mount', layout: layoutName, page: pageComponent.name })
297
+ }
298
+
299
+ return instance
300
+ }
301
+
302
+ /**
303
+ * Get the currently active layout instance.
304
+ *
305
+ * @returns {Component|null}
306
+ */
307
+ export function getCurrentLayout() {
308
+ return _currentLayout
309
+ }
310
+
311
+ /**
312
+ * Subscribe to layout events.
313
+ *
314
+ * @param {Function} callback - Event listener
315
+ * @returns {Function} Unsubscribe function
316
+ */
317
+ export function subscribeToLayouts(callback) {
318
+ _listeners.push(callback)
319
+ return () => {
320
+ const index = _listeners.indexOf(callback)
321
+ if (index > -1) _listeners.splice(index, 1)
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Clear all layouts and reset state (useful for testing).
327
+ */
328
+ export function clearLayouts() {
329
+ _layouts.clear()
330
+ _routeLayouts.clear()
331
+ _listeners.length = 0
332
+ _defaultLayout = 'default'
333
+ _currentLayout = null
334
+ }
335
+
336
+ /**
337
+ * Layout decorator for setting layout on component class.
338
+ *
339
+ * @param {string} name - Layout identifier
340
+ * @returns {Function} Decorator function
341
+ *
342
+ * @example
343
+ * @layout('admin')
344
+ * export class AdminPage extends Component {
345
+ * // ...
346
+ * }
347
+ */
348
+ export function layout(name) {
349
+ return function decorator(ComponentClass) {
350
+ ComponentClass.layout = name
351
+ return ComponentClass
352
+ }
353
+ }
354
+
355
+ /**
356
+ * DefineLayout decorator with additional options.
357
+ *
358
+ * @param {string} name - Layout identifier
359
+ * @param {object} options - Layout options
360
+ * @returns {Function} Decorator function
361
+ *
362
+ * @example
363
+ * @defineLayout('admin', { persistent: true })
364
+ * export class AdminPage extends Component {
365
+ * // ...
366
+ * }
367
+ */
368
+ export function defineLayout(name, options = {}) {
369
+ return function decorator(ComponentClass) {
370
+ ComponentClass.layout = name
371
+ ComponentClass.layoutOptions = options
372
+ return ComponentClass
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Build layouts from import.meta.glob result (file-based layouts).
378
+ *
379
+ * Convention:
380
+ * './layouts/default/DefaultLayout.js' → layout 'default'
381
+ * './layouts/admin/AdminLayout.js' → layout 'admin'
382
+ *
383
+ * @param {Record<string, object>} modules - import.meta.glob result
384
+ * @returns {object} Map of layout names to component classes
385
+ */
386
+ export function buildLayouts(modules) {
387
+ const layouts = {}
388
+
389
+ for (const [key, mod] of Object.entries(modules)) {
390
+ // Extract layout name from path: './layouts/default/DefaultLayout.js'
391
+ const match = key.match(/\.\/layouts\/([^/]+)/)
392
+ if (!match) continue
393
+
394
+ const layoutName = match[1]
395
+ const ComponentClass = mod.default || Object.values(mod).find(v => typeof v === 'function')
396
+
397
+ if (ComponentClass) {
398
+ layouts[layoutName] = ComponentClass
399
+ registerLayout(layoutName, ComponentClass)
400
+ }
401
+ }
402
+
403
+ return layouts
404
+ }
405
+
406
+ /**
407
+ * Auto-discover and register layouts from the layouts directory.
408
+ *
409
+ * @param {object} options
410
+ * @param {string} [options.defaultLayout='default'] - Default layout name
411
+ * @returns {Promise<object>} Registered layouts
412
+ *
413
+ * @example
414
+ * // metaowl.js
415
+ * import { discoverLayouts } from 'metaowl'
416
+ *
417
+ * await discoverLayouts({ defaultLayout: 'default' })
418
+ * boot()
419
+ */
420
+ export async function discoverLayouts(options = {}) {
421
+ const { defaultLayout = 'default' } = options
422
+
423
+ // This will be transformed by Vite plugin at build time
424
+ const modules = import.meta.glob('./layouts/**/*.js', { eager: true })
425
+ const layouts = buildLayouts(modules)
426
+
427
+ // Set default if specified layout exists
428
+ if (layouts[defaultLayout]) {
429
+ setDefaultLayout(defaultLayout)
430
+ }
431
+
432
+ return layouts
433
+ }