diff --git a/packages/x-archetype-utils/src/__tests__/css-injector.spec.ts b/packages/x-archetype-utils/src/__tests__/css-injector.spec.ts deleted file mode 100644 index ae0e4af52e..0000000000 --- a/packages/x-archetype-utils/src/__tests__/css-injector.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { WindowWithInjector } from '../css-injector/css-injector.types' -import { CssInjector } from '../css-injector/css-injector' - -const getInstance = () => (window as WindowWithInjector).xCSSInjector as CssInjector - -// NOTE: this test is expected to run secuentialy -describe('test custom css injector', () => { - it('reuses the same instance between initializations', () => { - const injector1 = new CssInjector() - const injector2 = new CssInjector() - - expect(injector1 === injector2).toBe(true) - }) - - it('is appended to the window under xCSSInjector', () => { - expect((window as WindowWithInjector).xCSSInjector).toBeInstanceOf(CssInjector) - }) - - it('can set the host element that will receive the styles', () => { - const injector = getInstance() - - // @ts-expect-error Property host is protected. - expect(injector.hosts.size).toBe(0) - - injector.setHost(document.head) - - // @ts-expect-error Property host is protected. - expect(injector.hosts.has(document)).toBe(true) - }) - - it('can remove host', () => { - const injector = getInstance() - - // TODO: after remove the deprecated method: injector.addHost(document) - // @ts-expect-error Property host is protected. - expect(injector.hosts.size).toBe(1) - - injector.removeHost(document) - - // @ts-expect-error Property host is protected. - expect(injector.hosts.size).toBe(0) - }) - - it('can add host', () => { - const injector = getInstance() - const domElement = document.createElement('div') - const shadowRoot = domElement.attachShadow({ mode: 'open' }) - - // @ts-expect-error Property host is protected. - expect(injector.hosts.size).toBe(0) - - injector.addHost(document) - injector.addHost(shadowRoot) - - // @ts-expect-error Property host is protected. - expect(injector.hosts.size).toBe(2) - // @ts-expect-error Property host is protected. - expect(injector.hosts.has(document)).toBeTruthy() - // @ts-expect-error Property host is protected. - expect(injector.hosts.has(shadowRoot)).toBeTruthy() - }) - - // adoptedStyleSheets.replaceSync is not implemented in jsdom - it.skip('adds styles string to all the hosts', () => { - const injector = getInstance() - - const styles = { - source: "* { background: 'red' }", - } - - injector.addStyle(styles) - - // @ts-expect-error Property host is protected. - expect(document.adoptedStyleSheets).toEqual(injector.stylesToAdopt) - }) -}) diff --git a/packages/x-archetype-utils/src/build/rollup/rollup.config.ts b/packages/x-archetype-utils/src/build/rollup/rollup.config.ts index db4c0f2560..dfc255e33d 100644 --- a/packages/x-archetype-utils/src/build/rollup/rollup.config.ts +++ b/packages/x-archetype-utils/src/build/rollup/rollup.config.ts @@ -1,11 +1,5 @@ export const rollupCssInjectorConfig = { - replace: { - // Replace X CSS injector by our custom one. - 'export default injectCss': - 'export default (css) => window.xCSSInjector.addStyle({ source: css });', - delimiters: ['', ''], - }, styles: { - mode: ['inject', (varname: string) => `window.xCSSInjector.addStyle({ source: ${varname} });`], + mode: ['inject', (varname: string) => `(window.xCSSInjector ??= []).push(${varname});`], }, } diff --git a/packages/x-archetype-utils/src/build/vite/css-injector-plugin.ts b/packages/x-archetype-utils/src/build/vite/css-injector-plugin.ts new file mode 100644 index 0000000000..8adad56e91 --- /dev/null +++ b/packages/x-archetype-utils/src/build/vite/css-injector-plugin.ts @@ -0,0 +1,17 @@ +/** + * This plugin add a custom block to Vue SFC files that injects the css styles using the global xCSSInjector. + */ +export function viteCssInjectorPlugin() { + return { + name: 'css-injector-plugin', + //enforce: 'pre', + transform(code: string, id: string) { + if (!id.endsWith('.vue') || !code.includes('')) return + return `${code} + + export default component => Promise.resolve(component).then(comp => (window.xCSSInjector ??= []).push(...comp.styles)); + + ` + }, + } +} diff --git a/packages/x-archetype-utils/src/css-injector/css-injector.ts b/packages/x-archetype-utils/src/css-injector/css-injector.ts deleted file mode 100644 index 01ffff20b9..0000000000 --- a/packages/x-archetype-utils/src/css-injector/css-injector.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Style, WindowWithInjector, XCSSInjector } from './css-injector.types' - -/** Singleton instance of the injector that will be used across all the initializations. */ -let instance: CssInjector | null = null - -/** - * Custom CSS injector that allows to inject styles into a host element. - * - * @public - */ -export class CssInjector implements XCSSInjector { - protected hosts = new Set() - protected stylesToAdopt: CSSStyleSheet[] = [] - - /** - * Initializes the instance of the injector if it's not already initialized and sets it in the - * window object if it's required. - * - * @param setInWindow - Whether to set the injector instance in the window object. - */ - public constructor(setInWindow = true) { - if (!(instance instanceof CssInjector)) { - // eslint-disable-next-line ts/no-this-alias - instance = this - } - - if (setInWindow) { - this.setInWindow() - } - - return instance - } - - /** - * Adds the style to the host element. - * - * @param style - The styles to be added. - * @param style.source - Styles source. - */ - addStyle(style: Style): void { - const sheet = new CSSStyleSheet() - sheet.replaceSync(style.source) - this.stylesToAdopt.push(sheet) - this.hosts.forEach(host => (host.adoptedStyleSheets = this.stylesToAdopt)) - } - - /** - * Sets the host element. Alias of addHost method. - * - * @param host - The host element. - * @deprecated Use addHost instead. - */ - setHost(host: Element | ShadowRoot): void { - this.addHost(host instanceof ShadowRoot ? host : document) - } - - /** - * Adds the element to the hosts set. - * - * @param host - The host element. - */ - addHost(host: Document | ShadowRoot): void { - this.hosts.add(host) - host.adoptedStyleSheets = this.stylesToAdopt - } - - /** - * Removes the element from the hosts set. - * - * @param host - The host element to remove. - */ - removeHost(host: Document | ShadowRoot): void { - this.hosts.delete(host) - } - - /** - * Sets the injector instance in the window object. - */ - setInWindow(): void { - if (typeof window !== 'undefined' && instance) { - ;(window as WindowWithInjector).xCSSInjector = instance - } - } - - /** - * Checks if the injector instance is in the window object. - * - * @returns Whether the injector instance is in the window object. - */ - isInWindow(): boolean { - return typeof window === 'undefined' - ? false - : (window as WindowWithInjector).xCSSInjector === instance - } -} - -/** - * Instance of the injector. - * - * @public - */ -export const cssInjector = new CssInjector(typeof window !== 'undefined') diff --git a/packages/x-archetype-utils/src/css-injector/css-injector.types.ts b/packages/x-archetype-utils/src/css-injector/css-injector.types.ts deleted file mode 100644 index ebce28ac13..0000000000 --- a/packages/x-archetype-utils/src/css-injector/css-injector.types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** The style payload interface. */ -export interface Style { - /** Css source. */ - source: string -} - -export interface XCSSInjector { - /** Function that will add the styles to the host. */ - addStyle: (style: Style) => void - /** @deprecated Use addHost method. */ - setHost: (el: Element | ShadowRoot) => void - /** Adds the element to the hosts set. */ - addHost: (el: Document | ShadowRoot) => void - /** Removes the element from the hosts set. */ - removeHost: (el: Document | ShadowRoot) => void - /** Set injector instance in the window object. */ - setInWindow: () => void - /** Check if the instance is set in the window object. */ - isInWindow: () => boolean -} - -export type WindowWithInjector = Window & { xCSSInjector?: XCSSInjector } diff --git a/packages/x-archetype-utils/src/index.ts b/packages/x-archetype-utils/src/index.ts index 39f56d20e6..032fddf153 100644 --- a/packages/x-archetype-utils/src/index.ts +++ b/packages/x-archetype-utils/src/index.ts @@ -1,6 +1,4 @@ export * from './build/rollup/rollup.config' export * from './build/webpack/webpack.config' -export * from './css-injector/css-injector' -export * from './css-injector/css-injector.types' export * from './i18n/i18n.plugin' export * from './i18n/i18n.types' diff --git a/packages/x-components/build/rollup.config.ts b/packages/x-components/build/rollup.config.ts index e970999269..26032845f1 100644 --- a/packages/x-components/build/rollup.config.ts +++ b/packages/x-components/build/rollup.config.ts @@ -81,13 +81,7 @@ export const rollupConfig: RollupOptions = { }) as Plugin, styles({ minimize: true, - mode: [ - 'inject', - varname => { - const pathInjector = path.resolve('./tools/inject-css.js') - return `import injectCss from '${pathInjector}';injectCss(${varname});` - }, - ], + mode: ['inject', varname => `(window.xCSSInjector ??= []).push(${varname});`], }), vueDocs, generateEntryFiles({ buildPath, jsOutputDir, typesOutputDir }), diff --git a/packages/x-components/build/tools/inject-css.js b/packages/x-components/build/tools/inject-css.js deleted file mode 100644 index e59854a047..0000000000 --- a/packages/x-components/build/tools/inject-css.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Simple CSS injector to append styles to the head. - * This injector can be overwritten at build time. - * - * @params css - CSS code. - */ -function injectCss(css) { - if (document) { - const el = document.createElement('style') - el.textContent = css - document.head.appendChild(el) - } -} - -export default injectCss diff --git a/packages/x-components/index.html b/packages/x-components/index.html index 6601d1fdf8..8dcfbe5b55 100644 --- a/packages/x-components/index.html +++ b/packages/x-components/index.html @@ -15,7 +15,6 @@ /> -
diff --git a/packages/x-components/package.json b/packages/x-components/package.json index e8795bb5e1..63714fb21e 100644 --- a/packages/x-components/package.json +++ b/packages/x-components/package.json @@ -116,6 +116,7 @@ "ts-node": "10.9.2", "typescript": "5.9.3", "vite": "6.4.1", + "vite-plugin-css-injected-by-js": "4.0.1", "vite-plugin-vue-inspector": "5.3.2", "vue": "3.5.28", "vue-docgen-cli": "4.79.0", diff --git a/packages/x-components/src/components/base-teleport.vue b/packages/x-components/src/components/base-teleport.vue index 1043a3f6fb..39c4e8d2a9 100644 --- a/packages/x-components/src/components/base-teleport.vue +++ b/packages/x-components/src/components/base-teleport.vue @@ -16,6 +16,7 @@ import { watch, watchEffect, } from 'vue' +import { cssInjector } from '../utils/css-injector/css-injector' export default defineComponent({ name: 'BaseTeleport', @@ -71,7 +72,7 @@ export default defineComponent({ onUnmounted(() => { if (isIsolated && teleportHost.value) { - ;(window as any).xCSSInjector.removeHost(teleportHost.value.shadowRoot) + cssInjector.removeHost(teleportHost.value.shadowRoot!) } }) @@ -158,7 +159,7 @@ export default defineComponent({ isIsolated = instance?.appContext.app._container instanceof ShadowRoot if (isIsolated) { teleportHost.value.attachShadow({ mode: 'open' }) - ;(window as any).xCSSInjector.addHost(teleportHost.value.shadowRoot) + cssInjector.addHost(teleportHost.value.shadowRoot!) } } diff --git a/packages/x-components/src/main.ts b/packages/x-components/src/main.ts index 60238cec04..a80f89dabe 100644 --- a/packages/x-components/src/main.ts +++ b/packages/x-components/src/main.ts @@ -1,8 +1,13 @@ +/* eslint-disable perfectionist/sort-imports */ +// It must be the first, it setups the global cssInjector used by the styles injection system +import './utils/css-injector/css-injector' +import type { SnippetConfig } from './x-installer' + import type { App } from 'vue' -// eslint-disable-next-line import/no-named-default -import { default as AppComponent } from './App.vue' +import AppComponent from './App.vue' import { setupDevtools } from './plugins/devtools/devtools.plugin' import router from './router' +import { createXRoot } from './utils/create-x-root' import { baseInstallXOptions, baseSnippetConfig } from './views/base-config' import { XInstaller } from './x-installer/x-installer/x-installer' import { FilterEntityFactory } from './x-modules/facets/entities/filter-entity.factory' @@ -24,21 +29,19 @@ FilterEntityFactory.instance.registerModifierByFilterModelName( SingleSelectModifier as any, ) +const snippetConfig = retrieveSnippetConfig() + const installer = new XInstaller({ ...baseInstallXOptions, rootComponent: AppComponent, - domElement: '#app', + domElement: createXRoot(snippetConfig), onCreateApp: initDevtools, installExtraPlugins({ app }) { app.use(router) }, }) -if (window.initX) { - void installer.init() -} else { - void installer.init(baseSnippetConfig) -} +void installer.init(snippetConfig) /** * If an app is provided, initialise the devtools. @@ -51,5 +54,17 @@ function initDevtools(app: App): void { setupDevtools(app) } } +/** + * Tries to retrieve the snippet config from the window.initX function or object. + */ +function retrieveSnippetConfig(): SnippetConfig { + if (typeof window.initX === 'function') { + return window.initX() + } + if (typeof window.initX === 'object') { + return window.initX + } + return baseSnippetConfig +} /* eslint-enable ts/no-unsafe-argument */ diff --git a/packages/x-components/src/utils/__tests__/css-injector.spec.ts b/packages/x-components/src/utils/__tests__/css-injector.spec.ts new file mode 100644 index 0000000000..9a90d16f80 --- /dev/null +++ b/packages/x-components/src/utils/__tests__/css-injector.spec.ts @@ -0,0 +1,43 @@ +import type { WindowWithInjector } from '../css-injector/css-injector.types' +import { cssInjector as injector } from '../css-injector/css-injector' + +// NOTE: this test is expected to run secuentialy +describe('test custom css injector', () => { + it('is appended to the window under xCSSInjector', () => { + expect((window as WindowWithInjector).xCSSInjector).toBe(injector) + }) + + it('can remove host', () => { + // TODO: after remove the deprecated method: injector.addHost(document) + expect(injector.hosts.size).toBe(1) + + injector.removeHost(document) + + expect(injector.hosts.size).toBe(0) + }) + + it('can add host', () => { + const domElement = document.createElement('div') + const shadowRoot = domElement.attachShadow({ mode: 'open' }) + + expect(injector.hosts.size).toBe(0) + + injector.addHost(document) + injector.addHost(shadowRoot) + + expect(injector.hosts.size).toBe(2) + expect(injector.hosts.has(document)).toBeTruthy() + expect(injector.hosts.has(shadowRoot)).toBeTruthy() + }) + + // adoptedStyleSheets.replaceSync is not implemented in jsdom + it.skip('adds styles string to all the hosts', () => { + const styles = { + source: "* { background: 'red' }", + } + + injector.addStyle(styles) + + expect(document.adoptedStyleSheets).toEqual(injector.stylesToAdopt) + }) +}) diff --git a/packages/x-components/src/utils/create-x-root.ts b/packages/x-components/src/utils/create-x-root.ts new file mode 100644 index 0000000000..1efa36ccc6 --- /dev/null +++ b/packages/x-components/src/utils/create-x-root.ts @@ -0,0 +1,22 @@ +import type { SnippetConfig } from '../x-installer' + +/** + * Creates a DOM element to mount the X Components app. + * + * @param snippetConfig - The snippet configuration. + * @param snippetConfig.isolate - Whether to isolate the DOM element using Shadow DOM. + * @returns The DOM element. + */ +export function createXRoot({ isolate }: SnippetConfig): ShadowRoot | HTMLElement { + const container = document.createElement('div') + container.classList.add('x-root-container') + document.body.appendChild(container) + + // Isolated by default + if (isolate !== false) { + const shadowRoot = container.attachShadow({ mode: 'open' }) + return shadowRoot + } else { + return container + } +} diff --git a/packages/x-components/src/utils/css-injector/css-injector.ts b/packages/x-components/src/utils/css-injector/css-injector.ts new file mode 100644 index 0000000000..1acd5ee075 --- /dev/null +++ b/packages/x-components/src/utils/css-injector/css-injector.ts @@ -0,0 +1,52 @@ +import type { WindowWithInjector, XCSSInjector } from './css-injector.types' + +/** + * Custom CSS injector that allows to inject styles into a host element. + * + * @public + */ +export const cssInjector: XCSSInjector = { + hosts: new Set(), + stylesToAdopt: [] as CSSStyleSheet[], + /** + * Adds the style to the host element. + * @remark push is used to be compatible as array + * + * @param css - The styles to be added. + */ + push(css: string): void { + if (!css) { + return + } + const sheet = new CSSStyleSheet() + sheet.replaceSync(css) + this.stylesToAdopt.push(sheet) + this.hosts.forEach(host => host.adoptedStyleSheets.push(sheet)) + }, + /** + * Adds the element to the hosts set. + * + * @param host - The host element. + */ + addHost(host: Document | ShadowRoot): void { + this.hosts.add(host) + host.adoptedStyleSheets = [...host.adoptedStyleSheets, ...this.stylesToAdopt] + }, + /** + * Removes the element from the hosts set. + * + * @param host - The host element to remove. + */ + removeHost(host: Document | ShadowRoot): void { + host.adoptedStyleSheets = host.adoptedStyleSheets.filter( + sheet => !this.stylesToAdopt.includes(sheet), + ) + this.hosts.delete(host) + }, +} + +if (typeof window !== 'undefined') { + const toAdd = ((window as WindowWithInjector).xCSSInjector ?? []) as string[] + toAdd.forEach(css => cssInjector.push(css)) + ;(window as WindowWithInjector).xCSSInjector ??= cssInjector +} diff --git a/packages/x-components/src/utils/css-injector/css-injector.types.ts b/packages/x-components/src/utils/css-injector/css-injector.types.ts new file mode 100644 index 0000000000..9f56ad9f4d --- /dev/null +++ b/packages/x-components/src/utils/css-injector/css-injector.types.ts @@ -0,0 +1,28 @@ +/** + * The style payload interface. + * @public + */ +/** + * The XCSSInjector interface. + * Custom CSS injector that allows to inject styles into a host element. + * @public + */ +export interface XCSSInjector { + /** Set of hosts that will receive the styles. */ + hosts: Set + /** Styles that will be injected into the hosts. */ + stylesToAdopt: CSSStyleSheet[] + /** Function that will add the styles to the host. */ + push: (css: string) => void + /** Adds the element to the hosts set. */ + addHost: (el: Document | ShadowRoot) => void + /** Removes the element from the hosts set. */ + removeHost: (el: Document | ShadowRoot) => void +} + +/** + * The XCSSInjector is added to window. + * @public + * @deprecated - It should not be required. It will be removed in the future. + */ +export type WindowWithInjector = Window & { xCSSInjector?: XCSSInjector } diff --git a/packages/x-components/src/utils/index.ts b/packages/x-components/src/utils/index.ts index c34f1487f6..b642331455 100644 --- a/packages/x-components/src/utils/index.ts +++ b/packages/x-components/src/utils/index.ts @@ -1,6 +1,8 @@ export * from './array' export * from './cancellable-promise' export * from './clone' +export * from './css-injector/css-injector' +export * from './css-injector/css-injector.types' export * from './currency-formatter' export { debounce as debounceFunction } from './debounce' export * from './filters' diff --git a/packages/x-components/src/views/base-config.ts b/packages/x-components/src/views/base-config.ts index cea82006bb..47e5e83353 100644 --- a/packages/x-components/src/views/base-config.ts +++ b/packages/x-components/src/views/base-config.ts @@ -7,6 +7,7 @@ export const baseSnippetConfig: SnippetConfig = { lang: 'en', env: 'staging', scope: 'x-components-development', + isolate: false } // eslint-disable-next-line ts/no-unsafe-assignment diff --git a/packages/x-components/src/views/home/Home.vue b/packages/x-components/src/views/home/Home.vue index a997ef1b86..331a6e47b8 100644 --- a/packages/x-components/src/views/home/Home.vue +++ b/packages/x-components/src/views/home/Home.vue @@ -554,7 +554,12 @@ - This is the teleport content + + Teleporting inside App Start + + + Teleporting outside App Start +