Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-cats-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lynx-js/web-elements": patch
---

feat: implement x-webview component
52 changes: 42 additions & 10 deletions packages/web-platform/web-elements/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,18 @@ When implementing a new web element, follow these strict guidelines:
5. **Exports**:
- Export the component in `src/elements/all.ts`.
- Add export config to `package.json` under `"exports"` (types and default).
6. **Testing**:
- Create a directory in `tests/fixtures/` matching the tag name (e.g., `tests/fixtures/x-my-element/`).
- Create one HTML file per test case/feature (Atomic Testing).
- Add E2E tests in `tests/web-elements.spec.ts`.
6. **Attribute Handling (Critical)**:
- **Do NOT** implement attribute logic (handlers decorated with `@registerAttributeHandler`) directly on the main Element class (the one decorated with `@Component`). They will **NOT** be registered.
- **Pattern**: Create a separate "Attribute Class" (e.g., `XWebViewAttribute.ts`) that implements `AttributeReactiveClass` logic.
- **Register**: Pass this Attribute Class as the second argument to the `@Component` decorator.
- **Lazy Access**: Use `genDomGetter` to safely access internal elements within the Shadow DOM.

7. **Testing**:
- **Structure**: Create a separate spec file for new components (e.g., `tests/x-webview.spec.ts`) instead of adding to the monolithic `web-elements.spec.ts`.
- **Network Mocking**: If the component makes external requests (e.g., via `iframe` or `fetch`), you **MUST** mock them using `page.route` to ensure tests are hermetic and fast.
- **Shadow DOM**: Playwright relies on specific strategies to access Shadow DOM.
- **Locators**: `page.locator('x-elem').locator('inner-elem')` works automatically if the shadow root is open.
- **evaluate/waitForFunction**: You must explicit traverse `.shadowRoot`. Example: `el.shadowRoot.querySelector('iframe')`.

### 4. Example Test (Playwright)

Expand All @@ -121,16 +129,40 @@ import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';

