@okalit/cli 0.1.0 → 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.
- package/README.md +13 -9
- package/lib/cli.js +38 -30
- package/package.json +1 -1
- package/templates/app/@okalit/Okalit.js +163 -89
- package/templates/app/@okalit/channel.js +177 -0
- package/templates/app/@okalit/define-element.js +30 -0
- package/templates/app/@okalit/i18n.js +88 -53
- package/templates/app/@okalit/index.js +8 -10
- package/templates/app/@okalit/mixins.js +106 -0
- package/templates/app/@okalit/performance.js +211 -0
- package/templates/app/@okalit/router-outlet.js +66 -0
- package/templates/app/@okalit/router.js +240 -0
- package/templates/app/@okalit/service.js +318 -23
- package/templates/app/index.html +0 -1
- package/templates/app/package.json +2 -3
- package/templates/app/public/i18n/en.json +47 -1
- package/templates/app/public/i18n/es.json +47 -1
- package/templates/app/src/{app.routes.ts → app.routes.js} +1 -1
- package/templates/app/src/channels/example.channel.js +15 -0
- package/templates/app/src/components/lazy-widget.js +13 -0
- package/templates/app/src/guards/auth.guard.js +17 -0
- package/templates/app/src/layouts/app-layout.js +75 -0
- package/templates/app/src/main-app.js +19 -9
- package/templates/app/src/modules/example/example.module.js +8 -3
- package/templates/app/src/modules/example/example.routes.js +27 -4
- package/templates/app/src/modules/example/pages/detail/example-detail.js +21 -0
- package/templates/app/src/modules/example/pages/home/example-home.js +39 -0
- package/templates/app/src/modules/example/pages/lifecycle/example-lifecycle.js +74 -0
- package/templates/app/src/modules/example/pages/performance/example-performance.js +59 -0
- package/templates/app/src/modules/example/pages/services/example-services.js +80 -0
- package/templates/app/src/services/rickandmorty.service.js +17 -0
- package/templates/app/src/services/user.service.js +33 -0
- package/templates/app/src/styles/global.scss +250 -0
- package/templates/app/src/styles/index.css +11 -2
- package/templates/app/vite.config.js +2 -0
- package/templates/app/@okalit/AppMixin.js +0 -29
- package/templates/app/@okalit/EventBus.js +0 -152
- package/templates/app/@okalit/ModuleMixin.js +0 -7
- package/templates/app/@okalit/OkalitService.js +0 -145
- package/templates/app/@okalit/defineElement.js +0 -65
- package/templates/app/@okalit/idle.js +0 -40
- package/templates/app/@okalit/lazy.js +0 -32
- package/templates/app/@okalit/okalit-router.js +0 -309
- package/templates/app/@okalit/trigger.js +0 -14
- package/templates/app/@okalit/viewport.js +0 -69
- package/templates/app/@okalit/when.js +0 -40
- package/templates/app/public/lit.svg +0 -1
- package/templates/app/src/modules/example/pages/example.page.js +0 -43
- package/templates/app/src/modules/example/pages/example.page.scss +0 -76
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function defineElement({ tag, styles = [], props = [], params = [] }) {
|
|
2
|
+
return function (cls, context) {
|
|
3
|
+
// Inject styles, props and params as static properties on the class
|
|
4
|
+
cls.styles = styles;
|
|
5
|
+
cls.props = props;
|
|
6
|
+
cls.params = params;
|
|
7
|
+
|
|
8
|
+
// Build a map of prop name → type config for attribute coercion
|
|
9
|
+
const propMap = {};
|
|
10
|
+
for (const propDef of props) {
|
|
11
|
+
const [name, config] = Object.entries(propDef)[0];
|
|
12
|
+
propMap[name] = config;
|
|
13
|
+
}
|
|
14
|
+
cls._propMap = propMap;
|
|
15
|
+
|
|
16
|
+
// Tell the browser which attributes to observe (kebab-case)
|
|
17
|
+
cls.observedAttributes = Object.keys(propMap).map(toKebabCase);
|
|
18
|
+
|
|
19
|
+
// Register the custom element after the class is fully defined
|
|
20
|
+
context.addInitializer(function () {
|
|
21
|
+
if (!customElements.get(tag)) {
|
|
22
|
+
customElements.define(tag, cls);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toKebabCase(str) {
|
|
29
|
+
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
30
|
+
}
|
|
@@ -1,89 +1,124 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { signal, computed } from 'uhtml';
|
|
2
|
+
|
|
3
|
+
let instance = null;
|
|
4
|
+
|
|
5
|
+
class I18n {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
if (instance) return instance;
|
|
8
|
+
instance = this;
|
|
9
|
+
|
|
10
|
+
this._translations = {};
|
|
11
|
+
this._defaultLocale = config.default || 'en';
|
|
12
|
+
this._locales = config.locales || [this._defaultLocale];
|
|
13
|
+
this.locale = signal(this._detectLocale());
|
|
14
|
+
this._version = signal(0);
|
|
9
15
|
this._ready = false;
|
|
16
|
+
this._readyPromise = this._loadLocale(this.locale.value);
|
|
10
17
|
}
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
locales: config.locales || [],
|
|
16
|
-
path: config.path || '/i18n',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const persisted = localStorage.getItem('okalit:i18n:locale');
|
|
20
|
-
const initialLocale = (persisted && this._config.locales.includes(persisted))
|
|
21
|
-
? persisted
|
|
22
|
-
: this._config.default;
|
|
19
|
+
static getInstance() {
|
|
20
|
+
return instance;
|
|
21
|
+
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
_detectLocale() {
|
|
24
|
+
// Check localStorage first, then browser language
|
|
25
|
+
const stored = localStorage.getItem('okalit:locale');
|
|
26
|
+
if (stored && this._locales.includes(stored)) return stored;
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const browserLang = navigator.language?.split('-')[0];
|
|
29
|
+
if (browserLang && this._locales.includes(browserLang)) return browserLang;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
EventBus.listen('i18n:set-locale', ({ locale }) => this.setLocale(locale));
|
|
31
|
+
return this._defaultLocale;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
async _loadLocale(locale) {
|
|
35
|
-
if (this._translations[locale])
|
|
35
|
+
if (this._translations[locale]) {
|
|
36
|
+
this._ready = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
36
39
|
|
|
37
|
-
const path = `${this._config.path}/${locale}.json`;
|
|
38
40
|
try {
|
|
39
|
-
const res = await fetch(
|
|
40
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
41
|
+
const res = await fetch(`/i18n/${locale}.json`);
|
|
41
42
|
this._translations[locale] = await res.json();
|
|
43
|
+
this._ready = true;
|
|
44
|
+
this._version.value++;
|
|
42
45
|
} catch (e) {
|
|
43
|
-
console.
|
|
46
|
+
console.warn(`[i18n] Failed to load locale: ${locale}`, e);
|
|
44
47
|
this._translations[locale] = {};
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
async setLocale(locale) {
|
|
49
|
-
if (!this.
|
|
50
|
-
console.warn(`[
|
|
52
|
+
if (!this._locales.includes(locale)) {
|
|
53
|
+
console.warn(`[i18n] Unknown locale: ${locale}`);
|
|
51
54
|
return;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
await this._loadLocale(locale);
|
|
55
|
-
this.
|
|
56
|
-
localStorage.setItem('okalit:
|
|
57
|
-
EventBus.emit('i18n:locale-changed', { locale });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
get locale() {
|
|
61
|
-
return this._locale.get();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
get locales() {
|
|
65
|
-
return this._config?.locales || [];
|
|
58
|
+
this.locale.value = locale;
|
|
59
|
+
localStorage.setItem('okalit:locale', locale);
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Translate a key. Supports nested keys with dots: t('SECTION.KEY')
|
|
64
|
+
* Supports interpolation: t('HELLO', { name: 'World' }) → "Hello, World"
|
|
65
|
+
*/
|
|
66
|
+
translate(key, params) {
|
|
67
|
+
const lang = this.locale.value;
|
|
68
|
+
const dict = this._translations[lang] || {};
|
|
72
69
|
|
|
73
|
-
//
|
|
70
|
+
// Support nested keys: 'SECTION.KEY'
|
|
74
71
|
let value = key.split('.').reduce((obj, k) => obj?.[k], dict);
|
|
75
72
|
|
|
76
73
|
if (value === undefined) {
|
|
77
|
-
|
|
74
|
+
// Fallback to default locale
|
|
75
|
+
const fallback = this._translations[this._defaultLocale] || {};
|
|
76
|
+
value = key.split('.').reduce((obj, k) => obj?.[k], fallback);
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
if (value === undefined) return key;
|
|
80
|
+
|
|
81
|
+
// Interpolation: replace {{ name }} with params.name
|
|
82
|
+
if (params) {
|
|
83
|
+
value = value.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => params[k] ?? '');
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
return value;
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
get ready() {
|
|
90
|
+
return this._readyPromise;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
destroy() {
|
|
94
|
+
instance = null;
|
|
95
|
+
}
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Initialize i18n. Called from AppMixin.
|
|
100
|
+
*/
|
|
101
|
+
export function createI18n(config) {
|
|
102
|
+
return new I18n(config);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Translate a key. Reactive — re-renders when locale changes.
|
|
107
|
+
* Usage: t('WELCOME') or t('HELLO', { name: 'World' })
|
|
108
|
+
*/
|
|
109
|
+
export function t(key, params) {
|
|
110
|
+
const i18n = I18n.getInstance();
|
|
111
|
+
if (!i18n) return key;
|
|
112
|
+
// Reading locale.value + _version makes this reactive inside uhtml effects
|
|
113
|
+
i18n.locale.value;
|
|
114
|
+
i18n._version.value;
|
|
115
|
+
i18n.locale.value;
|
|
116
|
+
return i18n.translate(key, params);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get the i18n instance for setLocale, etc.
|
|
121
|
+
*/
|
|
122
|
+
export function getI18n() {
|
|
123
|
+
return I18n.getInstance();
|
|
124
|
+
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
export { Okalit } from
|
|
2
|
-
export { defineElement } from './
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export { service, getService, injectServices } from './service.js';
|
|
10
|
-
export { OkalitI18n } from './i18n.js';
|
|
1
|
+
export { Okalit, html, signal, computed, effect, batch } from './Okalit.js';
|
|
2
|
+
export { defineElement } from './define-element.js';
|
|
3
|
+
export { defineChannel } from './channel.js';
|
|
4
|
+
export { Router, navigate } from './router.js';
|
|
5
|
+
export { AppMixin, ModuleMixin, PageMixin } from './mixins.js';
|
|
6
|
+
export { t, getI18n } from './i18n.js';
|
|
7
|
+
export { OkalitService, OkalitGraphqlService, RequestControl, service, inject } from './service.js';
|
|
8
|
+
export { OIdle, OWhen, OViewport } from './performance.js';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { html} from 'uhtml';
|
|
2
|
+
import { Router, navigate } from './router.js';
|
|
3
|
+
import { createI18n } from './i18n.js';
|
|
4
|
+
import './router-outlet.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AppMixin — for the root application component.
|
|
8
|
+
* Initializes the router with the provided routes.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* class MainApp extends AppMixin(Okalit) {
|
|
12
|
+
* static routes = [...];
|
|
13
|
+
* }
|
|
14
|
+
*/
|
|
15
|
+
export const AppMixin = (Base) => class extends Base {
|
|
16
|
+
static routes = [];
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
this._router = new Router(this.constructor.routes);
|
|
21
|
+
|
|
22
|
+
const i18nConfig = this.constructor.i18n;
|
|
23
|
+
if (i18nConfig) {
|
|
24
|
+
this._i18n = createI18n(i18nConfig);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async switchLocale(locale) {
|
|
29
|
+
await this._i18n?.setLocale(locale);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get router() {
|
|
33
|
+
return this._router;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
navigate(path, options) {
|
|
37
|
+
this._router.navigate(path, options);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disconnectedCallback() {
|
|
41
|
+
super.disconnectedCallback?.();
|
|
42
|
+
this._router.destroy();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
render() {
|
|
46
|
+
return html`
|
|
47
|
+
<okalit-router></okalit-router>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ModuleMixin — for feature modules that group pages.
|
|
54
|
+
* Provides a nested <okalit-router> for child routes.
|
|
55
|
+
*
|
|
56
|
+
* Usage:
|
|
57
|
+
* class ExampleModule extends ModuleMixin(Okalit) {
|
|
58
|
+
* render() {
|
|
59
|
+
* return html`<okalit-router></okalit-router>`;
|
|
60
|
+
* }
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
export const ModuleMixin = (Base) => class extends Base {
|
|
64
|
+
get router() {
|
|
65
|
+
return Router.getInstance();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
navigate(path, options) {
|
|
69
|
+
Router.getInstance()?.navigate(path, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render() {
|
|
73
|
+
return html`
|
|
74
|
+
<okalit-router></okalit-router>
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* PageMixin — for individual pages within a module.
|
|
81
|
+
* Provides router access and navigation helpers.
|
|
82
|
+
*
|
|
83
|
+
* Usage:
|
|
84
|
+
* class HomePage extends PageMixin(Okalit) {
|
|
85
|
+
* render() { ... }
|
|
86
|
+
* }
|
|
87
|
+
*/
|
|
88
|
+
export const PageMixin = (Base) => class extends Base {
|
|
89
|
+
get router() {
|
|
90
|
+
return Router.getInstance();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get routeParams() {
|
|
94
|
+
return Router.getInstance()?.params.value || {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get queryParams() {
|
|
98
|
+
return Router.getInstance()?.query.value || {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
navigate(path, options) {
|
|
102
|
+
Router.getInstance()?.navigate(path, options);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export { navigate };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ── Shared styles ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
const SHARED_STYLES = `
|
|
4
|
+
:host { display: contents; }
|
|
5
|
+
::slotted([slot="fallback"]) { display: none; }
|
|
6
|
+
:host(:not([loaded])) ::slotted(:not([slot])) { display: none; }
|
|
7
|
+
:host(:not([loaded])) ::slotted([slot="fallback"]) { display: contents; }
|
|
8
|
+
`;
|
|
9
|
+
|
|
10
|
+
// ── Shared loader logic ────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalise .loader — accepts a single function or an array.
|
|
14
|
+
* Resolves all imports with Promise.all and flips #isLoaded.
|
|
15
|
+
* On error, dispatches 'o-error' on the host element.
|
|
16
|
+
*/
|
|
17
|
+
async function executeLoader(host) {
|
|
18
|
+
if (host._done || host._loading) return;
|
|
19
|
+
host._loading = true;
|
|
20
|
+
|
|
21
|
+
const loaders = Array.isArray(host.loader) ? host.loader : [host.loader];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await Promise.all(loaders.map((fn) => fn()));
|
|
25
|
+
host._done = true;
|
|
26
|
+
host.setAttribute('loaded', '');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
host.dispatchEvent(
|
|
29
|
+
new CustomEvent('o-error', {
|
|
30
|
+
detail: err,
|
|
31
|
+
bubbles: true,
|
|
32
|
+
composed: true,
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
} finally {
|
|
36
|
+
host._loading = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applySharedSetup(shadowRoot) {
|
|
41
|
+
const style = document.createElement('style');
|
|
42
|
+
style.textContent = SHARED_STYLES;
|
|
43
|
+
shadowRoot.append(
|
|
44
|
+
style,
|
|
45
|
+
document.createElement('slot'), // default slot
|
|
46
|
+
Object.assign(document.createElement('slot'), { name: 'fallback' }),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── <o-idle> ───────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export class OIdle extends HTMLElement {
|
|
53
|
+
_done = false;
|
|
54
|
+
_loading = false;
|
|
55
|
+
#loader = null;
|
|
56
|
+
#idleId = null;
|
|
57
|
+
#timeoutId = null;
|
|
58
|
+
#connected = false;
|
|
59
|
+
|
|
60
|
+
get loader() { return this.#loader; }
|
|
61
|
+
set loader(fn) {
|
|
62
|
+
if (this._done || this._loading) return;
|
|
63
|
+
this.#loader = fn;
|
|
64
|
+
if (fn && this.#connected) this.#schedule();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
constructor() {
|
|
68
|
+
super();
|
|
69
|
+
this.attachShadow({ mode: 'open' });
|
|
70
|
+
applySharedSetup(this.shadowRoot);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
connectedCallback() {
|
|
74
|
+
this.#connected = true;
|
|
75
|
+
if (this.#loader) this.#schedule();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#schedule() {
|
|
79
|
+
if (this._done || this._loading || this.#idleId != null || this.#timeoutId != null) return;
|
|
80
|
+
if (typeof requestIdleCallback === 'function') {
|
|
81
|
+
this.#idleId = requestIdleCallback(() => executeLoader(this));
|
|
82
|
+
} else {
|
|
83
|
+
this.#timeoutId = setTimeout(() => executeLoader(this), 200);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
disconnectedCallback() {
|
|
88
|
+
this.#connected = false;
|
|
89
|
+
if (this.#idleId != null) {
|
|
90
|
+
cancelIdleCallback(this.#idleId);
|
|
91
|
+
this.#idleId = null;
|
|
92
|
+
}
|
|
93
|
+
if (this.#timeoutId != null) {
|
|
94
|
+
clearTimeout(this.#timeoutId);
|
|
95
|
+
this.#timeoutId = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── <o-when> ───────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export class OWhen extends HTMLElement {
|
|
103
|
+
_done = false;
|
|
104
|
+
_loading = false;
|
|
105
|
+
#loader = null;
|
|
106
|
+
#condition = false;
|
|
107
|
+
#triggered = false;
|
|
108
|
+
|
|
109
|
+
get loader() { return this.#loader; }
|
|
110
|
+
set loader(fn) {
|
|
111
|
+
if (this._done || this._loading) return;
|
|
112
|
+
this.#loader = fn;
|
|
113
|
+
this.#tryLoad();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
set condition(val) {
|
|
117
|
+
this.#condition = !!val;
|
|
118
|
+
this.#tryLoad();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#tryLoad() {
|
|
122
|
+
if (this.#triggered || this._done || this._loading) return;
|
|
123
|
+
if (this.#condition && this.#loader) {
|
|
124
|
+
this.#triggered = true;
|
|
125
|
+
executeLoader(this);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
constructor() {
|
|
130
|
+
super();
|
|
131
|
+
this.attachShadow({ mode: 'open' });
|
|
132
|
+
applySharedSetup(this.shadowRoot);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── <o-viewport> ───────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export class OViewport extends HTMLElement {
|
|
139
|
+
_done = false;
|
|
140
|
+
_loading = false;
|
|
141
|
+
#loader = null;
|
|
142
|
+
#observer = null;
|
|
143
|
+
#connected = false;
|
|
144
|
+
#sentinel = null;
|
|
145
|
+
|
|
146
|
+
get loader() { return this.#loader; }
|
|
147
|
+
set loader(fn) {
|
|
148
|
+
if (this._done || this._loading) return;
|
|
149
|
+
this.#loader = fn;
|
|
150
|
+
if (fn && this.#connected) this.#observe();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
constructor() {
|
|
154
|
+
super();
|
|
155
|
+
this.attachShadow({ mode: 'open' });
|
|
156
|
+
|
|
157
|
+
// Sentinel: a 1px element the IntersectionObserver can track,
|
|
158
|
+
// because :host { display: contents } has no box model.
|
|
159
|
+
this.#sentinel = document.createElement('span');
|
|
160
|
+
this.#sentinel.style.cssText = 'display:block;width:1px;height:1px;pointer-events:none;';
|
|
161
|
+
|
|
162
|
+
const style = document.createElement('style');
|
|
163
|
+
style.textContent = `
|
|
164
|
+
:host { display: contents; }
|
|
165
|
+
::slotted([slot="fallback"]) { display: none; }
|
|
166
|
+
:host(:not([loaded])) ::slotted(:not([slot])) { display: none; }
|
|
167
|
+
:host(:not([loaded])) ::slotted([slot="fallback"]) { display: contents; }
|
|
168
|
+
`;
|
|
169
|
+
this.shadowRoot.append(
|
|
170
|
+
style,
|
|
171
|
+
this.#sentinel,
|
|
172
|
+
document.createElement('slot'),
|
|
173
|
+
Object.assign(document.createElement('slot'), { name: 'fallback' }),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
connectedCallback() {
|
|
178
|
+
this.#connected = true;
|
|
179
|
+
if (this.#loader) this.#observe();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#observe() {
|
|
183
|
+
if (this._done || this._loading || this.#observer) return;
|
|
184
|
+
this.#observer = new IntersectionObserver(
|
|
185
|
+
(entries) => {
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (entry.isIntersecting) {
|
|
188
|
+
this.#observer.disconnect();
|
|
189
|
+
this.#observer = null;
|
|
190
|
+
executeLoader(this);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{ rootMargin: '200px' }
|
|
196
|
+
);
|
|
197
|
+
this.#observer.observe(this.#sentinel);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
disconnectedCallback() {
|
|
201
|
+
this.#connected = false;
|
|
202
|
+
this.#observer?.disconnect();
|
|
203
|
+
this.#observer = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Register elements ──────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
if (!customElements.get('o-idle')) customElements.define('o-idle', OIdle);
|
|
210
|
+
if (!customElements.get('o-when')) customElements.define('o-when', OWhen);
|
|
211
|
+
if (!customElements.get('o-viewport')) customElements.define('o-viewport', OViewport);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Router } from './router.js';
|
|
2
|
+
|
|
3
|
+
export class OkalitRouter extends HTMLElement {
|
|
4
|
+
constructor() {
|
|
5
|
+
super();
|
|
6
|
+
this.attachShadow({ mode: 'open' });
|
|
7
|
+
const style = document.createElement('style');
|
|
8
|
+
style.textContent = ':host { display: contents; }';
|
|
9
|
+
this.shadowRoot.appendChild(style);
|
|
10
|
+
this._currentComponent = null;
|
|
11
|
+
this._depth = 0;
|
|
12
|
+
this._renderVersion = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
connectedCallback() {
|
|
16
|
+
this._depth = 0;
|
|
17
|
+
let root = this.getRootNode();
|
|
18
|
+
while (root && root.host) {
|
|
19
|
+
if (root.host.tagName === 'OKALIT-ROUTER') {
|
|
20
|
+
this._depth++;
|
|
21
|
+
}
|
|
22
|
+
root = root.host.getRootNode();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const router = Router.getInstance();
|
|
26
|
+
if (router) {
|
|
27
|
+
router.registerOutlet(this);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
disconnectedCallback() {
|
|
32
|
+
const router = Router.getInstance();
|
|
33
|
+
if (router) {
|
|
34
|
+
router.unregisterOutlet(this);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async _renderRoute(match) {
|
|
39
|
+
const route = match.chain[this._depth];
|
|
40
|
+
if (!route) return;
|
|
41
|
+
|
|
42
|
+
// Skip if same component already rendered
|
|
43
|
+
if (this._currentComponent === route.component) return;
|
|
44
|
+
|
|
45
|
+
// Version guard: if another _renderRoute starts while we await,
|
|
46
|
+
// the earlier one becomes stale and should bail out.
|
|
47
|
+
const version = ++this._renderVersion;
|
|
48
|
+
|
|
49
|
+
if (route.import) {
|
|
50
|
+
await route.import();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Bail out if a newer render was triggered while awaiting
|
|
54
|
+
if (version !== this._renderVersion) return;
|
|
55
|
+
|
|
56
|
+
this._currentComponent = route.component;
|
|
57
|
+
|
|
58
|
+
this.shadowRoot.innerHTML = '';
|
|
59
|
+
const el = document.createElement(route.component);
|
|
60
|
+
this.shadowRoot.appendChild(el);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!customElements.get('okalit-router')) {
|
|
65
|
+
customElements.define('okalit-router', OkalitRouter);
|
|
66
|
+
}
|