Crear un widget Beyond
Construye un widget React 19 declarado por su module.json, dale una hoja de estilos Tailwind y una dependencia pública con estilos de otro paquete, míralo montarse en su propio shadow root, cambia su código y su CSS sin recargar, y haz lo mismo con una vista Vue o Svelte.
- Disponibilidad: Experimental
- Evidencia: Ejecución registrada
- Tutorial
Qué construyes
Un elemento personalizado, <hello-widget>, cuya vista es un componente React 19. Tiene su propia hoja de estilos Tailwind, adopta la hoja compartida de su paquete y muestra una insignia que un módulo público de otro paquete aporta con su propia hoja, todo dentro del shadow root del elemento. Después cambias su código y su CSS con la página abierta, y cargas el mismo widget con una vista Vue o Svelte.
Declara el widget
Un widget es un módulo público cuyo manifiesto declara el elemento en widget: su etiqueta y los atributos que observa. El bundler ts lo compila, ejecutándose sobre el runtime de desarrollo, y genera el registro del elemento: importar el módulo basta para que la página conozca <hello-widget>. El manifiesto también declara las fuentes que Tailwind escanea.
{
"platforms": ["web", "node"],
"entry": "index.ts",
"widget": {
"element": { "name": "hello-widget", "attrs": ["subject"] }
},
"tailwind": { "sources": ["view.tsx"] }
}El paquete declara su bundler, su runtime, sus dependencias y la hoja de estilos compartida que publica como ./global:
{
"name": "@testbed/widget-app",
"version": "0.1.0",
"private": true,
"description": "A root React 19 widget with its own Tailwind stylesheet, a shared global stylesheet and a styled public dependency of another package",
"exports": {
"./global": "./global.css"
},
"beyond": {
"modules": ".",
"bundler": "ts"
},
"bundlers": {
"ts": {
"specifier": "@beyond-js/packages/bundlers/ts",
"runtime": "@beyond-js/local-2026/bundle"
}
},
"dependencies": {
"@testbed/badge": "0.1.0",
"@beyond-js/widgets": "^1.1.0",
"@beyond-js/react-19-widgets": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}El paquete Widgets, el adaptador React 19 y el propio React los aporta la cadena de herramientas y los sirve el mismo servicio de desarrollo: el workspace no instala nada.
Escribe el controlador y la vista
El controlador extiende el adaptador React 19 y nombra la vista. El mismo controlador sirve al navegador, donde el adaptador monta la vista, y a Node, donde la renderiza a HTML.
/**
* The controller of the widget `hello-widget`: a React 19 widget whose view is `View`. The same controller
* serves the browser, where the adapter mounts the view, and Node, where the adapter renders it to HTML.
*/
import { ReactWidgetController } from '@beyond-js/react-19-widgets/base';
import { View } from './view';
export class Controller extends ReactWidgetController {
get Widget() {
return View;
}
}La vista es un componente React común. Recibe el widget, sus atributos y su store como props, mantiene su propio estado e importa la insignia desde otro módulo público por su especificador bare:
import * as React from 'react';
import { badge } from '@testbed/badge/badge';
/**
* The view of the widget: a heading styled by the module's own stylesheet, a badge supplied by another
* public module with its own stylesheet, and a counter that survives updates of the code and the styles
*/
export function View({ attributes }: { attributes: Map<string, string> }) {
const [count, setCount] = React.useState(0);
const subject = attributes.get('subject') ?? 'Beyond';
return (
<div className="card p-4">
<h1 className="greeting text-brand">Hello {subject}!</h1>
<span className="badge-slot" dangerouslySetInnerHTML={{ __html: badge('React 19') }} />
<button className="counter" onClick={() => setCount(count + 1)}>
Clicked {count}
</button>
</div>
);
}/* The stylesheet of the widget: Tailwind utilities for the classes in view.tsx, the theme, and its own rules */
@import "tailwindcss";
@theme {
--color-brand: rgb(30, 64, 175);
}
.greeting {
font-size: 28px;
margin: 0 0 8px;
}
.counter {
background-color: rgb(30, 64, 175);
color: rgb(255, 255, 255);
border: 0;
padding: 8px 12px;
}La dependencia con sus propios estilos
@testbed/badge/badge es un módulo público de otro paquete del workspace, no un widget. El paquete de la insignia selecciona el mismo bundler y runtime y no declara nada más; su hoja de estilos viaja con el módulo: un widget que importa el módulo adopta la hoja en su propia raíz.
{
"name": "@testbed/badge",
"version": "0.1.0",
"private": true,
"description": "A public module with its own stylesheet, imported by the widget of another package by its bare specifier",
"beyond": {
"modules": ".",
"bundler": "ts"
},
"bundlers": {
"ts": {
"specifier": "@beyond-js/packages/bundlers/ts",
"runtime": "@beyond-js/local-2026/bundle"
}
}
}/**
* A badge: markup styled by this module's stylesheet, which the widget that imports the module adopts in
* its root together with its own sheets
*/
export const badge = (text: string): string => `<span class="badge">${text}</span>`;.badge {
display: inline-block;
background-color: rgb(22, 101, 52);
color: rgb(255, 255, 255);
border-radius: 4px;
padding: 2px 8px;
}El widget compilado conserva from '@testbed/badge/badge' como referencia bare: la implementación de la insignia nunca se copia dentro del widget.
Ponlo en una página
El módulo de entrada de la página importa el módulo del widget, que registra el elemento, y agrega una instancia:
/**
* The entry point of the application: importing the widget module registers its element, and the page
* gets one instance. Nothing else is needed to show a widget.
*/
import '@testbed/widget-app/hello';
const widget = document.createElement('hello-widget');
widget.setAttribute('subject', 'Beyond');
document.body.append(widget);Inicia el servidor con BEYOND_SERVICE_EXTENSIONS=@beyond-js/packages/development beyond run y abre <endpoint>/preview/?entry=@testbed/widget-app/main. El widget muestra una tarjeta con Hello Beyond! en azul (rgb(30, 64, 175), la utilidad text-brand de su tema), la insignia verde (rgb(22, 101, 52), la hoja del módulo de la insignia) y un botón Clicked 0. Su shadow root contiene tres enlaces a hojas de estilos, en este orden: la hoja global del paquete, la hoja del widget y la hoja de la insignia. El documento no enlaza ninguna.
Cámbialo mientras se ejecuta
| Edición | Qué hace la página abierta |
|---|---|
El texto de la insignia en badge/index.ts |
El siguiente renderizado del widget muestra la insignia nueva, a través de su importación original. El contador y el elemento se conservan; el módulo @testbed/badge/note del paquete de la insignia, que no está relacionado, no se recompila (mismo ETag) |
El color en badge.css |
La hoja de la insignia se reemplaza en la raíz del widget; el contador se conserva |
Una clase de Tailwind agregada a view.tsx |
La utilidad se emite y el widget renderiza el componente nuevo; un componente cuyo módulo cambió se crea de nuevo, así que su useState vuelve a empezar mientras el elemento y su controlador permanecen. Quitar la clase elimina la utilidad |
Un error de sintaxis en badge.css o en badge/index.ts |
El módulo queda invalid y responde 422 BUILD_FAILED; la página conserva su último estado bueno y corregir el archivo lo recupera |
Nada de esto hace navegar la página.
El mismo widget con una vista Vue o Svelte
El controlador de un widget selecciona su adaptador; la vista es un componente de archivo único compilado por Packages. El manifiesto declara el elemento de la misma manera, sin mencionar el framework:
{
"platforms": ["web", "node"],
"entry": "index.ts",
"widget": {
"element": { "name": "hello-vue", "attrs": ["label"] }
}
}import { VueWidgetController } from '@beyond-js/vue-widgets/base';
import View from './view.vue';
/**
* A Vue widget with a label attribute and a counter of its own. The same controller renders on Node.
*/
export class Controller extends VueWidgetController {
get Widget() {
return View;
}
}<script setup lang="ts">
import { ref } from 'vue';
import { label } from '@testbed/boundaries/shared';
const props = defineProps<{ attributes: Map<string, string> }>();
const count = ref(0);
</script>
<template>
<div class="vue">
<h2 class="title">{{ props.attributes.get('label') }}</h2>
<p class="shared-label">{{ label('Vue count', count) }}</p>
<button class="counter" @click="count++">Add</button>
</div>
</template>
<style scoped>
.title {
color: rgb(202, 138, 4);
margin: 0;
}
</style>import { SvelteWidgetController } from '@beyond-js/svelte-widgets/base';
import View from './view.svelte';
/**
* A Svelte 5 widget with a label attribute and a counter of its own. The same controller renders on Node.
*/
export class Controller extends SvelteWidgetController {
get Widget() {
return View;
}
}<script lang="ts">
import { label } from '@testbed/boundaries/shared';
let { attributes }: { attributes: Map<string, string> } = $props();
let count = $state(0);
</script>
<div class="svelte">
<h2 class="title">{attributes.get('label')}</h2>
<p class="shared-label">{label('Svelte count', count)}</p>
<button class="counter" onclick={() => count++}>Add</button>
</div>
<style>
.title {
color: rgb(190, 24, 93);
margin: 0;
}
</style>Un componente Vue se convierte en su script, su función de renderizado y una fachada, y cada bloque <style> va a la hoja de estilos del módulo, con alcance cuando el bloque lo tiene; un componente Svelte 5 se compila con runas y su CSS se recoge en la hoja del módulo. Un widget React, uno Vue y uno Svelte se montan lado a lado en una página, cada uno en su propia raíz con sus propios estilos; un widget anidado dentro de otro es dueño de su raíz, y la regla del padre no lo alcanza. Dos instancias de una misma etiqueta mantienen atributos y estado separados.
Renderízalo en un servidor
Cada módulo de widget de estos ejemplos también compila para Node. Un host Node carga los módulos desde el servicio, renderiza cada widget con su controlador de servidor (HTML determinista, con los enlaces a las hojas de sus dependencias), y un navegador hidrata ese marcado en lugar de reemplazarlo: el encabezado que escribió el servidor es el mismo nodo después de la hidratación, y los contadores funcionan. Lee Crear un entorno de ejecución modular para el host.
Siguiente
Escribe un widget sin framework de vistas, o integra otro: Integrar un framework de vistas. Pon el widget en una página que no fue construida con Beyond: Incrustar un widget en una página existente. Entrégalo desde un release del CDN: Widgets y frameworks de vista.