const goto = async (page: Page, fixtureName: string) => {
// Use relative path for fixtures
await page.goto(`tests/fixtures/${fixtureName}.html`, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
};

test('my-element should update style on attribute change', async ({ page }) => {
await goto(page, 'my-element');
const element = page.locator('my-element');

await element.evaluate(el => el.setAttribute('custom-color', 'red'));
await expect(element).toHaveCSS('background-color', 'rgb(255, 0, 0)');
test.describe('my-element', () => {
// NETWORK MOCKING EXAMPLE
test.beforeEach(async ({ page }) => {
await page.route('https://example.com/*', async route => {
await route.fulfill({ status: 200, body: 'Mocked Content' });
});
});

test('should update style on attribute change', async ({ page }) => {
await goto(page, 'my-element/basic'); // Subdirectory in fixtures
const element = page.locator('my-element');

await element.evaluate(el => el.setAttribute('custom-color', 'red'));
await expect(element).toHaveCSS('background-color', 'rgb(255, 0, 0)');
});

test('should access internal shadow dom element', async ({ page }) => {
await goto(page, 'my-element/basic');
// 1. Using Locators (Preferred)
const inner = page.locator('my-element').locator('#inner-id');
await expect(inner).toBeVisible();

// 2. Using evaluate/waitForFunction (If necessary)
// NOTE: document.querySelector('x-element #inner') fails. Access shadowRoot explicitly.
await page.waitForFunction(() => {
const el = document.querySelector('my-element');
return el?.shadowRoot?.querySelector('#inner-id') !== null;
});
});
});
```

Expand Down
1 change: 1 addition & 0 deletions packages/web-platform/web-elements/elements.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@
@import url("./src/elements/XSwiper/x-swiper.css");
@import url("./src/elements/XTextarea/x-textarea.css");
@import url("./src/elements/XList/x-list.css");
@import url("./src/elements/XWebView/x-webview.css");
5 changes: 5 additions & 0 deletions packages/web-platform/web-elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@
"types": "./dist/elements/XList/index.d.ts",
"default": "./dist/elements/XList/index.js"
},
"./XWebView": {
"source": "./src/elements/XWebView/index.ts",
"types": "./dist/elements/XWebView/index.d.ts",
"default": "./dist/elements/XWebView/index.js"
},
"./html-templates": {
"source": "./src/elements/htmlTemplates.ts",
"types": "./dist/elements/htmlTemplates.d.ts",
Expand Down
138 changes: 138 additions & 0 deletions packages/web-platform/web-elements/src/elements/XWebView/XWebView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
*/
import { Component, genDomGetter } from '../../element-reactive/index.js';
import { CommonEventsAndMethods } from '../common/CommonEventsAndMethods.js';
import { templateXWebView } from '../htmlTemplates.js';
import { XWebViewAttribute } from './XWebViewAttribute.js';

@Component<typeof XWebView>(
'x-webview',
[CommonEventsAndMethods, XWebViewAttribute],
templateXWebView,
)
export class XWebView extends HTMLElement {
#getWebView = genDomGetter<HTMLIFrameElement>(
() => this.shadowRoot!,
'#webview',
);

/**
* @internal
*/
readonly #handleLoad = () => {
this.dispatchEvent(
new CustomEvent('load', {
bubbles: true,
composed: true,
detail: {
url: this.#getWebView().src,
},
}),
);
this.dispatchEvent(
new CustomEvent('bindload', {
bubbles: true,
composed: true,
detail: {
url: this.#getWebView().src,
},
}),
);
};

/**
* @internal
*/
readonly #handleError = (e: ErrorEvent | Event) => {
this.dispatchEvent(
new CustomEvent('error', {
bubbles: true,
composed: true,
detail: {
errorMsg: (e as ErrorEvent).message || 'unknown error',
},
}),
);
this.dispatchEvent(
new CustomEvent('binderror', {
bubbles: true,
composed: true,
detail: {
errorMsg: (e as ErrorEvent).message || 'unknown error',
},
}),
);
};

/**
* @internal
*/
readonly #handleMessage = (e: MessageEvent) => {
if (e.source !== this.#getWebView().contentWindow) {
return;
}
this.dispatchEvent(
new CustomEvent('message', {
bubbles: true,
composed: true,
detail: {
msg: e.data, // compatible with bindmessage
data: e.data, // standard CustomEvent
},
}),
);
this.dispatchEvent(
new CustomEvent('bindmessage', {
bubbles: true,
composed: true,
detail: {
msg: e.data,
},
}),
);
};

connectedCallback() {
this.#getWebView().addEventListener('load', this.#handleLoad);
this.#getWebView().addEventListener('error', this.#handleError);
window.addEventListener('message', this.#handleMessage);
}

disconnectedCallback() {
this.#getWebView().removeEventListener('load', this.#handleLoad);
this.#getWebView().removeEventListener('error', this.#handleError);
window.removeEventListener('message', this.#handleMessage);
}

get src() {
return this.getAttribute('src');
}

set src(val: string | null) {
if (val === null) {
this.removeAttribute('src');
} else {
this.setAttribute('src', val);
}
}

get html() {
return this.getAttribute('html');
}

set html(val: string | null) {
if (val === null) {
this.removeAttribute('html');
} else {
this.setAttribute('html', val);
}
}

reload() {
// eslint-disable-next-line no-self-assign
this.#getWebView().src = this.#getWebView().src;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
*/
import {
type AttributeReactiveClass,
bindToAttribute,
genDomGetter,
registerAttributeHandler,
} from '../../element-reactive/index.js';

export class XWebViewAttribute
implements InstanceType<AttributeReactiveClass<typeof HTMLElement>>
{
static observedAttributes = ['src', 'html'];
#dom: HTMLElement;

#getWebView = genDomGetter<HTMLIFrameElement>(
() => this.#dom.shadowRoot!,
'#webview',
);

constructor(dom: HTMLElement) {
this.#dom = dom;
}

@registerAttributeHandler('src', true)
_handleSrc = bindToAttribute(this.#getWebView, 'src');

@registerAttributeHandler('html', true)
_handleHtml = bindToAttribute(this.#getWebView, 'srcdoc');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

/**
* @module elements/XWebView
*
* `x-webview` provides a web container that allows loading web pages.
*
* Attributes:
* - `src`: The URL of the web page to load.
* - `html`: The HTML content to load (via srcdoc).
*
* Events:
* - `bindload`: Fired when the page loads. Detail: `{ url }`.
* - `binderror`: Fired when an error occurs. Detail: `{ errorMsg }`.
* - `bindmessage`: Fired when a message is received from the page. Detail: `{ msg }`.
*
* Methods:
* - `reload()`: Reloads the current page.
*/
export { XWebView } from './XWebView.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
x-webview {
display: flex;
flex-direction: column;
overflow: hidden;
}
1 change: 1 addition & 0 deletions packages/web-platform/web-elements/src/elements/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ import './XView/index.js';
import './XViewpagerNg/index.js';
import './XList/index.js';
import './XList/ListItem.js';
import './XWebView/index.js';
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,15 @@ export const templateXViewpageNg = `<style>
<slot></slot>
</div>`;

export const templateXWebView = `<style>
iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
<iframe id="webview" part="webview"></iframe>`;

export const templateXSvg = () => {
return `<img part="img" alt="" loading="lazy" id="img" /> `;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '@lynx-js/web-elements/compat/LinearContainer';
import '@lynx-js/web-elements/all';
import '@lynx-js/web-elements/index.css';
import '../../src/compat/LinearContainer/LinearContainer.js';
import '../../src/elements/all.js';
import '../../index.css';
import '@lynx-js/playwright-fixtures/common.css';
// Trigger rebuild
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
}
x-webview {
width: 300px;
height: 200px;
background: #eee;
display: flex;
}
</style>
<link href="/main.css" rel="stylesheet" />
<script src="/main.js" defer></script>
</head>
<body>
<x-webview id="webview" src="https://example.com"></x-webview>
<script>
const webview = document.getElementById('webview');
webview.addEventListener('bindload', (e) => {
console.log('bindload fired', e.detail);
window._bindload_fired = true;
});
webview.addEventListener('bindmessage', (e) => {
console.log('bindmessage fired', e.detail);
window._bindmessage_fired = true;
window._bindmessage_data = e.detail;
});
</script>
</body>
</html>
Loading
Loading