diff --git a/.github/styles/config/vocabularies/Docs/accept.txt b/.github/styles/config/vocabularies/Docs/accept.txt index b11856162b..a111576b91 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 @@ -166,7 +166,7 @@ OpenAPI pageable [pP]asswordless Payara -performant +[pP]erformant [pP]ersister Pivotal [pP]luggable @@ -184,7 +184,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..4bc2788664 --- /dev/null +++ b/articles/flow/component-internals/web-components/advanced-integration.adoc @@ -0,0 +1,450 @@ +--- +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 `@example/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/example-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/ExampleWidget.java[tags=annotations,indent=0] +@NpmPackage(value = "@example/widget", version = "2.0.0") +include::{root}/src/main/java/com/vaadin/demo/component/internals/ExampleWidget.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] +---- +ExampleWidget widget = new ExampleWidget(); +widget.setTitle("Dashboard Widget"); +widget.setConfig(new ExampleWidget.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 }) + max: number = 10; + + // Internal: only used within this component + @state() + private _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 }) + 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. This is usually the right choice for reusable or distributable components, where you don't want the application's styles to affect the component's internals. + +For project-specific components, the opposite is often true: you may want the application's theme and global styles to apply to the component's content. In that case, rendering into Light DOM is a reasonable choice, because it lets page styles reach inside the component. Light DOM also lets the wrapped content participate in form submission. To render into Light DOM, override `createRenderRoot()` to return the element itself: + +[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
`; + } +} +---- + +.Light DOM trade-offs +[NOTE] +Light DOM components have no style encapsulation: their styles can leak out, and page styles leak in. For a project-specific component meant to match the application theme, that's exactly what you want. For a reusable component intended to look the same everywhere, it makes the component harder to maintain -- prefer Shadow DOM in that case. + + +[[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, 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 }) +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 lists and maps, use `setPropertyList()` and `setPropertyMap()`. Vaadin serializes the elements with Jackson: + +[source,java] +---- +getElement().setPropertyList("selectedIds", List.of(1, 2, 3)); +getElement().setPropertyMap("labels", Map.of("x", "Revenue", "y", "Quarter")); +---- + +For cases where you need lower-level control, `setPropertyJson()` accepts a Jackson `BaseJsonNode`: + +[source,java] +---- +ObjectNode config = JacksonUtils.createObjectNode(); +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`:* + +Event data is deserialized with Jackson, so you can bind the expression directly to a typed value such as a `List`: + +[source,java] +---- +@DomEvent("selection-changed") +public static class SelectionChangedEvent + extends ComponentEvent { + + private final List selectedIds; + + public SelectionChangedEvent(MyComponent source, boolean fromClient, + @EventData("event.detail.selectedIds") List selectedIds) { + super(source, fromClient); + this.selectedIds = selectedIds; + } + + public List getSelectedIds() { + return selectedIds; + } +} +---- + +*Java side using `addEventListener`:* + +The low-level listener receives the raw event data as a Jackson `JsonNode`. Use `getEventData(TypeReference)` to deserialize the whole event payload into a typed object, or navigate the `JsonNode` manually: + +[source,java] +---- +getElement().addEventListener("selection-changed", event -> { + JsonNode ids = event.getEventData().get("event.detail.selectedIds"); + // process selection, e.g. ids.get(0).asInt() +}).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 _loading: boolean = true; + +@state() +private _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..06e8b07c7c 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 }) + label: string = ''; + + @property({ type: Number }) + 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..5ec8a81f69 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 @@ -60,7 +60,7 @@ See <<../events#,Events>> for the full reference, including event data expressio == Calling Element Functions -Use [methodname]`getElement().callJsFunction()` to invoke JavaScript methods on the element. Supported parameter types are `String`, `Boolean`, `Integer`, `Double`, the corresponding primitive types, `JsonValue`, and `Element` and `Component` references. The method returns a server-side promise for the return value. +Use [methodname]`getElement().callJsFunction()` to invoke JavaScript methods on the element. It accepts all types supported by Jackson for JSON serialization, so you can pass primitives, strings, records, beans, collections, and Jackson `JsonNode` values directly. `Element` and `Component` references are a special case: they're sent as DOM element references when attached, or as `null` otherwise. The method returns a server-side promise for the return value. See <<../client-server-rpc#,Remote Procedure Calls>> for the full reference. @@ -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/example-widget-wrapper.ts b/frontend/demo/component-internals/example-widget-wrapper.ts new file mode 100644 index 0000000000..02702a3482 --- /dev/null +++ b/frontend/demo/component-internals/example-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 '@example/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('example-widget-wrapper') +class ExampleWidgetWrapper extends LitElement { + // -- Reactive properties (synced with Java via element properties) -- + + @property({ type: String }) + title: string = ''; + + @property({ type: Object }) + config: WidgetConfig = {}; + + @property({ type: Boolean }) + interactive: boolean = true; + + // -- Internal state (not exposed as attributes) -- + + @state() + private _widget: Widget | null = null; + + @state() + private _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 { ExampleWidgetWrapper }; +// end::class[] diff --git a/src/main/java/com/vaadin/demo/component/internals/ExampleWidget.java b/src/main/java/com/vaadin/demo/component/internals/ExampleWidget.java new file mode 100644 index 0000000000..551d5d91cf --- /dev/null +++ b/src/main/java/com/vaadin/demo/component/internals/ExampleWidget.java @@ -0,0 +1,74 @@ +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("example-widget-wrapper") +// end::annotations[] +// tag::body[] +@JsModule("./component-internals/example-widget-wrapper.ts") +public class ExampleWidget extends Component { + + public record WidgetConfig(String type, boolean animate) { + } + + public ExampleWidget() { + } + + 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(ExampleWidget 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[]