metaowl 0.4.1 → 0.6.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/CHANGELOG.md +50 -0
- package/README.md +267 -2
- package/build/runtime/bin/metaowl-build.js +10 -0
- package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
- package/build/runtime/bin/metaowl-dev.js +10 -0
- package/build/runtime/bin/metaowl-generate.js +231 -0
- package/build/runtime/bin/metaowl-lint.js +58 -0
- package/build/runtime/bin/utils.js +68 -0
- package/build/runtime/index.js +144 -0
- package/build/runtime/modules/app-mounter.js +73 -0
- package/build/runtime/modules/auto-import.js +140 -0
- package/build/runtime/modules/cache.js +49 -0
- package/build/runtime/modules/composables.js +353 -0
- package/build/runtime/modules/constants.js +38 -0
- package/build/runtime/modules/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +207 -0
- package/build/runtime/modules/fonts.js +172 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +180 -0
- package/build/runtime/modules/image.js +175 -0
- package/build/runtime/modules/layouts.js +214 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +265 -0
- package/build/runtime/modules/pwa.js +272 -0
- package/build/runtime/modules/router.js +384 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +198 -0
- package/build/runtime/modules/templates-manager.js +52 -0
- package/build/runtime/modules/test-utils.js +238 -0
- package/build/runtime/vite/plugin.js +197 -0
- package/eslint.js +29 -0
- package/package.json +45 -27
- package/CONTRIBUTING.md +0 -49
- package/bin/metaowl-build.js +0 -12
- package/bin/metaowl-dev.js +0 -12
- package/bin/metaowl-generate.js +0 -339
- package/bin/metaowl-lint.js +0 -71
- package/bin/utils.js +0 -82
- package/eslint.config.js +0 -3
- package/index.js +0 -328
- package/modules/app-mounter.js +0 -104
- package/modules/auto-import.js +0 -225
- package/modules/cache.js +0 -59
- package/modules/composables.js +0 -600
- package/modules/error-boundary.js +0 -228
- package/modules/fetch.js +0 -51
- package/modules/file-router.js +0 -478
- package/modules/forms.js +0 -353
- package/modules/i18n.js +0 -333
- package/modules/layouts.js +0 -431
- package/modules/link.js +0 -255
- package/modules/meta.js +0 -119
- package/modules/odoo-rpc.js +0 -511
- package/modules/pwa.js +0 -515
- package/modules/router.js +0 -769
- package/modules/seo.js +0 -501
- package/modules/store.js +0 -409
- package/modules/templates-manager.js +0 -89
- package/modules/test-utils.js +0 -532
- package/test/auto-import.test.js +0 -110
- package/test/cache.test.js +0 -55
- package/test/composables.test.js +0 -103
- package/test/dynamic-routes.test.js +0 -469
- package/test/error-boundary.test.js +0 -126
- package/test/fetch.test.js +0 -100
- package/test/file-router.test.js +0 -55
- package/test/forms.test.js +0 -203
- package/test/i18n.test.js +0 -188
- package/test/layouts.test.js +0 -395
- package/test/link.test.js +0 -189
- package/test/meta.test.js +0 -146
- package/test/odoo-rpc.test.js +0 -547
- package/test/pwa.test.js +0 -154
- package/test/router-guards.test.js +0 -229
- package/test/router.test.js +0 -77
- package/test/seo.test.js +0 -353
- package/test/store.test.js +0 -476
- package/test/templates-manager.test.js +0 -83
- package/test/test-utils.test.js +0 -314
- package/vite/plugin.js +0 -290
- package/vitest.config.js +0 -8
package/modules/layouts.js
DELETED
|
@@ -1,431 +0,0 @@
|
|
|
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, mount } 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 = {}, templates } = 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
|
-
return mount(pageComponent, target, { ...config, props, templates })
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Create wrapper that combines layout and page
|
|
286
|
-
const WrapperClass = createLayoutWrapper(LayoutClass, pageComponent, props)
|
|
287
|
-
|
|
288
|
-
const instance = await mount(WrapperClass, target, { ...config, templates })
|
|
289
|
-
|
|
290
|
-
_currentLayout = instance
|
|
291
|
-
|
|
292
|
-
// Notify listeners
|
|
293
|
-
for (const listener of _listeners) {
|
|
294
|
-
listener({ type: 'mount', layout: layoutName, page: pageComponent.name })
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return instance
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Get the currently active layout instance.
|
|
302
|
-
*
|
|
303
|
-
* @returns {Component|null}
|
|
304
|
-
*/
|
|
305
|
-
export function getCurrentLayout() {
|
|
306
|
-
return _currentLayout
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Subscribe to layout events.
|
|
311
|
-
*
|
|
312
|
-
* @param {Function} callback - Event listener
|
|
313
|
-
* @returns {Function} Unsubscribe function
|
|
314
|
-
*/
|
|
315
|
-
export function subscribeToLayouts(callback) {
|
|
316
|
-
_listeners.push(callback)
|
|
317
|
-
return () => {
|
|
318
|
-
const index = _listeners.indexOf(callback)
|
|
319
|
-
if (index > -1) _listeners.splice(index, 1)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Clear all layouts and reset state (useful for testing).
|
|
325
|
-
*/
|
|
326
|
-
export function clearLayouts() {
|
|
327
|
-
_layouts.clear()
|
|
328
|
-
_routeLayouts.clear()
|
|
329
|
-
_listeners.length = 0
|
|
330
|
-
_defaultLayout = 'default'
|
|
331
|
-
_currentLayout = null
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Layout decorator for setting layout on component class.
|
|
336
|
-
*
|
|
337
|
-
* @param {string} name - Layout identifier
|
|
338
|
-
* @returns {Function} Decorator function
|
|
339
|
-
*
|
|
340
|
-
* @example
|
|
341
|
-
* @layout('admin')
|
|
342
|
-
* export class AdminPage extends Component {
|
|
343
|
-
* // ...
|
|
344
|
-
* }
|
|
345
|
-
*/
|
|
346
|
-
export function layout(name) {
|
|
347
|
-
return function decorator(ComponentClass) {
|
|
348
|
-
ComponentClass.layout = name
|
|
349
|
-
return ComponentClass
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* DefineLayout decorator with additional options.
|
|
355
|
-
*
|
|
356
|
-
* @param {string} name - Layout identifier
|
|
357
|
-
* @param {object} options - Layout options
|
|
358
|
-
* @returns {Function} Decorator function
|
|
359
|
-
*
|
|
360
|
-
* @example
|
|
361
|
-
* @defineLayout('admin', { persistent: true })
|
|
362
|
-
* export class AdminPage extends Component {
|
|
363
|
-
* // ...
|
|
364
|
-
* }
|
|
365
|
-
*/
|
|
366
|
-
export function defineLayout(name, options = {}) {
|
|
367
|
-
return function decorator(ComponentClass) {
|
|
368
|
-
ComponentClass.layout = name
|
|
369
|
-
ComponentClass.layoutOptions = options
|
|
370
|
-
return ComponentClass
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Build layouts from import.meta.glob result (file-based layouts).
|
|
376
|
-
*
|
|
377
|
-
* Convention:
|
|
378
|
-
* './layouts/default/DefaultLayout.js' → layout 'default'
|
|
379
|
-
* './layouts/admin/AdminLayout.js' → layout 'admin'
|
|
380
|
-
*
|
|
381
|
-
* @param {Record<string, object>} modules - import.meta.glob result
|
|
382
|
-
* @returns {object} Map of layout names to component classes
|
|
383
|
-
*/
|
|
384
|
-
export function buildLayouts(modules) {
|
|
385
|
-
const layouts = {}
|
|
386
|
-
|
|
387
|
-
for (const [key, mod] of Object.entries(modules)) {
|
|
388
|
-
// Extract layout name from path: './layouts/default/DefaultLayout.js'
|
|
389
|
-
const match = key.match(/\.\/layouts\/([^/]+)/)
|
|
390
|
-
if (!match) continue
|
|
391
|
-
|
|
392
|
-
const layoutName = match[1]
|
|
393
|
-
const ComponentClass = mod.default || Object.values(mod).find(v => typeof v === 'function')
|
|
394
|
-
|
|
395
|
-
if (ComponentClass) {
|
|
396
|
-
layouts[layoutName] = ComponentClass
|
|
397
|
-
registerLayout(layoutName, ComponentClass)
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
return layouts
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Auto-discover and register layouts from the layouts directory.
|
|
406
|
-
*
|
|
407
|
-
* @param {object} options
|
|
408
|
-
* @param {string} [options.defaultLayout='default'] - Default layout name
|
|
409
|
-
* @returns {Promise<object>} Registered layouts
|
|
410
|
-
*
|
|
411
|
-
* @example
|
|
412
|
-
* // metaowl.js
|
|
413
|
-
* import { discoverLayouts } from 'metaowl'
|
|
414
|
-
*
|
|
415
|
-
* await discoverLayouts({ defaultLayout: 'default' })
|
|
416
|
-
* boot()
|
|
417
|
-
*/
|
|
418
|
-
export async function discoverLayouts(options = {}) {
|
|
419
|
-
const { defaultLayout = 'default' } = options
|
|
420
|
-
|
|
421
|
-
// This will be transformed by Vite plugin at build time
|
|
422
|
-
const modules = import.meta.glob('./layouts/**/*.js', { eager: true })
|
|
423
|
-
const layouts = buildLayouts(modules)
|
|
424
|
-
|
|
425
|
-
// Set default if specified layout exists
|
|
426
|
-
if (layouts[defaultLayout]) {
|
|
427
|
-
setDefaultLayout(defaultLayout)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
return layouts
|
|
431
|
-
}
|
package/modules/link.js
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module Link
|
|
3
|
-
*
|
|
4
|
-
* SPA Link component for metaowl with automatic external link detection.
|
|
5
|
-
*
|
|
6
|
-
* This component renders a link that navigates via history.pushState
|
|
7
|
-
* for internal targets (without page reload) and navigates normally
|
|
8
|
-
* for external targets.
|
|
9
|
-
*
|
|
10
|
-
* Features:
|
|
11
|
-
* - Automatic detection of external links (http://, https://, //, mailto:, tel:, etc.)
|
|
12
|
-
* - SPA navigation for internal links (no page reload)
|
|
13
|
-
* - Support for active link styling
|
|
14
|
-
* - Respects modifier keys (Ctrl, Meta, Alt) for normal browser navigation
|
|
15
|
-
* - Accessible links with correct href attributes
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* // Internal link
|
|
19
|
-
* <t-link to="/about">About Us</t-link>
|
|
20
|
-
*
|
|
21
|
-
* // With CSS classes
|
|
22
|
-
* <t-link to="/user/profile" class="btn btn-primary">Profile</t-link>
|
|
23
|
-
*
|
|
24
|
-
* // Active link styling
|
|
25
|
-
* <t-link to="/about" activeClass="active">About Us</t-link>
|
|
26
|
-
*
|
|
27
|
-
* // External link (automatically detected)
|
|
28
|
-
* <t-link to="https://example.com">External</t-link>
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { Component, useState, onMounted, onWillUnmount } from '@odoo/owl'
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Regex for detecting external URLs.
|
|
35
|
-
* Matches: http://, https://, // (protocol-relative), mailto:, tel:, ftp:, etc.
|
|
36
|
-
* @type {RegExp}
|
|
37
|
-
*/
|
|
38
|
-
const EXTERNAL_URL_REGEX = /^(https?:|\/\/|mailto:|tel:|ftp:|file:|javascript:)/i
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Checks if a URL is external.
|
|
42
|
-
*
|
|
43
|
-
* @param {string} url - The URL to check
|
|
44
|
-
* @returns {boolean} True if external
|
|
45
|
-
*/
|
|
46
|
-
function isExternalUrl(url) {
|
|
47
|
-
if (!url || typeof url !== 'string') return false
|
|
48
|
-
return EXTERNAL_URL_REGEX.test(url)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Checks if a link is considered "active" (for styling).
|
|
53
|
-
*
|
|
54
|
-
* @param {string} linkPath - The link path
|
|
55
|
-
* @param {string} currentPath - The current path
|
|
56
|
-
* @returns {boolean} True if active
|
|
57
|
-
*/
|
|
58
|
-
function isActiveLink(linkPath, currentPath) {
|
|
59
|
-
if (!linkPath || !currentPath) return false
|
|
60
|
-
// Exact match or subpath
|
|
61
|
-
const normalizedLink = linkPath.replace(/\/$/, '') || '/'
|
|
62
|
-
const normalizedCurrent = currentPath.replace(/\/$/, '') || '/'
|
|
63
|
-
return normalizedCurrent === normalizedLink ||
|
|
64
|
-
(normalizedLink !== '/' && normalizedCurrent.startsWith(normalizedLink + '/'))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Link component for SPA navigation.
|
|
69
|
-
*
|
|
70
|
-
* Renders an <a> element that performs internal navigation
|
|
71
|
-
* without page reload.
|
|
72
|
-
*/
|
|
73
|
-
export class Link extends Component {
|
|
74
|
-
static template = 'Link'
|
|
75
|
-
static props = {
|
|
76
|
-
to: { type: String, optional: false },
|
|
77
|
-
class: { type: String, optional: true },
|
|
78
|
-
activeClass: { type: String, optional: true },
|
|
79
|
-
target: { type: String, optional: true },
|
|
80
|
-
rel: { type: String, optional: true },
|
|
81
|
-
title: { type: String, optional: true },
|
|
82
|
-
download: { type: [String, Boolean], optional: true },
|
|
83
|
-
hreflang: { type: String, optional: true },
|
|
84
|
-
type: { type: String, optional: true },
|
|
85
|
-
ping: { type: String, optional: true },
|
|
86
|
-
referrerpolicy: { type: String, optional: true },
|
|
87
|
-
media: { type: String, optional: true },
|
|
88
|
-
'*': true,
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
setup() {
|
|
92
|
-
this.state = useState({
|
|
93
|
-
isActive: false
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
// Reference to navigation function (injected from outside)
|
|
97
|
-
this._navigate = null
|
|
98
|
-
|
|
99
|
-
onMounted(() => {
|
|
100
|
-
this._updateActiveState()
|
|
101
|
-
// Listen to PopState events for updating active status
|
|
102
|
-
window.addEventListener('popstate', this._updateActiveState)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
onWillUnmount(() => {
|
|
106
|
-
window.removeEventListener('popstate', this._updateActiveState)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
this._updateActiveState = () => {
|
|
110
|
-
if (this.props.activeClass) {
|
|
111
|
-
this.state.isActive = isActiveLink(this.props.to, document.location.pathname)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Getter for combined CSS classes.
|
|
118
|
-
* @returns {string}
|
|
119
|
-
*/
|
|
120
|
-
get linkClasses() {
|
|
121
|
-
const classes = []
|
|
122
|
-
if (this.props.class) {
|
|
123
|
-
classes.push(this.props.class)
|
|
124
|
-
}
|
|
125
|
-
if (this.state.isActive && this.props.activeClass) {
|
|
126
|
-
classes.push(this.props.activeClass)
|
|
127
|
-
}
|
|
128
|
-
return classes.join(' ')
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Getter for the rel attribute.
|
|
133
|
-
* Automatically adds noopener noreferrer for external links with target="_blank".
|
|
134
|
-
* @returns {string|undefined}
|
|
135
|
-
*/
|
|
136
|
-
get linkRel() {
|
|
137
|
-
if (this.props.rel) return this.props.rel
|
|
138
|
-
if (isExternalUrl(this.props.to) && this.props.target === '_blank') {
|
|
139
|
-
return 'noopener noreferrer'
|
|
140
|
-
}
|
|
141
|
-
return undefined
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Forward unknown component props as native <a> attributes.
|
|
146
|
-
* This allows id/style/aria-* / data-* and similar anchor attributes.
|
|
147
|
-
* @returns {Record<string, any>}
|
|
148
|
-
*/
|
|
149
|
-
get forwardedAttrs() {
|
|
150
|
-
const attrs = { ...this.props }
|
|
151
|
-
delete attrs.to
|
|
152
|
-
delete attrs.class
|
|
153
|
-
delete attrs.activeClass
|
|
154
|
-
delete attrs.target
|
|
155
|
-
delete attrs.rel
|
|
156
|
-
delete attrs.title
|
|
157
|
-
delete attrs.download
|
|
158
|
-
return attrs
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Handler for click events.
|
|
163
|
-
* Checks if SPA navigation is possible or normal navigation should be used.
|
|
164
|
-
*
|
|
165
|
-
* @param {MouseEvent} ev - The click event
|
|
166
|
-
*/
|
|
167
|
-
onClick(ev) {
|
|
168
|
-
const url = this.props.to
|
|
169
|
-
|
|
170
|
-
// External URLs: Normal navigation
|
|
171
|
-
if (isExternalUrl(url)) {
|
|
172
|
-
return // Let browser handle normal navigation
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Modifier keys: Normal navigation (new tab, download, etc.)
|
|
176
|
-
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
|
|
177
|
-
return
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Right click: Context menu
|
|
181
|
-
if (ev.button !== 0) {
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Download link: Normal navigation
|
|
186
|
-
if (this.props.download) {
|
|
187
|
-
return
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Internal SPA navigation
|
|
191
|
-
ev.preventDefault()
|
|
192
|
-
|
|
193
|
-
// Update URL immediately so components reading window.location.pathname
|
|
194
|
-
// (e.g. isActive in sidebar) already see the correct path when the new
|
|
195
|
-
// app mounts. The generation counter in _spaNavigate guarantees that only
|
|
196
|
-
// the last-triggered navigation actually calls mountApp.
|
|
197
|
-
window.history.pushState({ path: url }, '', url)
|
|
198
|
-
|
|
199
|
-
if (typeof window.__metaowlNavigate === 'function') {
|
|
200
|
-
window.__metaowlNavigate(url)
|
|
201
|
-
} else {
|
|
202
|
-
// Fallback: normal navigation (URL already updated above)
|
|
203
|
-
window.location.href = url
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Template for the Link component.
|
|
210
|
-
* Must be registered in the app's templates file or loaded dynamically.
|
|
211
|
-
*/
|
|
212
|
-
export const LinkTemplate = /* xml */ `
|
|
213
|
-
<templates>
|
|
214
|
-
<t t-name="Link">
|
|
215
|
-
<a
|
|
216
|
-
t-att="forwardedAttrs"
|
|
217
|
-
t-att-href="props.to"
|
|
218
|
-
t-att-class="linkClasses"
|
|
219
|
-
t-att-target="props.target"
|
|
220
|
-
t-att-rel="linkRel"
|
|
221
|
-
t-att-title="props.title"
|
|
222
|
-
t-att-download="props.download"
|
|
223
|
-
t-on-click="onClick"
|
|
224
|
-
>
|
|
225
|
-
<t t-slot="default"/>
|
|
226
|
-
</a>
|
|
227
|
-
</t>
|
|
228
|
-
</templates>
|
|
229
|
-
`
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Helper function to register the Link template.
|
|
233
|
-
* Called automatically on app startup.
|
|
234
|
-
*
|
|
235
|
-
* @param {object} templates - The app's templates object
|
|
236
|
-
* @returns {string|void} Modified templates if string was passed
|
|
237
|
-
*/
|
|
238
|
-
export function registerLinkTemplate(templates) {
|
|
239
|
-
if (typeof templates === 'string') {
|
|
240
|
-
// If templates is a string, add the Link template
|
|
241
|
-
// Remove outer <templates> tags from LinkTemplate
|
|
242
|
-
const linkContent = LinkTemplate
|
|
243
|
-
.replace('<templates>', '')
|
|
244
|
-
.replace('</templates>', '')
|
|
245
|
-
.trim()
|
|
246
|
-
// Insert before closing </templates> tag
|
|
247
|
-
return templates.replace('</templates>', linkContent + '\n</templates>')
|
|
248
|
-
}
|
|
249
|
-
// If templates is an object, register the template
|
|
250
|
-
if (templates && typeof templates === 'object') {
|
|
251
|
-
templates.Link = LinkTemplate
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export default Link
|