diff --git a/.github/styles/config/vocabularies/Docs/accept.txt b/.github/styles/config/vocabularies/Docs/accept.txt index ff6b99537c..d49ab4a548 100644 --- a/.github/styles/config/vocabularies/Docs/accept.txt +++ b/.github/styles/config/vocabularies/Docs/accept.txt @@ -11,9 +11,9 @@ Anthropic # Allow for Spring @Autowired [aA]utowir(e|ed|ing) Arial -async -automount -autorun +[aA]sync +[aA]utomount +[aA]utorun Azure [bB]oldens [bB]oolean @@ -64,7 +64,7 @@ DSL Entra executeJs [fF]ailsafe -favicon +[fF]avicon FDOs [fF]etch Figma @@ -165,7 +165,7 @@ OpenAPI pageable [pP]asswordless Payara -performant +[pP]erformant [pP]ersister Pivotal [pP]luggable @@ -183,7 +183,7 @@ Postgre(SQL|s) Quarkus [qQ]uantiles? [rR]eact -reactively +[rR]eactively readonly [rR]enderers? Roboto diff --git a/articles/flow/component-internals/web-components/advanced-integration.adoc b/articles/flow/component-internals/web-components/advanced-integration.adoc new file mode 100644 index 0000000000..9b9d25b02b --- /dev/null +++ b/articles/flow/component-internals/web-components/advanced-integration.adoc @@ -0,0 +1,434 @@ +--- +title: Advanced Integration +page-title: Advanced web component integration with TypeScript and Lit +description: How to wrap npm packages, use advanced Lit features, and handle complex state synchronization. +meta-description: Learn advanced patterns for integrating web components in Vaadin Flow using TypeScript, Lit lifecycle, and npm package wrapping. +order: 25 +--- + + += Advanced Web Component Integration + +This guide covers advanced patterns for creating Web Component wrappers in Vaadin, including wrapping npm packages with TypeScript, using Lit lifecycle hooks and decorators, and managing complex state between the client and server. + +For basic integration, see <> and <<{articles}/building-apps/components/wrap-web-component#,Wrap a Web Component>>. +For integrating React components, see <<{articles}/flow/integrations/react#,Using React Components in Flow>>. + + +[[typescript-wrappers]] +== TypeScript for Web Component Wrappers + +TypeScript is recommended for non-trivial Web Component wrappers. It provides: + +- Type-safe property definitions that catch mismatches at build time +- Interfaces for configuration objects passed between Java and the client +- Better IDE support with auto-completion when using third-party libraries + +A typical TypeScript wrapper defines interfaces for any complex data structures: + +[source,typescript] +---- +interface ChartConfig { + type: 'bar' | 'line' | 'pie'; + animate: boolean; + colors?: string[]; +} + +interface DataPoint { + label: string; + value: number; +} +---- + +These types ensure that the objects passed from Java via `setPropertyBean()` conform to the expected shape, and that event data dispatched back to Java is consistent. + + +[[wrapping-npm-packages]] +== Wrapping npm Packages with TypeScript + +Wrapping a third-party npm library involves three parts working together: the `@NpmPackage` annotation declares the dependency, a TypeScript file imports and wraps the library as a Web Component, and a Java class exposes the API to server-side code. + +This section demonstrates the pattern using a fictional `@acme/widget` package. The same approach applies to any npm library. + + +=== Step 1: The TypeScript Wrapper + +The TypeScript file imports the npm package and wraps it in a LitElement-based Web Component: + +[source,typescript] +---- +include::{root}/frontend/demo/component-internals/acme-widget-wrapper.ts[tags=class,indent=0] +---- + +Key points: + +- The `@property` decorator exposes reactive properties that Java can set via `getElement().setProperty()` or `setPropertyBean()`. When a property changes, Lit automatically re-renders. +- The `@state` decorator marks internal state that triggers re-renders but is not exposed as HTML attributes. +- The `config` property uses `type: Object` so that Vaadin's `setPropertyBean()` can pass a Java record directly as a JavaScript object -- no manual JSON serialization needed. +- The `firstUpdated` lifecycle callback initializes the third-party widget after the component's DOM is ready. +- The `disconnectedCallback` lifecycle callback cleans up the widget instance to prevent memory leaks when the component is removed from the DOM. +- A `CustomEvent` is dispatched to communicate changes back to the Java side. + + +=== Step 2: The Java Component Class + +The Java class declares the npm dependency and provides a typed API: + +[source,java] +---- +include::{root}/src/main/java/com/vaadin/demo/component/internals/AcmeWidget.java[tags=annotations,indent=0] +@NpmPackage(value = "@acme/widget", version = "2.0.0") +include::{root}/src/main/java/com/vaadin/demo/component/internals/AcmeWidget.java[tags=body,indent=0] +---- + +Key points: + +- `@NpmPackage` declares the npm dependency. Vaadin installs it automatically during the build. +- `@JsModule` points to the `.ts` file. Vaadin compiles TypeScript as part of the frontend build. +- The `WidgetConfig` record is passed to the client via `setPropertyBean()`, which serializes it to a JavaScript object automatically. For simple properties, use `setProperty` directly. +- `@DomEvent` and `@EventData` map the client-side `CustomEvent` to a typed Java event class. + + +=== Step 3: Using the Component + +[source,java] +---- +AcmeWidget widget = new AcmeWidget(); +widget.setTitle("Dashboard Widget"); +widget.setConfig(new AcmeWidget.WidgetConfig("bar", true)); +widget.addWidgetChangeListener(event -> { + Notification.show(event.getLabel() + ": " + event.getValue()); +}); +add(widget); +---- + + +[[advanced-lit-features]] +== Advanced Lit Features + +When creating Web Component wrappers, Lit provides features beyond basic property binding and rendering. + + +=== Reactive Properties vs. Internal State + +Use `@property` for values that should be settable from Java (via element properties or attributes). Use `@state` for internal values that affect rendering but shouldn't be part of the public API: + +[source,typescript] +---- +import { LitElement, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +@customElement('my-counter') +class MyCounter extends LitElement { + // Public: settable from Java via getElement().setProperty("max", 100) + @property({ type: Number }) + accessor max: number = 10; + + // Internal: only used within this component + @state() + private accessor _count: number = 0; + + render() { + return html` + ${this._count} / ${this.max} + + `; + } + + private _increment() { + this._count++; + this.dispatchEvent( + new CustomEvent('count-changed', { + detail: { count: this._count }, + bubbles: true, + }) + ); + } +} +---- + +Both `@property` and `@state` trigger re-renders when they change. The difference is that `@property` values can be set via HTML attributes and are part of the component's public API. + + +=== Lifecycle Callbacks + +Lit provides several lifecycle callbacks beyond the standard Web Component ones: + +[cols="1,2"] +|=== +|Callback |When to Use + +|`connectedCallback()` +|The element is added to the DOM. Set up event listeners or start periodic tasks. Always call `super.connectedCallback()`. + +|`disconnectedCallback()` +|The element is removed from the DOM. Clean up event listeners, timers, or third-party library instances to prevent memory leaks. Always call `super.disconnectedCallback()`. + +|`firstUpdated(changedProperties)` +|Called once after the component's first render. Use this to initialize third-party libraries that need a DOM element to attach to. + +|`updated(changedProperties)` +|Called after every render. Use this to react to property changes, such as reconfiguring a wrapped library. The `changedProperties` map contains the previous values. + +|`willUpdate(changedProperties)` +|Called before rendering. Use this to compute derived values from properties before they're used in the template. +|=== + +Example using `firstUpdated` to initialize a library and `disconnectedCallback` to clean it up: + +[source,typescript] +---- +@customElement('chart-wrapper') +class ChartWrapper extends LitElement { + private _chart: Chart | null = null; + + @property({ type: String }) + accessor type: string = 'bar'; + + override firstUpdated() { + const canvas = this.renderRoot.querySelector('canvas'); + this._chart = new Chart(canvas, { type: this.type }); + } + + override updated(changed: PropertyValues) { + if (changed.has('type') && this._chart) { + this._chart.config.type = this.type; + this._chart.update(); + } + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._chart?.destroy(); + this._chart = null; + } + + override render() { + return html``; + } +} +---- + + +=== Shadow DOM vs. Light DOM + +By default, Lit renders into Shadow DOM, which encapsulates styles. Most wrapper components should use Shadow DOM. However, if you need the wrapped content to inherit page styles or participate in form submission, you can render into Light DOM: + +[source,typescript] +---- +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('light-dom-wrapper') +class LightDomWrapper extends LitElement { + override createRenderRoot() { + // Render into Light DOM instead of Shadow DOM + return this; + } + + override render() { + return html`
Content here
`; + } +} +---- + +.When to avoid Light DOM +[NOTE] +Light DOM components don't have style encapsulation. Their styles can leak out and page styles can leak in, which makes them harder to maintain. Prefer Shadow DOM unless you have a specific reason to use Light DOM. + + +[[state-synchronization]] +== State Synchronization Patterns + +Communication between the Java server and the client-side Web Component happens through element properties and events. + + +=== Simple Properties + +For primitive values, set them directly from Java: + +[source,java] +---- +getElement().setProperty("label", "Hello"); +getElement().setProperty("count", 42); +getElement().setProperty("visible", true); +---- + +These are accessible in the TypeScript component as reactive properties. + + +=== Complex Objects with `setPropertyBean` + +For objects and arrays, use `setPropertyBean()` to pass a Java record or bean directly to the client. Vaadin handles the JSON serialization automatically: + +*Java side:* +[source,java] +---- +record ChartConfig(String type, boolean animate) {} + +ChartConfig config = new ChartConfig("bar", true); +getElement().setPropertyBean("config", config); +---- + +*TypeScript side:* +[source,typescript] +---- +@property({ type: Object }) +accessor config: ChartConfig = { type: 'bar', animate: false }; +---- + +The client receives the bean as a plain JavaScript object. No manual JSON parsing is needed. + +You can also read the bean back on the server using `getPropertyBean()`: + +[source,java] +---- +ChartConfig config = getElement().getPropertyBean("config", ChartConfig.class); +---- + +For cases where you need lower-level control, `setPropertyJson()` accepts an elemental `JsonValue`: + +[source,java] +---- +JsonObject config = Json.createObject(); +config.put("type", "bar"); +config.put("animate", true); +getElement().setPropertyJson("config", config); +---- + + +=== Two-Way Binding with Custom Events + +To send state changes from the client back to the server, dispatch a `CustomEvent` and listen for it in Java: + +*TypeScript side:* +[source,typescript] +---- +this.dispatchEvent(new CustomEvent('selection-changed', { + detail: { selectedIds: [1, 2, 3] }, + bubbles: true, + composed: true, +})); +---- + +*Java side using `@DomEvent`:* +[source,java] +---- +@DomEvent("selection-changed") +public static class SelectionChangedEvent + extends ComponentEvent { + + private final JsonArray selectedIds; + + public SelectionChangedEvent(MyComponent source, boolean fromClient, + @EventData("event.detail.selectedIds") JsonArray selectedIds) { + super(source, fromClient); + this.selectedIds = selectedIds; + } + + public JsonArray getSelectedIds() { + return selectedIds; + } +} +---- + +*Java side using `addEventListener`:* +[source,java] +---- +getElement().addEventListener("selection-changed", event -> { + JsonArray ids = event.getEventData().getArray("event.detail.selectedIds"); + // process selection +}).addEventData("event.detail.selectedIds"); +---- + +Set `composed: true` on events that need to cross Shadow DOM boundaries. + + +[[practical-patterns]] +== Practical Patterns + + +=== Loading States and Async Initialization + +When wrapping libraries that require async initialization (e.g., loading data from a remote source), use internal state to track loading: + +[source,typescript] +---- +@state() +private accessor _loading: boolean = true; + +@state() +private accessor _error: string | null = null; + +override async firstUpdated() { + try { + await this._initializeLibrary(); + this._loading = false; + } catch (e) { + this._error = e instanceof Error ? e.message : 'Initialization failed'; + this._loading = false; + } +} + +override render() { + if (this._error) { + return html`
${this._error}
`; + } + if (this._loading) { + return html`
Loading...
`; + } + return html`
`; +} +---- + + +=== Cleanup and Memory Management + +Always clean up when the component is disconnected. This is critical for third-party libraries that create DOM elements, register global event listeners, or start timers: + +[source,typescript] +---- +private _resizeObserver: ResizeObserver | null = null; +private _refreshInterval: number | null = null; + +override connectedCallback() { + super.connectedCallback(); + this._resizeObserver = new ResizeObserver(() => this._handleResize()); + this._resizeObserver.observe(this); + this._refreshInterval = window.setInterval(() => this._refresh(), 30000); +} + +override disconnectedCallback() { + super.disconnectedCallback(); + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + + if (this._refreshInterval !== null) { + clearInterval(this._refreshInterval); + this._refreshInterval = null; + } +} +---- + + +=== Error Handling in Event Dispatch + +When dispatching events with data that might fail to serialize, validate before dispatching: + +[source,typescript] +---- +private _notifyChange(data: unknown): void { + // Only dispatch if there's meaningful data + if (data == null) return; + + this.dispatchEvent(new CustomEvent('data-changed', { + detail: data, + bubbles: true, + composed: true, + })); +} +---- + + +[discussion-id]`4FA5E312-9C1B-4A3E-B7D2-6A8C3F2E1D09` diff --git a/articles/flow/component-internals/web-components/index.adoc b/articles/flow/component-internals/web-components/index.adoc index 6c55918c63..0920ca5376 100644 --- a/articles/flow/component-internals/web-components/index.adoc +++ b/articles/flow/component-internals/web-components/index.adoc @@ -51,7 +51,7 @@ The `@Tag` annotation here defines the name of the HTML element. The `@JsModule` Your component may require in-project frontend files, such as additional JavaScript modules. In which case, add them to the `src/main/resources/META-INF/frontend` directory so that they're packaged in the component JAR if you choose to make an add-on of your component. -As a example, you might use the `@JsModule` annotation to add a local JavaScript module like so: +As a example, you might use the `@JsModule` annotation to add a local JavaScript or TypeScript module like so: [source,java] ---- @JsModule("./my-local-module.js") @@ -60,3 +60,51 @@ As a example, you might use the `@JsModule` annotation to add a local JavaScript .Use explicit relative paths in CSS imports [NOTE] When importing CSS files within other CSS files, always use explicit relative paths (e.g., `@import "./second.css";` instead of `@import "second.css";`). Some tools like Tailwind CSS require this notation to resolve imports correctly. See <> for more details. + + +[[typescript-support]] +== TypeScript Support + +Vaadin fully supports TypeScript for client-side files. You can use `.ts` files with the `@JsModule` annotation exactly as you would use `.js` files: + +[source,java] +---- +@JsModule("./my-component.ts") +---- + +TypeScript provides type safety for property definitions, event data, and configuration objects, which is especially valuable for complex components. It also enables better IDE support with auto-completion and inline documentation. + +When using Lit to define Web Components, TypeScript enables typed decorators for properties and events: + +[source,typescript] +---- +import { LitElement, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('my-component') +class MyComponent extends LitElement { + @property({ type: String }) + accessor label: string = ''; + + @property({ type: Number }) + accessor count: number = 0; + + render() { + return html`${this.label}: ${this.count}`; + } +} +---- + +See <<{articles}/building-apps/components/wrap-web-component#,Wrap a Web Component>> for a complete example, and <> for guidance on wrapping npm packages with TypeScript and using advanced Lit features. + + +[[npm-package-design]] +== Why `@NpmPackage` Is Declared on Components + +You might wonder why the `@NpmPackage` annotation is placed directly on component classes rather than in a centralized configuration file. This design is intentional: + +*Automatic tree-shaking*:: Only npm packages referenced by components that are actually used in the application are installed. If a component class is never referenced, its npm dependencies are excluded from the bundle. + +*Self-contained add-ons*:: When creating a reusable add-on (e.g., for the Vaadin Directory), the component class carries its own dependency declarations. Consumers don't need to manually configure npm packages -- adding the Java dependency is sufficient. + +*Clear dependency graph*:: Each component explicitly declares what it needs. This makes it straightforward to understand which client-side libraries a component relies on, and avoids hidden or implicit dependencies. diff --git a/articles/flow/component-internals/web-components/java-api-for-a-web-component.adoc b/articles/flow/component-internals/web-components/java-api-for-a-web-component.adoc index 6f90ec2749..98d0a2eec4 100644 --- a/articles/flow/component-internals/web-components/java-api-for-a-web-component.adoc +++ b/articles/flow/component-internals/web-components/java-api-for-a-web-component.adoc @@ -72,4 +72,10 @@ If the component accepts child elements, implement [interfacename]`HasComponents See <<../container#,Component Containers>> for the full reference. +== See Also + +- <> -- TypeScript wrappers, npm package integration, and complex state synchronization +- <<{articles}/building-apps/components/wrap-web-component#,Wrap a Web Component>> -- step-by-step guide including writing your own web component + + [discussion-id]`AACDBA11-3ECD-4E3B-9A36-C64E3963C26C` diff --git a/frontend/demo/component-internals/acme-widget-wrapper.ts b/frontend/demo/component-internals/acme-widget-wrapper.ts new file mode 100644 index 0000000000..25605bd94a --- /dev/null +++ b/frontend/demo/component-internals/acme-widget-wrapper.ts @@ -0,0 +1,148 @@ +// tag::class[] +import { css, html, LitElement, type PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +// In a real project, you would import from the npm package: +// import { Widget, type WidgetConfig } from '@acme/widget'; +// +// For this example, the types are defined locally to illustrate the pattern. + +interface WidgetConfig { + type?: string; + animate?: boolean; + interactive?: boolean; + onChange?(data: WidgetData): void; +} + +interface WidgetData { + label: string; + value: number; +} + +// Placeholder for the third-party Widget class. +// In a real project, this comes from the npm package. +class Widget { + private _config: WidgetConfig; + + constructor(_container: HTMLElement, config: WidgetConfig) { + this._config = config; + } + + updateConfig(config: WidgetConfig): void { + this._config = { ...this._config, ...config }; + } + + setInteractive(interactive: boolean): void { + this._config.interactive = interactive; + } + + destroy(): void { + // cleanup + } +} + +@customElement('acme-widget-wrapper') +class AcmeWidgetWrapper extends LitElement { + // -- Reactive properties (synced with Java via element properties) -- + + @property({ type: String }) + accessor title: string = ''; + + @property({ type: Object }) + accessor config: WidgetConfig = {}; + + @property({ type: Boolean }) + accessor interactive: boolean = true; + + // -- Internal state (not exposed as attributes) -- + + @state() + private accessor _widget: Widget | null = null; + + @state() + private accessor _loading: boolean = true; + + // -- Styles -- + + static override styles = css` + :host { + display: block; + } + .container { + border: 1px solid var(--lumo-contrast-20pct, #ccc); + border-radius: var(--lumo-border-radius-m, 4px); + padding: var(--lumo-space-m, 16px); + } + .loading { + color: var(--lumo-secondary-text-color, #999); + } + `; + + // -- Lifecycle -- + + override firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + this._initWidget(); + } + + override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + // Re-configure when config changes after initial render + if (changedProperties.has('config') && this._widget) { + this._widget.updateConfig(this.config); + } + + if (changedProperties.has('interactive') && this._widget) { + this._widget.setInteractive(this.interactive); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + // Clean up to prevent memory leaks + if (this._widget) { + this._widget.destroy(); + this._widget = null; + } + } + + // -- Private methods -- + + private _initWidget(): void { + const container = this.renderRoot.querySelector('#widget-root'); + if (!container) return; + + this._widget = new Widget(container as HTMLElement, { + ...this.config, + interactive: this.interactive, + onChange: (data: WidgetData) => { + // Dispatch event for Java-side listener + this.dispatchEvent( + new CustomEvent('widget-change', { + detail: data, + bubbles: true, + composed: true, + }) + ); + }, + }); + + this._loading = false; + } + + // -- Render -- + + override render() { + return html` +
+ ${this.title ? html`

${this.title}

` : ''} + ${this._loading ? html`
Loading widget...
` : ''} +
+
+ `; + } +} + +export { AcmeWidgetWrapper }; +// end::class[] diff --git a/src/main/java/com/vaadin/demo/component/internals/AcmeWidget.java b/src/main/java/com/vaadin/demo/component/internals/AcmeWidget.java new file mode 100644 index 0000000000..03e0aef2da --- /dev/null +++ b/src/main/java/com/vaadin/demo/component/internals/AcmeWidget.java @@ -0,0 +1,73 @@ +package com.vaadin.demo.component.internals; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.DomEvent; +import com.vaadin.flow.component.EventData; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.shared.Registration; + +// tag::annotations[] +@Tag("acme-widget-wrapper") +// end::annotations[] +// tag::body[] +@JsModule("./component-internals/acme-widget-wrapper.ts") +public class AcmeWidget extends Component { + + public record WidgetConfig(String type, boolean animate) { + } + + public AcmeWidget() { + } + + public void setTitle(String title) { + getElement().setProperty("title", title); + } + + public String getTitle() { + return getElement().getProperty("title", ""); + } + + public void setConfig(WidgetConfig config) { + getElement().setPropertyBean("config", config); + } + + public void setInteractive(boolean interactive) { + getElement().setProperty("interactive", interactive); + } + + public boolean isInteractive() { + return getElement().getProperty("interactive", true); + } + + public Registration addWidgetChangeListener( + ComponentEventListener listener) { + return addListener(WidgetChangeEvent.class, listener); + } + + @DomEvent("widget-change") + public static class WidgetChangeEvent extends ComponentEvent { + + private final String label; + private final double value; + + public WidgetChangeEvent(AcmeWidget source, boolean fromClient, + @EventData("event.detail.label") String label, + @EventData("event.detail.value") double value) { + super(source, fromClient); + this.label = label; + this.value = value; + } + + public String getLabel() { + return label; + } + + public double getValue() { + return value; + } + } +} +// end::body[]